From c3dff60b424d379fa90e27f7ccd6d0f4c0a0e178 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:52:38 +0200 Subject: [PATCH 01/31] refactor(tick): extract BeginTick (timing phase) from RunTick Move-only: the idle colour filter, dt, zoom timeline step, stale press stamp and tray frame sample move verbatim into BeginTick (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 040e1560..7c70a6cc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1014,8 +1014,10 @@ static void ZoomTimelineStep(TickState& t, long long nowQpc) { (void)nowQpc; } -static void RunTick(TickState& t) { - TickWorkTimer workTimer(t); +// Tick phase: timing. The idle colour filter, dt (raw, and clamped to one frame after an idle +// wake), the zoom timeline step, the stale press stamp and the tray's frame-time sample. +struct TickClock { LARGE_INTEGER now; double dt; double rawDt; bool woke; }; +static TickClock BeginTick(TickState& t) { // Idle (1x) colour filter; a zoomed tick re-decides below with the engine known. if (!t.prevActive) UpdateColorFilter(t, false, false, nullptr); LARGE_INTEGER now; @@ -1039,6 +1041,15 @@ static void RunTick(TickState& t) { // One float store per tick, for the tray's frame-pacing readout. Deliberately the cheapest // possible coupling to the hot path: no lock, no allocation, and nothing reads it here. if (g_trayBlock && !woke) g_trayBlock->ticks.push((float)(dt * 1000.0)); + return { now, dt, rawDt, woke }; +} + +static void RunTick(TickState& t) { + TickWorkTimer workTimer(t); + const TickClock clk = BeginTick(t); + const LARGE_INTEGER now = clk.now; + const double dt = clk.dt, rawDt = clk.rawDt; + const bool woke = clk.woke; // Config hot-reload. A directory-change notification tells us WHEN to re-check magnifier.ini, // so the idle render thread does NO per-second filesystem stat (the old 1 Hz GetFileAttributesExW From 6c812f96590b41ead004e54f4a64be5e85b73b9e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:53:24 +0200 Subject: [PATCH 02/31] refactor(tick): extract PollConfigReload from RunTick Move-only: the config hot-reload block moves verbatim into PollConfigReload (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 7c70a6cc..d6c37f7e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1044,13 +1044,8 @@ static TickClock BeginTick(TickState& t) { return { now, dt, rawDt, woke }; } -static void RunTick(TickState& t) { - TickWorkTimer workTimer(t); - const TickClock clk = BeginTick(t); - const LARGE_INTEGER now = clk.now; - const double dt = clk.dt, rawDt = clk.rawDt; - const bool woke = clk.woke; - +// Tick phase: config hot-reload (rawDt drives the check cadence). +static void PollConfigReload(TickState& t, double rawDt) { // Config hot-reload. A directory-change notification tells us WHEN to re-check magnifier.ini, // so the idle render thread does NO per-second filesystem stat (the old 1 Hz GetFileAttributesExW // poll caused a ~1s frametime spike under AV/disk contention). WaitForSingleObject(h, 0) is a @@ -1181,6 +1176,16 @@ static void RunTick(TickState& t) { } // core-relevant change guard (StripUiOnlyKeys) } } +} + +static void RunTick(TickState& t) { + TickWorkTimer workTimer(t); + const TickClock clk = BeginTick(t); + const LARGE_INTEGER now = clk.now; + const double dt = clk.dt, rawDt = clk.rawDt; + const bool woke = clk.woke; + + PollConfigReload(t, rawDt); // Effective held state = mouse side-button (set by the hook/raw input) OR keyboard key held. // Lets users without side-buttons zoom from the keyboard. When the LL keyboard hook is active it From 3af435caa5b16176e655842df5969093deff0755 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:53:56 +0200 Subject: [PATCH 03/31] refactor(tick): extract WatchForegroundAndKbHook from RunTick Move-only: the launch-quiesce cover watch, the noSwallowApps hook suspension and the keyboard-hook watchdog move verbatim into WatchForegroundAndKbHook (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index d6c37f7e..ce148e6c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1178,19 +1178,9 @@ static void PollConfigReload(TickState& t, double rawDt) { } } -static void RunTick(TickState& t) { - TickWorkTimer workTimer(t); - const TickClock clk = BeginTick(t); - const LARGE_INTEGER now = clk.now; - const double dt = clk.dt, rawDt = clk.rawDt; - const bool woke = clk.woke; - - PollConfigReload(t, rawDt); - - // Effective held state = mouse side-button (set by the hook/raw input) OR keyboard key held. - // Lets users without side-buttons zoom from the keyboard. When the LL keyboard hook is active it - // is the authority for bound-key down-state (a swallowed key never appears in GetAsyncKeyState), - // so read keyPressed(); otherwise (hook install failed / WIND_NOHOOK) fall back to polling. +// Tick phase: foreground watch (launch-quiesce cover, noSwallowApps hook suspension) and the +// LL keyboard-hook watchdog. +static void WatchForegroundAndKbHook(TickState& t) { // Suspend the LL keyboard hook while a borderless fullscreen app (a game) is foreground. // // A WH_KEYBOARD_LL hook taxes the SYSTEM's input pipeline, not just ours: the raw input thread @@ -1270,6 +1260,22 @@ static void RunTick(TickState& t) { } else { t.kbHookDivergentSinceMs = 0; } +} + +static void RunTick(TickState& t) { + TickWorkTimer workTimer(t); + const TickClock clk = BeginTick(t); + const LARGE_INTEGER now = clk.now; + const double dt = clk.dt, rawDt = clk.rawDt; + const bool woke = clk.woke; + + PollConfigReload(t, rawDt); + + // Effective held state = mouse side-button (set by the hook/raw input) OR keyboard key held. + // Lets users without side-buttons zoom from the keyboard. When the LL keyboard hook is active it + // is the authority for bound-key down-state (a swallowed key never appears in GetAsyncKeyState), + // so read keyPressed(); otherwise (hook install failed / WIND_NOHOOK) fall back to polling. + WatchForegroundAndKbHook(t); const bool kbHook = g_input.kbHookActive(); auto keyDown = [&](int vk) { if (vk == 0) return false; From decccab4364d4ea6cb26b2b3cd8c123aee8f2180 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:54:48 +0200 Subject: [PATCH 04/31] refactor(tick): extract BindKeyDown/BindModsHeld/BindComboHeld from RunTick Move-only: the keyDown/modsHeld/comboHeld lambdas become static functions taking the tick's kbHook read, so later phases can share them; comboHeld stays as a one-line forwarding lambda in RunTick (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index ce148e6c..bbcd279f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1262,6 +1262,27 @@ static void WatchForegroundAndKbHook(TickState& t) { } } +// Effective held state = mouse side-button (set by the hook/raw input) OR keyboard key held. +// Lets users without side-buttons zoom from the keyboard. When the LL keyboard hook is active it +// is the authority for bound-key down-state (a swallowed key never appears in GetAsyncKeyState), +// so read keyPressed(); otherwise (hook install failed / WIND_NOHOOK) fall back to polling. +// kbHook is the tick's one read of g_input.kbHookActive(). +static bool BindKeyDown(bool kbHook, int vk) { + if (vk == 0) return false; + if (kbHook && g_input.isBoundKey(vk)) return g_input.keyPressed(vk); + return (GetAsyncKeyState(vk) & 0x8000) != 0; +} +// Modifier mask: bit 1=Ctrl, 2=Alt, 4=Shift, 8=Win. 0 = no modifiers required. Extra modifiers +// never disqualify (so a "Ctrl+F1" combo still fires when Ctrl+Shift+F1 is held). +static bool BindModsHeld(int mods) { + if ((mods & 1) && !(GetAsyncKeyState(VK_CONTROL) & 0x8000)) return false; + if ((mods & 2) && !(GetAsyncKeyState(VK_MENU) & 0x8000)) return false; + if ((mods & 4) && !(GetAsyncKeyState(VK_SHIFT) & 0x8000)) return false; + if ((mods & 8) && !((GetAsyncKeyState(VK_LWIN) & 0x8000) || (GetAsyncKeyState(VK_RWIN) & 0x8000))) return false; + return true; +} +static bool BindComboHeld(bool kbHook, int vk, int mods) { return BindKeyDown(kbHook, vk) && BindModsHeld(mods); } + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1271,27 +1292,9 @@ static void RunTick(TickState& t) { PollConfigReload(t, rawDt); - // Effective held state = mouse side-button (set by the hook/raw input) OR keyboard key held. - // Lets users without side-buttons zoom from the keyboard. When the LL keyboard hook is active it - // is the authority for bound-key down-state (a swallowed key never appears in GetAsyncKeyState), - // so read keyPressed(); otherwise (hook install failed / WIND_NOHOOK) fall back to polling. WatchForegroundAndKbHook(t); const bool kbHook = g_input.kbHookActive(); - auto keyDown = [&](int vk) { - if (vk == 0) return false; - if (kbHook && g_input.isBoundKey(vk)) return g_input.keyPressed(vk); - return (GetAsyncKeyState(vk) & 0x8000) != 0; - }; - // Modifier mask: bit 1=Ctrl, 2=Alt, 4=Shift, 8=Win. 0 = no modifiers required. Extra modifiers - // never disqualify (so a "Ctrl+F1" combo still fires when Ctrl+Shift+F1 is held). - auto modsHeld = [](int mods) { - if ((mods & 1) && !(GetAsyncKeyState(VK_CONTROL) & 0x8000)) return false; - if ((mods & 2) && !(GetAsyncKeyState(VK_MENU) & 0x8000)) return false; - if ((mods & 4) && !(GetAsyncKeyState(VK_SHIFT) & 0x8000)) return false; - if ((mods & 8) && !((GetAsyncKeyState(VK_LWIN) & 0x8000) || (GetAsyncKeyState(VK_RWIN) & 0x8000))) return false; - return true; - }; - auto comboHeld = [&](int vk, int mods) { return keyDown(vk) && modsHeld(mods); }; + auto comboHeld = [&](int vk, int mods) { return BindComboHeld(kbHook, vk, mods); }; // Quick-zoom modifier mode (see below): holding the modifier turns a zoom-key press into a // quick-zoom tap. A bind that ITSELF includes that modifier (Ctrl+Alt+click with the Ctrl // modifier, #285) is a normal hold-to-zoom bind, so every held bind is sorted into "includes the From 2438cc3a89461ae7d8a27e111c9b4cdb411470ec Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:55:25 +0200 Subject: [PATCH 05/31] refactor(tick): extract ResolveInputAndZoom from RunTick Move-only: held-bind resolution, the zoom controller step, the smooth-zoom release stop, recenter and Inspect toggle edges, quick zoom and the tray level publish move verbatim into ResolveInputAndZoom (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index bbcd279f..978d6558 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1283,16 +1283,10 @@ static bool BindModsHeld(int mods) { } static bool BindComboHeld(bool kbHook, int vk, int mods) { return BindKeyDown(kbHook, vk) && BindModsHeld(mods); } -static void RunTick(TickState& t) { - TickWorkTimer workTimer(t); - const TickClock clk = BeginTick(t); - const LARGE_INTEGER now = clk.now; - const double dt = clk.dt, rawDt = clk.rawDt; - const bool woke = clk.woke; - - PollConfigReload(t, rawDt); - - WatchForegroundAndKbHook(t); +// Tick phase: input and zoom. Held binds (mouse, keyboard, wheel), the ZoomController step, the +// smooth-zoom release stop, the recenter and Inspect toggle edges, quick zoom, and the tray level. +struct TickInput { bool kbHook; bool inHeld, outHeld; bool recenter; double lvl; }; +static TickInput ResolveInputAndZoom(TickState& t, double dt) { const bool kbHook = g_input.kbHookActive(); auto comboHeld = [&](int vk, int mods) { return BindComboHeld(kbHook, vk, mods); }; // Quick-zoom modifier mode (see below): holding the modifier turns a zoom-key press into a @@ -1425,6 +1419,25 @@ static void RunTick(TickState& t) { ts_.level = lvl; wind::PublishTrayStatus(g_trayBlock, ts_); } + return { kbHook, inHeld, outHeld, recenter, lvl }; +} + +static void RunTick(TickState& t) { + TickWorkTimer workTimer(t); + const TickClock clk = BeginTick(t); + const LARGE_INTEGER now = clk.now; + const double dt = clk.dt, rawDt = clk.rawDt; + const bool woke = clk.woke; + + PollConfigReload(t, rawDt); + + WatchForegroundAndKbHook(t); + const TickInput in = ResolveInputAndZoom(t, dt); + const bool kbHook = in.kbHook; + auto comboHeld = [&](int vk, int mods) { return BindComboHeld(kbHook, vk, mods); }; + const bool inHeld = in.inHeld, outHeld = in.outHeld; + const bool recenter = in.recenter; + const double lvl = in.lvl; int rawDx, rawDy; g_input.drainRaw(rawDx, rawDy); // Raw motion from before this activation (up to a 100 ms idle sleep of it, #71) never pans the From 6438c3475d96f29f29ab8f38f0eb67ce292a4ecf Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:55:55 +0200 Subject: [PATCH 06/31] refactor(tick): extract ResolveTickModes from RunTick Move-only: the raw-motion drain, the zoomed/inspect/active flags, the stale-lock reset, the zoom timeline arm and the keyboard-pan arm move verbatim into ResolveTickModes (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 45 ++++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 978d6558..0e64d862 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1422,23 +1422,10 @@ static TickInput ResolveInputAndZoom(TickState& t, double dt) { return { kbHook, inHeld, outHeld, recenter, lvl }; } -static void RunTick(TickState& t) { - TickWorkTimer workTimer(t); - const TickClock clk = BeginTick(t); - const LARGE_INTEGER now = clk.now; - const double dt = clk.dt, rawDt = clk.rawDt; - const bool woke = clk.woke; - - PollConfigReload(t, rawDt); - - WatchForegroundAndKbHook(t); - const TickInput in = ResolveInputAndZoom(t, dt); - const bool kbHook = in.kbHook; - auto comboHeld = [&](int vk, int mods) { return BindComboHeld(kbHook, vk, mods); }; - const bool inHeld = in.inHeld, outHeld = in.outHeld; - const bool recenter = in.recenter; - const double lvl = in.lvl; - +// Tick phase: this tick's modes. Raw motion, zoomed / Inspect / active, the stale-lock reset, the +// zoom timeline arm on the enter tick, and the keyboard-pan arm published to the hook. +struct TickModes { int rawDx, rawDy; bool zoomed, inspect, active, panArmed; }; +static TickModes ResolveTickModes(TickState& t, double lvl, const LARGE_INTEGER& now) { int rawDx, rawDy; g_input.drainRaw(rawDx, rawDy); // Raw motion from before this activation (up to a 100 ms idle sleep of it, #71) never pans the // first zoomed frame: at 1x it is unused, so an idle->active tick starts from zero. @@ -1469,6 +1456,30 @@ static void RunTick(TickState& t) { const bool panArmed = lvl > 1.001 && !inspect && !t.lockEff; g_input.setPanArmed(panArmed); if (!panArmed) t.keyPan.reset(); + return { rawDx, rawDy, zoomed, inspect, active, panArmed }; +} + +static void RunTick(TickState& t) { + TickWorkTimer workTimer(t); + const TickClock clk = BeginTick(t); + const LARGE_INTEGER now = clk.now; + const double dt = clk.dt, rawDt = clk.rawDt; + const bool woke = clk.woke; + + PollConfigReload(t, rawDt); + + WatchForegroundAndKbHook(t); + const TickInput in = ResolveInputAndZoom(t, dt); + const bool kbHook = in.kbHook; + auto comboHeld = [&](int vk, int mods) { return BindComboHeld(kbHook, vk, mods); }; + const bool inHeld = in.inHeld, outHeld = in.outHeld; + const bool recenter = in.recenter; + const double lvl = in.lvl; + + const TickModes md = ResolveTickModes(t, lvl, now); + int rawDx = md.rawDx, rawDy = md.rawDy; + const bool zoomed = md.zoomed, inspect = md.inspect, active = md.active; + const bool panArmed = md.panArmed; if (active) { bool enterActive = !t.prevActive; // idle -> active (overlay just turned on) From 23f59d2a64a4cc0e41e06565a4ec7d858eb02b22 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:56:35 +0200 Subject: [PATCH 07/31] refactor(tick): extract RetargetSessionMonitor from RunTick Move-only: the zoom-in monitor retarget (both engines, refresh rate, mapper, detector) moves verbatim into RetargetSessionMonitor (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 85 +++++++++++++++++++++++++++------------------------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0e64d862..0624be53 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1459,6 +1459,50 @@ static TickModes ResolveTickModes(TickState& t, double lvl, const LARGE_INTEGER& return { rawDx, rawDy, zoomed, inspect, active, panArmed }; } +// Session enter: retarget both engines to the session's monitor (before the engine pick). +static void RetargetSessionMonitor(TickState& t, bool zoomed, bool inspect) { + // Follow the cursor's monitor (multiMonitor on, only when zoomed). Only reconfigure when + // it actually changed; retarget() returns false on multi-GPU/failure, in which case we keep + // the current monitor. The overlay is still at alpha 0 here, so a move never flashes. + // RETARGET BEFORE THE ENGINE PICK: the pick must evaluate the SESSION's monitor - the + // old order evaluated the PREVIOUS session's monitor, so switching monitors between + // zooms could hand a secondary-monitor session the transform engine (or a primary + // game session the render one). Retarget through the render model: it owns the + // overlay, and hybrid may still be holding the transform half from the last session. + // multiMonitor decides WHICH monitor to follow, not WHETHER to notice the current + // one's geometry (issue #230). It used to gate both, so with it off - the shipped + // default - a display-mode change was never picked up at all; and with it on, only the + // render model was retargeted, leaving the transform clamping against the old size. + // A game that switches to a lower resolution therefore let the view pan off the real + // desktop. Both engines are retargeted now, and the geometry is tracked either way. + // Inspect at 1x also needs the session's monitor (the look point maps onto it). + if (zoomed || inspect) { + MonitorTarget nt = t.cfg.multiMonitor ? MonitorUnderCursor() : PrimaryMonitor(); + IMagnifierModel* rt = t.mRender ? t.mRender : t.model; + bool ok = SameMonitor(nt, t.mon) ? false : rt->retarget(nt); + const bool renderOk = ok; + const bool hadTransformHalf = t.mTransform && t.mTransform != rt; + // The transform half owns its own bounds and must follow even when the render + // half refuses (retarget returns false across adapters, where the overlay cannot + // move but the transform is still valid on the new geometry). + if (!SameMonitor(nt, t.mon) && t.mTransform && t.mTransform != rt) { + if (t.mTransform->retarget(nt)) ok = true; + } + // Hybrid with a render half that refused while the transform half took it: the + // overlay is left on the old monitor, so this session must not pick render. + if (ok && hadTransformHalf && !renderOk) t.renderStale = true; + else if (ok) t.renderStale = false; + if (ok) { + t.mon = nt; + int nhz = DetectRefreshHz(nt.device); // pace off the new monitor's refresh (#74) + if (nhz > 0) t.hz = nhz; + // Everything tuned in ticks follows the new tick rate (issue #223). + t.mapper = CursorMapper(nt.w, nt.h); + t.detector.setTickRate(t.hz); + } + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1500,46 +1544,7 @@ static void RunTick(TickState& t) { t.viewDetached = false; t.viewVx = 0; t.viewVy = 0; t.keyPan.reset(); - // Follow the cursor's monitor (multiMonitor on, only when zoomed). Only reconfigure when - // it actually changed; retarget() returns false on multi-GPU/failure, in which case we keep - // the current monitor. The overlay is still at alpha 0 here, so a move never flashes. - // RETARGET BEFORE THE ENGINE PICK: the pick must evaluate the SESSION's monitor - the - // old order evaluated the PREVIOUS session's monitor, so switching monitors between - // zooms could hand a secondary-monitor session the transform engine (or a primary - // game session the render one). Retarget through the render model: it owns the - // overlay, and hybrid may still be holding the transform half from the last session. - // multiMonitor decides WHICH monitor to follow, not WHETHER to notice the current - // one's geometry (issue #230). It used to gate both, so with it off - the shipped - // default - a display-mode change was never picked up at all; and with it on, only the - // render model was retargeted, leaving the transform clamping against the old size. - // A game that switches to a lower resolution therefore let the view pan off the real - // desktop. Both engines are retargeted now, and the geometry is tracked either way. - // Inspect at 1x also needs the session's monitor (the look point maps onto it). - if (zoomed || inspect) { - MonitorTarget nt = t.cfg.multiMonitor ? MonitorUnderCursor() : PrimaryMonitor(); - IMagnifierModel* rt = t.mRender ? t.mRender : t.model; - bool ok = SameMonitor(nt, t.mon) ? false : rt->retarget(nt); - const bool renderOk = ok; - const bool hadTransformHalf = t.mTransform && t.mTransform != rt; - // The transform half owns its own bounds and must follow even when the render - // half refuses (retarget returns false across adapters, where the overlay cannot - // move but the transform is still valid on the new geometry). - if (!SameMonitor(nt, t.mon) && t.mTransform && t.mTransform != rt) { - if (t.mTransform->retarget(nt)) ok = true; - } - // Hybrid with a render half that refused while the transform half took it: the - // overlay is left on the old monitor, so this session must not pick render. - if (ok && hadTransformHalf && !renderOk) t.renderStale = true; - else if (ok) t.renderStale = false; - if (ok) { - t.mon = nt; - int nhz = DetectRefreshHz(nt.device); // pace off the new monitor's refresh (#74) - if (nhz > 0) t.hz = nhz; - // Everything tuned in ticks follows the new tick rate (issue #223). - t.mapper = CursorMapper(nt.w, nt.h); - t.detector.setTickRate(t.hz); - } - } + RetargetSessionMonitor(t, zoomed, inspect); if (t.mTransform) { // Hybrid engine pick, per zoom-in session (pure predicate: engine_pick.h, tested). // Only ever swapped here or in the settled instant-switch below, so every From d40b49a0fbadb2aaeaf7692f26162162942864e2 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:56:57 +0200 Subject: [PATCH 08/31] refactor(tick): extract PickEngineAtZoomIn from RunTick Move-only: the per-session hybrid engine pick at zoom-in moves verbatim into PickEngineAtZoomIn (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 61 ++++++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0624be53..f39e2798 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1503,6 +1503,38 @@ static void RetargetSessionMonitor(TickState& t, bool zoomed, bool inspect) { } } +// Session enter: the hybrid engine pick for this zoom-in session. +static void PickEngineAtZoomIn(TickState& t) { + if (t.mTransform) { + // Hybrid engine pick, per zoom-in session (pure predicate: engine_pick.h, tested). + // Only ever swapped here or in the settled instant-switch below, so every + // activation's teardown calls route to the same engine that activated. + HWND fgw = GetForegroundWindow(); + // Re-derive once per zoom-in even for the same window (review 2026-10-09 M1): the + // cache is keyed on the HWND, but what it caches can change under the same handle + // (an app marked churny after a driver reset keeps its window; a DRM child surface + // appears later). The mid-zoom re-pick below still reuses it every tick. + t.fgCacheHwnd = nullptr; + RefreshFgCache(t, fgw); + t.monRotated = DetectRotatedOutput(t.mon.device); + auto* tAvail = dynamic_cast(t.mTransform); + EnginePickInputs pin; + pin.coversMonitor = ForegroundCoversMonitor(t.mon); + pin.borderless = fgw && !(GetWindowLongPtrW(fgw, GWL_STYLE) & WS_CAPTION); + pin.primaryMonitor = t.mon.x == 0 && t.mon.y == 0; + pin.shellDesktop = t.fgCacheShell; + pin.excluded = t.fgCacheExcluded; + pin.churny = t.fgCacheChurny; + pin.tdrHarness = t.cfg.tdrTest > 0; + pin.desktopTransformOptIn = t.cfg.desktopTransform != 0; + pin.inputTransformOk = tAvail && tAvail->inputTransformAvailable(); + pin.renderLost = RenderDeviceLost(t); + FillCategoryInputs(t, pin); + IMagnifierModel* pick = wind::PickTransformFor(t.cfg.model, pin, t.renderStale) ? t.mTransform : t.mRender; + if (pick && pick != t.model) t.model = pick; + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1545,34 +1577,7 @@ static void RunTick(TickState& t) { t.viewVx = 0; t.viewVy = 0; t.keyPan.reset(); RetargetSessionMonitor(t, zoomed, inspect); - if (t.mTransform) { - // Hybrid engine pick, per zoom-in session (pure predicate: engine_pick.h, tested). - // Only ever swapped here or in the settled instant-switch below, so every - // activation's teardown calls route to the same engine that activated. - HWND fgw = GetForegroundWindow(); - // Re-derive once per zoom-in even for the same window (review 2026-10-09 M1): the - // cache is keyed on the HWND, but what it caches can change under the same handle - // (an app marked churny after a driver reset keeps its window; a DRM child surface - // appears later). The mid-zoom re-pick below still reuses it every tick. - t.fgCacheHwnd = nullptr; - RefreshFgCache(t, fgw); - t.monRotated = DetectRotatedOutput(t.mon.device); - auto* tAvail = dynamic_cast(t.mTransform); - EnginePickInputs pin; - pin.coversMonitor = ForegroundCoversMonitor(t.mon); - pin.borderless = fgw && !(GetWindowLongPtrW(fgw, GWL_STYLE) & WS_CAPTION); - pin.primaryMonitor = t.mon.x == 0 && t.mon.y == 0; - pin.shellDesktop = t.fgCacheShell; - pin.excluded = t.fgCacheExcluded; - pin.churny = t.fgCacheChurny; - pin.tdrHarness = t.cfg.tdrTest > 0; - pin.desktopTransformOptIn = t.cfg.desktopTransform != 0; - pin.inputTransformOk = tAvail && tAvail->inputTransformAvailable(); - pin.renderLost = RenderDeviceLost(t); - FillCategoryInputs(t, pin); - IMagnifierModel* pick = wind::PickTransformFor(t.cfg.model, pin, t.renderStale) ? t.mTransform : t.mRender; - if (pick && pick != t.model) t.model = pick; - } + PickEngineAtZoomIn(t); t.vbounds = QueryVirtualBounds(); // refresh cached clip-detect bounds (topology may have changed) // REFRESH RATE CAN CHANGE WITHOUT THE GEOMETRY CHANGING (issue #232). The retarget // above only fires when SameMonitor is false, and that compares origin, size and From 6cf96100b74c03d4f97e8ada668ab2fae22928e5 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:57:26 +0200 Subject: [PATCH 09/31] refactor(tick): extract EnterSession from RunTick Move-only: the body of the enterActive branch (per-session resets, retarget and engine pick calls, refresh-rate re-read, mapper/baseline/detector seeds, warp-lock seed, cursor hide, onActivate) moves verbatim into EnterSession (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 146 +++++++++++++++++++++++++++------------------------ 1 file changed, 76 insertions(+), 70 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index f39e2798..ffe279b8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1535,6 +1535,81 @@ static void PickEngineAtZoomIn(TickState& t) { } } +// Session enter (idle -> active): reset the per-session view state, retarget, pick the engine, +// re-read the refresh rate, seed the mapper, baseline and lock detector, and activate the engine. +static void EnterSession(TickState& t, bool zoomed, bool inspect) { + // Pristine pointers back BEFORE any engine hides or captures the pointer: DWM composes + // the transform engine's pointer through the colour effect and the render engine draws + // and filters the real shape itself, so a tinted source would be tinted twice (#288). + // Direct swaps, no scheme reload. + g_tint.restore(false); + t.outlineIdleSec = 0.0; // each activation starts with the outline fully shown + // StepViewOwner runs only while zoomed, so the owner, its latched caret target and the + // detached view survived the 1x gap and the next zoom-in opened on the previous + // session's caret. Every session starts with the mouse in charge. + wind::ResetViewOwnerForSession(t.viewOwner); + t.ptrHist.clear(); // no samples from the previous session in the first lagged read (#443) + t.postInspectHold = false; // a new session starts with DWM centring (#445) + t.viewDetached = false; + t.viewVx = 0; t.viewVy = 0; + t.keyPan.reset(); + RetargetSessionMonitor(t, zoomed, inspect); + PickEngineAtZoomIn(t); + t.vbounds = QueryVirtualBounds(); // refresh cached clip-detect bounds (topology may have changed) + // REFRESH RATE CAN CHANGE WITHOUT THE GEOMETRY CHANGING (issue #232). The retarget + // above only fires when SameMonitor is false, and that compares origin, size and + // device name - not the rate. A game switching to 60Hz at the desktop's resolution, + // or a driver-side refresh toggle, therefore left t.hz at its startup value forever. + // Everything derived from it is then wrong by that ratio: the timer pacing, the + // mapper's smoothing, the lock detector, and the tick counts TicksAtHz scales (its + // own comment notes a 144Hz-tuned count runs 2.4x longer at 60Hz). One + // EnumDisplaySettingsW per zoom-in is a cheap price for not being silently mistuned. + { + const int curHz = DetectRefreshHz(t.mon.device); + if (curHz > 0 && curHz != t.hz) { + wind::Log(wind::LogLevel::Info, "tick", "refresh rate %dHz -> %dHz", t.hz, curHz); + t.hz = curHz; + t.mapper = CursorMapper(t.mon.w, t.mon.h); + t.detector.setTickRate(t.hz); + } + } + POINT pt; GetCursorPos(&pt); + t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); // virtual -> local monitor coords + t.lastSetVirtual = pt; // baseline for the OS-cursor delta (first delta = 0) + t.detector.reset(); // start free + // Warp-lock seeding (issue #221 round 3, field report: any motion-based tell still needs a + // wiggle as evidence). Zooming in over a COVERING app whose cursor is already + // hidden by the APP is mouselook with near-certainty (the game-inspect tell, valid + // at this instant because this session has hidden nothing yet) - start LOCKED so + // the raw-mickey pan works from the first tick, motionless. A menu or the desktop + // (cursor shown) seeds nothing; a wrong seed over fullscreen video self-heals in + // ~100ms once the player re-shows the pointer and it tracks the hand. + if (t.cfg.warpLock != 0 && !t.cursorHiddenByUs && ForegroundCoversMonitor(t.mon)) { + CURSORINFO ci{}; ci.cbSize = sizeof(ci); + if (GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING) == 0) { + t.detector.seedLock(); + t.prevDetLocked = true; + wind::Log(wind::LogLevel::Info, "lock", + "seeded LOCKED at zoom-in (app cursor hidden, covering foreground)"); + } + } + // Transform sessions use the REAL cursor (issue #369): a free session never welds and + // DWM centres on the pointer; only the locked and Inspect regimes weld it to the lens + // point (see transform_model.cpp). Hover, drags and clicks are native either way. The + // old game-session FREEZE (1px clip + sprite aim point + click re-routing) was retired; + // git history has the machinery. + if (dynamic_cast(t.model)) { + // Record the session app for the device-lost churny backstop: if the GPU resets + // within 30s of a transform game session, that app is remembered in + // churny_apps.txt and future zoom-ins over it pick render (one crash, never two). + t.transformExe = ExeNameOf(GetForegroundWindow()); + wind::Log(wind::LogLevel::Info, "hybrid", "transform session (welded cursor)"); + } else { + SetSystemCursorHidden(t, t.model, true); + } + { wind::SpanScope span_(wind::kSpanActivate); t.model->onActivate(); } // grab a live frame, not a stale cached one +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1561,76 +1636,7 @@ static void RunTick(TickState& t) { bool enterActive = !t.prevActive; // idle -> active (overlay just turned on) bool inspectEnter = inspect && !t.prevInspect; if (enterActive) { - // Pristine pointers back BEFORE any engine hides or captures the pointer: DWM composes - // the transform engine's pointer through the colour effect and the render engine draws - // and filters the real shape itself, so a tinted source would be tinted twice (#288). - // Direct swaps, no scheme reload. - g_tint.restore(false); - t.outlineIdleSec = 0.0; // each activation starts with the outline fully shown - // StepViewOwner runs only while zoomed, so the owner, its latched caret target and the - // detached view survived the 1x gap and the next zoom-in opened on the previous - // session's caret. Every session starts with the mouse in charge. - wind::ResetViewOwnerForSession(t.viewOwner); - t.ptrHist.clear(); // no samples from the previous session in the first lagged read (#443) - t.postInspectHold = false; // a new session starts with DWM centring (#445) - t.viewDetached = false; - t.viewVx = 0; t.viewVy = 0; - t.keyPan.reset(); - RetargetSessionMonitor(t, zoomed, inspect); - PickEngineAtZoomIn(t); - t.vbounds = QueryVirtualBounds(); // refresh cached clip-detect bounds (topology may have changed) - // REFRESH RATE CAN CHANGE WITHOUT THE GEOMETRY CHANGING (issue #232). The retarget - // above only fires when SameMonitor is false, and that compares origin, size and - // device name - not the rate. A game switching to 60Hz at the desktop's resolution, - // or a driver-side refresh toggle, therefore left t.hz at its startup value forever. - // Everything derived from it is then wrong by that ratio: the timer pacing, the - // mapper's smoothing, the lock detector, and the tick counts TicksAtHz scales (its - // own comment notes a 144Hz-tuned count runs 2.4x longer at 60Hz). One - // EnumDisplaySettingsW per zoom-in is a cheap price for not being silently mistuned. - { - const int curHz = DetectRefreshHz(t.mon.device); - if (curHz > 0 && curHz != t.hz) { - wind::Log(wind::LogLevel::Info, "tick", "refresh rate %dHz -> %dHz", t.hz, curHz); - t.hz = curHz; - t.mapper = CursorMapper(t.mon.w, t.mon.h); - t.detector.setTickRate(t.hz); - } - } - POINT pt; GetCursorPos(&pt); - t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); // virtual -> local monitor coords - t.lastSetVirtual = pt; // baseline for the OS-cursor delta (first delta = 0) - t.detector.reset(); // start free - // Warp-lock seeding (issue #221 round 3, field report: any motion-based tell still needs a - // wiggle as evidence). Zooming in over a COVERING app whose cursor is already - // hidden by the APP is mouselook with near-certainty (the game-inspect tell, valid - // at this instant because this session has hidden nothing yet) - start LOCKED so - // the raw-mickey pan works from the first tick, motionless. A menu or the desktop - // (cursor shown) seeds nothing; a wrong seed over fullscreen video self-heals in - // ~100ms once the player re-shows the pointer and it tracks the hand. - if (t.cfg.warpLock != 0 && !t.cursorHiddenByUs && ForegroundCoversMonitor(t.mon)) { - CURSORINFO ci{}; ci.cbSize = sizeof(ci); - if (GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING) == 0) { - t.detector.seedLock(); - t.prevDetLocked = true; - wind::Log(wind::LogLevel::Info, "lock", - "seeded LOCKED at zoom-in (app cursor hidden, covering foreground)"); - } - } - // Transform sessions use the REAL cursor (issue #369): a free session never welds and - // DWM centres on the pointer; only the locked and Inspect regimes weld it to the lens - // point (see transform_model.cpp). Hover, drags and clicks are native either way. The - // old game-session FREEZE (1px clip + sprite aim point + click re-routing) was retired; - // git history has the machinery. - if (dynamic_cast(t.model)) { - // Record the session app for the device-lost churny backstop: if the GPU resets - // within 30s of a transform game session, that app is remembered in - // churny_apps.txt and future zoom-ins over it pick render (one crash, never two). - t.transformExe = ExeNameOf(GetForegroundWindow()); - wind::Log(wind::LogLevel::Info, "hybrid", "transform session (welded cursor)"); - } else { - SetSystemCursorHidden(t, t.model, true); - } - { wind::SpanScope span_(wind::kSpanActivate); t.model->onActivate(); } // grab a live frame, not a stale cached one + EnterSession(t, zoomed, inspect); } if (inspectEnter) { // Freeze the real cursor where it is; the look point (mapper center) starts there. From 022027464272fe9f661759189c955ea161c3671e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:57:49 +0200 Subject: [PATCH 10/31] refactor(tick): extract EnterInspect from RunTick Move-only: the body of the inspectEnter branch (freeze clip, look-point seed, game-inspect decision and log) moves verbatim into EnterInspect (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 92 ++++++++++++++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index ffe279b8..a777a5c1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1610,6 +1610,54 @@ static void EnterSession(TickState& t, bool zoomed, bool inspect) { { wind::SpanScope span_(wind::kSpanActivate); t.model->onActivate(); } // grab a live frame, not a stale cached one } +// Inspect enter: freeze the real cursor, seed the look point, and decide game-inspect. +// Zeroes this tick's raw motion so the first tick after entry pans by zero. +static void EnterInspect(TickState& t, bool zoomed, int& rawDx, int& rawDy) { + // Freeze the real cursor where it is; the look point (mapper center) starts there. + POINT pt; GetCursorPos(&pt); + // Read before the freeze clip below replaces the clip (M4, InspectLockTell). + RECT preClip{}; GetClipCursor(&preClip); + const bool pinnedAtEdge = + !wind::ClipRectConfines((int)(preClip.right - preClip.left), + (int)(preClip.bottom - preClip.top), t.mon.w, t.mon.h) && + wind::PointerPinnedAtEdge(pt.x, pt.y, preClip.left, preClip.top, + preClip.right, preClip.bottom); + t.frozenCursor = pt; + t.clickReleaseTicks = 0; // start frozen (clear any stale click-release window) + // Zero the sub-pixel carry and this tick's raw motion so the first tick after entry + // pans by zero. + rawDx = 0; rawDy = 0; + t.inspectPanRemX = 0.0; t.inspectPanRemY = 0.0; + t.inspectLookMoved = false; + t.postInspectHold = false; + t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); + t.lastSetVirtual = pt; + RECT fz{ pt.x, pt.y, pt.x + 1, pt.y + 1 }; + ClipCursor(&fz); + SetSystemCursorHidden(t, t.model, true); // hide the real cursor; we draw the crosshair + { wind::SpanScope span_(wind::kSpanActivate); t.model->onActivate(); } + // Game-inspect (issue #144): if a mouselook game holds the mouse, the freeze alone is + // not enough - its raw-input camera still receives every mickey. Steal foreground to + // the invisible helper so the game stops getting input. Deferred via + // inspectStealPending: the reveal logic later this tick must still see the GAME as + // foreground (ForegroundCoversMonitor decides the composite-gated reveal). + t.inspectGame = wind::ShouldGameInspect(zoomed, + wind::InspectLockTell(t.detector.locked(), pinnedAtEdge), + t.inspectCursorWasShowing, + t.inspectMagHidCursor); + if (t.inspectGame) { + t.inspectPrevFg = GetForegroundWindow(); + t.inspectStealPending = true; + } + // Logged either way: a DECLINE is the interesting case in the field (the camera keeps + // moving), and without the inputs there is nothing to diagnose it from. + wind::Log(wind::LogLevel::Info, "inspect", + "game-inspect %s (zoomed=%d detLocked=%d pinned=%d cursorShown=%d weHid=%d)", + t.inspectGame ? "engaged" : "DECLINED", (int)zoomed, + (int)t.detector.locked(), (int)pinnedAtEdge, (int)t.inspectCursorWasShowing, + (int)t.inspectMagHidCursor); +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1639,49 +1687,7 @@ static void RunTick(TickState& t) { EnterSession(t, zoomed, inspect); } if (inspectEnter) { - // Freeze the real cursor where it is; the look point (mapper center) starts there. - POINT pt; GetCursorPos(&pt); - // Read before the freeze clip below replaces the clip (M4, InspectLockTell). - RECT preClip{}; GetClipCursor(&preClip); - const bool pinnedAtEdge = - !wind::ClipRectConfines((int)(preClip.right - preClip.left), - (int)(preClip.bottom - preClip.top), t.mon.w, t.mon.h) && - wind::PointerPinnedAtEdge(pt.x, pt.y, preClip.left, preClip.top, - preClip.right, preClip.bottom); - t.frozenCursor = pt; - t.clickReleaseTicks = 0; // start frozen (clear any stale click-release window) - // Zero the sub-pixel carry and this tick's raw motion so the first tick after entry - // pans by zero. - rawDx = 0; rawDy = 0; - t.inspectPanRemX = 0.0; t.inspectPanRemY = 0.0; - t.inspectLookMoved = false; - t.postInspectHold = false; - t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); - t.lastSetVirtual = pt; - RECT fz{ pt.x, pt.y, pt.x + 1, pt.y + 1 }; - ClipCursor(&fz); - SetSystemCursorHidden(t, t.model, true); // hide the real cursor; we draw the crosshair - { wind::SpanScope span_(wind::kSpanActivate); t.model->onActivate(); } - // Game-inspect (issue #144): if a mouselook game holds the mouse, the freeze alone is - // not enough - its raw-input camera still receives every mickey. Steal foreground to - // the invisible helper so the game stops getting input. Deferred via - // inspectStealPending: the reveal logic later this tick must still see the GAME as - // foreground (ForegroundCoversMonitor decides the composite-gated reveal). - t.inspectGame = wind::ShouldGameInspect(zoomed, - wind::InspectLockTell(t.detector.locked(), pinnedAtEdge), - t.inspectCursorWasShowing, - t.inspectMagHidCursor); - if (t.inspectGame) { - t.inspectPrevFg = GetForegroundWindow(); - t.inspectStealPending = true; - } - // Logged either way: a DECLINE is the interesting case in the field (the camera keeps - // moving), and without the inputs there is nothing to diagnose it from. - wind::Log(wind::LogLevel::Info, "inspect", - "game-inspect %s (zoomed=%d detLocked=%d pinned=%d cursorShown=%d weHid=%d)", - t.inspectGame ? "engaged" : "DECLINED", (int)zoomed, - (int)t.detector.locked(), (int)pinnedAtEdge, (int)t.inspectCursorWasShowing, - (int)t.inspectMagHidCursor); + EnterInspect(t, zoomed, rawDx, rawDy); } bool inspectExit = !inspect && t.prevInspect; // Inspect just turned off but overlay stays (zoomed) if (inspectExit) { From cc18d4bd9b894036f00faf7bdc9f4d45f2d22717 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:58:16 +0200 Subject: [PATCH 11/31] refactor(tick): extract ExitInspect from RunTick Move-only: the body of the inspectExit branch (game-inspect end, click drain, unfreeze, look-point warp) moves verbatim into ExitInspect (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 62 ++++++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index a777a5c1..2bbf575f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1658,6 +1658,39 @@ static void EnterInspect(TickState& t, bool zoomed, int& rawDx, int& rawDy) { (int)t.inspectMagHidCursor); } +// Inspect exit while the overlay stays (zoomed): end game-inspect, drop pending clicks, release +// the freeze and resume at the look point. +static void ExitInspect(TickState& t) { + EndGameInspect(t); // hand foreground back to the game before resuming normal follow + // Un-drained swallowed clicks die with the session: a click swallowed in the final + // tick before toggle-off would otherwise linger and fire as a phantom injected click + // at the lens centre on the NEXT activation (possibly much later, anywhere on screen). + g_input.state().commitLeft.exchange(0); + g_input.state().commitRight.exchange(0); + // Leaving Inspect resumes at the LOOK POINT for BOTH models: the crosshair is what the + // user was aiming with, so the cursor belongs there - snapping back to the pre-Inspect + // position (which the transform model used to do, because it was forbidden from + // placing the cursor) throws away the aim the user just spent the mode establishing. + ClipCursor(nullptr); + POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, + (int)(t.mapper.centerY() + 0.5) + t.mon.y }; + const bool transformTx = dynamic_cast(t.model) != nullptr; + const bool warp = wind::InspectExitWarps(t.inspectLookMoved, transformTx); + t.postInspectHold = wind::HoldWindViewAfterInspect(t.inspectLookMoved, transformTx); + if (warp) SetCursorPos(lp.x, lp.y); + t.lastSetVirtual = warp ? lp : t.frozenCursor; + // The cursor SHAPE is stale after the warp (issue #229): Windows re-evaluates the + // shape only on a cursor event, so GetCursorInfo keeps reporting whatever cursor + // was active at the FROZEN spot (an I-beam over text, a link hand) and the sprite + // draws that stale shape until the user happens to move. Same 1px jiggle the + // transform model's session-end path uses - a zero-delta injected move is DROPPED + // by Windows and refreshes nothing (field-verified). + if (warp) { + SetCursorPos(lp.x + 1, lp.y); + SetCursorPos(lp.x, lp.y); + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1691,34 +1724,7 @@ static void RunTick(TickState& t) { } bool inspectExit = !inspect && t.prevInspect; // Inspect just turned off but overlay stays (zoomed) if (inspectExit) { - EndGameInspect(t); // hand foreground back to the game before resuming normal follow - // Un-drained swallowed clicks die with the session: a click swallowed in the final - // tick before toggle-off would otherwise linger and fire as a phantom injected click - // at the lens centre on the NEXT activation (possibly much later, anywhere on screen). - g_input.state().commitLeft.exchange(0); - g_input.state().commitRight.exchange(0); - // Leaving Inspect resumes at the LOOK POINT for BOTH models: the crosshair is what the - // user was aiming with, so the cursor belongs there - snapping back to the pre-Inspect - // position (which the transform model used to do, because it was forbidden from - // placing the cursor) throws away the aim the user just spent the mode establishing. - ClipCursor(nullptr); - POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, - (int)(t.mapper.centerY() + 0.5) + t.mon.y }; - const bool transformTx = dynamic_cast(t.model) != nullptr; - const bool warp = wind::InspectExitWarps(t.inspectLookMoved, transformTx); - t.postInspectHold = wind::HoldWindViewAfterInspect(t.inspectLookMoved, transformTx); - if (warp) SetCursorPos(lp.x, lp.y); - t.lastSetVirtual = warp ? lp : t.frozenCursor; - // The cursor SHAPE is stale after the warp (issue #229): Windows re-evaluates the - // shape only on a cursor event, so GetCursorInfo keeps reporting whatever cursor - // was active at the FROZEN spot (an I-beam over text, a link hand) and the sprite - // draws that stale shape until the user happens to move. Same 1px jiggle the - // transform model's session-end path uses - a zero-delta injected move is DROPPED - // by Windows and refreshes nothing (field-verified). - if (warp) { - SetCursorPos(lp.x + 1, lp.y); - SetCursorPos(lp.x, lp.y); - } + ExitInspect(t); } if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); t.lastSetVirtual = pt; } // Resolve the pan delta. FREE: the OS cursor's own motion since we last placed it - Windows' From 3c3ba7fc8dd85c238a1c5c8defcd3b4ff7696436 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:58:43 +0200 Subject: [PATCH 12/31] refactor(tick): extract ResolvePanDelta from RunTick Move-only: the pan-delta resolve (free / locked / Inspect regimes, lock detector, lockApps, LockApplies, gain learner, drag-follow, per-tick bound) moves verbatim into ResolvePanDelta (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 302 ++++++++++++++++++++++++++------------------------- 1 file changed, 157 insertions(+), 145 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 2bbf575f..3f77704e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1691,6 +1691,158 @@ static void ExitInspect(TickState& t) { } } +// Active tick: the pan delta in one of three regimes (free, locked, Inspect), with the lock +// detector, lockApps, LockApplies, the gain learner and drag-follow. cur is the start-of-tick read. +struct PanDelta { POINT cur; int curDx, curDy; int dx, dy; bool dragFollow; }; +static PanDelta ResolvePanDelta(TickState& t, bool inspect, double lvl, double dt, int rawDx, int rawDy) { + // Resolve the pan delta. FREE: the OS cursor's own motion since we last placed it - Windows' + // pointer acceleration is already applied, so we auto-match the real cursor (DPI/accel), then + // scale by cursorSensitivity as a speed knob (1.0 = exact match, the default). LOCKED: a game + // has the cursor clipped/recentered, so pan from raw mickeys scaled by the same cursorSensitivity + // (acceleration doesn't apply to relative-mouse game input). INSPECT: the real cursor is frozen + // in place, so its delta is irrelevant; the look point roams from raw mickeys instead. + POINT cur; GetCursorPos(&cur); + int curDx = cur.x - t.lastSetVirtual.x; + int curDy = cur.y - t.lastSetVirtual.y; + t.postInspectHold = wind::PostInspectHoldKeeps(t.postInspectHold && !inspect, curDx != 0 || curDy != 0); + int dx, dy; + bool dragFollow = false; // set in the free render branch below; drives ex.suppressCursorSync + if (inspect) { + // The OS cursor is frozen, so pan the look point from raw mickeys scaled by the + // MEASURED desktop gain (GainLearner, the same replay as the locked path) so the look + // point moves at the same speed/DPI as the desktop cursor. cursorSensitivity stays a + // user multiplier on top; carry the sub-pixel remainder so slow precise motion is not + // quantized away. + const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); + const double g = t.gainLearner.gainFor(inC, dt * 1000.0); + t.inspectPanRemX += rawDx * g * t.cfg.cursorSensitivity; + t.inspectPanRemY += rawDy * g * t.cfg.cursorSensitivity; + dx = (int)t.inspectPanRemX; t.inspectPanRemX -= dx; // truncate toward zero, carry the rest + dy = (int)t.inspectPanRemY; t.inspectPanRemY -= dy; + if (dx != 0 || dy != 0) t.inspectLookMoved = true; + } else { + RECT clip{}; GetClipCursor(&clip); + // A clip is a lock signal only when it is meaningfully SMALLER than the monitor + // (issue #169): a machine-wide work-area clip (desktop minus taskbar, ~95%) is + // desktop-like, not a game confining the pointer - the old any-clip test made every + // zoomed desktop session run the locked path on this rig. See ClipRectConfines. + bool clipConfined = wind::ClipRectConfines((int)(clip.right - clip.left), + (int)(clip.bottom - clip.top), + t.mon.w, t.mon.h); + int rawMag = std::abs(rawDx) + std::abs(rawDy); + // Mouse edge mode: the pointer is real and free, so pushing it into a screen edge or + // corner is not mouselook. Hide that motion from the lock tell (see PointerPinnedAtEdge). + if (t.cfg.mouseAlign == 1 && t.viewDetached && !clipConfined && + wind::PointerPinnedAtEdge(cur.x, cur.y, clip.left, clip.top, clip.right, clip.bottom)) + rawMag = 0; + bool locked = t.detector.update(clipConfined, + rawMag, + std::abs(curDx) + std::abs(curDy), + t.cfg.warpLock != 0, cur.x, cur.y); + // lockApps (issue #221): listed foreground exe = locked outright, no heuristics. + // The list IS the feature (empty = off); warpLock=1 adds the smart tells globally. + // MUST force through the DETECTOR, not just this tick's local: the free-cursor gate + // reads t.detector.locked() below, and a local-only force left the transform view + // pinned to the warped pointer (field regression: the list "did nothing" once the + // zoom-in seeding was scoped behind warpLock - gameplay had been riding the seed). + bool lockAppFg = false; + if (!t.cfg.lockApps.empty()) { + RefreshFgCache(t, GetForegroundWindow()); // per-HWND cache: no OpenProcess every tick + lockAppFg = t.fgCacheLockApp; + } + const bool forcedLock = t.cfg.lockForce != 0 || lockAppFg; + if (forcedLock && !locked) { + t.detector.seedLock(); + locked = true; + } + if (locked != t.prevDetLocked) { + // Field diagnosis (issue #221): which tell engaged, and when, in wind-core.log. + wind::Log(wind::LogLevel::Info, "lock", "detector %s%s lvl=%.2f", + locked ? "LOCKED" : "free", + (locked && t.detector.warpLocked()) ? " (warp-anchor)" : "", lvl); + t.prevDetLocked = locked; + } + // A shown pointer is a free pointer in a transform session (LockApplies): a game's + // menus keep DWM centring, its mouselook (pointer hidden) keeps the locked pan. The + // legacy sprite session (txNativeCursor=0, #463) keeps the old rule, like render. + { + const auto* tmLock = dynamic_cast(t.model); + const bool transformTx = tmLock != nullptr && tmLock->nativeSession(); + bool showing = false; + if (locked && transformTx) { + CURSORINFO ci{}; ci.cbSize = sizeof(ci); + showing = GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING) && ci.hCursor != nullptr; + } + const bool applies = wind::LockApplies(locked, transformTx, showing); + const bool freed = locked && !applies; + if (freed != t.lockFreed) + wind::Log(wind::LogLevel::Info, "lock", "%s lvl=%.2f", + freed ? "pointer shown: free (native cursor)" : "lock applies", lvl); + t.lockFreed = freed; + t.lockEff = applies; + locked = applies; + } + if (locked) { + // LOCKED pan at the TRUE desktop-cursor speed (issue: "cursor slower zoomed + // in DOOM"). Not modelled - MEASURED: free ticks record the OS's own in->out + // ratio per speed (gain_learner.h) and this replays it. Modelling was tried + // twice and missed both ways (raw = too slow, cooked curve = too fast) because + // a per-packet speed estimate cannot see WM_INPUT coalescing. + if (t.cfg.lockedBallistics != 0) { + const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); + const double g = t.gainLearner.gainFor(inC, dt * 1000.0); + t.lockedPanRemX += rawDx * g * t.cfg.cursorSensitivity; + t.lockedPanRemY += rawDy * g * t.cfg.cursorSensitivity; + dx = (int)t.lockedPanRemX; t.lockedPanRemX -= dx; + dy = (int)t.lockedPanRemY; t.lockedPanRemY -= dy; + } else { + dx = (int)std::lround(rawDx * t.cfg.cursorSensitivity); + dy = (int)std::lround(rawDy * t.cfg.cursorSensitivity); + t.lockedPanRemX = 0.0; t.lockedPanRemY = 0.0; + } + } else { + // Drag-follow (issue #169): while a mouse button is physically held, the pointer IS + // the interaction (window drag, text selection) - the per-tick weld would fight the + // hand and the dragged content flickers between the two positions. Suspend the weld + // (ex.suppressCursorSync below) and follow the pointer 1:1 unscaled: scaling would + // desync the lens from the pointer that owns the drag. The press itself landed + // under the welded cursor (the weld was live until the button went down), and the + // release lands where the pointer and the dragged content both are - correct by + // construction. Weld resumes on release. Only the render engine welds a free session + // (a transform one never does), so this path matters for it alone. + const bool anyButtonDown = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) || + (GetAsyncKeyState(VK_RBUTTON) & 0x8000) || + (GetAsyncKeyState(VK_MBUTTON) & 0x8000); + // FREE tick: both ends of the OS pointer pipeline are visible right here - + // rawDx/rawDy went in, curDx/curDy came out - so teach the learner the REAL + // ballistics at this speed. Gated on no confining clip: a clamped cursor + // under-reports output and would teach a too-low gain. + // Also skip a pointer pinned against the clip bound: it cannot move, so the output + // under-reports exactly like a confined clip (modest downward bias otherwise). + if (!clipConfined && (std::abs(rawDx) + std::abs(rawDy)) >= wind::GainLearner::kMinCounts && + !wind::PointerPinnedAtEdge(cur.x, cur.y, clip.left, clip.top, clip.right, clip.bottom)) { + const double dtMs_ = dt * 1000.0; + const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); + const double outC = std::sqrt((double)curDx * curDx + (double)curDy * curDy); + t.gainLearner.observe(inC, outC, dtMs_); + } + dragFollow = wind::ShouldDragFollow(locked, inspect, anyButtonDown); + if (dragFollow) { + dx = curDx; + dy = curDy; + } else { + dx = (int)std::lround(curDx * t.cfg.cursorSensitivity); // auto-matched OS delta, speed-scaled + dy = (int)std::lround(curDy * t.cfg.cursorSensitivity); + } + } + } + // Defensive: bound one tick's pan to the monitor span so a stray cursor jump (e.g. the OS + // cursor briefly escaping to another monitor) cannot teleport the lens. cx_ also clamps. + if (dx > t.mon.w) dx = t.mon.w; else if (dx < -t.mon.w) dx = -t.mon.w; + if (dy > t.mon.h) dy = t.mon.h; else if (dy < -t.mon.h) dy = -t.mon.h; + return { cur, curDx, curDy, dx, dy, dragFollow }; +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1727,151 +1879,11 @@ static void RunTick(TickState& t) { ExitInspect(t); } if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); t.lastSetVirtual = pt; } - // Resolve the pan delta. FREE: the OS cursor's own motion since we last placed it - Windows' - // pointer acceleration is already applied, so we auto-match the real cursor (DPI/accel), then - // scale by cursorSensitivity as a speed knob (1.0 = exact match, the default). LOCKED: a game - // has the cursor clipped/recentered, so pan from raw mickeys scaled by the same cursorSensitivity - // (acceleration doesn't apply to relative-mouse game input). INSPECT: the real cursor is frozen - // in place, so its delta is irrelevant; the look point roams from raw mickeys instead. - POINT cur; GetCursorPos(&cur); - int curDx = cur.x - t.lastSetVirtual.x; - int curDy = cur.y - t.lastSetVirtual.y; - t.postInspectHold = wind::PostInspectHoldKeeps(t.postInspectHold && !inspect, curDx != 0 || curDy != 0); - int dx, dy; - bool dragFollow = false; // set in the free render branch below; drives ex.suppressCursorSync - if (inspect) { - // The OS cursor is frozen, so pan the look point from raw mickeys scaled by the - // MEASURED desktop gain (GainLearner, the same replay as the locked path) so the look - // point moves at the same speed/DPI as the desktop cursor. cursorSensitivity stays a - // user multiplier on top; carry the sub-pixel remainder so slow precise motion is not - // quantized away. - const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); - const double g = t.gainLearner.gainFor(inC, dt * 1000.0); - t.inspectPanRemX += rawDx * g * t.cfg.cursorSensitivity; - t.inspectPanRemY += rawDy * g * t.cfg.cursorSensitivity; - dx = (int)t.inspectPanRemX; t.inspectPanRemX -= dx; // truncate toward zero, carry the rest - dy = (int)t.inspectPanRemY; t.inspectPanRemY -= dy; - if (dx != 0 || dy != 0) t.inspectLookMoved = true; - } else { - RECT clip{}; GetClipCursor(&clip); - // A clip is a lock signal only when it is meaningfully SMALLER than the monitor - // (issue #169): a machine-wide work-area clip (desktop minus taskbar, ~95%) is - // desktop-like, not a game confining the pointer - the old any-clip test made every - // zoomed desktop session run the locked path on this rig. See ClipRectConfines. - bool clipConfined = wind::ClipRectConfines((int)(clip.right - clip.left), - (int)(clip.bottom - clip.top), - t.mon.w, t.mon.h); - int rawMag = std::abs(rawDx) + std::abs(rawDy); - // Mouse edge mode: the pointer is real and free, so pushing it into a screen edge or - // corner is not mouselook. Hide that motion from the lock tell (see PointerPinnedAtEdge). - if (t.cfg.mouseAlign == 1 && t.viewDetached && !clipConfined && - wind::PointerPinnedAtEdge(cur.x, cur.y, clip.left, clip.top, clip.right, clip.bottom)) - rawMag = 0; - bool locked = t.detector.update(clipConfined, - rawMag, - std::abs(curDx) + std::abs(curDy), - t.cfg.warpLock != 0, cur.x, cur.y); - // lockApps (issue #221): listed foreground exe = locked outright, no heuristics. - // The list IS the feature (empty = off); warpLock=1 adds the smart tells globally. - // MUST force through the DETECTOR, not just this tick's local: the free-cursor gate - // reads t.detector.locked() below, and a local-only force left the transform view - // pinned to the warped pointer (field regression: the list "did nothing" once the - // zoom-in seeding was scoped behind warpLock - gameplay had been riding the seed). - bool lockAppFg = false; - if (!t.cfg.lockApps.empty()) { - RefreshFgCache(t, GetForegroundWindow()); // per-HWND cache: no OpenProcess every tick - lockAppFg = t.fgCacheLockApp; - } - const bool forcedLock = t.cfg.lockForce != 0 || lockAppFg; - if (forcedLock && !locked) { - t.detector.seedLock(); - locked = true; - } - if (locked != t.prevDetLocked) { - // Field diagnosis (issue #221): which tell engaged, and when, in wind-core.log. - wind::Log(wind::LogLevel::Info, "lock", "detector %s%s lvl=%.2f", - locked ? "LOCKED" : "free", - (locked && t.detector.warpLocked()) ? " (warp-anchor)" : "", lvl); - t.prevDetLocked = locked; - } - // A shown pointer is a free pointer in a transform session (LockApplies): a game's - // menus keep DWM centring, its mouselook (pointer hidden) keeps the locked pan. The - // legacy sprite session (txNativeCursor=0, #463) keeps the old rule, like render. - { - const auto* tmLock = dynamic_cast(t.model); - const bool transformTx = tmLock != nullptr && tmLock->nativeSession(); - bool showing = false; - if (locked && transformTx) { - CURSORINFO ci{}; ci.cbSize = sizeof(ci); - showing = GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING) && ci.hCursor != nullptr; - } - const bool applies = wind::LockApplies(locked, transformTx, showing); - const bool freed = locked && !applies; - if (freed != t.lockFreed) - wind::Log(wind::LogLevel::Info, "lock", "%s lvl=%.2f", - freed ? "pointer shown: free (native cursor)" : "lock applies", lvl); - t.lockFreed = freed; - t.lockEff = applies; - locked = applies; - } - if (locked) { - // LOCKED pan at the TRUE desktop-cursor speed (issue: "cursor slower zoomed - // in DOOM"). Not modelled - MEASURED: free ticks record the OS's own in->out - // ratio per speed (gain_learner.h) and this replays it. Modelling was tried - // twice and missed both ways (raw = too slow, cooked curve = too fast) because - // a per-packet speed estimate cannot see WM_INPUT coalescing. - if (t.cfg.lockedBallistics != 0) { - const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); - const double g = t.gainLearner.gainFor(inC, dt * 1000.0); - t.lockedPanRemX += rawDx * g * t.cfg.cursorSensitivity; - t.lockedPanRemY += rawDy * g * t.cfg.cursorSensitivity; - dx = (int)t.lockedPanRemX; t.lockedPanRemX -= dx; - dy = (int)t.lockedPanRemY; t.lockedPanRemY -= dy; - } else { - dx = (int)std::lround(rawDx * t.cfg.cursorSensitivity); - dy = (int)std::lround(rawDy * t.cfg.cursorSensitivity); - t.lockedPanRemX = 0.0; t.lockedPanRemY = 0.0; - } - } else { - // Drag-follow (issue #169): while a mouse button is physically held, the pointer IS - // the interaction (window drag, text selection) - the per-tick weld would fight the - // hand and the dragged content flickers between the two positions. Suspend the weld - // (ex.suppressCursorSync below) and follow the pointer 1:1 unscaled: scaling would - // desync the lens from the pointer that owns the drag. The press itself landed - // under the welded cursor (the weld was live until the button went down), and the - // release lands where the pointer and the dragged content both are - correct by - // construction. Weld resumes on release. Only the render engine welds a free session - // (a transform one never does), so this path matters for it alone. - const bool anyButtonDown = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) || - (GetAsyncKeyState(VK_RBUTTON) & 0x8000) || - (GetAsyncKeyState(VK_MBUTTON) & 0x8000); - // FREE tick: both ends of the OS pointer pipeline are visible right here - - // rawDx/rawDy went in, curDx/curDy came out - so teach the learner the REAL - // ballistics at this speed. Gated on no confining clip: a clamped cursor - // under-reports output and would teach a too-low gain. - // Also skip a pointer pinned against the clip bound: it cannot move, so the output - // under-reports exactly like a confined clip (modest downward bias otherwise). - if (!clipConfined && (std::abs(rawDx) + std::abs(rawDy)) >= wind::GainLearner::kMinCounts && - !wind::PointerPinnedAtEdge(cur.x, cur.y, clip.left, clip.top, clip.right, clip.bottom)) { - const double dtMs_ = dt * 1000.0; - const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); - const double outC = std::sqrt((double)curDx * curDx + (double)curDy * curDy); - t.gainLearner.observe(inC, outC, dtMs_); - } - dragFollow = wind::ShouldDragFollow(locked, inspect, anyButtonDown); - if (dragFollow) { - dx = curDx; - dy = curDy; - } else { - dx = (int)std::lround(curDx * t.cfg.cursorSensitivity); // auto-matched OS delta, speed-scaled - dy = (int)std::lround(curDy * t.cfg.cursorSensitivity); - } - } - } - // Defensive: bound one tick's pan to the monitor span so a stray cursor jump (e.g. the OS - // cursor briefly escaping to another monitor) cannot teleport the lens. cx_ also clamps. - if (dx > t.mon.w) dx = t.mon.w; else if (dx < -t.mon.w) dx = -t.mon.w; - if (dy > t.mon.h) dy = t.mon.h; else if (dy < -t.mon.h) dy = -t.mon.h; + const PanDelta pd = ResolvePanDelta(t, inspect, lvl, dt, rawDx, rawDy); + POINT cur = pd.cur; + const int curDx = pd.curDx, curDy = pd.curDy; + const int dx = pd.dx, dy = pd.dy; + const bool dragFollow = pd.dragFollow; // Foreground facts for this tick, read ONCE and reused by the pan wall, the perf levers, // and the hybrid instant switch below (GetForegroundWindow + window queries per tick add // up at 144Hz, and split reads can disagree mid-tick). From a8fdc83837b660755c2af266e93e81b12bbf6d25 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:59:34 +0200 Subject: [PATCH 13/31] refactor(tick): extract ReadForegroundAndArmWall from RunTick Move-only: the per-tick foreground facts, the MPO pan wall / write clamp arming, the device-lost backstop stamp and the per-tick launch-cover tracking move verbatim into ReadForegroundAndArmWall (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 121 ++++++++++++++++++++++++++++----------------------- 1 file changed, 66 insertions(+), 55 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 3f77704e..e404bc96 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1843,6 +1843,68 @@ static PanDelta ResolvePanDelta(TickState& t, bool inspect, double lvl, double d return { cur, curDx, curDy, dx, dy, dragFollow }; } +// Active tick: the foreground facts (read once per tick), the MPO pan wall and write clamp, the +// device-lost backstop stamp and the launch-quiesce cover tracking. +struct TickForeground { HWND fgTick; bool fsCover, fgBorderless; bool wallNeeded, liftIfTransform; }; +static TickForeground ReadForegroundAndArmWall(TickState& t, double lvl) { + // Foreground facts for this tick, read ONCE and reused by the pan wall, the perf levers, + // and the hybrid instant switch below (GetForegroundWindow + window queries per tick add + // up at 144Hz, and split reads can disagree mid-tick). + HWND fgTick = GetForegroundWindow(); + const bool fsCover = ForegroundCoversMonitor(t.mon); + const bool fgBorderless = fgTick && !(GetWindowLongPtrW(fgTick, GWL_STYLE) & WS_CAPTION); + // Pan wall (issue #148) - EVERY transform session on an MPO-enabled machine, not just + // games (issue #197). The driver packs DWM's magnification translation into a 16-bit + // overlay-plane field, so |src*level| > 32767 wraps and takes the compositor or the + // driver down. #148 assumed only fullscreen GAMES ride overlay planes; the field + // disproved it - a Mica-backdrop BROWSER at high zoom crashed dwm.exe in dwmcore.dll + // five times in twenty minutes. Desktop-class windows get planes too, so the guard is + // keyed to the ENGINE and the MPO boot state alone. + auto* tmWall = dynamic_cast(t.model); + const bool transformGame = tmWall != nullptr && fsCover && fgBorderless; + const bool mpoExposed = tmWall != nullptr && !g_mpoDisabled; + // SAMPLING MODE DECIDES THE CRASH PATH (field 2026-08-29, issue #242; refines #148/#191). + // Three TDR episodes (LiveKernelEvent 117, nvlddmkm storms) at 25x bottom-right on the + // plain DESKTOP, every one under NEAREST sampling with the MPO ghost verifiably shown and + // settled - the #191 "ghost settled -> no plane -> no 16-bit field" lift is FALSIFIED for + // nearest: the field lives in the nearest magnification path itself (plausibly DWM handing + // the scale+pan to plane hardware), which no window geometry can demote. SMOOTH sampling + // takes the shader/float path: the same corner at the same level survived repeated A/B/A + // on this rig, and native Magnifier (smooth by default) survives it too. + // So: MPO on + nearest = walls, ALWAYS. Smooth keeps the #191 ghost-gated lift (shown + + // settled >=350ms + rect intact; fail-closed). tdrTest=4 is the field harness override. + const bool nearestSampling = t.cfg.txSamplingMode == 0; + // PLANE-FREE SESSION (issue #369): smooth sampling (the resample property) and the MPO guard + // effect both make the scaled desktop visual need an external layer, so DWM composes the zoomed + // desktop and no hardware plane carries the translation. Measured on an MPO boot: a full-screen + // flip app goes from Hardware Composed: Independent Flip to Composed: Flip at zoom-in in both + // cases. Then the pan walls, the write clamp and the MPO ghost are all unnecessary; the ghost + // alone cost 5-7 ms at every zoom-out (16-21 ms landing stalls). Behind mpoGuardLiftWall until + // the far edge is proven on an MPO boot (shipped on: mpoGuardLiftWall=1). + // What a TRANSFORM session would get, whichever engine is active now: the mid-zoom + // render -> transform switch below hands the same tick to an engine this block did not see. + const bool liftIfTransform = !g_mpoDisabled && t.cfg.mpoGuardLiftWall != 0 && + (!nearestSampling || t.cfg.mpoGuard != 0); + const bool guardLift = mpoExposed && liftIfTransform; + const bool wallNeeded = mpoExposed && t.cfg.tdrTest != 4 && !guardLift && + (nearestSampling || + !(t.cfg.mpoBuster != 0 && tmWall->mpoGhostSettled())); + t.mapper.setMaxSourceLeft(wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0); + // Y wall too (issue #191): |srcY*level| overflows the same 16-bit field - the bottom + // strip above ~16.2x on 2160 was reachable-lethal with the X-only wall. + t.mapper.setMaxSourceTop(wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0); + if (tmWall) { + tmWall->setMpoBusterWanted(mpoExposed && t.cfg.mpoBuster != 0 && !guardLift); + tmWall->setMpoExposed(mpoExposed && !guardLift); // no write clamp under the guard lift + } + if (transformGame) t.lastTransformGameMs = GetTickCount64(); // device-lost backstop window + // Launch quiesce: per-tick cover tracking while zoomed (a mid-session takeover by a + // launching game must still arm the hold). Anchoring and the once-per-process rule live + // in TrackLaunchCover; the hold is consumed via QuiesceHoldActive below. + TrackLaunchCover(t, fgTick, fsCover, fgBorderless); + return { fgTick, fsCover, fgBorderless, wallNeeded, liftIfTransform }; +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1884,61 +1946,10 @@ static void RunTick(TickState& t) { const int curDx = pd.curDx, curDy = pd.curDy; const int dx = pd.dx, dy = pd.dy; const bool dragFollow = pd.dragFollow; - // Foreground facts for this tick, read ONCE and reused by the pan wall, the perf levers, - // and the hybrid instant switch below (GetForegroundWindow + window queries per tick add - // up at 144Hz, and split reads can disagree mid-tick). - HWND fgTick = GetForegroundWindow(); - const bool fsCover = ForegroundCoversMonitor(t.mon); - const bool fgBorderless = fgTick && !(GetWindowLongPtrW(fgTick, GWL_STYLE) & WS_CAPTION); - // Pan wall (issue #148) - EVERY transform session on an MPO-enabled machine, not just - // games (issue #197). The driver packs DWM's magnification translation into a 16-bit - // overlay-plane field, so |src*level| > 32767 wraps and takes the compositor or the - // driver down. #148 assumed only fullscreen GAMES ride overlay planes; the field - // disproved it - a Mica-backdrop BROWSER at high zoom crashed dwm.exe in dwmcore.dll - // five times in twenty minutes. Desktop-class windows get planes too, so the guard is - // keyed to the ENGINE and the MPO boot state alone. - auto* tmWall = dynamic_cast(t.model); - const bool transformGame = tmWall != nullptr && fsCover && fgBorderless; - const bool mpoExposed = tmWall != nullptr && !g_mpoDisabled; - // SAMPLING MODE DECIDES THE CRASH PATH (field 2026-08-29, issue #242; refines #148/#191). - // Three TDR episodes (LiveKernelEvent 117, nvlddmkm storms) at 25x bottom-right on the - // plain DESKTOP, every one under NEAREST sampling with the MPO ghost verifiably shown and - // settled - the #191 "ghost settled -> no plane -> no 16-bit field" lift is FALSIFIED for - // nearest: the field lives in the nearest magnification path itself (plausibly DWM handing - // the scale+pan to plane hardware), which no window geometry can demote. SMOOTH sampling - // takes the shader/float path: the same corner at the same level survived repeated A/B/A - // on this rig, and native Magnifier (smooth by default) survives it too. - // So: MPO on + nearest = walls, ALWAYS. Smooth keeps the #191 ghost-gated lift (shown + - // settled >=350ms + rect intact; fail-closed). tdrTest=4 is the field harness override. - const bool nearestSampling = t.cfg.txSamplingMode == 0; - // PLANE-FREE SESSION (issue #369): smooth sampling (the resample property) and the MPO guard - // effect both make the scaled desktop visual need an external layer, so DWM composes the zoomed - // desktop and no hardware plane carries the translation. Measured on an MPO boot: a full-screen - // flip app goes from Hardware Composed: Independent Flip to Composed: Flip at zoom-in in both - // cases. Then the pan walls, the write clamp and the MPO ghost are all unnecessary; the ghost - // alone cost 5-7 ms at every zoom-out (16-21 ms landing stalls). Behind mpoGuardLiftWall until - // the far edge is proven on an MPO boot (shipped on: mpoGuardLiftWall=1). - // What a TRANSFORM session would get, whichever engine is active now: the mid-zoom - // render -> transform switch below hands the same tick to an engine this block did not see. - const bool liftIfTransform = !g_mpoDisabled && t.cfg.mpoGuardLiftWall != 0 && - (!nearestSampling || t.cfg.mpoGuard != 0); - const bool guardLift = mpoExposed && liftIfTransform; - const bool wallNeeded = mpoExposed && t.cfg.tdrTest != 4 && !guardLift && - (nearestSampling || - !(t.cfg.mpoBuster != 0 && tmWall->mpoGhostSettled())); - t.mapper.setMaxSourceLeft(wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0); - // Y wall too (issue #191): |srcY*level| overflows the same 16-bit field - the bottom - // strip above ~16.2x on 2160 was reachable-lethal with the X-only wall. - t.mapper.setMaxSourceTop(wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0); - if (tmWall) { - tmWall->setMpoBusterWanted(mpoExposed && t.cfg.mpoBuster != 0 && !guardLift); - tmWall->setMpoExposed(mpoExposed && !guardLift); // no write clamp under the guard lift - } - if (transformGame) t.lastTransformGameMs = GetTickCount64(); // device-lost backstop window - // Launch quiesce: per-tick cover tracking while zoomed (a mid-session takeover by a - // launching game must still arm the hold). Anchoring and the once-per-process rule live - // in TrackLaunchCover; the hold is consumed via QuiesceHoldActive below. - TrackLaunchCover(t, fgTick, fsCover, fgBorderless); + const TickForeground tf = ReadForegroundAndArmWall(t, lvl); + const HWND fgTick = tf.fgTick; + const bool fsCover = tf.fsCover, fgBorderless = tf.fgBorderless; + const bool wallNeeded = tf.wallNeeded, liftIfTransform = tf.liftIfTransform; // FREE CURSOR (issue #205) - native Magnifier's model, measured not assumed. // // tools/mag_formula_probe.ps1 + mag_trackmode_probe.ps1 drove the real Magnifier and read From d9e8ccaa5f259c62a8957746a02e79d75b046e38 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:00:06 +0200 Subject: [PATCH 14/31] refactor(tick): extract MapPointer from RunTick Move-only: the free-cursor decision, pan glide, game-drawn cursor lag, pointer history pin and the mapper step move verbatim into MapPointer (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 185 +++++++++++++++++++++++++++------------------------ 1 file changed, 98 insertions(+), 87 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index e404bc96..121c56ed 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1905,6 +1905,101 @@ static TickForeground ReadForegroundAndArmWall(TickState& t, double lvl) { return { fgTick, fsCover, fgBorderless, wallNeeded, liftIfTransform }; } +// Active tick: the free-cursor decision, the pan glide, the game-drawn cursor lag, pinning the +// mapper to the real pointer, and the mapper step. The glide may move the pointer (cur follows). +struct PointerMap { MapResult r; bool freeCursor, gameCursor; }; +static PointerMap MapPointer(TickState& t, bool inspect, double lvl, double dt, bool fsCover, + POINT& cur, int curDx, int curDy, int dx, int dy) { + // FREE CURSOR (issue #205) - native Magnifier's model, measured not assumed. + // + // tools/mag_formula_probe.ps1 + mag_trackmode_probe.ps1 drove the real Magnifier and read + // back what it wrote via MagGetFullscreenTransform: + // offset = clamp(cursor - screen/(2*level), 0, screen - screen/level), truncated + // and it tracks the pointer CONTINUOUSLY, 1:1 (45/45 twelve-pixel steps moved the view by + // exactly twelve, none left it still). Its view position is a PURE FUNCTION of the current + // cursor position - no integration, no smoothing, no state. + // + // Ours was structurally different: integrate per-tick deltas into a SMOOTHED centre, then + // WELD the pointer to that centre with SetCursorPos. The cursor position then depends on + // the centre and the centre depends on cursor deltas - a feedback loop, which is what + // issue #169 chased and what the long-standing wobble is. Native has no loop to oscillate. + // + // Pinning the mapper to the real cursor each tick reproduces native's formula exactly (the + // mapper already clamps the source rect the same way) and leaves nothing to feed back. + // cursorSensitivity does NOT apply here by construction: the pointer IS the input, so there + // is no delta to scale. + // + // Gated OFF wherever the OS cursor is not the truth: + // - Inspect mode freezes the pointer and pans from raw mickeys, so reading it would pin + // the view solid; + // - a mouselook game clips/recentres the pointer, which is exactly why the locked path + // integrates raw deltas instead (issue #3 / #158). + const bool freeCursor = !inspect && !t.lockEff && + dynamic_cast(t.model) != nullptr; + // (A shell input panel needs nothing special: DWM draws its pointer above the panels and + // keeps it centred, so the hand moves the pointer directly.) + // PAN GLIDE (issue #430, src/pan_glide.h): a soft stop, a few screen px at most. The pointer + // itself eases on and the view follows by DWM centring. curDx/curDy is this tick's HAND motion: during a + // glide the previous tick ended with the baseline on Wind's own step, so any motion here is + // the hand, which (like a button or leaving a plain zoomed desktop pan) ends the glide. + { + const bool btn = ((GetAsyncKeyState(VK_LBUTTON) | GetAsyncKeyState(VK_RBUTTON) | + GetAsyncKeyState(VK_MBUTTON)) & 0x8000) != 0; + const bool glideOk = t.cfg.panGlideMaxPx > 0 && freeCursor && lvl > 1.001 && !fsCover && + !t.viewDetached && !btn; + if (t.glide.active && (!glideOk || curDx != 0 || curDy != 0)) wind::PanGlideCancel(t.glide); + if (!t.glide.active) + wind::PanGlideObserve(t.glide, curDx, curDy, dt, cur.x, cur.y, wind::PanGlideTauS(t.cfg.panGlideMaxPx), + t.cfg.panGlideMaxPx / lvl, glideOk); // the cap is in screen px + double gx = 0.0, gy = 0.0; + if (t.glide.active && wind::PanGlideStep(t.glide, dt, gx, gy)) { + POINT np{ (LONG)std::lround(gx), (LONG)std::lround(gy) }; + const LONG l = t.mon.x, tp = t.mon.y, r = t.mon.x + t.mon.w - 1, b = t.mon.y + t.mon.h - 1; + if (np.x < l || np.x > r || np.y < tp || np.y > b) { // reached the edge: stop there + np.x = (std::min)((std::max)(np.x, l), r); + np.y = (std::min)((std::max)(np.y, tp), b); + wind::PanGlideCancel(t.glide); + } + if (np.x != cur.x || np.y != cur.y) { + SetCursorPos(np.x, np.y); + cur = np; // the tick's baseline ends here, so the next tick reads hand motion only + } + } + } + // GAME-DRAWN CURSOR (issue #443, src/game_cursor.h): a covering app that hides the pointer + // draws its own cursor about a display frame late, so the view follows the pointer that late + // (and Wind, not DWM centring, writes it). The pointer itself is untouched. + bool gameCursor = false; + const double gameLagMs = wind::GameCursorLagMs(t.cfg.gameCursorLagMs, t.hz); + if (freeCursor && fsCover && gameLagMs > 0.0) { + CURSORINFO ci{}; ci.cbSize = sizeof(ci); + const bool showing = !GetCursorInfo(&ci) || (ci.flags & CURSOR_SHOWING) != 0; + const bool byWind = t.cursorHidden || t.cursorHiddenByUs || t.cfg.cursorVisibility == "never"; + gameCursor = wind::GameCursorStep(t.gameCursor, wind::GameDrawsCursor(freeCursor, fsCover, showing, byWind), + curDx != 0 || curDy != 0); + } + if (gameCursor != t.gameCursor) { + wind::Log(wind::LogLevel::Info, "lock", "game-drawn cursor %s (lag %.1f ms) lvl=%.2f", + gameCursor ? "on" : "off", gameLagMs, lvl); + t.gameCursor = gameCursor; + } + if (freeCursor) { + POINT cp; + if (GetCursorPos(&cp)) { + LARGE_INTEGER qc; QueryPerformanceCounter(&qc); + const double nowMs = double(qc.QuadPart) * 1000.0 / double(t.freq.QuadPart); + t.ptrHist.push(nowMs, double(cp.x), double(cp.y)); + double px = cp.x, py = cp.y; + if (gameCursor) t.ptrHist.at(nowMs - gameLagMs, px, py); + t.mapper.reset(px - t.mon.x, py - t.mon.y); + } + } else { + t.ptrHist.clear(); + } + MapResult r = t.mapper.update(freeCursor ? 0 : dx, freeCursor ? 0 : dy, lvl); + return { r, freeCursor, gameCursor }; +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -1950,93 +2045,9 @@ static void RunTick(TickState& t) { const HWND fgTick = tf.fgTick; const bool fsCover = tf.fsCover, fgBorderless = tf.fgBorderless; const bool wallNeeded = tf.wallNeeded, liftIfTransform = tf.liftIfTransform; - // FREE CURSOR (issue #205) - native Magnifier's model, measured not assumed. - // - // tools/mag_formula_probe.ps1 + mag_trackmode_probe.ps1 drove the real Magnifier and read - // back what it wrote via MagGetFullscreenTransform: - // offset = clamp(cursor - screen/(2*level), 0, screen - screen/level), truncated - // and it tracks the pointer CONTINUOUSLY, 1:1 (45/45 twelve-pixel steps moved the view by - // exactly twelve, none left it still). Its view position is a PURE FUNCTION of the current - // cursor position - no integration, no smoothing, no state. - // - // Ours was structurally different: integrate per-tick deltas into a SMOOTHED centre, then - // WELD the pointer to that centre with SetCursorPos. The cursor position then depends on - // the centre and the centre depends on cursor deltas - a feedback loop, which is what - // issue #169 chased and what the long-standing wobble is. Native has no loop to oscillate. - // - // Pinning the mapper to the real cursor each tick reproduces native's formula exactly (the - // mapper already clamps the source rect the same way) and leaves nothing to feed back. - // cursorSensitivity does NOT apply here by construction: the pointer IS the input, so there - // is no delta to scale. - // - // Gated OFF wherever the OS cursor is not the truth: - // - Inspect mode freezes the pointer and pans from raw mickeys, so reading it would pin - // the view solid; - // - a mouselook game clips/recentres the pointer, which is exactly why the locked path - // integrates raw deltas instead (issue #3 / #158). - const bool freeCursor = !inspect && !t.lockEff && - dynamic_cast(t.model) != nullptr; - // (A shell input panel needs nothing special: DWM draws its pointer above the panels and - // keeps it centred, so the hand moves the pointer directly.) - // PAN GLIDE (issue #430, src/pan_glide.h): a soft stop, a few screen px at most. The pointer - // itself eases on and the view follows by DWM centring. curDx/curDy is this tick's HAND motion: during a - // glide the previous tick ended with the baseline on Wind's own step, so any motion here is - // the hand, which (like a button or leaving a plain zoomed desktop pan) ends the glide. - { - const bool btn = ((GetAsyncKeyState(VK_LBUTTON) | GetAsyncKeyState(VK_RBUTTON) | - GetAsyncKeyState(VK_MBUTTON)) & 0x8000) != 0; - const bool glideOk = t.cfg.panGlideMaxPx > 0 && freeCursor && lvl > 1.001 && !fsCover && - !t.viewDetached && !btn; - if (t.glide.active && (!glideOk || curDx != 0 || curDy != 0)) wind::PanGlideCancel(t.glide); - if (!t.glide.active) - wind::PanGlideObserve(t.glide, curDx, curDy, dt, cur.x, cur.y, wind::PanGlideTauS(t.cfg.panGlideMaxPx), - t.cfg.panGlideMaxPx / lvl, glideOk); // the cap is in screen px - double gx = 0.0, gy = 0.0; - if (t.glide.active && wind::PanGlideStep(t.glide, dt, gx, gy)) { - POINT np{ (LONG)std::lround(gx), (LONG)std::lround(gy) }; - const LONG l = t.mon.x, tp = t.mon.y, r = t.mon.x + t.mon.w - 1, b = t.mon.y + t.mon.h - 1; - if (np.x < l || np.x > r || np.y < tp || np.y > b) { // reached the edge: stop there - np.x = (std::min)((std::max)(np.x, l), r); - np.y = (std::min)((std::max)(np.y, tp), b); - wind::PanGlideCancel(t.glide); - } - if (np.x != cur.x || np.y != cur.y) { - SetCursorPos(np.x, np.y); - cur = np; // the tick's baseline ends here, so the next tick reads hand motion only - } - } - } - // GAME-DRAWN CURSOR (issue #443, src/game_cursor.h): a covering app that hides the pointer - // draws its own cursor about a display frame late, so the view follows the pointer that late - // (and Wind, not DWM centring, writes it). The pointer itself is untouched. - bool gameCursor = false; - const double gameLagMs = wind::GameCursorLagMs(t.cfg.gameCursorLagMs, t.hz); - if (freeCursor && fsCover && gameLagMs > 0.0) { - CURSORINFO ci{}; ci.cbSize = sizeof(ci); - const bool showing = !GetCursorInfo(&ci) || (ci.flags & CURSOR_SHOWING) != 0; - const bool byWind = t.cursorHidden || t.cursorHiddenByUs || t.cfg.cursorVisibility == "never"; - gameCursor = wind::GameCursorStep(t.gameCursor, wind::GameDrawsCursor(freeCursor, fsCover, showing, byWind), - curDx != 0 || curDy != 0); - } - if (gameCursor != t.gameCursor) { - wind::Log(wind::LogLevel::Info, "lock", "game-drawn cursor %s (lag %.1f ms) lvl=%.2f", - gameCursor ? "on" : "off", gameLagMs, lvl); - t.gameCursor = gameCursor; - } - if (freeCursor) { - POINT cp; - if (GetCursorPos(&cp)) { - LARGE_INTEGER qc; QueryPerformanceCounter(&qc); - const double nowMs = double(qc.QuadPart) * 1000.0 / double(t.freq.QuadPart); - t.ptrHist.push(nowMs, double(cp.x), double(cp.y)); - double px = cp.x, py = cp.y; - if (gameCursor) t.ptrHist.at(nowMs - gameLagMs, px, py); - t.mapper.reset(px - t.mon.x, py - t.mon.y); - } - } else { - t.ptrHist.clear(); - } - MapResult r = t.mapper.update(freeCursor ? 0 : dx, freeCursor ? 0 : dy, lvl); + const PointerMap pm = MapPointer(t, inspect, lvl, dt, fsCover, cur, curDx, curDy, dx, dy); + const bool freeCursor = pm.freeCursor, gameCursor = pm.gameCursor; + MapResult r = pm.r; // --- Tracking (issue #276): caret / focus own the view; the pointer is never moved. --- // fsCover (read once above, see the "Foreground facts for this tick" comment) is the // borderless-fullscreen-game tell; reused here rather than a second ForegroundCoversMonitor From d717b6ecda801946bab921d915ad7f7a94b5f27e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:00:43 +0200 Subject: [PATCH 15/31] refactor(tick): extract StepTracking from RunTick Move-only: caret/focus tracking, keyboard panning and the view-owner step move verbatim into StepTracking; it builds its own comboHeld from the tick's kbHook read, so RunTick no longer needs one (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 311 ++++++++++++++++++++++++++------------------------- 1 file changed, 159 insertions(+), 152 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 121c56ed..3d0d76f2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2000,6 +2000,164 @@ static PointerMap MapPointer(TickState& t, bool inspect, double lvl, double dt, return { r, freeCursor, gameCursor }; } +// Active tick: caret/focus tracking, keyboard panning and the view owner. May detach the view, +// warp the pointer into it (cur follows) and replace the mapped result r. +static void StepTracking(TickState& t, double lvl, bool inspect, bool fsCover, bool panArmed, bool kbHook, + double dt, bool wallNeeded, POINT& cur, int curDx, int curDy, MapResult& r) { + auto comboHeld = [&](int vk, int mods) { return BindComboHeld(kbHook, vk, mods); }; + // --- Tracking (issue #276): caret / focus own the view; the pointer is never moved. --- + // fsCover (read once above, see the "Foreground facts for this tick" comment) is the + // borderless-fullscreen-game tell; reused here rather than a second ForegroundCoversMonitor + // call (it is also what fsGame below aliases). + const bool trackEnabled = lvl > 1.001 && !inspect && !t.lockEff && !fsCover && + (t.cfg.trackCaret != 0 || t.cfg.trackFocus != 0); + { wind::SpanScope span_(wind::kSpanTrack); g_track.setActive(trackEnabled, t.cfg.trackCaret != 0, t.cfg.trackFocus != 0, t.cfg.trackLog != 0); } + if (t.cfg.trackLog) { // #326: why tracking is on or off, logged on every change + const int bits = (lvl > 1.001 ? 1 : 0) | (inspect ? 4 : 0) | + (t.lockEff ? 8 : 0) | (fsCover ? 16 : 0); + if (bits != t.diagEnableBits) { + t.diagEnableBits = bits; + wind::Log(wind::LogLevel::Info, "track", "diag enabled=%d zoomed=%d inspect=%d locked=%d fsCover=%d lvl=%.2f", + trackEnabled ? 1 : 0, bits & 1 ? 1 : 0, bits & 4 ? 1 : 0, + bits & 8 ? 1 : 0, bits & 16 ? 1 : 0, lvl); + } + } + // Keyboard panning (#287): only presses the hook swallowed count (a key that went to the app + // at 1x never pans after a zoom-in mid-press); without the hook, the polled combo. + const bool panEnabled = panArmed; + double panDx = 0, panDy = 0; + if (panEnabled) { + const bool kb = g_input.kbHookActive(); + auto panHeld = [&](int vk, int mods) { return vk && (kb ? g_input.keySwallowed(vk) : comboHeld(vk, mods)); }; + bool held[4] = { panHeld(t.cfg.panLeftVk, t.cfg.panLeftMods), panHeld(t.cfg.panRightVk, t.cfg.panRightMods), + panHeld(t.cfg.panUpVk, t.cfg.panUpMods), panHeld(t.cfg.panDownVk, t.cfg.panDownMods) }; + // A tap that went down and up between two samples counts as held for this step. + for (int i = 0; i < 4; ++i) if (g_input.drainPanPresses(i) > 0) held[i] = true; + t.keyPan.step(held, (dt > 0.05 ? 0.05 : dt) * 1000.0, lvl, t.mon.w, t.mon.h, t.cfg.panSpeed, panDx, panDy); + } else { + t.keyPan.reset(); + } + { + wind::ViewOwnerInputs vi; + vi.enabled = trackEnabled || panEnabled; + vi.panning = panEnabled && t.keyPan.active(); + vi.trackActive = trackEnabled; + vi.trackCaret = t.cfg.trackCaret != 0; vi.trackFocus = t.cfg.trackFocus != 0; + vi.mouseDx = curDx; vi.mouseDy = curDy; + vi.buttonDown = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) || + (GetAsyncKeyState(VK_RBUTTON) & 0x8000) || + (GetAsyncKeyState(VK_MBUTTON) & 0x8000); + const unsigned long long nowMs = GetTickCount64(); + if (vi.buttonDown) t.lastButtonMs = nowMs; + { const unsigned long long edge = g_input.lastButtonEdgeMs(); if (edge > t.lastButtonMs) t.lastButtonMs = edge; } + vi.msSinceButton = t.lastButtonMs ? double(nowMs - t.lastButtonMs) : 1e9; + // Keyboard-driven only (#289). The hook and Raw Input both stamp the clock, so a suspended + // hook no longer switches the gate off. No stamp at all and no hook: no information, no gate. + const unsigned long long lastKey = g_input.lastAnyKeyDownMs(); + vi.msSinceKey = lastKey ? double(nowMs - lastKey) : (g_input.kbHookActive() ? 1e9 : 0.0); + // #328: a key after the last mouse button means typing, so the click quiet period ends early. + // Only a fresh non-modifier key down counts: a Ctrl/Shift release after a Ctrl/Shift+click, + // or auto-repeat, is not typing (review #349). + vi.keyAfterButton = wind::KeyAfterButton(g_input.lastTypingKeyDownMs(), t.lastButtonMs); + vi.dtMs = dt * 1000.0; + { wind::SpanScope span_(wind::kSpanTrack); vi.snap = g_track.snapshot(); } + const wind::ViewOwner was = t.viewOwner.owner; + const unsigned diagTargetSeqBefore = t.viewOwner.target.seq; + const wind::ViewOwner owner = wind::StepViewOwner(t.viewOwner, vi); + if (t.cfg.trackLog && vi.snap.seq != t.diagSeq) { // #326: every caret/focus event, with the gate inputs + t.diagSeq = vi.snap.seq; + const bool latched = t.viewOwner.target.seq == vi.snap.seq && t.viewOwner.target.seq != diagTargetSeqBefore; + double ax = -1, ay = -1; + const wind::TrackRect drc{ vi.snap.l - t.mon.x, vi.snap.t - t.mon.y, vi.snap.r - t.mon.x, vi.snap.b - t.mon.y }; + wind::TrackTargetCenter(drc, t.viewCx, t.viewCy, lvl, t.mon.w, t.mon.h, t.cfg.trackAlign, t.cfg.trackMarginPct, ax, ay); + static const char* kOwn[] = { "mouse", "caret", "focus", "keys" }; + wind::Log(wind::LogLevel::Info, "track", + "diag evt seq=%u kind=%d rect=%.0f,%.0f-%.0f,%.0f owner=%s latched=%d msKey=%.0f msBtn=%.0f en=%d lvl=%.2f view=%.0f,%.0f (%s) aim=%.0f,%.0f viewW=%.0f", + vi.snap.seq, (int)vi.snap.kind, drc.l, drc.t, drc.r, drc.b, kOwn[(int)owner], latched ? 1 : 0, + vi.msSinceKey, vi.msSinceButton, vi.enabled ? 1 : 0, lvl, + t.viewDetached ? t.viewCx : r.centerX, t.viewDetached ? t.viewCy : r.centerY, + t.viewDetached ? "detached" : "mouse", ax, ay, t.mon.w / lvl); + } + if (owner != was && t.cfg.trackLog) { + static const char* kName[] = { "mouse", "caret", "focus", "keys" }; + wind::Log(wind::LogLevel::Info, "track", "view %s -> %s%s", kName[(int)was], kName[(int)owner], + t.viewOwner.warpPointer ? " (pointer placed in the view)" : ""); + } + if (owner == wind::ViewOwner::Keys) { + // Keyboard panning (#287): the view moves by the KeyPan delta, the pointer stays put + // until the mouse moves (then it comes to the view, the warpPointer branch below). + // From a centred view seed at the pointer's view; mouse edge mode's detached centre + // is already the real view (seeding from the mapper there jumped the view). + if (was == wind::ViewOwner::Mouse && !t.viewDetached) { t.viewCx = r.centerX; t.viewCy = r.centerY; } + t.viewVx = 0; t.viewVy = 0; + const double hw = t.mon.w / (2.0 * lvl), hh = t.mon.h / (2.0 * lvl); + double nx = t.viewCx + panDx, ny = t.viewCy + panDy; + // The MPO wall (#148/#242) caps the source left/top like mouse edge mode does. + const double wall = wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0; + if (wall >= 0) { if (nx - hw > wall) nx = wall + hw; if (ny - hh > wall) ny = wall + hh; } + nx = (std::min)((std::max)(nx, hw), t.mon.w - hw); + ny = (std::min)((std::max)(ny, hh), t.mon.h - hh); + t.viewCx = nx; t.viewCy = ny; + r = wind::DetachedMap(t.viewCx, t.viewCy, cur.x - t.mon.x, cur.y - t.mon.y, lvl, t.mon.w, t.mon.h); + t.mapper.reset(t.viewCx, t.viewCy); + t.lastSetVirtual = cur; + t.viewDetached = true; + } else if (owner != wind::ViewOwner::Mouse) { + if (was == wind::ViewOwner::Mouse && !t.viewDetached) { t.viewCx = r.centerX; t.viewCy = r.centerY; } // glide from where we are + const double ptrX = cur.x - t.mon.x, ptrY = cur.y - t.mon.y; + double tx = t.viewCx, ty = t.viewCy; + // The LATCHED target: only caret/focus events that passed the gates move the view. + const wind::TrackSnapshot& tg = t.viewOwner.target; + const wind::TrackRect rc{ tg.l - t.mon.x, tg.t - t.mon.y, tg.r - t.mon.x, tg.b - t.mon.y }; + double ox, oy; + if (wind::TrackTargetCenter(rc, t.viewCx, t.viewCy, lvl, t.mon.w, t.mon.h, + t.cfg.trackAlign, t.cfg.trackMarginPct, ox, oy)) { tx = ox; ty = oy; } + if (was == wind::ViewOwner::Mouse) { t.viewVx = 0; t.viewVy = 0; } + t.viewCx = wind::SpringToward(t.viewCx, tx, t.viewVx, vi.dtMs, t.cfg.trackGlideMs); + t.viewCy = wind::SpringToward(t.viewCy, ty, t.viewVy, vi.dtMs, t.cfg.trackGlideMs); + r = wind::DetachedMap(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h); + t.mapper.reset(t.viewCx, t.viewCy); // hybrid switches and the next tick start here + t.lastSetVirtual = cur; // measure the next hand motion from here + t.viewDetached = true; + } else if (t.viewOwner.warpPointer) { + // The mouse moved while the view showed the caret/focus: the POINTER comes to the + // view, the view stays (owner decision, field test 2026-09-29). Centred mode places + // it at the view centre; edge mode (mouseAlign=1) just inside the band, nearest to + // where it was. + const bool edges = t.cfg.mouseAlign == 1; + double px = t.viewCx, py = t.viewCy; + if (edges) + wind::EdgeClampPointer(t.viewCx, t.viewCy, cur.x - t.mon.x, cur.y - t.mon.y, lvl, + t.mon.w, t.mon.h, t.cfg.mouseMarginPct, px, py, CurrentCursorBody(t)); + const int wx = (int)(px + 0.5) + t.mon.x, wy = (int)(py + 0.5) + t.mon.y; + SetCursorPos(wx, wy); + cur.x = wx; cur.y = wy; + t.mapper.reset(edges ? t.viewCx : px, edges ? t.viewCy : py); + t.lastSetVirtual = cur; + r = wind::DetachedMap(t.viewCx, t.viewCy, px, py, lvl, t.mon.w, t.mon.h); + t.viewDetached = edges; // edge mode keeps the view where it is + } else if (t.cfg.mouseAlign == 1 && lvl > 1.001 && !inspect && !t.lockEff) { + // MOUSE EDGE MODE (issue #276 phase 2): the pointer roams freely inside the view and + // the view moves only when it reaches the margin band, just far enough. No weld: + // the pointer is real, so clicks are native. Mouselook (locked) and Inspect keep the + // centred path. + if (!t.viewDetached) { t.viewCx = r.centerX; t.viewCy = r.centerY; } // start from here + const double ptrX = cur.x - t.mon.x, ptrY = cur.y - t.mon.y; + const double wall = wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0; + double ecx, ecy; + wind::EdgePanCenter(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h, + t.cfg.mouseMarginPct, wall, wall, ecx, ecy, CurrentCursorBody(t)); + t.viewCx = ecx; t.viewCy = ecy; // no glide: the hand pushes the view directly + r = wind::DetachedMap(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h); + t.mapper.reset(t.viewCx, t.viewCy); + t.lastSetVirtual = cur; + t.viewDetached = true; + } else { + t.viewDetached = false; + } + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2012,7 +2170,6 @@ static void RunTick(TickState& t) { WatchForegroundAndKbHook(t); const TickInput in = ResolveInputAndZoom(t, dt); const bool kbHook = in.kbHook; - auto comboHeld = [&](int vk, int mods) { return BindComboHeld(kbHook, vk, mods); }; const bool inHeld = in.inHeld, outHeld = in.outHeld; const bool recenter = in.recenter; const double lvl = in.lvl; @@ -2048,157 +2205,7 @@ static void RunTick(TickState& t) { const PointerMap pm = MapPointer(t, inspect, lvl, dt, fsCover, cur, curDx, curDy, dx, dy); const bool freeCursor = pm.freeCursor, gameCursor = pm.gameCursor; MapResult r = pm.r; - // --- Tracking (issue #276): caret / focus own the view; the pointer is never moved. --- - // fsCover (read once above, see the "Foreground facts for this tick" comment) is the - // borderless-fullscreen-game tell; reused here rather than a second ForegroundCoversMonitor - // call (it is also what fsGame below aliases). - const bool trackEnabled = lvl > 1.001 && !inspect && !t.lockEff && !fsCover && - (t.cfg.trackCaret != 0 || t.cfg.trackFocus != 0); - { wind::SpanScope span_(wind::kSpanTrack); g_track.setActive(trackEnabled, t.cfg.trackCaret != 0, t.cfg.trackFocus != 0, t.cfg.trackLog != 0); } - if (t.cfg.trackLog) { // #326: why tracking is on or off, logged on every change - const int bits = (lvl > 1.001 ? 1 : 0) | (inspect ? 4 : 0) | - (t.lockEff ? 8 : 0) | (fsCover ? 16 : 0); - if (bits != t.diagEnableBits) { - t.diagEnableBits = bits; - wind::Log(wind::LogLevel::Info, "track", "diag enabled=%d zoomed=%d inspect=%d locked=%d fsCover=%d lvl=%.2f", - trackEnabled ? 1 : 0, bits & 1 ? 1 : 0, bits & 4 ? 1 : 0, - bits & 8 ? 1 : 0, bits & 16 ? 1 : 0, lvl); - } - } - // Keyboard panning (#287): only presses the hook swallowed count (a key that went to the app - // at 1x never pans after a zoom-in mid-press); without the hook, the polled combo. - const bool panEnabled = panArmed; - double panDx = 0, panDy = 0; - if (panEnabled) { - const bool kb = g_input.kbHookActive(); - auto panHeld = [&](int vk, int mods) { return vk && (kb ? g_input.keySwallowed(vk) : comboHeld(vk, mods)); }; - bool held[4] = { panHeld(t.cfg.panLeftVk, t.cfg.panLeftMods), panHeld(t.cfg.panRightVk, t.cfg.panRightMods), - panHeld(t.cfg.panUpVk, t.cfg.panUpMods), panHeld(t.cfg.panDownVk, t.cfg.panDownMods) }; - // A tap that went down and up between two samples counts as held for this step. - for (int i = 0; i < 4; ++i) if (g_input.drainPanPresses(i) > 0) held[i] = true; - t.keyPan.step(held, (dt > 0.05 ? 0.05 : dt) * 1000.0, lvl, t.mon.w, t.mon.h, t.cfg.panSpeed, panDx, panDy); - } else { - t.keyPan.reset(); - } - { - wind::ViewOwnerInputs vi; - vi.enabled = trackEnabled || panEnabled; - vi.panning = panEnabled && t.keyPan.active(); - vi.trackActive = trackEnabled; - vi.trackCaret = t.cfg.trackCaret != 0; vi.trackFocus = t.cfg.trackFocus != 0; - vi.mouseDx = curDx; vi.mouseDy = curDy; - vi.buttonDown = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) || - (GetAsyncKeyState(VK_RBUTTON) & 0x8000) || - (GetAsyncKeyState(VK_MBUTTON) & 0x8000); - const unsigned long long nowMs = GetTickCount64(); - if (vi.buttonDown) t.lastButtonMs = nowMs; - { const unsigned long long edge = g_input.lastButtonEdgeMs(); if (edge > t.lastButtonMs) t.lastButtonMs = edge; } - vi.msSinceButton = t.lastButtonMs ? double(nowMs - t.lastButtonMs) : 1e9; - // Keyboard-driven only (#289). The hook and Raw Input both stamp the clock, so a suspended - // hook no longer switches the gate off. No stamp at all and no hook: no information, no gate. - const unsigned long long lastKey = g_input.lastAnyKeyDownMs(); - vi.msSinceKey = lastKey ? double(nowMs - lastKey) : (g_input.kbHookActive() ? 1e9 : 0.0); - // #328: a key after the last mouse button means typing, so the click quiet period ends early. - // Only a fresh non-modifier key down counts: a Ctrl/Shift release after a Ctrl/Shift+click, - // or auto-repeat, is not typing (review #349). - vi.keyAfterButton = wind::KeyAfterButton(g_input.lastTypingKeyDownMs(), t.lastButtonMs); - vi.dtMs = dt * 1000.0; - { wind::SpanScope span_(wind::kSpanTrack); vi.snap = g_track.snapshot(); } - const wind::ViewOwner was = t.viewOwner.owner; - const unsigned diagTargetSeqBefore = t.viewOwner.target.seq; - const wind::ViewOwner owner = wind::StepViewOwner(t.viewOwner, vi); - if (t.cfg.trackLog && vi.snap.seq != t.diagSeq) { // #326: every caret/focus event, with the gate inputs - t.diagSeq = vi.snap.seq; - const bool latched = t.viewOwner.target.seq == vi.snap.seq && t.viewOwner.target.seq != diagTargetSeqBefore; - double ax = -1, ay = -1; - const wind::TrackRect drc{ vi.snap.l - t.mon.x, vi.snap.t - t.mon.y, vi.snap.r - t.mon.x, vi.snap.b - t.mon.y }; - wind::TrackTargetCenter(drc, t.viewCx, t.viewCy, lvl, t.mon.w, t.mon.h, t.cfg.trackAlign, t.cfg.trackMarginPct, ax, ay); - static const char* kOwn[] = { "mouse", "caret", "focus", "keys" }; - wind::Log(wind::LogLevel::Info, "track", - "diag evt seq=%u kind=%d rect=%.0f,%.0f-%.0f,%.0f owner=%s latched=%d msKey=%.0f msBtn=%.0f en=%d lvl=%.2f view=%.0f,%.0f (%s) aim=%.0f,%.0f viewW=%.0f", - vi.snap.seq, (int)vi.snap.kind, drc.l, drc.t, drc.r, drc.b, kOwn[(int)owner], latched ? 1 : 0, - vi.msSinceKey, vi.msSinceButton, vi.enabled ? 1 : 0, lvl, - t.viewDetached ? t.viewCx : r.centerX, t.viewDetached ? t.viewCy : r.centerY, - t.viewDetached ? "detached" : "mouse", ax, ay, t.mon.w / lvl); - } - if (owner != was && t.cfg.trackLog) { - static const char* kName[] = { "mouse", "caret", "focus", "keys" }; - wind::Log(wind::LogLevel::Info, "track", "view %s -> %s%s", kName[(int)was], kName[(int)owner], - t.viewOwner.warpPointer ? " (pointer placed in the view)" : ""); - } - if (owner == wind::ViewOwner::Keys) { - // Keyboard panning (#287): the view moves by the KeyPan delta, the pointer stays put - // until the mouse moves (then it comes to the view, the warpPointer branch below). - // From a centred view seed at the pointer's view; mouse edge mode's detached centre - // is already the real view (seeding from the mapper there jumped the view). - if (was == wind::ViewOwner::Mouse && !t.viewDetached) { t.viewCx = r.centerX; t.viewCy = r.centerY; } - t.viewVx = 0; t.viewVy = 0; - const double hw = t.mon.w / (2.0 * lvl), hh = t.mon.h / (2.0 * lvl); - double nx = t.viewCx + panDx, ny = t.viewCy + panDy; - // The MPO wall (#148/#242) caps the source left/top like mouse edge mode does. - const double wall = wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0; - if (wall >= 0) { if (nx - hw > wall) nx = wall + hw; if (ny - hh > wall) ny = wall + hh; } - nx = (std::min)((std::max)(nx, hw), t.mon.w - hw); - ny = (std::min)((std::max)(ny, hh), t.mon.h - hh); - t.viewCx = nx; t.viewCy = ny; - r = wind::DetachedMap(t.viewCx, t.viewCy, cur.x - t.mon.x, cur.y - t.mon.y, lvl, t.mon.w, t.mon.h); - t.mapper.reset(t.viewCx, t.viewCy); - t.lastSetVirtual = cur; - t.viewDetached = true; - } else if (owner != wind::ViewOwner::Mouse) { - if (was == wind::ViewOwner::Mouse && !t.viewDetached) { t.viewCx = r.centerX; t.viewCy = r.centerY; } // glide from where we are - const double ptrX = cur.x - t.mon.x, ptrY = cur.y - t.mon.y; - double tx = t.viewCx, ty = t.viewCy; - // The LATCHED target: only caret/focus events that passed the gates move the view. - const wind::TrackSnapshot& tg = t.viewOwner.target; - const wind::TrackRect rc{ tg.l - t.mon.x, tg.t - t.mon.y, tg.r - t.mon.x, tg.b - t.mon.y }; - double ox, oy; - if (wind::TrackTargetCenter(rc, t.viewCx, t.viewCy, lvl, t.mon.w, t.mon.h, - t.cfg.trackAlign, t.cfg.trackMarginPct, ox, oy)) { tx = ox; ty = oy; } - if (was == wind::ViewOwner::Mouse) { t.viewVx = 0; t.viewVy = 0; } - t.viewCx = wind::SpringToward(t.viewCx, tx, t.viewVx, vi.dtMs, t.cfg.trackGlideMs); - t.viewCy = wind::SpringToward(t.viewCy, ty, t.viewVy, vi.dtMs, t.cfg.trackGlideMs); - r = wind::DetachedMap(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h); - t.mapper.reset(t.viewCx, t.viewCy); // hybrid switches and the next tick start here - t.lastSetVirtual = cur; // measure the next hand motion from here - t.viewDetached = true; - } else if (t.viewOwner.warpPointer) { - // The mouse moved while the view showed the caret/focus: the POINTER comes to the - // view, the view stays (owner decision, field test 2026-09-29). Centred mode places - // it at the view centre; edge mode (mouseAlign=1) just inside the band, nearest to - // where it was. - const bool edges = t.cfg.mouseAlign == 1; - double px = t.viewCx, py = t.viewCy; - if (edges) - wind::EdgeClampPointer(t.viewCx, t.viewCy, cur.x - t.mon.x, cur.y - t.mon.y, lvl, - t.mon.w, t.mon.h, t.cfg.mouseMarginPct, px, py, CurrentCursorBody(t)); - const int wx = (int)(px + 0.5) + t.mon.x, wy = (int)(py + 0.5) + t.mon.y; - SetCursorPos(wx, wy); - cur.x = wx; cur.y = wy; - t.mapper.reset(edges ? t.viewCx : px, edges ? t.viewCy : py); - t.lastSetVirtual = cur; - r = wind::DetachedMap(t.viewCx, t.viewCy, px, py, lvl, t.mon.w, t.mon.h); - t.viewDetached = edges; // edge mode keeps the view where it is - } else if (t.cfg.mouseAlign == 1 && lvl > 1.001 && !inspect && !t.lockEff) { - // MOUSE EDGE MODE (issue #276 phase 2): the pointer roams freely inside the view and - // the view moves only when it reaches the margin band, just far enough. No weld: - // the pointer is real, so clicks are native. Mouselook (locked) and Inspect keep the - // centred path. - if (!t.viewDetached) { t.viewCx = r.centerX; t.viewCy = r.centerY; } // start from here - const double ptrX = cur.x - t.mon.x, ptrY = cur.y - t.mon.y; - const double wall = wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0; - double ecx, ecy; - wind::EdgePanCenter(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h, - t.cfg.mouseMarginPct, wall, wall, ecx, ecy, CurrentCursorBody(t)); - t.viewCx = ecx; t.viewCy = ecy; // no glide: the hand pushes the view directly - r = wind::DetachedMap(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h); - t.mapper.reset(t.viewCx, t.viewCy); - t.lastSetVirtual = cur; - t.viewDetached = true; - } else { - t.viewDetached = false; - } - } + StepTracking(t, lvl, inspect, fsCover, panArmed, kbHook, dt, wallNeeded, cur, curDx, curDy, r); // Dead-zone probe (probeClicks=1, diagnostic): the field annotates hover dead zones by // clicking. Plain click = "hover works here" (OK), Ctrl+click = "dead here" (DEAD). Each // click logs every coordinate space in the chain plus what Windows hit-tests at the From 26eacc62a1c5d9f57459cc7d21d38fd080be5776 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:01:11 +0200 Subject: [PATCH 16/31] refactor(tick): extract ProbeClicks from RunTick Move-only: the dead-zone probe (probeClicks) moves verbatim into ProbeClicks (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 107 +++++++++++++++++++++++++++------------------------ 1 file changed, 56 insertions(+), 51 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 3d0d76f2..74833d24 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2158,6 +2158,61 @@ static void StepTracking(TickState& t, double lvl, bool inspect, bool fsCover, b } } +// Active tick: the dead-zone probe (probeClicks=1/2, diagnostic only). +static void ProbeClicks(TickState& t, double lvl, const POINT& cur, const MapResult& r) { + // Dead-zone probe (probeClicks=1, diagnostic): the field annotates hover dead zones by + // clicking. Plain click = "hover works here" (OK), Ctrl+click = "dead here" (DEAD). Each + // click logs every coordinate space in the chain plus what Windows hit-tests at the + // pointer, so a divergence between the weld point, the applied DWM transform, and the + // hit-test target names itself. Zero cost unless the knob is on AND transform is active. + if (t.cfg.probeClicks && dynamic_cast(t.model)) { + // Mode 2: continuous trace (every 4th tick ~36Hz) of the physical pre-weld cursor vs + // the weld target - the physical stream is what pointer-framework apps perceive + // (SetCursorPos emits no pointer frames; rig-proven), so a divergence here IS the + // hover input XAML actually gets. + if (t.cfg.probeClicks == 2 && (++t.probeTraceTick & 3) == 0) { + auto* tw = dynamic_cast(t.model); + wind::Log(wind::LogLevel::Info, "ptrace", + "lvl=%.2f pre=(%ld,%ld) weld=(%d,%d) d=(%ld,%ld) welded=%d src=(%.1f,%.1f) cs=(%.1f,%.1f)", + lvl, cur.x, cur.y, + r.clickDesktopX + t.mon.x, r.clickDesktopY + t.mon.y, + cur.x - (r.clickDesktopX + t.mon.x), cur.y - (r.clickDesktopY + t.mon.y), + tw && tw->weldedLastFrame() ? 1 : 0, + r.srcLeft, r.srcTop, r.cursorScreenX, r.cursorScreenY); + } + const bool lDown = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0; + if (lDown && !t.probePrevLDown) { + const bool dead = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; + POINT cp{}; GetCursorPos(&cp); + RECT clip{}; GetClipCursor(&clip); + float mLvl = 0.f; int mOffX = 0, mOffY = 0; + MagGetFullscreenTransform(&mLvl, &mOffX, &mOffY); + HWND under = WindowFromPoint(cp); + wchar_t ucls[64]{}; if (under) GetClassNameW(under, ucls, 64); + DWORD upid = 0; if (under) GetWindowThreadProcessId(under, &upid); + // Cross-space check: what sits at the SCREEN position where the aim point is + // DISPLAYED (if hover follows this instead of the weld point, spaces are mixed). + POINT sp{ (int)(r.cursorScreenX + 0.5) + t.mon.x, (int)(r.cursorScreenY + 0.5) + t.mon.y }; + HWND underS = WindowFromPoint(sp); + wchar_t scls[64]{}; if (underS) GetClassNameW(underS, scls, 64); + DWORD spid = 0; if (underS) GetWindowThreadProcessId(underS, &spid); + const DWORD ourPid = GetCurrentProcessId(); + wind::Log(wind::LogLevel::Info, "probe", + "%s lvl=%.2f weld=(%d,%d) cur=(%ld,%ld) src=(%.1f,%.1f) center=(%.1f,%.1f) " + "cursorScreen=(%.1f,%.1f) dwm(lvl=%.2f off=%d,%d) clip=(%ld,%ld)-(%ld,%ld) " + "underCur=%ls%s underScreen=%ls%s", + dead ? "DEAD" : "OK", lvl, + r.clickDesktopX + t.mon.x, r.clickDesktopY + t.mon.y, cp.x, cp.y, + r.srcLeft, r.srcTop, r.centerX, r.centerY, + r.cursorScreenX, r.cursorScreenY, mLvl, mOffX, mOffY, + clip.left, clip.top, clip.right, clip.bottom, + ucls, upid == ourPid ? L" (OURS)" : L"", + scls, spid == ourPid ? L" (OURS)" : L""); + } + t.probePrevLDown = lDown; + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2206,57 +2261,7 @@ static void RunTick(TickState& t) { const bool freeCursor = pm.freeCursor, gameCursor = pm.gameCursor; MapResult r = pm.r; StepTracking(t, lvl, inspect, fsCover, panArmed, kbHook, dt, wallNeeded, cur, curDx, curDy, r); - // Dead-zone probe (probeClicks=1, diagnostic): the field annotates hover dead zones by - // clicking. Plain click = "hover works here" (OK), Ctrl+click = "dead here" (DEAD). Each - // click logs every coordinate space in the chain plus what Windows hit-tests at the - // pointer, so a divergence between the weld point, the applied DWM transform, and the - // hit-test target names itself. Zero cost unless the knob is on AND transform is active. - if (t.cfg.probeClicks && dynamic_cast(t.model)) { - // Mode 2: continuous trace (every 4th tick ~36Hz) of the physical pre-weld cursor vs - // the weld target - the physical stream is what pointer-framework apps perceive - // (SetCursorPos emits no pointer frames; rig-proven), so a divergence here IS the - // hover input XAML actually gets. - if (t.cfg.probeClicks == 2 && (++t.probeTraceTick & 3) == 0) { - auto* tw = dynamic_cast(t.model); - wind::Log(wind::LogLevel::Info, "ptrace", - "lvl=%.2f pre=(%ld,%ld) weld=(%d,%d) d=(%ld,%ld) welded=%d src=(%.1f,%.1f) cs=(%.1f,%.1f)", - lvl, cur.x, cur.y, - r.clickDesktopX + t.mon.x, r.clickDesktopY + t.mon.y, - cur.x - (r.clickDesktopX + t.mon.x), cur.y - (r.clickDesktopY + t.mon.y), - tw && tw->weldedLastFrame() ? 1 : 0, - r.srcLeft, r.srcTop, r.cursorScreenX, r.cursorScreenY); - } - const bool lDown = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0; - if (lDown && !t.probePrevLDown) { - const bool dead = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; - POINT cp{}; GetCursorPos(&cp); - RECT clip{}; GetClipCursor(&clip); - float mLvl = 0.f; int mOffX = 0, mOffY = 0; - MagGetFullscreenTransform(&mLvl, &mOffX, &mOffY); - HWND under = WindowFromPoint(cp); - wchar_t ucls[64]{}; if (under) GetClassNameW(under, ucls, 64); - DWORD upid = 0; if (under) GetWindowThreadProcessId(under, &upid); - // Cross-space check: what sits at the SCREEN position where the aim point is - // DISPLAYED (if hover follows this instead of the weld point, spaces are mixed). - POINT sp{ (int)(r.cursorScreenX + 0.5) + t.mon.x, (int)(r.cursorScreenY + 0.5) + t.mon.y }; - HWND underS = WindowFromPoint(sp); - wchar_t scls[64]{}; if (underS) GetClassNameW(underS, scls, 64); - DWORD spid = 0; if (underS) GetWindowThreadProcessId(underS, &spid); - const DWORD ourPid = GetCurrentProcessId(); - wind::Log(wind::LogLevel::Info, "probe", - "%s lvl=%.2f weld=(%d,%d) cur=(%ld,%ld) src=(%.1f,%.1f) center=(%.1f,%.1f) " - "cursorScreen=(%.1f,%.1f) dwm(lvl=%.2f off=%d,%d) clip=(%ld,%ld)-(%ld,%ld) " - "underCur=%ls%s underScreen=%ls%s", - dead ? "DEAD" : "OK", lvl, - r.clickDesktopX + t.mon.x, r.clickDesktopY + t.mon.y, cp.x, cp.y, - r.srcLeft, r.srcTop, r.centerX, r.centerY, - r.cursorScreenX, r.cursorScreenY, mLvl, mOffX, mOffY, - clip.left, clip.top, clip.right, clip.bottom, - ucls, upid == ourPid ? L" (OURS)" : L"", - scls, spid == ourPid ? L" (OURS)" : L""); - } - t.probePrevLDown = lDown; - } + ProbeClicks(t, lvl, cur, r); // Inspect click-to-look-point: the hook swallowed real click(s) and handed us per-button counts // (counts, not a flag, so a fast double-click before this drains isn't lost). Fire a clean ABSOLUTE // click at the crosshair (mapper center = look point) per pending press, so each lands where you From f94cfe2c99d254ee9b103f076404bedfed06b5e4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:01:37 +0200 Subject: [PATCH 17/31] refactor(tick): extract CommitInspectClicks from RunTick Move-only: the Inspect click-to-look-point injection moves verbatim into CommitInspectClicks (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 83 ++++++++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 74833d24..b92d59d9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2213,6 +2213,49 @@ static void ProbeClicks(TickState& t, double lvl, const POINT& cur, const MapRes } } +// Active tick: fire the Inspect clicks the hook swallowed at the look point. +static void CommitInspectClicks(TickState& t, const MapResult& r) { + // Inspect click-to-look-point: the hook swallowed real click(s) and handed us per-button counts + // (counts, not a flag, so a fast double-click before this drains isn't lost). Fire a clean ABSOLUTE + // click at the crosshair (mapper center = look point) per pending press, so each lands where you + // aim, at any zoom. ABSOLUTE coords are immune to the re-freeze SetCursorPos below, and an absolute + // injected move is skipped by the raw accumulator (WM_INPUT ignores MOUSE_MOVE_ABSOLUTE), so the + // look point isn't disturbed. Inspect stays on; the cursor re-freezes at frozenCursor afterwards. + int nLeft = g_input.state().commitLeft.exchange(0); + int nRight = g_input.state().commitRight.exchange(0); + // Game-inspect: drain but DISCARD clicks. A synthesized click over the game window would + // re-activate it (mouse clicks activate), re-engaging mouselook mid-inspect; with the game + // backgrounded and cursorless there is nothing meaningful to click anyway. + if (nLeft + nRight > 0 && !t.inspectGame) { + ClipCursor(nullptr); // release the 1px freeze so the absolute click can reach the look point + t.clickReleaseTicks = TicksAtHz(2, t.hz); // ...and keep it released ~14ms before re-freezing (below) + const VirtualBounds& vb = t.vbounds; // cached at activation; equals the SM_*VIRTUALSCREEN metrics + if (vb.w > 1 && vb.h > 1) { + int lx = r.clickDesktopX + t.mon.x, ly = r.clickDesktopY + t.mon.y; + LONG ax = (LONG)((lx - vb.x) * 65535.0 / (vb.w - 1) + 0.5); + LONG ay = (LONG)((ly - vb.y) * 65535.0 / (vb.h - 1) + 0.5); + auto fireClicks = [&](DWORD downF, DWORD upF, int count) { + for (int k = 0; k < count; ++k) { + INPUT clk[3] = {}; + for (int i = 0; i < 3; ++i) { clk[i].type = INPUT_MOUSE; clk[i].mi.dx = ax; clk[i].mi.dy = ay; + clk[i].mi.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; } // never a click bind (#285) + clk[0].mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; + clk[1].mi.dwFlags = downF | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; + clk[2].mi.dwFlags = upF | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; + SendInput(3, clk, sizeof(INPUT)); + } + }; + fireClicks(MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, nLeft); + fireClicks(MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, nRight); + if (dynamic_cast(t.model)) { + // The injected absolute move races transform writes (issue #148 TDR class), so + // SERIALIZE: skip transform writes for a couple ticks around it (ex.pauseWrites). + t.clickPauseTicks = TicksAtHz(3, t.hz); + } + } + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2262,45 +2305,7 @@ static void RunTick(TickState& t) { MapResult r = pm.r; StepTracking(t, lvl, inspect, fsCover, panArmed, kbHook, dt, wallNeeded, cur, curDx, curDy, r); ProbeClicks(t, lvl, cur, r); - // Inspect click-to-look-point: the hook swallowed real click(s) and handed us per-button counts - // (counts, not a flag, so a fast double-click before this drains isn't lost). Fire a clean ABSOLUTE - // click at the crosshair (mapper center = look point) per pending press, so each lands where you - // aim, at any zoom. ABSOLUTE coords are immune to the re-freeze SetCursorPos below, and an absolute - // injected move is skipped by the raw accumulator (WM_INPUT ignores MOUSE_MOVE_ABSOLUTE), so the - // look point isn't disturbed. Inspect stays on; the cursor re-freezes at frozenCursor afterwards. - int nLeft = g_input.state().commitLeft.exchange(0); - int nRight = g_input.state().commitRight.exchange(0); - // Game-inspect: drain but DISCARD clicks. A synthesized click over the game window would - // re-activate it (mouse clicks activate), re-engaging mouselook mid-inspect; with the game - // backgrounded and cursorless there is nothing meaningful to click anyway. - if (nLeft + nRight > 0 && !t.inspectGame) { - ClipCursor(nullptr); // release the 1px freeze so the absolute click can reach the look point - t.clickReleaseTicks = TicksAtHz(2, t.hz); // ...and keep it released ~14ms before re-freezing (below) - const VirtualBounds& vb = t.vbounds; // cached at activation; equals the SM_*VIRTUALSCREEN metrics - if (vb.w > 1 && vb.h > 1) { - int lx = r.clickDesktopX + t.mon.x, ly = r.clickDesktopY + t.mon.y; - LONG ax = (LONG)((lx - vb.x) * 65535.0 / (vb.w - 1) + 0.5); - LONG ay = (LONG)((ly - vb.y) * 65535.0 / (vb.h - 1) + 0.5); - auto fireClicks = [&](DWORD downF, DWORD upF, int count) { - for (int k = 0; k < count; ++k) { - INPUT clk[3] = {}; - for (int i = 0; i < 3; ++i) { clk[i].type = INPUT_MOUSE; clk[i].mi.dx = ax; clk[i].mi.dy = ay; - clk[i].mi.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; } // never a click bind (#285) - clk[0].mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; - clk[1].mi.dwFlags = downF | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; - clk[2].mi.dwFlags = upF | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; - SendInput(3, clk, sizeof(INPUT)); - } - }; - fireClicks(MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, nLeft); - fireClicks(MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, nRight); - if (dynamic_cast(t.model)) { - // The injected absolute move races transform writes (issue #148 TDR class), so - // SERIALIZE: skip transform writes for a couple ticks around it (ex.pauseWrites). - t.clickPauseTicks = TicksAtHz(3, t.hz); - } - } - } + CommitInspectClicks(t, r); // Fullscreen-game tell for the per-tick perf levers (issue #148): crop the capture copy to // the magnified region (gameCrop), skip the periodic topmost backstop, and optionally cap // our own present rate (gameFpsCap). fgTick/fsCover/fgBorderless were read once above. From a6c224d7bb8f76cc055e74339442f4b2737f2630 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:02:05 +0200 Subject: [PATCH 18/31] refactor(tick): extract InstantHybridSwitch from RunTick Move-only: the mid-session hybrid engine switch moves verbatim into InstantHybridSwitch (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 188 ++++++++++++++++++++++++++------------------------- 1 file changed, 97 insertions(+), 91 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index b92d59d9..227cb420 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2256,6 +2256,102 @@ static void CommitInspectClicks(TickState& t, const MapResult& r) { } } +// Active tick: the hybrid instant switch (re-pick the engine mid-session on a foreground change). +static void InstantHybridSwitch(TickState& t, bool enterActive, bool inspect, HWND fgTick, bool fsCover, + bool fgBorderless, bool liftIfTransform) { + // Instant hybrid switch (always on): re-pick the engine WHILE ZOOMED when the foreground + // changes, handing over mid-session. The controller and mapper are untouched, so the + // zoom level and lens position carry across the swap (render 8x -> tab into a game -> + // transform 8x). Inspect sessions are never switched under. + // Freeze the engine choice while an overlay is in front (see IsOverlayFg). Skipping the + // whole block, rather than just the swap, is deliberate: it leaves wantModel/wantSinceMs + // untouched, so the overlay never becomes the settled candidate and handing foreground back + // is a no-op instead of a second handover. + if (t.mTransform && !enterActive && !inspect && !IsOverlayFg(fgTick)) { + // Same pure predicate as the zoom-in pick (engine_pick.h) - the two sites can no + // longer drift apart. Exe-derived inputs come from the per-HWND cache: this block + // runs every zoomed tick and the process-open per tick was measurable waste. + RefreshFgCache(t, fgTick); + auto* tAvail = dynamic_cast(t.mTransform); + EnginePickInputs pin; + pin.coversMonitor = fsCover; + pin.borderless = fgBorderless; + pin.primaryMonitor = t.mon.x == 0 && t.mon.y == 0; + pin.shellDesktop = t.fgCacheShell; + pin.excluded = t.fgCacheExcluded; + pin.churny = t.fgCacheChurny; + pin.tdrHarness = t.cfg.tdrTest > 0; + pin.desktopTransformOptIn = t.cfg.desktopTransform != 0; + pin.inputTransformOk = tAvail && tAvail->inputTransformAvailable(); + pin.renderLost = RenderDeviceLost(t); + FillCategoryInputs(t, pin); + IMagnifierModel* want = wind::PickTransformFor(t.cfg.model, pin, t.renderStale) ? t.mTransform : t.mRender; + // STICKY (field: the engine flapped render<->transform inside one zoom session, and + // each flip releases and rebuilds DWM's magnification context - a stall every time). + // A real alt-tab still switches; a one-frame wobble in the foreground reads does not. + if (want != t.wantModel) { t.wantModel = want; t.wantSinceMs = GetTickCount64(); } + const bool wantSettled = GetTickCount64() - t.wantSinceMs >= 350; + const bool fgIsStealer = fgTick && fgTick == g_focusStealer; // game-inspect helper holds fg + if (want && want != t.model && wantSettled && !fgIsStealer) { + if (t.restAfterReveal) { // rapid double-switch: settle the previous handover + { wind::SpanScope span_(wind::kSpanActivate); t.restAfterReveal->setActive(false); } + t.restAfterReveal = nullptr; + t.restOverlapTicks = 0; + UpdateColorFilter(t, true, RenderOverlayShown(t), nullptr); // colour follows what is visible + } + IMagnifierModel* old = t.model; + SetSystemCursorHidden(t, old, false); + t.model = want; + // Both engines weld the cursor to the lens point, so the handover is seamless: + // render hides the OS cursor immediately; the transform hides it in its own + // present (level > 1.001) and re-welds at the same point. + if (dynamic_cast(t.model)) SetSystemCursorHidden(t, t.model, true); + else t.transformExe = ExeNameOf(fgTick); // device-lost backstop attribution + { wind::SpanScope span_(wind::kSpanActivate); t.model->onActivate(); } + if (auto* rm = dynamic_cast(t.model)) { + t.revealNeedsComposite = ForegroundCoversMonitor(t.mon); + if (t.revealNeedsComposite) rm->primeReveal(); + t.revealPending = (t.hz > 0 ? t.hz : 60) / 4; + if (t.revealPending < 2) t.revealPending = 2; + // SEAMLESS HANDOVER (field report: a bare-desktop frame flashed at the 9x + // crossover): the transform keeps the screen magnified while the overlay's + // evidence-gated reveal is pending, and RESTS A FEW TICKS AFTER the reveal + // lands - the rest write and the alpha flip travel different DWM channels, + // so a same-tick rest still left a one-composite unmagnified gap. The + // overlap's worst case is a ~14ms over-zoom pulse (still magnified content), + // never a bare desktop. + t.restAfterReveal = old; + t.restOverlapTicks = 0; // armed when the reveal actually fires + } else { + // render -> transform: activate the transform THIS tick, keep the overlay up + // for the same short overlap, then drop it. + { wind::SpanScope span_(wind::kSpanActivate); t.model->setActive(true); } + // The pan wall and MPO state above were computed for the outgoing render + // engine (review 2026-10-09 M2), so this tick's view was mapped without walls. + // Give the incoming transform its MPO state now: its write-site backstop then + // keeps |translation| under the 16-bit field on this first present (the ghost + // is not settled yet, so the backstop is armed at either sampling). The next + // tick computes the walls for the transform as usual. + if (auto* tmIn = dynamic_cast(t.model)) { + const bool exposedIn = !g_mpoDisabled; + tmIn->setMpoBusterWanted(exposedIn && t.cfg.mpoBuster != 0 && !liftIfTransform); + tmIn->setMpoExposed(exposedIn && !liftIfTransform); + } + t.restAfterReveal = old; + t.restOverlapTicks = TicksAtHz(3, t.hz); + } + // Name the trigger: if another transient surface ever ping-pongs the engine the + // way the taskbar flyout did (issue #180), the log identifies it directly. + wchar_t fgCls[64]{}; + if (fgTick) GetClassNameW(fgTick, fgCls, 64); + wind::Log(wind::LogLevel::Info, "hybrid", + "instant switch -> %s (level preserved; fg cls=%ls exe=%ls)", + dynamic_cast(t.model) ? "render" : "transform", + fgCls, ExeNameOf(fgTick).c_str()); + } + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2310,97 +2406,7 @@ static void RunTick(TickState& t) { // the magnified region (gameCrop), skip the periodic topmost backstop, and optionally cap // our own present rate (gameFpsCap). fgTick/fsCover/fgBorderless were read once above. const bool fsGame = fsCover; - // Instant hybrid switch (always on): re-pick the engine WHILE ZOOMED when the foreground - // changes, handing over mid-session. The controller and mapper are untouched, so the - // zoom level and lens position carry across the swap (render 8x -> tab into a game -> - // transform 8x). Inspect sessions are never switched under. - // Freeze the engine choice while an overlay is in front (see IsOverlayFg). Skipping the - // whole block, rather than just the swap, is deliberate: it leaves wantModel/wantSinceMs - // untouched, so the overlay never becomes the settled candidate and handing foreground back - // is a no-op instead of a second handover. - if (t.mTransform && !enterActive && !inspect && !IsOverlayFg(fgTick)) { - // Same pure predicate as the zoom-in pick (engine_pick.h) - the two sites can no - // longer drift apart. Exe-derived inputs come from the per-HWND cache: this block - // runs every zoomed tick and the process-open per tick was measurable waste. - RefreshFgCache(t, fgTick); - auto* tAvail = dynamic_cast(t.mTransform); - EnginePickInputs pin; - pin.coversMonitor = fsCover; - pin.borderless = fgBorderless; - pin.primaryMonitor = t.mon.x == 0 && t.mon.y == 0; - pin.shellDesktop = t.fgCacheShell; - pin.excluded = t.fgCacheExcluded; - pin.churny = t.fgCacheChurny; - pin.tdrHarness = t.cfg.tdrTest > 0; - pin.desktopTransformOptIn = t.cfg.desktopTransform != 0; - pin.inputTransformOk = tAvail && tAvail->inputTransformAvailable(); - pin.renderLost = RenderDeviceLost(t); - FillCategoryInputs(t, pin); - IMagnifierModel* want = wind::PickTransformFor(t.cfg.model, pin, t.renderStale) ? t.mTransform : t.mRender; - // STICKY (field: the engine flapped render<->transform inside one zoom session, and - // each flip releases and rebuilds DWM's magnification context - a stall every time). - // A real alt-tab still switches; a one-frame wobble in the foreground reads does not. - if (want != t.wantModel) { t.wantModel = want; t.wantSinceMs = GetTickCount64(); } - const bool wantSettled = GetTickCount64() - t.wantSinceMs >= 350; - const bool fgIsStealer = fgTick && fgTick == g_focusStealer; // game-inspect helper holds fg - if (want && want != t.model && wantSettled && !fgIsStealer) { - if (t.restAfterReveal) { // rapid double-switch: settle the previous handover - { wind::SpanScope span_(wind::kSpanActivate); t.restAfterReveal->setActive(false); } - t.restAfterReveal = nullptr; - t.restOverlapTicks = 0; - UpdateColorFilter(t, true, RenderOverlayShown(t), nullptr); // colour follows what is visible - } - IMagnifierModel* old = t.model; - SetSystemCursorHidden(t, old, false); - t.model = want; - // Both engines weld the cursor to the lens point, so the handover is seamless: - // render hides the OS cursor immediately; the transform hides it in its own - // present (level > 1.001) and re-welds at the same point. - if (dynamic_cast(t.model)) SetSystemCursorHidden(t, t.model, true); - else t.transformExe = ExeNameOf(fgTick); // device-lost backstop attribution - { wind::SpanScope span_(wind::kSpanActivate); t.model->onActivate(); } - if (auto* rm = dynamic_cast(t.model)) { - t.revealNeedsComposite = ForegroundCoversMonitor(t.mon); - if (t.revealNeedsComposite) rm->primeReveal(); - t.revealPending = (t.hz > 0 ? t.hz : 60) / 4; - if (t.revealPending < 2) t.revealPending = 2; - // SEAMLESS HANDOVER (field report: a bare-desktop frame flashed at the 9x - // crossover): the transform keeps the screen magnified while the overlay's - // evidence-gated reveal is pending, and RESTS A FEW TICKS AFTER the reveal - // lands - the rest write and the alpha flip travel different DWM channels, - // so a same-tick rest still left a one-composite unmagnified gap. The - // overlap's worst case is a ~14ms over-zoom pulse (still magnified content), - // never a bare desktop. - t.restAfterReveal = old; - t.restOverlapTicks = 0; // armed when the reveal actually fires - } else { - // render -> transform: activate the transform THIS tick, keep the overlay up - // for the same short overlap, then drop it. - { wind::SpanScope span_(wind::kSpanActivate); t.model->setActive(true); } - // The pan wall and MPO state above were computed for the outgoing render - // engine (review 2026-10-09 M2), so this tick's view was mapped without walls. - // Give the incoming transform its MPO state now: its write-site backstop then - // keeps |translation| under the 16-bit field on this first present (the ghost - // is not settled yet, so the backstop is armed at either sampling). The next - // tick computes the walls for the transform as usual. - if (auto* tmIn = dynamic_cast(t.model)) { - const bool exposedIn = !g_mpoDisabled; - tmIn->setMpoBusterWanted(exposedIn && t.cfg.mpoBuster != 0 && !liftIfTransform); - tmIn->setMpoExposed(exposedIn && !liftIfTransform); - } - t.restAfterReveal = old; - t.restOverlapTicks = TicksAtHz(3, t.hz); - } - // Name the trigger: if another transient surface ever ping-pongs the engine the - // way the taskbar flyout did (issue #180), the log identifies it directly. - wchar_t fgCls[64]{}; - if (fgTick) GetClassNameW(fgTick, fgCls, 64); - wind::Log(wind::LogLevel::Info, "hybrid", - "instant switch -> %s (level preserved; fg cls=%ls exe=%ls)", - dynamic_cast(t.model) ? "render" : "transform", - fgCls, ExeNameOf(fgTick).c_str()); - } - } + InstantHybridSwitch(t, enterActive, inspect, fgTick, fsCover, fgBorderless, liftIfTransform); // Per-tick render-only overrides go through PresentExtras; the model's present() runs // FillRenderParams and applies these on top. ex.outline seeds with the same base value // FillRenderParams would compute, so the dwell/idle logic below reads an identical start. From 050cb845342c516187fe1191bda04cc06e7e345a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:02:36 +0200 Subject: [PATCH 19/31] refactor(tick): extract BuildPresentExtras from RunTick Move-only: the PresentExtras build (outline dwell and idle fade, cursor mode, weld suppression, colour filter, write pauses, DWM centring, tray-menu guard, Inspect freeze re-assert) moves verbatim into BuildPresentExtras (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 231 +++++++++++++++++++++++++++------------------------ 1 file changed, 121 insertions(+), 110 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 227cb420..4d08aed0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2352,6 +2352,125 @@ static void InstantHybridSwitch(TickState& t, bool enterActive, bool inspect, HW } } +// Active tick: build this tick's PresentExtras (outline dwell and idle fade, cursor mode, weld +// suppression, colour, write pauses, DWM centring) and keep the Inspect freeze. +static PresentExtras BuildPresentExtras(TickState& t, double lvl, double dt, bool zoomed, bool inspect, + bool fsGame, bool wallNeeded, bool freeCursor, bool gameCursor, + bool dragFollow, int curDx, int curDy, int rawDx, int rawDy, + const MapResult& r) { + // Per-tick render-only overrides go through PresentExtras; the model's present() runs + // FillRenderParams and applies these on top. ex.outline seeds with the same base value + // FillRenderParams would compute, so the dwell/idle logic below reads an identical start. + PresentExtras ex; + ex.fsGame = fsGame; + ex.forceCrop = fsGame && t.cfg.gameCrop != 0; + ex.outline = OutlineVisibleAtLevel(t.cfg, lvl); + ex.outlineAlpha = 1.0f; + ex.cursorMode = CursorModeFromCfg(t.cfg); + ex.cursorLocked = false; + // Low-zoom dwell: with "only at low zoom" on, show the outline only after the zoom settles + // at a STABLE level inside the band for kOutlineDwellSec. "Stable" = the level is unchanged + // since last tick (the controller freezes the level exactly when no zoom direction is held), + // so an actively-changing level - zooming through the band, or repeatedly nudging in/out - + // never accumulates: the countdown starts only once you stop on a level in the band. Any + // level change or leaving the band resets it (OutlineDwellSeconds returns 0 when !inBand). + // Always-on mode (lowZoomOnly off) is unaffected. The reset also fires on zoom-out to idle + // via the teardown branch below, so cycling 1x<->in-band can never bank partial dwell. + if (t.cfg.outline != 0 && t.cfg.outlineLowZoomOnly != 0) { + const double kOutlineDwellSec = 1.0; + bool stable = std::fabs(lvl - t.prevLvl) <= 1e-4; // level held constant => settled + bool inBand = ex.outline && lvl > 1.0 && stable; + t.outlineZoneSec = OutlineDwellSeconds(inBand, t.outlineZoneSec, dt, kOutlineDwellSec); + if (t.outlineZoneSec < kOutlineDwellSec) ex.outline = false; // not dwelled long enough yet + } else { + t.outlineZoneSec = 0.0; // keep ready for when the cutoff is toggled on mid-session + } + // Idle-hide fade: when enabled and the outline is visible, accumulate idle time (reset on + // any hand motion - free OS-cursor delta or raw mickeys), then map it to the fade alpha. + // dt is the per-tick elapsed time computed at the top of RunTick. Fade duration is 0.3s. + const bool outlineMoved = (std::abs(curDx) + std::abs(curDy) + std::abs(rawDx) + std::abs(rawDy)) > 0; + if (t.cfg.outlineIdleHide && ex.outline) { + t.outlineIdleSec = outlineMoved ? 0.0 : (t.outlineIdleSec + dt); + ex.outlineAlpha = (float)OutlineIdleAlpha(t.outlineIdleSec, t.cfg.outlineIdleSeconds, 0.3); + } else { + t.outlineIdleSec = 0.0; // keep ready for when idle-hide is toggled on mid-session + } + if (t.cursorHidden) ex.cursorMode = 2; // hotkey override; CursorModeFromCfg already set 0/1/2 from cfg + // cursorMode is now final for this tick; derive drawCursor from it so the transform model + // (which only reads drawCursor, not cursorMode - see magnifier_model.h) also honours + // cursorVisibility=never and the hide-cursor hotkey. The render model never reads drawCursor + // (it reads cursorMode directly via FillRenderParams), so this cannot change render behaviour. + ex.drawCursor = (ex.cursorMode != 2); + // Drag-follow (issue #169): the pan resolve above chose to follow the pointer this tick, so + // neither engine may weld it back to the lens centre - suspend the SetCursorPos. + // freeCursor: never weld. The weld is what the whole native-model change removes - with the + // view already positioned so the pointer lands centre-screen, SetCursorPos has nothing to + // correct and only reintroduces the feedback loop. + ex.suppressCursorSync = dragFollow || freeCursor || t.viewDetached; // tracking never moves the pointer (#276) + { wind::SpanScope span_(wind::kSpanColor); UpdateColorFilter(t, lvl > 1.0, RenderOverlayShown(t), &ex); } + // Serialize transform writes around an Inspect click's injected absolute move (issue #148 + // TDR class): the injection and a transform write racing each other is the proven trigger. + // The launch quiesce holds writes AND the weld for its whole window (see above). + // Deadline-based, so a short press can never strand leftover hold into the next session + // (the old tick countdown only decremented while zoomed, which did exactly that). + const bool quiesceHold = QuiesceHoldActive(t); + ex.pauseWrites = t.clickPauseTicks > 0 || quiesceHold; + { + auto& zs = g_input.state(); + ex.zoomDriven = zs.inHeld.load() || zs.outHeld.load() || g_input.anyBoundKeyPressed() || + t.zoom.hasTarget(); + } + if (quiesceHold) ex.suppressCursorSync = true; + // Native cursor (issue #369): DWM may own the pan only where the view is a pure function of + // the pointer. + { + wind::DwmCentreIn dc; + dc.zoomed = lvl > 1.001; + dc.freeCursor = (freeCursor && !gameCursor && !t.postInspectHold) || // #443, #445 + wind::InspectKeepsDwmView(inspect, t.inspectLookMoved); // #445 + dc.viewDetached = t.viewDetached; + dc.wallNeeded = wind::NearWall(wallNeeded, r.srcLeft, r.srcTop, lvl, kMaxSafeTxMagnitude, 64.0); + dc.quiesce = quiesceHold; + ex.dwmCentre = wind::WantDwmCentring(dc); + // Warm pulses wherever Wind, not DWM centring, writes the view: a game-drawn cursor and + // the hold after Inspect write it from a free pointer too (review of #444/#446). + ex.warmAllowed = !dc.freeCursor; + } + // Our tray menu is open (in WindTray.exe, flagged through the shared block): the pointer + // belongs to the USER (they are aiming at menu items), + // so the weld must not re-park it - at full tick rate it pins the cursor outright + // (field-reported as a frozen cursor the moment the tray opened). The view keeps panning; + // only the cursor re-park is suspended, exactly like drag-follow during a button hold. + if (wind::TrayMenuOpen(g_trayBlock)) ex.suppressCursorSync = true; + if (t.clickPauseTicks > 0) --t.clickPauseTicks; + if (inspect) { + if (t.clickReleaseTicks > 0) { + // A click was just committed: keep the freeze released for these ticks so the synthesized + // absolute click reaches the look point (re-clamping to the 1px frozen rect would send the + // click to the frozen point instead). Leave p.clickDesktop at the look point so renderFrame + // holds the cursor there for the click; re-freeze once the window elapses. + --t.clickReleaseTicks; + } else { + // Re-assert the freeze (Windows can drop a clip on focus changes) and pin renderFrame's + // SetCursorPos at the frozen point (a no-op inside the clip). DEDUPED (issue #148): + // ClipCursor is a win32k cursor-subsystem write - the TDR class under an active + // fullscreen transform - so it only runs when the read shows the clip was lost. + RECT have{}; GetClipCursor(&have); + if (have.left != t.frozenCursor.x || have.top != t.frozenCursor.y || + have.right != t.frozenCursor.x + 1 || have.bottom != t.frozenCursor.y + 1) { + RECT fz{ t.frozenCursor.x, t.frozenCursor.y, t.frozenCursor.x + 1, t.frozenCursor.y + 1 }; + ClipCursor(&fz); + } + ex.clickOverride = true; + ex.clickDesktopX = t.frozenCursor.x; + ex.clickDesktopY = t.frozenCursor.y; + } + ex.cursorLocked = true; // draw the crosshair at the look point (cursorScreen) + if (!zoomed) ex.outline = false; // no lens outline on the 1:1 view at 1x + } + return ex; +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2407,116 +2526,8 @@ static void RunTick(TickState& t) { // our own present rate (gameFpsCap). fgTick/fsCover/fgBorderless were read once above. const bool fsGame = fsCover; InstantHybridSwitch(t, enterActive, inspect, fgTick, fsCover, fgBorderless, liftIfTransform); - // Per-tick render-only overrides go through PresentExtras; the model's present() runs - // FillRenderParams and applies these on top. ex.outline seeds with the same base value - // FillRenderParams would compute, so the dwell/idle logic below reads an identical start. - PresentExtras ex; - ex.fsGame = fsGame; - ex.forceCrop = fsGame && t.cfg.gameCrop != 0; - ex.outline = OutlineVisibleAtLevel(t.cfg, lvl); - ex.outlineAlpha = 1.0f; - ex.cursorMode = CursorModeFromCfg(t.cfg); - ex.cursorLocked = false; - // Low-zoom dwell: with "only at low zoom" on, show the outline only after the zoom settles - // at a STABLE level inside the band for kOutlineDwellSec. "Stable" = the level is unchanged - // since last tick (the controller freezes the level exactly when no zoom direction is held), - // so an actively-changing level - zooming through the band, or repeatedly nudging in/out - - // never accumulates: the countdown starts only once you stop on a level in the band. Any - // level change or leaving the band resets it (OutlineDwellSeconds returns 0 when !inBand). - // Always-on mode (lowZoomOnly off) is unaffected. The reset also fires on zoom-out to idle - // via the teardown branch below, so cycling 1x<->in-band can never bank partial dwell. - if (t.cfg.outline != 0 && t.cfg.outlineLowZoomOnly != 0) { - const double kOutlineDwellSec = 1.0; - bool stable = std::fabs(lvl - t.prevLvl) <= 1e-4; // level held constant => settled - bool inBand = ex.outline && lvl > 1.0 && stable; - t.outlineZoneSec = OutlineDwellSeconds(inBand, t.outlineZoneSec, dt, kOutlineDwellSec); - if (t.outlineZoneSec < kOutlineDwellSec) ex.outline = false; // not dwelled long enough yet - } else { - t.outlineZoneSec = 0.0; // keep ready for when the cutoff is toggled on mid-session - } - // Idle-hide fade: when enabled and the outline is visible, accumulate idle time (reset on - // any hand motion - free OS-cursor delta or raw mickeys), then map it to the fade alpha. - // dt is the per-tick elapsed time computed at the top of RunTick. Fade duration is 0.3s. - const bool outlineMoved = (std::abs(curDx) + std::abs(curDy) + std::abs(rawDx) + std::abs(rawDy)) > 0; - if (t.cfg.outlineIdleHide && ex.outline) { - t.outlineIdleSec = outlineMoved ? 0.0 : (t.outlineIdleSec + dt); - ex.outlineAlpha = (float)OutlineIdleAlpha(t.outlineIdleSec, t.cfg.outlineIdleSeconds, 0.3); - } else { - t.outlineIdleSec = 0.0; // keep ready for when idle-hide is toggled on mid-session - } - if (t.cursorHidden) ex.cursorMode = 2; // hotkey override; CursorModeFromCfg already set 0/1/2 from cfg - // cursorMode is now final for this tick; derive drawCursor from it so the transform model - // (which only reads drawCursor, not cursorMode - see magnifier_model.h) also honours - // cursorVisibility=never and the hide-cursor hotkey. The render model never reads drawCursor - // (it reads cursorMode directly via FillRenderParams), so this cannot change render behaviour. - ex.drawCursor = (ex.cursorMode != 2); - // Drag-follow (issue #169): the pan resolve above chose to follow the pointer this tick, so - // neither engine may weld it back to the lens centre - suspend the SetCursorPos. - // freeCursor: never weld. The weld is what the whole native-model change removes - with the - // view already positioned so the pointer lands centre-screen, SetCursorPos has nothing to - // correct and only reintroduces the feedback loop. - ex.suppressCursorSync = dragFollow || freeCursor || t.viewDetached; // tracking never moves the pointer (#276) - { wind::SpanScope span_(wind::kSpanColor); UpdateColorFilter(t, lvl > 1.0, RenderOverlayShown(t), &ex); } - // Serialize transform writes around an Inspect click's injected absolute move (issue #148 - // TDR class): the injection and a transform write racing each other is the proven trigger. - // The launch quiesce holds writes AND the weld for its whole window (see above). - // Deadline-based, so a short press can never strand leftover hold into the next session - // (the old tick countdown only decremented while zoomed, which did exactly that). - const bool quiesceHold = QuiesceHoldActive(t); - ex.pauseWrites = t.clickPauseTicks > 0 || quiesceHold; - { - auto& zs = g_input.state(); - ex.zoomDriven = zs.inHeld.load() || zs.outHeld.load() || g_input.anyBoundKeyPressed() || - t.zoom.hasTarget(); - } - if (quiesceHold) ex.suppressCursorSync = true; - // Native cursor (issue #369): DWM may own the pan only where the view is a pure function of - // the pointer. - { - wind::DwmCentreIn dc; - dc.zoomed = lvl > 1.001; - dc.freeCursor = (freeCursor && !gameCursor && !t.postInspectHold) || // #443, #445 - wind::InspectKeepsDwmView(inspect, t.inspectLookMoved); // #445 - dc.viewDetached = t.viewDetached; - dc.wallNeeded = wind::NearWall(wallNeeded, r.srcLeft, r.srcTop, lvl, kMaxSafeTxMagnitude, 64.0); - dc.quiesce = quiesceHold; - ex.dwmCentre = wind::WantDwmCentring(dc); - // Warm pulses wherever Wind, not DWM centring, writes the view: a game-drawn cursor and - // the hold after Inspect write it from a free pointer too (review of #444/#446). - ex.warmAllowed = !dc.freeCursor; - } - // Our tray menu is open (in WindTray.exe, flagged through the shared block): the pointer - // belongs to the USER (they are aiming at menu items), - // so the weld must not re-park it - at full tick rate it pins the cursor outright - // (field-reported as a frozen cursor the moment the tray opened). The view keeps panning; - // only the cursor re-park is suspended, exactly like drag-follow during a button hold. - if (wind::TrayMenuOpen(g_trayBlock)) ex.suppressCursorSync = true; - if (t.clickPauseTicks > 0) --t.clickPauseTicks; - if (inspect) { - if (t.clickReleaseTicks > 0) { - // A click was just committed: keep the freeze released for these ticks so the synthesized - // absolute click reaches the look point (re-clamping to the 1px frozen rect would send the - // click to the frozen point instead). Leave p.clickDesktop at the look point so renderFrame - // holds the cursor there for the click; re-freeze once the window elapses. - --t.clickReleaseTicks; - } else { - // Re-assert the freeze (Windows can drop a clip on focus changes) and pin renderFrame's - // SetCursorPos at the frozen point (a no-op inside the clip). DEDUPED (issue #148): - // ClipCursor is a win32k cursor-subsystem write - the TDR class under an active - // fullscreen transform - so it only runs when the read shows the clip was lost. - RECT have{}; GetClipCursor(&have); - if (have.left != t.frozenCursor.x || have.top != t.frozenCursor.y || - have.right != t.frozenCursor.x + 1 || have.bottom != t.frozenCursor.y + 1) { - RECT fz{ t.frozenCursor.x, t.frozenCursor.y, t.frozenCursor.x + 1, t.frozenCursor.y + 1 }; - ClipCursor(&fz); - } - ex.clickOverride = true; - ex.clickDesktopX = t.frozenCursor.x; - ex.clickDesktopY = t.frozenCursor.y; - } - ex.cursorLocked = true; // draw the crosshair at the look point (cursorScreen) - if (!zoomed) ex.outline = false; // no lens outline on the 1:1 view at 1x - } + PresentExtras ex = BuildPresentExtras(t, lvl, dt, zoomed, inspect, fsGame, wallNeeded, freeCursor, + gameCursor, dragFollow, curDx, curDy, rawDx, rawDy, r); // Game pacing (issue #148): ONLY for the opt-in knobs. The default zoomed path keeps the // vsync-locked blocking Present - it is what makes panning smooth (refresh-locked cadence), // and at normal GPU priority it blocks a few frames at worst, never wedges. The timer-paced From 2cf9f78c08a0a64df5260125e55094e3f0cb66f8 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:03:05 +0200 Subject: [PATCH 20/31] refactor(tick): extract PaceAndPresent from RunTick Move-only: the game pacing levers and the engine present (with its zoom timeline stamp and pointer-hidden read-back) move verbatim into PaceAndPresent (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 154 +++++++++++++++++++++++++++------------------------ 1 file changed, 81 insertions(+), 73 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 4d08aed0..5d3ce26f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2471,6 +2471,86 @@ static PresentExtras BuildPresentExtras(TickState& t, double lvl, double dt, boo return ex; } +// Active tick: game pacing (reduced-push and timer-paced modes), then the engine present. +// Returns whether this tick presented. +static bool PaceAndPresent(TickState& t, PresentExtras& ex, const MapResult& r, double lvl, double dt, + bool zoomed, bool fsGame, bool enterActive) { + // Game pacing (issue #148): ONLY for the opt-in knobs. The default zoomed path keeps the + // vsync-locked blocking Present - it is what makes panning smooth (refresh-locked cadence), + // and at normal GPU priority it blocks a few frames at worst, never wedges. The timer-paced + // Present(0,0) + fence-gate mode below exists because lowGpuPriority=1 can starve our GPU + // work for MINUTES under a saturated game (a blocking present then wedges input, teardown, + // and the cursor restore), and because gameFpsCap needs presents decoupled from ticks. Both + // trade present cadence for safety/headroom, so they must never engage by default - an + // earlier build enabled this mode for every "foreground covers the monitor" case (which + // also matches any MAXIMIZED desktop window) and made panning judder everywhere. + // While engaged: the main-loop timer paces (t.gamePacing), presents are Present(0,0) + // (ex.noVsync), and renderFrame skips the whole frame while the previous present hasn't + // executed on the GPU (ex.gatePresent). Skipped ticks still sample input and advance the + // mapper. The activation and reveal-pending ticks always attempt a present (the gated + // reveal depends on frames reaching the redirection surface). + bool doPresent = true; + // Reduced-PUSH game mode (issue #148, measured in Foundation): under a game, DWM + // services our redirected window's presents at only ~78/s while compositing at 144/s - + // pushing 144 presents/s into that path builds a standing queue, so every Present waits + // ~a queue's worth with jitter (the stutter). gameFpsCap>0 over a fullscreen game + // presents every Nth vblank instead (N = ceil(hz/cap)), BELOW the service rate, so the + // queue stays empty and each present retires on the next composite. Skip ticks block on + // IDXGIOutput::WaitForVBlank (in the skip branch below), keeping the loop vblank-locked + // with no timer jitter; input/pan still samples every tick. Activation/reveal ticks + // always present (the gated reveal needs frames on the redirection surface). + const bool capVsync = fsGame && zoomed && t.cfg.gameFpsCap > 0 && + t.cfg.vsync != 0 && t.cfg.dwmFlush == 0; + if (capVsync && !(enterActive || t.revealPending > 0)) { + int div = (t.hz + t.cfg.gameFpsCap - 1) / t.cfg.gameFpsCap; // ceil(hz/cap) + if (div < 1) div = 1; + if (++t.pushPhase >= div) t.pushPhase = 0; + if (t.pushPhase != 0) doPresent = false; + } else { + t.pushPhase = 0; + } + const bool gamePacing = fsGame && zoomed && !capVsync && + (EffectiveGpuPriority(t.cfg) < 0 || t.cfg.gameFpsCap > 0); + t.gamePacing = gamePacing; + if (gamePacing) { + ex.noVsync = true; + ex.gatePresent = true; + if (t.cfg.gameFpsCap > 0) { + const double interval = 1.0 / (double)t.cfg.gameFpsCap; + t.presentAccum += dt; + if (enterActive || t.revealPending > 0) { + t.presentAccum = 0.0; // reveal path: present every tick + } else if (t.presentAccum >= interval) { + t.presentAccum -= interval; + if (t.presentAccum > interval) t.presentAccum = interval; // hitch: no burst catch-up + } else { + doPresent = false; + } + } + } else { + t.presentAccum = 0.0; + } + if (doPresent) { + LARGE_INTEGER zp0{}; if (t.zt.armed && t.zt.step == 0) QueryPerformanceCounter(&zp0); + { wind::SpanScope span_(wind::kSpanPresent); t.model->present(r, lvl, t.cfg, t.mon, ex); } // render+present (never blocks the ramp) + // The transform model hides and restores the pointer on its own (Inspect exit, the + // hide-cursor hotkey), so read its state back: a mirrored flag went stale after both and + // made the next Inspect toggle read our own hiding as the app's mouselook tell. + if (auto* tm = dynamic_cast(t.model)) t.cursorHiddenByUs = tm->pointerHidden(); + if (t.zt.armed && t.zt.step == 0 && t.zt.presentMs == 0) { + LARGE_INTEGER zp1; QueryPerformanceCounter(&zp1); t.zt.presentMs = QpcMs(t, zp0.QuadPart, zp1.QuadPart); + } + } else if (capVsync) { + // Reduced-push skip tick: block to the next vblank so the loop cadence stays + // vblank-locked (Present paces the present ticks, this paces the skips). Fallback + // sleep if the output can't wait (device transition) so we never spin. + if (auto* rm = dynamic_cast(t.model)) { + if (!rm->waitVBlank()) Sleep(3); + } + } + return doPresent; +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2528,79 +2608,7 @@ static void RunTick(TickState& t) { InstantHybridSwitch(t, enterActive, inspect, fgTick, fsCover, fgBorderless, liftIfTransform); PresentExtras ex = BuildPresentExtras(t, lvl, dt, zoomed, inspect, fsGame, wallNeeded, freeCursor, gameCursor, dragFollow, curDx, curDy, rawDx, rawDy, r); - // Game pacing (issue #148): ONLY for the opt-in knobs. The default zoomed path keeps the - // vsync-locked blocking Present - it is what makes panning smooth (refresh-locked cadence), - // and at normal GPU priority it blocks a few frames at worst, never wedges. The timer-paced - // Present(0,0) + fence-gate mode below exists because lowGpuPriority=1 can starve our GPU - // work for MINUTES under a saturated game (a blocking present then wedges input, teardown, - // and the cursor restore), and because gameFpsCap needs presents decoupled from ticks. Both - // trade present cadence for safety/headroom, so they must never engage by default - an - // earlier build enabled this mode for every "foreground covers the monitor" case (which - // also matches any MAXIMIZED desktop window) and made panning judder everywhere. - // While engaged: the main-loop timer paces (t.gamePacing), presents are Present(0,0) - // (ex.noVsync), and renderFrame skips the whole frame while the previous present hasn't - // executed on the GPU (ex.gatePresent). Skipped ticks still sample input and advance the - // mapper. The activation and reveal-pending ticks always attempt a present (the gated - // reveal depends on frames reaching the redirection surface). - bool doPresent = true; - // Reduced-PUSH game mode (issue #148, measured in Foundation): under a game, DWM - // services our redirected window's presents at only ~78/s while compositing at 144/s - - // pushing 144 presents/s into that path builds a standing queue, so every Present waits - // ~a queue's worth with jitter (the stutter). gameFpsCap>0 over a fullscreen game - // presents every Nth vblank instead (N = ceil(hz/cap)), BELOW the service rate, so the - // queue stays empty and each present retires on the next composite. Skip ticks block on - // IDXGIOutput::WaitForVBlank (in the skip branch below), keeping the loop vblank-locked - // with no timer jitter; input/pan still samples every tick. Activation/reveal ticks - // always present (the gated reveal needs frames on the redirection surface). - const bool capVsync = fsGame && zoomed && t.cfg.gameFpsCap > 0 && - t.cfg.vsync != 0 && t.cfg.dwmFlush == 0; - if (capVsync && !(enterActive || t.revealPending > 0)) { - int div = (t.hz + t.cfg.gameFpsCap - 1) / t.cfg.gameFpsCap; // ceil(hz/cap) - if (div < 1) div = 1; - if (++t.pushPhase >= div) t.pushPhase = 0; - if (t.pushPhase != 0) doPresent = false; - } else { - t.pushPhase = 0; - } - const bool gamePacing = fsGame && zoomed && !capVsync && - (EffectiveGpuPriority(t.cfg) < 0 || t.cfg.gameFpsCap > 0); - t.gamePacing = gamePacing; - if (gamePacing) { - ex.noVsync = true; - ex.gatePresent = true; - if (t.cfg.gameFpsCap > 0) { - const double interval = 1.0 / (double)t.cfg.gameFpsCap; - t.presentAccum += dt; - if (enterActive || t.revealPending > 0) { - t.presentAccum = 0.0; // reveal path: present every tick - } else if (t.presentAccum >= interval) { - t.presentAccum -= interval; - if (t.presentAccum > interval) t.presentAccum = interval; // hitch: no burst catch-up - } else { - doPresent = false; - } - } - } else { - t.presentAccum = 0.0; - } - if (doPresent) { - LARGE_INTEGER zp0{}; if (t.zt.armed && t.zt.step == 0) QueryPerformanceCounter(&zp0); - { wind::SpanScope span_(wind::kSpanPresent); t.model->present(r, lvl, t.cfg, t.mon, ex); } // render+present (never blocks the ramp) - // The transform model hides and restores the pointer on its own (Inspect exit, the - // hide-cursor hotkey), so read its state back: a mirrored flag went stale after both and - // made the next Inspect toggle read our own hiding as the app's mouselook tell. - if (auto* tm = dynamic_cast(t.model)) t.cursorHiddenByUs = tm->pointerHidden(); - if (t.zt.armed && t.zt.step == 0 && t.zt.presentMs == 0) { - LARGE_INTEGER zp1; QueryPerformanceCounter(&zp1); t.zt.presentMs = QpcMs(t, zp0.QuadPart, zp1.QuadPart); - } - } else if (capVsync) { - // Reduced-push skip tick: block to the next vblank so the loop cadence stays - // vblank-locked (Present paces the present ticks, this paces the skips). Fallback - // sleep if the output can't wait (device transition) so we never spin. - if (auto* rm = dynamic_cast(t.model)) { - if (!rm->waitVBlank()) Sleep(3); - } - } + const bool doPresent = PaceAndPresent(t, ex, r, lvl, dt, zoomed, fsGame, enterActive); // Reveal AFTER the live frame is presented: setVisible flips the layer alpha over the // now-current front buffer, so the overlay never shows its retained previous-session // frame (the alt-tab "previous window"). capture() also drained to the latest frame. From 314fa1feaa91a3f83b524963829d69c1e029b0f1 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:03:33 +0200 Subject: [PATCH 21/31] refactor(tick): extract RevealAndHandover from RunTick Move-only: the post-present reveal gating and the handover overlap countdown move verbatim into RevealAndHandover (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 142 +++++++++++++++++++++++++++------------------------ 1 file changed, 74 insertions(+), 68 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 5d3ce26f..0676e4cf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2551,6 +2551,79 @@ static bool PaceAndPresent(TickState& t, PresentExtras& ex, const MapResult& r, return doPresent; } +// Active tick: the gated reveal after the present (render) or the immediate activation (transform), +// then the engine-handover overlap countdown. +static void RevealAndHandover(TickState& t, bool enterActive, bool fsGame) { + // Reveal AFTER the live frame is presented: setVisible flips the layer alpha over the + // now-current front buffer, so the overlay never shows its retained previous-session + // frame (the alt-tab "previous window"). capture() also drained to the latest frame. + // Reveal/prime is render-specific (needs ForegroundCoversMonitor + capture priming); guard it + // behind the RenderModel downcast. A non-render model just reveals immediately on activation. + if (auto* rm = dynamic_cast(t.model)) { + if (enterActive) { + // EVERY reveal is gated on the session's first Present having EXECUTED on the GPU + // (revealFrameDone: an event query fenced right after Present). The blt into the + // layered redirection surface is GPU work, but the alpha flip is a CPU call DWM + // honours at its next composite - under GPU load the flip won that race and DWM + // composited the surface's RETAINED frame: the last thing the PREVIOUS zoom + // session presented (issue #140, second mechanism; the first was capture-side). + // A fullscreen app (game on an independent-flip/MPO plane, issue #90) additionally + // needs a post-prime composite in the capture (frameCompositedSincePrime), since + // Desktop Duplication can't see the game until the alpha-1 prime forces DWM to + // composite it. All non-blocking - the smooth-zoom ramp runs undisturbed; + // revealPending is only the fallback cap (~250 ms) so nothing can wedge the reveal. + t.revealNeedsComposite = fsGame; // same ForegroundCoversMonitor read, this tick + if (t.revealNeedsComposite) rm->primeReveal(); + t.revealPending = (t.hz > 0 ? t.hz : 60) / 4; + if (t.revealPending < 2) t.revealPending = 2; + // Ordinary desktop zoom-in: spin a tiny budget on the fence so the idle-GPU common + // case still reveals within this same tick (the instant feel is kept); a loaded + // GPU misses the budget and defers to the per-tick checks below. + if (!t.revealNeedsComposite && rm->revealFrameDone(3.0)) { + { wind::SpanScope span_(wind::kSpanActivate); rm->setActive(true); } + t.revealPending = 0; + // Real-time overlap, same as the render -> transform path (issue #274): + // raw ticks were right only at 144 Hz. + if (t.restAfterReveal) t.restOverlapTicks = TicksAtHz(3, t.hz); + } + } else if (t.revealPending > 0) { + --t.revealPending; + const bool frameDone = rm->revealFrameDone(); + const bool composited = !t.revealNeedsComposite || rm->frameCompositedSincePrime(); + if ((frameDone && composited) || t.revealPending == 0) { + { wind::SpanScope span_(wind::kSpanActivate); rm->setActive(true); } + wind::Log(wind::LogLevel::Info, "render", + "deferred reveal: frameDone=%d composited=%d ticksLeft=%d", + (int)frameDone, (int)composited, t.revealPending); + t.revealPending = 0; + // Real-time overlap, same as the render -> transform path (issue #274): + // raw ticks were right only at 144 Hz. + if (t.restAfterReveal) t.restOverlapTicks = TicksAtHz(3, t.hz); + } + } + } else if (enterActive) { + LARGE_INTEGER zs0; QueryPerformanceCounter(&zs0); + { wind::SpanScope span_(wind::kSpanActivate); t.model->setActive(true); } // transform: reveal immediately, no capture priming + if (t.zt.armed) { + LARGE_INTEGER zs1; QueryPerformanceCounter(&zs1); t.zt.setActiveMs = QpcMs(t, zs0.QuadPart, zs1.QuadPart); + if (auto* tm = dynamic_cast(t.model)) { + const auto sp = tm->lastEnter(); t.zt.ensureMs = sp.ensureMagMs; + } + DWM_TIMING_INFO ti{}; ti.cbSize = sizeof(ti); // the composite count the first write must beat + if (SUCCEEDED(DwmGetCompositionTimingInfo(nullptr, &ti))) t.zt.cFrame0 = ti.cFrame; + } + } + // Handover overlap: the outgoing engine rests a few ticks after the incoming one is + // live, so the crossover never composites a bare unmagnified frame (see instant switch). + if (t.restAfterReveal && t.restOverlapTicks > 0 && --t.restOverlapTicks == 0) { + { wind::SpanScope span_(wind::kSpanActivate); t.restAfterReveal->setActive(false); } + t.restAfterReveal = nullptr; + // The render overlay just left the screen: the DWM effect takes the colour back THIS + // tick, not next tick's top-of-tick call (a one-frame unfiltered flash otherwise). + { wind::SpanScope span_(wind::kSpanColor); UpdateColorFilter(t, true, RenderOverlayShown(t), nullptr); } + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2609,74 +2682,7 @@ static void RunTick(TickState& t) { PresentExtras ex = BuildPresentExtras(t, lvl, dt, zoomed, inspect, fsGame, wallNeeded, freeCursor, gameCursor, dragFollow, curDx, curDy, rawDx, rawDy, r); const bool doPresent = PaceAndPresent(t, ex, r, lvl, dt, zoomed, fsGame, enterActive); - // Reveal AFTER the live frame is presented: setVisible flips the layer alpha over the - // now-current front buffer, so the overlay never shows its retained previous-session - // frame (the alt-tab "previous window"). capture() also drained to the latest frame. - // Reveal/prime is render-specific (needs ForegroundCoversMonitor + capture priming); guard it - // behind the RenderModel downcast. A non-render model just reveals immediately on activation. - if (auto* rm = dynamic_cast(t.model)) { - if (enterActive) { - // EVERY reveal is gated on the session's first Present having EXECUTED on the GPU - // (revealFrameDone: an event query fenced right after Present). The blt into the - // layered redirection surface is GPU work, but the alpha flip is a CPU call DWM - // honours at its next composite - under GPU load the flip won that race and DWM - // composited the surface's RETAINED frame: the last thing the PREVIOUS zoom - // session presented (issue #140, second mechanism; the first was capture-side). - // A fullscreen app (game on an independent-flip/MPO plane, issue #90) additionally - // needs a post-prime composite in the capture (frameCompositedSincePrime), since - // Desktop Duplication can't see the game until the alpha-1 prime forces DWM to - // composite it. All non-blocking - the smooth-zoom ramp runs undisturbed; - // revealPending is only the fallback cap (~250 ms) so nothing can wedge the reveal. - t.revealNeedsComposite = fsGame; // same ForegroundCoversMonitor read, this tick - if (t.revealNeedsComposite) rm->primeReveal(); - t.revealPending = (t.hz > 0 ? t.hz : 60) / 4; - if (t.revealPending < 2) t.revealPending = 2; - // Ordinary desktop zoom-in: spin a tiny budget on the fence so the idle-GPU common - // case still reveals within this same tick (the instant feel is kept); a loaded - // GPU misses the budget and defers to the per-tick checks below. - if (!t.revealNeedsComposite && rm->revealFrameDone(3.0)) { - { wind::SpanScope span_(wind::kSpanActivate); rm->setActive(true); } - t.revealPending = 0; - // Real-time overlap, same as the render -> transform path (issue #274): - // raw ticks were right only at 144 Hz. - if (t.restAfterReveal) t.restOverlapTicks = TicksAtHz(3, t.hz); - } - } else if (t.revealPending > 0) { - --t.revealPending; - const bool frameDone = rm->revealFrameDone(); - const bool composited = !t.revealNeedsComposite || rm->frameCompositedSincePrime(); - if ((frameDone && composited) || t.revealPending == 0) { - { wind::SpanScope span_(wind::kSpanActivate); rm->setActive(true); } - wind::Log(wind::LogLevel::Info, "render", - "deferred reveal: frameDone=%d composited=%d ticksLeft=%d", - (int)frameDone, (int)composited, t.revealPending); - t.revealPending = 0; - // Real-time overlap, same as the render -> transform path (issue #274): - // raw ticks were right only at 144 Hz. - if (t.restAfterReveal) t.restOverlapTicks = TicksAtHz(3, t.hz); - } - } - } else if (enterActive) { - LARGE_INTEGER zs0; QueryPerformanceCounter(&zs0); - { wind::SpanScope span_(wind::kSpanActivate); t.model->setActive(true); } // transform: reveal immediately, no capture priming - if (t.zt.armed) { - LARGE_INTEGER zs1; QueryPerformanceCounter(&zs1); t.zt.setActiveMs = QpcMs(t, zs0.QuadPart, zs1.QuadPart); - if (auto* tm = dynamic_cast(t.model)) { - const auto sp = tm->lastEnter(); t.zt.ensureMs = sp.ensureMagMs; - } - DWM_TIMING_INFO ti{}; ti.cbSize = sizeof(ti); // the composite count the first write must beat - if (SUCCEEDED(DwmGetCompositionTimingInfo(nullptr, &ti))) t.zt.cFrame0 = ti.cFrame; - } - } - // Handover overlap: the outgoing engine rests a few ticks after the incoming one is - // live, so the crossover never composites a bare unmagnified frame (see instant switch). - if (t.restAfterReveal && t.restOverlapTicks > 0 && --t.restOverlapTicks == 0) { - { wind::SpanScope span_(wind::kSpanActivate); t.restAfterReveal->setActive(false); } - t.restAfterReveal = nullptr; - // The render overlay just left the screen: the DWM effect takes the colour back THIS - // tick, not next tick's top-of-tick call (a one-frame unfiltered flash otherwise). - { wind::SpanScope span_(wind::kSpanColor); UpdateColorFilter(t, true, RenderOverlayShown(t), nullptr); } - } + RevealAndHandover(t, enterActive, fsGame); // Execute the deferred game-inspect steal now that the reveal logic has read the true // foreground, and RE-assert it if the game pulled foreground back mid-inspect (some // engines re-grab on their own timers; a user alt-tab to a THIRD app is respected). From 9fa4557bee5c21e2aab6f3a52ce93a2a8544bc36 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:03:56 +0200 Subject: [PATCH 22/31] refactor(tick): extract AssertGameInspectSteal from RunTick Move-only: the deferred game-inspect foreground steal moves verbatim into AssertGameInspectSteal (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0676e4cf..e1ad9a7a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2624,6 +2624,26 @@ static void RevealAndHandover(TickState& t, bool enterActive, bool fsGame) { } } +// Active tick: execute or re-assert the game-inspect foreground steal. +static void AssertGameInspectSteal(TickState& t, bool inspect) { + // Execute the deferred game-inspect steal now that the reveal logic has read the true + // foreground, and RE-assert it if the game pulled foreground back mid-inspect (some + // engines re-grab on their own timers; a user alt-tab to a THIRD app is respected). + // If the steal fails (unsigned dev build denied by the foreground lock), drop back to + // normal inspect: the camera moves again, but the state is never inconsistent. + if (inspect && t.inspectGame) { + HWND fg = GetForegroundWindow(); + bool wantSteal = t.inspectStealPending || (t.inspectPrevFg && fg == t.inspectPrevFg); + t.inspectStealPending = false; + if (wantSteal && !StealForeground(EnsureFocusStealer(t.mon))) { + wind::Log(wind::LogLevel::Warn, "inspect", + "game-inspect: foreground steal failed; falling back to normal inspect"); + t.inspectGame = false; + t.inspectPrevFg = nullptr; + } + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2683,22 +2703,7 @@ static void RunTick(TickState& t) { gameCursor, dragFollow, curDx, curDy, rawDx, rawDy, r); const bool doPresent = PaceAndPresent(t, ex, r, lvl, dt, zoomed, fsGame, enterActive); RevealAndHandover(t, enterActive, fsGame); - // Execute the deferred game-inspect steal now that the reveal logic has read the true - // foreground, and RE-assert it if the game pulled foreground back mid-inspect (some - // engines re-grab on their own timers; a user alt-tab to a THIRD app is respected). - // If the steal fails (unsigned dev build denied by the foreground lock), drop back to - // normal inspect: the camera moves again, but the state is never inconsistent. - if (inspect && t.inspectGame) { - HWND fg = GetForegroundWindow(); - bool wantSteal = t.inspectStealPending || (t.inspectPrevFg && fg == t.inspectPrevFg); - t.inspectStealPending = false; - if (wantSteal && !StealForeground(EnsureFocusStealer(t.mon))) { - wind::Log(wind::LogLevel::Warn, "inspect", - "game-inspect: foreground steal failed; falling back to normal inspect"); - t.inspectGame = false; - t.inspectPrevFg = nullptr; - } - } + AssertGameInspectSteal(t, inspect); // Bookkeeping for next tick's GetCursorPos delta. INSPECT keeps the explicit frozen point // (the 1px clip pins the pointer there; explicit is immune to the click-release window). // Everything else uses a MEASURED read (issue #169) - the baseline must be where the From 87ece43e25c9db9a0931b4f89f95cd4bbdd9a9b5 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:04:19 +0200 Subject: [PATCH 23/31] refactor(tick): extract UpdateCursorBaseline from RunTick Move-only: the next-tick cursor baseline and the divergence diagnostics move verbatim into UpdateCursorBaseline (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 123 +++++++++++++++++++++++++++------------------------ 1 file changed, 65 insertions(+), 58 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index e1ad9a7a..2077a63c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2644,6 +2644,70 @@ static void AssertGameInspectSteal(TickState& t, bool inspect) { } } +// Active tick: the measured cursor baseline for the next tick's delta, plus the divergence +// diagnostics (diagnostics=1). +static void UpdateCursorBaseline(TickState& t, bool inspect, bool doPresent, bool dragFollow, double lvl, + const POINT& cur, const MapResult& r) { + // Bookkeeping for next tick's GetCursorPos delta. INSPECT keeps the explicit frozen point + // (the 1px clip pins the pointer there; explicit is immune to the click-release window). + // Everything else uses a MEASURED read (issue #169) - the baseline must be where the + // pointer actually IS after this tick's present, never where we intended to put it: + // - welded (render park OR transform weld; both report whether SetCursorPos REALLY ran + // this frame): the call is synchronous, so the read equals the park/weld point - + // identical to the old assumed baseline. + // - weld deduped/suppressed (unchanged centre pixel, drag-follow, gatePresent or + // fps-cap skip ticks): the old code assumed the park landed anyway and baselined on + // the lens centre. The next delta then measured hand + (pointer - centre) gap, the + // mapper integrated the gap, the centre overshot the pointer, and the sign flipped + // every tick: an unstable servo, oscillating with amplitude proportional to hand + // speed. That was the #169 window-drag flicker - and, before the transform weld was + // recognized here, the #181 corner drift in transform game sessions. + if (inspect) { + t.lastSetVirtual = t.frozenCursor; + } else { + // Parked -> the park point: hand motion after the park (including during the Present + // block) is measured next tick from there. Not parked (weld deduped/suppressed, + // transform FOLLOW, skipped frame) -> THIS tick's start-of-tick read `cur`: motion + // after that read is measured next tick. NEVER a fresh post-present read - Present + // blocks ~a frame at vsync, and a read taken after it swallows all hand motion that + // occurred during the block, so the lens pans slower than the hand and the weld drags + // the pointer backwards (field-reported as stalling/slowed cursor; first shipped + // version of this fix had exactly that bug). + auto* rmodel = dynamic_cast(t.model); + auto* tweld = dynamic_cast(t.model); + const bool parkedNow = doPresent && + ((rmodel && rmodel->engine().parkedLastFrame()) || + (tweld && tweld->weldedLastFrame())); + if (parkedNow) { + t.lastSetVirtual.x = r.clickDesktopX + t.mon.x; + t.lastSetVirtual.y = r.clickDesktopY + t.mon.y; + } else { + t.lastSetVirtual = cur; + } + // Divergence diagnostics (issue #169, diagnostics=1 only): once a second, log how far + // the pointer sits from the lens centre and how many ticks drag-followed. If any + // oscillation survives the fix, this pinpoints the fighting pair from the field log. + if (t.cfg.diagnostics) { + const double divX = t.lastSetVirtual.x - (double)(r.clickDesktopX + t.mon.x); + const double divY = t.lastSetVirtual.y - (double)(r.clickDesktopY + t.mon.y); + const double div = std::sqrt(divX * divX + divY * divY); + if (div > t.dbgMaxDivergence) t.dbgMaxDivergence = div; + if (dragFollow) t.dbgDragFollowTicks++; + const unsigned long long nowD = GetTickCount64(); + if (nowD - t.dbgDivergenceLogMs >= 1000) { + if (t.dbgDivergenceLogMs != 0) { + wind::Log(wind::LogLevel::Info, "cursor", + "divergence max=%.0fpx dragFollowTicks=%u lvl=%.2f", + t.dbgMaxDivergence, t.dbgDragFollowTicks, lvl); + } + t.dbgDivergenceLogMs = nowD; + t.dbgMaxDivergence = 0.0; + t.dbgDragFollowTicks = 0; + } + } + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2704,64 +2768,7 @@ static void RunTick(TickState& t) { const bool doPresent = PaceAndPresent(t, ex, r, lvl, dt, zoomed, fsGame, enterActive); RevealAndHandover(t, enterActive, fsGame); AssertGameInspectSteal(t, inspect); - // Bookkeeping for next tick's GetCursorPos delta. INSPECT keeps the explicit frozen point - // (the 1px clip pins the pointer there; explicit is immune to the click-release window). - // Everything else uses a MEASURED read (issue #169) - the baseline must be where the - // pointer actually IS after this tick's present, never where we intended to put it: - // - welded (render park OR transform weld; both report whether SetCursorPos REALLY ran - // this frame): the call is synchronous, so the read equals the park/weld point - - // identical to the old assumed baseline. - // - weld deduped/suppressed (unchanged centre pixel, drag-follow, gatePresent or - // fps-cap skip ticks): the old code assumed the park landed anyway and baselined on - // the lens centre. The next delta then measured hand + (pointer - centre) gap, the - // mapper integrated the gap, the centre overshot the pointer, and the sign flipped - // every tick: an unstable servo, oscillating with amplitude proportional to hand - // speed. That was the #169 window-drag flicker - and, before the transform weld was - // recognized here, the #181 corner drift in transform game sessions. - if (inspect) { - t.lastSetVirtual = t.frozenCursor; - } else { - // Parked -> the park point: hand motion after the park (including during the Present - // block) is measured next tick from there. Not parked (weld deduped/suppressed, - // transform FOLLOW, skipped frame) -> THIS tick's start-of-tick read `cur`: motion - // after that read is measured next tick. NEVER a fresh post-present read - Present - // blocks ~a frame at vsync, and a read taken after it swallows all hand motion that - // occurred during the block, so the lens pans slower than the hand and the weld drags - // the pointer backwards (field-reported as stalling/slowed cursor; first shipped - // version of this fix had exactly that bug). - auto* rmodel = dynamic_cast(t.model); - auto* tweld = dynamic_cast(t.model); - const bool parkedNow = doPresent && - ((rmodel && rmodel->engine().parkedLastFrame()) || - (tweld && tweld->weldedLastFrame())); - if (parkedNow) { - t.lastSetVirtual.x = r.clickDesktopX + t.mon.x; - t.lastSetVirtual.y = r.clickDesktopY + t.mon.y; - } else { - t.lastSetVirtual = cur; - } - // Divergence diagnostics (issue #169, diagnostics=1 only): once a second, log how far - // the pointer sits from the lens centre and how many ticks drag-followed. If any - // oscillation survives the fix, this pinpoints the fighting pair from the field log. - if (t.cfg.diagnostics) { - const double divX = t.lastSetVirtual.x - (double)(r.clickDesktopX + t.mon.x); - const double divY = t.lastSetVirtual.y - (double)(r.clickDesktopY + t.mon.y); - const double div = std::sqrt(divX * divX + divY * divY); - if (div > t.dbgMaxDivergence) t.dbgMaxDivergence = div; - if (dragFollow) t.dbgDragFollowTicks++; - const unsigned long long nowD = GetTickCount64(); - if (nowD - t.dbgDivergenceLogMs >= 1000) { - if (t.dbgDivergenceLogMs != 0) { - wind::Log(wind::LogLevel::Info, "cursor", - "divergence max=%.0fpx dragFollowTicks=%u lvl=%.2f", - t.dbgMaxDivergence, t.dbgDragFollowTicks, lvl); - } - t.dbgDivergenceLogMs = nowD; - t.dbgMaxDivergence = 0.0; - t.dbgDragFollowTicks = 0; - } - } - } + UpdateCursorBaseline(t, inspect, doPresent, dragFollow, lvl, cur, r); } else if (t.prevActive) { // active -> idle: tear the overlay down // The caret/focus watcher is switched off only from the zoomed view block; a zoom-out that // snaps straight to 1.0 skipped it and left the watcher polling at 1x (#71). From 50ba625b914f726d7cbc3981f18a1b54c233f2b4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:04:44 +0200 Subject: [PATCH 24/31] refactor(tick): extract TeardownSession from RunTick Move-only: the active -> idle teardown branch body moves verbatim into TeardownSession (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 67 ++++++++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 2077a63c..5f664e25 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2708,6 +2708,41 @@ static void UpdateCursorBaseline(TickState& t, bool inspect, bool doPresent, boo } } +// Session exit (active -> idle): tear the overlay down, restore the cursor and leave Inspect. +static void TeardownSession(TickState& t) { + // The caret/focus watcher is switched off only from the zoomed view block; a zoom-out that + // snaps straight to 1.0 skipped it and left the watcher polling at 1x (#71). + { wind::SpanScope span_(wind::kSpanTrack); g_track.setActive(false, t.cfg.trackCaret != 0, t.cfg.trackFocus != 0, t.cfg.trackLog != 0); } + if (t.restAfterReveal) { t.restAfterReveal->setActive(false); t.restAfterReveal = nullptr; } + // DWM effect back BEFORE the overlay hides: worst case one double-filtered frame, never a + // bright unfiltered one (review 2026-09-30). + { wind::SpanScope span_(wind::kSpanColor); UpdateColorFilter(t, false, false, nullptr); } + LARGE_INTEGER zo0; QueryPerformanceCounter(&zo0); + { wind::SpanScope span_(wind::kSpanActivate); t.model->setActive(false); } + if (t.cfg.zoomTrace) { + LARGE_INTEGER zo1; QueryPerformanceCounter(&zo1); + t.zt.outSetActiveMs = QpcMs(t, zo0.QuadPart, zo1.QuadPart); t.zt.outPending = true; + } + SetSystemCursorHidden(t, t.model, false); + t.outlineZoneSec = 0.0; // zoom-out clears the low-zoom dwell (no banked partial) + t.gamePacing = false; // idle: normal timer pacing + t.pushPhase = 0; + t.presentAccum = 0.0; + if (t.prevInspect) { + EndGameInspect(t); // teardown-to-idle exits game-inspect too (foreground returned) + ClipCursor(nullptr); + // Same phantom-click guard as the zoomed Inspect exit: swallowed-but-undrained clicks + // must not survive into the next activation. + g_input.state().commitLeft.exchange(0); + g_input.state().commitRight.exchange(0); + POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, (int)(t.mapper.centerY() + 0.5) + t.mon.y }; + SetCursorPos(lp.x, lp.y); // resume at the look point + t.lastSetVirtual = lp; + } + t.revealPending = 0; // a quick tap may zoom out before the deferred reveal + g_tint.invalidate(); // the engines put the scheme back: re-tint when idle +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2770,37 +2805,7 @@ static void RunTick(TickState& t) { AssertGameInspectSteal(t, inspect); UpdateCursorBaseline(t, inspect, doPresent, dragFollow, lvl, cur, r); } else if (t.prevActive) { // active -> idle: tear the overlay down - // The caret/focus watcher is switched off only from the zoomed view block; a zoom-out that - // snaps straight to 1.0 skipped it and left the watcher polling at 1x (#71). - { wind::SpanScope span_(wind::kSpanTrack); g_track.setActive(false, t.cfg.trackCaret != 0, t.cfg.trackFocus != 0, t.cfg.trackLog != 0); } - if (t.restAfterReveal) { t.restAfterReveal->setActive(false); t.restAfterReveal = nullptr; } - // DWM effect back BEFORE the overlay hides: worst case one double-filtered frame, never a - // bright unfiltered one (review 2026-09-30). - { wind::SpanScope span_(wind::kSpanColor); UpdateColorFilter(t, false, false, nullptr); } - LARGE_INTEGER zo0; QueryPerformanceCounter(&zo0); - { wind::SpanScope span_(wind::kSpanActivate); t.model->setActive(false); } - if (t.cfg.zoomTrace) { - LARGE_INTEGER zo1; QueryPerformanceCounter(&zo1); - t.zt.outSetActiveMs = QpcMs(t, zo0.QuadPart, zo1.QuadPart); t.zt.outPending = true; - } - SetSystemCursorHidden(t, t.model, false); - t.outlineZoneSec = 0.0; // zoom-out clears the low-zoom dwell (no banked partial) - t.gamePacing = false; // idle: normal timer pacing - t.pushPhase = 0; - t.presentAccum = 0.0; - if (t.prevInspect) { - EndGameInspect(t); // teardown-to-idle exits game-inspect too (foreground returned) - ClipCursor(nullptr); - // Same phantom-click guard as the zoomed Inspect exit: swallowed-but-undrained clicks - // must not survive into the next activation. - g_input.state().commitLeft.exchange(0); - g_input.state().commitRight.exchange(0); - POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, (int)(t.mapper.centerY() + 0.5) + t.mon.y }; - SetCursorPos(lp.x, lp.y); // resume at the look point - t.lastSetVirtual = lp; - } - t.revealPending = 0; // a quick tap may zoom out before the deferred reveal - g_tint.invalidate(); // the engines put the scheme back: re-tint when idle + TeardownSession(t); } else { // Idle: let the transform model release its magnification context shortly after a zoom // ends. While a context is alive, DWM composites magnification-aware and every cursor From c87876338ae3762ef7344eff401f30c500b87211 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:05:06 +0200 Subject: [PATCH 25/31] refactor(tick): extract IdleTick from RunTick Move-only: the idle branch body (engine idleTick, cursor tint) moves verbatim into IdleTick (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 5f664e25..2fbbe485 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2743,6 +2743,18 @@ static void TeardownSession(TickState& t) { g_tint.invalidate(); // the engines put the scheme back: re-tint when idle } +// Idle tick (1x, no Inspect): engine idle work and the 1x cursor tint. +static void IdleTick(TickState& t) { + // Idle: let the transform model release its magnification context shortly after a zoom + // ends. While a context is alive, DWM composites magnification-aware and every cursor + // change an app makes costs a re-composite - a game that toggles its pointer on + // middle-click hitches at 1x (issue #148). Both the active model and hybrid's transform + // half get the tick (in model=transform the transform IS t.model); others no-op. + t.model->idleTick(); + if (t.mTransform && t.mTransform != t.model) t.mTransform->idleTick(); + UpdateCursorTint(t); +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2807,14 +2819,7 @@ static void RunTick(TickState& t) { } else if (t.prevActive) { // active -> idle: tear the overlay down TeardownSession(t); } else { - // Idle: let the transform model release its magnification context shortly after a zoom - // ends. While a context is alive, DWM composites magnification-aware and every cursor - // change an app makes costs a re-composite - a game that toggles its pointer on - // middle-click hitches at 1x (issue #148). Both the active model and hybrid's transform - // half get the tick (in model=transform the transform IS t.model); others no-op. - t.model->idleTick(); - if (t.mTransform && t.mTransform != t.model) t.mTransform->idleTick(); - UpdateCursorTint(t); + IdleTick(t); } t.prevLvl = lvl; t.prevActive = active; From e09301d7a35c054e89b7258101d9c7fc27540b79 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:05:38 +0200 Subject: [PATCH 26/31] refactor(tick): extract RunActiveTick from RunTick Move-only: the zoomed/Inspect branch body (now a sequence of phase calls) moves verbatim into RunActiveTick; RunTick's rawDx/rawDy become const (the Inspect-entry zeroing now acts on RunActiveTick's copy, and nothing after the branch read them) (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 88 ++++++++++++++++++++++++++++------------------------ 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 2fbbe485..ac839fdb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2755,6 +2755,52 @@ static void IdleTick(TickState& t) { UpdateCursorTint(t); } +// Active tick (zoomed or Inspect): the session phases in order, from the enter/Inspect edges to +// the measured baseline for the next tick. rawDx/rawDy are this tick's raw motion (Inspect entry +// zeroes the local copy). +static void RunActiveTick(TickState& t, double lvl, double dt, bool zoomed, bool inspect, bool recenter, + bool panArmed, bool kbHook, int rawDx, int rawDy) { + bool enterActive = !t.prevActive; // idle -> active (overlay just turned on) + bool inspectEnter = inspect && !t.prevInspect; + if (enterActive) { + EnterSession(t, zoomed, inspect); + } + if (inspectEnter) { + EnterInspect(t, zoomed, rawDx, rawDy); + } + bool inspectExit = !inspect && t.prevInspect; // Inspect just turned off but overlay stays (zoomed) + if (inspectExit) { + ExitInspect(t); + } + if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); t.lastSetVirtual = pt; } + const PanDelta pd = ResolvePanDelta(t, inspect, lvl, dt, rawDx, rawDy); + POINT cur = pd.cur; + const int curDx = pd.curDx, curDy = pd.curDy; + const int dx = pd.dx, dy = pd.dy; + const bool dragFollow = pd.dragFollow; + const TickForeground tf = ReadForegroundAndArmWall(t, lvl); + const HWND fgTick = tf.fgTick; + const bool fsCover = tf.fsCover, fgBorderless = tf.fgBorderless; + const bool wallNeeded = tf.wallNeeded, liftIfTransform = tf.liftIfTransform; + const PointerMap pm = MapPointer(t, inspect, lvl, dt, fsCover, cur, curDx, curDy, dx, dy); + const bool freeCursor = pm.freeCursor, gameCursor = pm.gameCursor; + MapResult r = pm.r; + StepTracking(t, lvl, inspect, fsCover, panArmed, kbHook, dt, wallNeeded, cur, curDx, curDy, r); + ProbeClicks(t, lvl, cur, r); + CommitInspectClicks(t, r); + // Fullscreen-game tell for the per-tick perf levers (issue #148): crop the capture copy to + // the magnified region (gameCrop), skip the periodic topmost backstop, and optionally cap + // our own present rate (gameFpsCap). fgTick/fsCover/fgBorderless were read once above. + const bool fsGame = fsCover; + InstantHybridSwitch(t, enterActive, inspect, fgTick, fsCover, fgBorderless, liftIfTransform); + PresentExtras ex = BuildPresentExtras(t, lvl, dt, zoomed, inspect, fsGame, wallNeeded, freeCursor, + gameCursor, dragFollow, curDx, curDy, rawDx, rawDy, r); + const bool doPresent = PaceAndPresent(t, ex, r, lvl, dt, zoomed, fsGame, enterActive); + RevealAndHandover(t, enterActive, fsGame); + AssertGameInspectSteal(t, inspect); + UpdateCursorBaseline(t, inspect, doPresent, dragFollow, lvl, cur, r); +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2772,50 +2818,12 @@ static void RunTick(TickState& t) { const double lvl = in.lvl; const TickModes md = ResolveTickModes(t, lvl, now); - int rawDx = md.rawDx, rawDy = md.rawDy; + const int rawDx = md.rawDx, rawDy = md.rawDy; const bool zoomed = md.zoomed, inspect = md.inspect, active = md.active; const bool panArmed = md.panArmed; if (active) { - bool enterActive = !t.prevActive; // idle -> active (overlay just turned on) - bool inspectEnter = inspect && !t.prevInspect; - if (enterActive) { - EnterSession(t, zoomed, inspect); - } - if (inspectEnter) { - EnterInspect(t, zoomed, rawDx, rawDy); - } - bool inspectExit = !inspect && t.prevInspect; // Inspect just turned off but overlay stays (zoomed) - if (inspectExit) { - ExitInspect(t); - } - if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); t.lastSetVirtual = pt; } - const PanDelta pd = ResolvePanDelta(t, inspect, lvl, dt, rawDx, rawDy); - POINT cur = pd.cur; - const int curDx = pd.curDx, curDy = pd.curDy; - const int dx = pd.dx, dy = pd.dy; - const bool dragFollow = pd.dragFollow; - const TickForeground tf = ReadForegroundAndArmWall(t, lvl); - const HWND fgTick = tf.fgTick; - const bool fsCover = tf.fsCover, fgBorderless = tf.fgBorderless; - const bool wallNeeded = tf.wallNeeded, liftIfTransform = tf.liftIfTransform; - const PointerMap pm = MapPointer(t, inspect, lvl, dt, fsCover, cur, curDx, curDy, dx, dy); - const bool freeCursor = pm.freeCursor, gameCursor = pm.gameCursor; - MapResult r = pm.r; - StepTracking(t, lvl, inspect, fsCover, panArmed, kbHook, dt, wallNeeded, cur, curDx, curDy, r); - ProbeClicks(t, lvl, cur, r); - CommitInspectClicks(t, r); - // Fullscreen-game tell for the per-tick perf levers (issue #148): crop the capture copy to - // the magnified region (gameCrop), skip the periodic topmost backstop, and optionally cap - // our own present rate (gameFpsCap). fgTick/fsCover/fgBorderless were read once above. - const bool fsGame = fsCover; - InstantHybridSwitch(t, enterActive, inspect, fgTick, fsCover, fgBorderless, liftIfTransform); - PresentExtras ex = BuildPresentExtras(t, lvl, dt, zoomed, inspect, fsGame, wallNeeded, freeCursor, - gameCursor, dragFollow, curDx, curDy, rawDx, rawDy, r); - const bool doPresent = PaceAndPresent(t, ex, r, lvl, dt, zoomed, fsGame, enterActive); - RevealAndHandover(t, enterActive, fsGame); - AssertGameInspectSteal(t, inspect); - UpdateCursorBaseline(t, inspect, doPresent, dragFollow, lvl, cur, r); + RunActiveTick(t, lvl, dt, zoomed, inspect, recenter, panArmed, kbHook, rawDx, rawDy); } else if (t.prevActive) { // active -> idle: tear the overlay down TeardownSession(t); } else { From 1b748ef826748d61b59de25a827f99ec0663df90 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:06:17 +0200 Subject: [PATCH 27/31] refactor(tick): extract LogHeldState from RunTick Move-only: the held-state timeline diagnostics (issue #113) move verbatim into LogHeldState; the bare scope braces go, so the body is dedented one level (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 95 +++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index ac839fdb..99d2d848 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2801,6 +2801,54 @@ static void RunActiveTick(TickState& t, double lvl, double dt, bool zoomed, bool UpdateCursorBaseline(t, inspect, doPresent, dragFollow, lvl, cur, r); } +// Tick diagnostics: the held-state timeline (issue #113 / #167). +static void LogHeldState(TickState& t, bool inHeld, bool outHeld, double lvl, double dt) { + // Diagnostics (issue #113): log the side-button held-state timeline so the intermittent stuck can + // be diagnosed from the log. On every rise/fall, dump the hook + Raw Input event counters and the + // held duration; a stuck shows as a rise with no matching fall (and the next event only on + // re-click). Also WARN once if a hold overstays (first line at 10 s, then every 5 s; well past any hold-to-zoom, which caps in + // ~2 s) - that line, with static counters, pinpoints a stuck episode. Edges/overstay only, so + // Log() is never hit on the per-frame path. + auto& st = g_input.state(); + auto snap = [&](const char* tag, bool held, bool& prev, double& secs) { + if (held != prev) { + // Edge: `secs` still holds the accumulated duration (meaningful on a fall; ~0 on a rise). + wind::Log(wind::LogLevel::Info, "input", + "%sHeld %d->%d held=%.2fs hook[d=%u u=%u dbl=%u / d=%u u=%u dbl=%u] raw[d=%u u=%u / d=%u u=%u] hookActive=%d lvl=%.2f", + tag, prev ? 1 : 0, held ? 1 : 0, secs, + st.dbgHookDown[1].load(), st.dbgHookUp[1].load(), st.dbgHookDbl[1].load(), + st.dbgHookDown[2].load(), st.dbgHookUp[2].load(), st.dbgHookDbl[2].load(), + st.dbgRawDown[1].load(), st.dbgRawUp[1].load(), + st.dbgRawDown[2].load(), st.dbgRawUp[2].load(), + g_input.hookActive() ? 1 : 0, lvl); + prev = held; + // Re-fire every 5 s while a hold overstays, not once. A stuck hold has no falling edge, + // so a single sample can never show HOW FAST the runaway zoom is climbing - and that + // rate (reported slower than the configured zoomInSpeed) is the open half of #167. A + // series of lvl= samples 5 s apart makes it computable from the log. Gated on the + // 5 s window rather than a flag, so it costs no extra state and cannot spam per tick. + } else if (held && secs > 6.0 && + static_cast(secs / 5.0) != static_cast((secs - dt) / 5.0)) { + wind::Log(wind::LogLevel::Warn, "input", + "%sHeld STUCK? held=%.1fs hook[d=%u u=%u dbl=%u / d=%u u=%u dbl=%u] raw[d=%u u=%u / d=%u u=%u] lvl=%.2f", + tag, secs, + st.dbgHookDown[1].load(), st.dbgHookUp[1].load(), st.dbgHookDbl[1].load(), + st.dbgHookDown[2].load(), st.dbgHookUp[2].load(), st.dbgHookDbl[2].load(), + st.dbgRawDown[1].load(), st.dbgRawUp[1].load(), + st.dbgRawDown[2].load(), st.dbgRawUp[2].load(), lvl); + } + // Accumulate AFTER edge handling so a fall reports the pre-reset duration; cleared at 0 when up. + secs = held ? (secs + dt) : 0.0; + }; + // Watch the EFFECTIVE held state - side-buttons OR keyboard binds - not just the + // side-button half (issue #167). A stuck KEYBOARD bind drove a runaway zoom that this + // detector could not see, which is why episodes of #167 never left a STUCK? line in the + // log despite being hit repeatedly. inHeld/outHeld are the same values that drive the + // zoom, so the diagnostic now reports what actually happened rather than half of it. + snap("in", inHeld, t.dbgPrevInHeld, t.dbgInHeldSec); + snap("out", outHeld, t.dbgPrevOutHeld, t.dbgOutHeldSec); +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2833,52 +2881,7 @@ static void RunTick(TickState& t) { t.prevActive = active; t.prevInspect = inspect; - // Diagnostics (issue #113): log the side-button held-state timeline so the intermittent stuck can - // be diagnosed from the log. On every rise/fall, dump the hook + Raw Input event counters and the - // held duration; a stuck shows as a rise with no matching fall (and the next event only on - // re-click). Also WARN once if a hold overstays (first line at 10 s, then every 5 s; well past any hold-to-zoom, which caps in - // ~2 s) - that line, with static counters, pinpoints a stuck episode. Edges/overstay only, so - // Log() is never hit on the per-frame path. - { - auto& st = g_input.state(); - auto snap = [&](const char* tag, bool held, bool& prev, double& secs) { - if (held != prev) { - // Edge: `secs` still holds the accumulated duration (meaningful on a fall; ~0 on a rise). - wind::Log(wind::LogLevel::Info, "input", - "%sHeld %d->%d held=%.2fs hook[d=%u u=%u dbl=%u / d=%u u=%u dbl=%u] raw[d=%u u=%u / d=%u u=%u] hookActive=%d lvl=%.2f", - tag, prev ? 1 : 0, held ? 1 : 0, secs, - st.dbgHookDown[1].load(), st.dbgHookUp[1].load(), st.dbgHookDbl[1].load(), - st.dbgHookDown[2].load(), st.dbgHookUp[2].load(), st.dbgHookDbl[2].load(), - st.dbgRawDown[1].load(), st.dbgRawUp[1].load(), - st.dbgRawDown[2].load(), st.dbgRawUp[2].load(), - g_input.hookActive() ? 1 : 0, lvl); - prev = held; - // Re-fire every 5 s while a hold overstays, not once. A stuck hold has no falling edge, - // so a single sample can never show HOW FAST the runaway zoom is climbing - and that - // rate (reported slower than the configured zoomInSpeed) is the open half of #167. A - // series of lvl= samples 5 s apart makes it computable from the log. Gated on the - // 5 s window rather than a flag, so it costs no extra state and cannot spam per tick. - } else if (held && secs > 6.0 && - static_cast(secs / 5.0) != static_cast((secs - dt) / 5.0)) { - wind::Log(wind::LogLevel::Warn, "input", - "%sHeld STUCK? held=%.1fs hook[d=%u u=%u dbl=%u / d=%u u=%u dbl=%u] raw[d=%u u=%u / d=%u u=%u] lvl=%.2f", - tag, secs, - st.dbgHookDown[1].load(), st.dbgHookUp[1].load(), st.dbgHookDbl[1].load(), - st.dbgHookDown[2].load(), st.dbgHookUp[2].load(), st.dbgHookDbl[2].load(), - st.dbgRawDown[1].load(), st.dbgRawUp[1].load(), - st.dbgRawDown[2].load(), st.dbgRawUp[2].load(), lvl); - } - // Accumulate AFTER edge handling so a fall reports the pre-reset duration; cleared at 0 when up. - secs = held ? (secs + dt) : 0.0; - }; - // Watch the EFFECTIVE held state - side-buttons OR keyboard binds - not just the - // side-button half (issue #167). A stuck KEYBOARD bind drove a runaway zoom that this - // detector could not see, which is why episodes of #167 never left a STUCK? line in the - // log despite being hit repeatedly. inHeld/outHeld are the same values that drive the - // zoom, so the diagnostic now reports what actually happened rather than half of it. - snap("in", inHeld, t.dbgPrevInHeld, t.dbgInHeldSec); - snap("out", outHeld, t.dbgPrevOutHeld, t.dbgOutHeldSec); - } + LogHeldState(t, inHeld, outHeld, lvl, dt); // Frame-pacing diagnostics: a 2 s window of loop-interval stats (dt = time between ticks = // the on-screen frame interval, since Present(1,0) paces while zoomed). maxDt and the hitch From 70b3e8236c50299a84ced17d49f73206a1a1c9b0 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:06:42 +0200 Subject: [PATCH 28/31] refactor(tick): extract LogFramePacing from RunTick Move-only: the frame-pacing diagnostics move verbatim into LogFramePacing (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 53 ++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 99d2d848..d6127212 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2849,6 +2849,34 @@ static void LogHeldState(TickState& t, bool inHeld, bool outHeld, double lvl, do snap("out", outHeld, t.dbgPrevOutHeld, t.dbgOutHeldSec); } +// Tick diagnostics: frame-pacing stats (diagnostics=1). +static void LogFramePacing(TickState& t, double lvl, double dt, bool woke) { + // Frame-pacing diagnostics: a 2 s window of loop-interval stats (dt = time between ticks = + // the on-screen frame interval, since Present(1,0) paces while zoomed). maxDt and the hitch + // count expose microstutter that an average would hide. + if (t.cfg.diagnostics && !woke) { + const double target = 1.0 / (t.hz > 0 ? t.hz : 60); + t.diagSumDt += dt; t.diagFrames++; t.diagAccum += dt; + if (dt > t.diagMaxDt) t.diagMaxDt = dt; + if (dt > target * 1.5) t.diagHitches++; + if (t.diagAccum >= 2.0 && t.diagFrames > 0) { + // Render/present CPU split from the engine (render model only): avg/max ms building + // the frame vs blocked inside Present - the present column is where GPU contention + // with a game shows up (issue #148). + double rSum = 0, rMax = 0, pSum = 0, pMax = 0; int pf = 0, skips = 0; + if (auto* rm = dynamic_cast(t.model)) + rm->engine().debugPerf(rSum, rMax, pSum, pMax, pf, skips, /*reset=*/true); + DiagLog("zoom=%.2f frames=%d ~fps=%.0f avgDt=%.2fms maxDt=%.2fms hitches>1.5x=%d " + "render(avg=%.2f max=%.2f)ms present(avg=%.2f max=%.2f)ms presented=%d gateSkips=%d", + lvl, t.diagFrames, t.diagFrames / t.diagAccum, + t.diagSumDt / t.diagFrames * 1000.0, t.diagMaxDt * 1000.0, t.diagHitches, + pf > 0 ? rSum / pf : 0.0, rMax, pf > 0 ? pSum / pf : 0.0, pMax, pf, skips); + t.diagAccum = 0.0; t.diagSumDt = 0.0; t.diagMaxDt = 0.0; + t.diagFrames = 0; t.diagHitches = 0; + } + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2883,30 +2911,7 @@ static void RunTick(TickState& t) { LogHeldState(t, inHeld, outHeld, lvl, dt); - // Frame-pacing diagnostics: a 2 s window of loop-interval stats (dt = time between ticks = - // the on-screen frame interval, since Present(1,0) paces while zoomed). maxDt and the hitch - // count expose microstutter that an average would hide. - if (t.cfg.diagnostics && !woke) { - const double target = 1.0 / (t.hz > 0 ? t.hz : 60); - t.diagSumDt += dt; t.diagFrames++; t.diagAccum += dt; - if (dt > t.diagMaxDt) t.diagMaxDt = dt; - if (dt > target * 1.5) t.diagHitches++; - if (t.diagAccum >= 2.0 && t.diagFrames > 0) { - // Render/present CPU split from the engine (render model only): avg/max ms building - // the frame vs blocked inside Present - the present column is where GPU contention - // with a game shows up (issue #148). - double rSum = 0, rMax = 0, pSum = 0, pMax = 0; int pf = 0, skips = 0; - if (auto* rm = dynamic_cast(t.model)) - rm->engine().debugPerf(rSum, rMax, pSum, pMax, pf, skips, /*reset=*/true); - DiagLog("zoom=%.2f frames=%d ~fps=%.0f avgDt=%.2fms maxDt=%.2fms hitches>1.5x=%d " - "render(avg=%.2f max=%.2f)ms present(avg=%.2f max=%.2f)ms presented=%d gateSkips=%d", - lvl, t.diagFrames, t.diagFrames / t.diagAccum, - t.diagSumDt / t.diagFrames * 1000.0, t.diagMaxDt * 1000.0, t.diagHitches, - pf > 0 ? rSum / pf : 0.0, rMax, pf > 0 ? pSum / pf : 0.0, pMax, pf, skips); - t.diagAccum = 0.0; t.diagSumDt = 0.0; t.diagMaxDt = 0.0; - t.diagFrames = 0; t.diagHitches = 0; - } - } + LogFramePacing(t, lvl, dt, woke); // Test telemetry (issue #225): one CSV sample per tick when the harness enabled it. The // engine queries run only on this path - the disabled cost is the single enabled() branch. From 954f84befd8097e6990c7b5ee9e058430643683c Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:07:07 +0200 Subject: [PATCH 29/31] refactor(tick): extract WriteTestTelemetry from RunTick Move-only: the test telemetry sample moves verbatim into WriteTestTelemetry (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index d6127212..454b07dc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2877,6 +2877,32 @@ static void LogFramePacing(TickState& t, double lvl, double dt, bool woke) { } } +// Tick diagnostics: the proving-ground telemetry sample (WIND_TESTLOG). +static void WriteTestTelemetry(TickState& t, const LARGE_INTEGER& now, double dt, bool active, double lvl) { + // Test telemetry (issue #225): one CSV sample per tick when the harness enabled it. The + // engine queries run only on this path - the disabled cost is the single enabled() branch. + if (g_testlog.enabled()) { + wind::TelemetrySample s{}; + s.tMs = double(now.QuadPart) / double(t.freq.QuadPart) * 1000.0; + s.dtMs = dt * 1000.0; + s.active = active ? 1 : 0; + s.level = lvl; + auto* rmT = dynamic_cast(t.model); + auto* tmT = dynamic_cast(t.model); + s.engine = rmT ? 'R' : (tmT ? 'T' : '-'); + s.mapX = t.mapper.centerX(); s.mapY = t.mapper.centerY(); + s.monX = t.mon.x; s.monY = t.mon.y; + s.curX = t.lastSetVirtual.x; s.curY = t.lastSetVirtual.y; + s.welded = (rmT && rmT->engine().parkedLastFrame()) || + (tmT && tmT->weldedLastFrame()) ? 1 : 0; + if (tmT) { + s.wLevel = tmT->writtenLevel(); + s.wTxX = tmT->writtenTxX(); s.wTxY = tmT->writtenTxY(); + } + g_testlog.write(s); + } +} + static void RunTick(TickState& t) { TickWorkTimer workTimer(t); const TickClock clk = BeginTick(t); @@ -2913,28 +2939,7 @@ static void RunTick(TickState& t) { LogFramePacing(t, lvl, dt, woke); - // Test telemetry (issue #225): one CSV sample per tick when the harness enabled it. The - // engine queries run only on this path - the disabled cost is the single enabled() branch. - if (g_testlog.enabled()) { - wind::TelemetrySample s{}; - s.tMs = double(now.QuadPart) / double(t.freq.QuadPart) * 1000.0; - s.dtMs = dt * 1000.0; - s.active = active ? 1 : 0; - s.level = lvl; - auto* rmT = dynamic_cast(t.model); - auto* tmT = dynamic_cast(t.model); - s.engine = rmT ? 'R' : (tmT ? 'T' : '-'); - s.mapX = t.mapper.centerX(); s.mapY = t.mapper.centerY(); - s.monX = t.mon.x; s.monY = t.mon.y; - s.curX = t.lastSetVirtual.x; s.curY = t.lastSetVirtual.y; - s.welded = (rmT && rmT->engine().parkedLastFrame()) || - (tmT && tmT->weldedLastFrame()) ? 1 : 0; - if (tmT) { - s.wLevel = tmT->writtenLevel(); - s.wTxX = tmT->writtenTxX(); s.wTxY = tmT->writtenTxY(); - } - g_testlog.write(s); - } + WriteTestTelemetry(t, now, dt, active, lvl); } // Message-handler: decodes raw mouse movement (survives cursor lock) and routes tray msgs. From 4dd68a4898d1f9e4d2d1ae0aa54a1c76370a5af4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:08:17 +0200 Subject: [PATCH 30/31] refactor(tick): extract ApplyReloadedConfig from PollConfigReload Move-only: the core-relevant reload apply (re-binds, hotkeys, live engine prefs, zoom and mapper rebuild) moves verbatim out of PollConfigReload into ApplyReloadedConfig (#478). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/main.cpp | 142 ++++++++++++++++++++++++++------------------------- 1 file changed, 73 insertions(+), 69 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 454b07dc..20a0cae0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1044,6 +1044,78 @@ static TickClock BeginTick(TickState& t) { return { now, dt, rawDt, woke }; } +// Config hot-reload, core-relevant change: apply the newly parsed config nc (re-binds, hotkeys, +// live engine prefs) and rebuild the zoom controller and mapper with the live level and centre. +static void ApplyReloadedConfig(TickState& t, Config& nc, const std::string& stripped) { + t.lastCoreIni = stripped; + // Issue #242: the high-res/MPO option is atomic at restart - while an MPO restart is + // pending (registry != boot) the BOOT state's look holds in both directions, and + // crisp never runs on an MPO-enabled boot (the 16-bit TDR combo; covers profile + // switches and hand edits too). The ini keeps the user's intent. + if (int eff = EffectiveSamplingMode(nc.txSamplingMode, g_mpoDisabled, + wind::MpoDisabledInRegistry(), nc.tdrTest, + nc.mpoNearestGuard != 0); + eff != nc.txSamplingMode) { + wind::Log(wind::LogLevel::Info, "config", + "sampling %d deferred, running %d: MPO restart pending or MPO-enabled " + "boot (issue #242)", nc.txSamplingMode, eff); + nc.txSamplingMode = eff; + } + // Re-bind the hook's button mapping if the user changed it via the config UI; without + // this the hook would keep firing the OLD button (the new VK works via GetAsyncKeyState + // but the mouse mapping is captured once in g_input.start at app launch). + if (wind::ButtonBindsChanged(nc, t.cfg)) { + g_input.setButtonBinds(nc.zoomInButton, nc.zoomInButtonMods, nc.zoomInButton2, nc.zoomInButton2Mods, + nc.zoomOutButton, nc.zoomOutButtonMods, nc.zoomOutButton2, nc.zoomOutButton2Mods); + } + g_input.setWheelMods(nc.zoomWheelMods); // scroll-wheel zoom (#285); one relaxed store + // Re-bind the keyboard hook's tracked/swallowed keys when any keyboard zoom/recenter + // bind changed (else the hook keeps swallowing the OLD key and ignores the new one). + if (wind::KeyBindsChanged(nc, t.cfg)) { + g_input.setKeys(nc.zoomInVk, nc.zoomInVk2, nc.zoomOutVk, nc.zoomOutVk2, nc.recenterVk, + nc.cursorLockVk); + } + g_input.setKeyMods(nc.zoomInMods, nc.zoomInMods2, nc.zoomOutMods, nc.zoomOutMods2, + nc.recenterMods, nc.cursorLockMods); + if (wind::PanBindsChanged(nc, t.cfg)) { + const int pv[4] = { nc.panLeftVk, nc.panRightVk, nc.panUpVk, nc.panDownVk }; + const int pm[4] = { nc.panLeftMods, nc.panRightMods, nc.panUpMods, nc.panDownMods }; + g_input.setPanKeys(pv, pm); // keyboard panning (#287) + } + if (nc.hideCursorVk != t.cfg.hideCursorVk || nc.hideCursorMods != t.cfg.hideCursorMods) { + RegisterHideCursorHotkey(t.hwnd, nc.hideCursorVk, nc.hideCursorMods); + } + if (nc.quickZoomHotkeyMode != t.cfg.quickZoomHotkeyMode + || nc.quickZoomVk != t.cfg.quickZoomVk || nc.quickZoomMods != t.cfg.quickZoomMods) { + RegisterQuickZoomHotkey(t.hwnd, (nc.quickZoomHotkeyMode && nc.quickZoomVk) ? nc.quickZoomVk : 0, + nc.quickZoomMods); + } + if (nc.model != t.cfg.model) // both engines are built: the next pick follows it, no restart + wind::Log(wind::LogLevel::Info, "config", "engine %s -> %s (applies live)", t.cfg.model.c_str(), nc.model.c_str()); + // txNativeCursor (#463): the next transform session's cursor (latched at zoom-in). + if (auto* tmHot = dynamic_cast(t.mTransform ? t.mTransform : t.model)) + tmHot->setNativeCursorPref(wind::UseNativeCursor(nc.txNativeCursor)); + t.cfg = nc; // pick up renderer knobs (smoothing, filter, cursor scale, zoom speed) + // transformExclude / renderExclude / the per-window-type engine keys may all have + // changed: drop the cache so every exe-derived predicate is re-resolved. Without this + // an edited list only took effect on the next foreground change, which reads as the + // setting not working. + t.fgCacheHwnd = nullptr; + // PRESERVE THE LIVE ZOOM ACROSS THE RELOAD (issue #234). ZoomController starts at its + // minimum, so rebuilding it for a possibly-changed maxLevel used to drop whatever the + // user was zoomed to: change any core setting while zoomed and the view collapsed to + // 1x instantly. That is exactly the moment someone tunes zoom speed or smoothing and + // watches the effect. setLevel clamps into the new range, so a maxLevel lowered below + // the current level lands on the new ceiling rather than snapping home. The mapper's + // centre is preserved two lines below for the same reason; the level was just missed. + const double keepLevel = t.zoom.level(); + t.zoom = ZoomController(1.0, nc.maxLevel); + t.zoom.setLevel(keepLevel); + double ocx = t.mapper.centerX(), ocy = t.mapper.centerY(); // preserve position + t.mapper = CursorMapper(t.mon.w, t.mon.h); + t.mapper.reset(ocx, ocy); +} + // Tick phase: config hot-reload (rawDt drives the check cadence). static void PollConfigReload(TickState& t, double rawDt) { // Config hot-reload. A directory-change notification tells us WHEN to re-check magnifier.ini, @@ -1105,75 +1177,7 @@ static void PollConfigReload(TickState& t, double rawDt) { t.lastMtime = m; t.configRetry = false; } - if (loaded) { - t.lastCoreIni = stripped; - // Issue #242: the high-res/MPO option is atomic at restart - while an MPO restart is - // pending (registry != boot) the BOOT state's look holds in both directions, and - // crisp never runs on an MPO-enabled boot (the 16-bit TDR combo; covers profile - // switches and hand edits too). The ini keeps the user's intent. - if (int eff = EffectiveSamplingMode(nc.txSamplingMode, g_mpoDisabled, - wind::MpoDisabledInRegistry(), nc.tdrTest, - nc.mpoNearestGuard != 0); - eff != nc.txSamplingMode) { - wind::Log(wind::LogLevel::Info, "config", - "sampling %d deferred, running %d: MPO restart pending or MPO-enabled " - "boot (issue #242)", nc.txSamplingMode, eff); - nc.txSamplingMode = eff; - } - // Re-bind the hook's button mapping if the user changed it via the config UI; without - // this the hook would keep firing the OLD button (the new VK works via GetAsyncKeyState - // but the mouse mapping is captured once in g_input.start at app launch). - if (wind::ButtonBindsChanged(nc, t.cfg)) { - g_input.setButtonBinds(nc.zoomInButton, nc.zoomInButtonMods, nc.zoomInButton2, nc.zoomInButton2Mods, - nc.zoomOutButton, nc.zoomOutButtonMods, nc.zoomOutButton2, nc.zoomOutButton2Mods); - } - g_input.setWheelMods(nc.zoomWheelMods); // scroll-wheel zoom (#285); one relaxed store - // Re-bind the keyboard hook's tracked/swallowed keys when any keyboard zoom/recenter - // bind changed (else the hook keeps swallowing the OLD key and ignores the new one). - if (wind::KeyBindsChanged(nc, t.cfg)) { - g_input.setKeys(nc.zoomInVk, nc.zoomInVk2, nc.zoomOutVk, nc.zoomOutVk2, nc.recenterVk, - nc.cursorLockVk); - } - g_input.setKeyMods(nc.zoomInMods, nc.zoomInMods2, nc.zoomOutMods, nc.zoomOutMods2, - nc.recenterMods, nc.cursorLockMods); - if (wind::PanBindsChanged(nc, t.cfg)) { - const int pv[4] = { nc.panLeftVk, nc.panRightVk, nc.panUpVk, nc.panDownVk }; - const int pm[4] = { nc.panLeftMods, nc.panRightMods, nc.panUpMods, nc.panDownMods }; - g_input.setPanKeys(pv, pm); // keyboard panning (#287) - } - if (nc.hideCursorVk != t.cfg.hideCursorVk || nc.hideCursorMods != t.cfg.hideCursorMods) { - RegisterHideCursorHotkey(t.hwnd, nc.hideCursorVk, nc.hideCursorMods); - } - if (nc.quickZoomHotkeyMode != t.cfg.quickZoomHotkeyMode - || nc.quickZoomVk != t.cfg.quickZoomVk || nc.quickZoomMods != t.cfg.quickZoomMods) { - RegisterQuickZoomHotkey(t.hwnd, (nc.quickZoomHotkeyMode && nc.quickZoomVk) ? nc.quickZoomVk : 0, - nc.quickZoomMods); - } - if (nc.model != t.cfg.model) // both engines are built: the next pick follows it, no restart - wind::Log(wind::LogLevel::Info, "config", "engine %s -> %s (applies live)", t.cfg.model.c_str(), nc.model.c_str()); - // txNativeCursor (#463): the next transform session's cursor (latched at zoom-in). - if (auto* tmHot = dynamic_cast(t.mTransform ? t.mTransform : t.model)) - tmHot->setNativeCursorPref(wind::UseNativeCursor(nc.txNativeCursor)); - t.cfg = nc; // pick up renderer knobs (smoothing, filter, cursor scale, zoom speed) - // transformExclude / renderExclude / the per-window-type engine keys may all have - // changed: drop the cache so every exe-derived predicate is re-resolved. Without this - // an edited list only took effect on the next foreground change, which reads as the - // setting not working. - t.fgCacheHwnd = nullptr; - // PRESERVE THE LIVE ZOOM ACROSS THE RELOAD (issue #234). ZoomController starts at its - // minimum, so rebuilding it for a possibly-changed maxLevel used to drop whatever the - // user was zoomed to: change any core setting while zoomed and the view collapsed to - // 1x instantly. That is exactly the moment someone tunes zoom speed or smoothing and - // watches the effect. setLevel clamps into the new range, so a maxLevel lowered below - // the current level lands on the new ceiling rather than snapping home. The mapper's - // centre is preserved two lines below for the same reason; the level was just missed. - const double keepLevel = t.zoom.level(); - t.zoom = ZoomController(1.0, nc.maxLevel); - t.zoom.setLevel(keepLevel); - double ocx = t.mapper.centerX(), ocy = t.mapper.centerY(); // preserve position - t.mapper = CursorMapper(t.mon.w, t.mon.h); - t.mapper.reset(ocx, ocy); - } // core-relevant change guard (StripUiOnlyKeys) + if (loaded) ApplyReloadedConfig(t, nc, stripped); // core-relevant change guard (StripUiOnlyKeys) } } } From d9f5e07af562646d9a8ac22b415a17ee871d4b50 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:09:12 +0200 Subject: [PATCH 31/31] docs: describe RunTick's phase functions (#478) 02-tick-loop gains a phase-to-function table; 04 and 05 name the phase functions that now hold reveal and wall arming. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/02-tick-loop.md | 43 ++++++++++++++++++++---- docs/architecture/04-render-engine.md | 5 +-- docs/architecture/05-transform-engine.md | 2 +- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index 35cd3d0c..e3a99068 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -1,9 +1,10 @@ # 02. The tick loop -Everything Wind does at runtime happens in one function, `RunTick` in `src/main.cpp`. Each tick -samples input, advances the zoom, resolves a pan and asks the current engine to present. This -chapter lists the phases in source order, then covers pacing, idle sleep and the threads that sit -beside the loop. +Everything Wind does at runtime happens in one tick, `RunTick` in `src/main.cpp`. `RunTick` is a +short sequence of calls to static phase functions in the same file (see +[Phase functions](#phase-functions)). Each tick samples input, advances the zoom, resolves a pan and +asks the current engine to present. This chapter lists the phases in source order, then covers +pacing, idle sleep and the threads that sit beside the loop. ## Why one loop @@ -92,13 +93,43 @@ builds its magnification context and cursor lens at 1x and keeps them warm (#369 [05](05-transform-engine.md)); it no longer releases them after a zoom. The tick ends with the stuck-input timeline and, under `diagnostics=1`, the 2 s frame-pacing window. +### Phase functions + +`RunTick` calls these in order; locals that a later phase reads come back in small structs +(`TickClock`, `TickInput`, `TickModes`, `PanDelta`, `TickForeground`, `PointerMap`). Everything +else lives in `TickState`. Add new per-tick work to the phase it belongs to, not to `RunTick`. + +| Phase | Function | +|---|---| +| Timing, idle colour filter, zoom timeline step | `BeginTick` | +| Config hot-reload (apply: `ApplyReloadedConfig`) | `PollConfigReload` | +| Launch-cover watch, `noSwallowApps`, keyboard-hook watchdog | `WatchForegroundAndKbHook` | +| Held binds, zoom step, recenter and Inspect edges, quick zoom | `ResolveInputAndZoom` (bind state: `BindComboHeld`) | +| Raw motion, zoomed / Inspect / active, pan arm | `ResolveTickModes` | +| Active tick (zoomed or Inspect) | `RunActiveTick`, which calls the rows below | +| Session enter: resets, retarget, engine pick, seeds | `EnterSession` (`RetargetSessionMonitor`, `PickEngineAtZoomIn`) | +| Inspect edges | `EnterInspect`, `ExitInspect` | +| Pan delta | `ResolvePanDelta` | +| Foreground facts, pan wall, launch cover | `ReadForegroundAndArmWall` | +| Free cursor, pan glide, game-drawn cursor, mapper step | `MapPointer` | +| Tracking, keyboard pan, view owner | `StepTracking` | +| Dead-zone probe, Inspect clicks | `ProbeClicks`, `CommitInspectClicks` | +| Hybrid instant switch | `InstantHybridSwitch` | +| `PresentExtras` | `BuildPresentExtras` | +| Game pacing and present | `PaceAndPresent` | +| Reveal gating and handover overlap | `RevealAndHandover` | +| Game-inspect foreground steal | `AssertGameInspectSteal` | +| Baseline | `UpdateCursorBaseline` | +| Teardown / idle | `TeardownSession` / `IdleTick` | +| Diagnostics and telemetry | `LogHeldState`, `LogFramePacing`, `WriteTestTelemetry` | + ## Config hot-reload There is no settings IPC. `WindConfig.exe` writes `magnifier.ini` and the core notices: - `wWinMain` arms `FindFirstChangeNotificationW` on the ini's directory (`LAST_WRITE` + `FILE_NAME`, so rename-based saves fire it too). -- `RunTick` polls the handle with a zero timeout about four times a second. Per-tick polling is a +- The tick (`PollConfigReload`) polls the handle with a zero timeout about four times a second. Per-tick polling is a kernel transition 144 times a second for a file a human changes. Without a watch handle the loop falls back to a ~1 s timed poll. - Only a changed mtime (`ConfigMTime`) proceeds to a reload. @@ -156,7 +187,7 @@ The main loop in `wWinMain` paces the tick: | Render, vsync (default) | `Present(1,0)` blocks to the refresh; the timer is skipped | | Render, `dwmFlush=1` | `Present(0,0)`, then `DwmFlush()` after the tick | | Transform | Always `DwmFlush` while zoomed (the write and the pointer land in one composite); an unpaced loop would flood DWM's transform queue until the view lags. Idle at 1x uses the timer or the idle sleep | -| Game pacing modes | Paced inside `RunTick` (vblank waits or the present accumulator) | +| Game pacing modes | Paced inside the tick, `PaceAndPresent` (vblank waits or the present accumulator) | `DetectRefreshHz` reads the current monitor's real rate and is re-read on retarget. **Tick counts are refresh-rate dependent**, so every tick-tuned constant derives from the detected rate: diff --git a/docs/architecture/04-render-engine.md b/docs/architecture/04-render-engine.md index bf0c073a..19452970 100644 --- a/docs/architecture/04-render-engine.md +++ b/docs/architecture/04-render-engine.md @@ -11,8 +11,9 @@ for it ([03](03-engines.md)). `RenderEngine` (`src/render_engine.h`) is a PIMPL class; D3D and DXGI headers stay in `src/render_engine.cpp`. `RenderModel` (`src/render_model.cpp`) adapts it to `IMagnifierModel`. -`RunTick` owns activation and reveal, because they need facts the engine does not have (does the -foreground cover the monitor, is this the idle-to-active edge). +`RunTick` owns activation and reveal (`EnterSession`, `RevealAndHandover` in `src/main.cpp`), +because they need facts the engine does not have (does the foreground cover the monitor, is this +the idle-to-active edge). Per frame, `renderFrame(RenderFrameParams)` captures if the desktop changed, draws three passes (magnify, outline, cursor) and presents. The view is a float source rect (`srcLeft`/`srcTop` plus diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index c39cd394..dfdd5f3c 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -191,7 +191,7 @@ channels and on the plain desktop too. With MPO disabled Smooth sampling takes a float path and survives the same corner, as does the built-in Magnifier, which samples smooth. -Defences (wall arming in `RunTick`, write clamp in `TransformModel::writeSiteTransform`): +Defences (wall arming in `RunTick`'s `ReadForegroundAndArmWall`, write clamp in `TransformModel::writeSiteTransform`): | MPO at boot | Sampling | Pan walls (`setMaxSourceLeft/Top`, `|src*level| <= 32000`) | |---|---|---|