From 12a1b67ea17b34de6993cd899946fe68fdd68c22 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:47:47 +0200 Subject: [PATCH 1/2] fix(inspect): no view shift when toggling Inspect at high zoom (#445) DWM centring sits a desktop pixel off Wind's own centre (learned from Wind's pixel-and-back nudges, which panning needs), so switching to Wind's centre at the Inspect toggle moved the view about 11 screen px at 10x. DWM centring now stays on until the look point first moves, and leaving without having moved skips the warp and its shape jiggle. Measured at 10.5x: entry shift 10-12 px in every run -> 0; exit shift in about half the runs -> 1 of 3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/07-cursor.md | 10 ++++++++++ src/main.cpp | 18 +++++++++++++----- src/native_cursor.h | 16 ++++++++++++++++ src/version.h | 4 ++-- tests/test_native_cursor.cpp | 17 +++++++++++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 36e9724..f9a0c23 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -165,6 +165,10 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: - **DWM's learned offset.** DWM learns the gap between `GetCursorPos` and its own cursor point and relearns it only when the cursor HANDLE changes, so a learn taken mid-jump can sit a few px off until the next shape change. Windows Magnifier has the same behaviour. + Measured 2026-10-11 (#445): Wind's own pixel-and-back nudges leave DWM centred 1 desktop px off + the pointer in x (either side, about 11 screen px at 10x). With every zoomed nudge removed DWM + matched Wind's centre exactly, but panning then shook the pointer up to 30 px, so the nudges stay. + A zero-distance injected move is dropped by Windows, so it is no substitute. - No cursor sprite, no blanking and no shell-panel handling in a normal zoom. The hide-cursor hotkey and `cursorVisibility=never` blank the pointer the way Inspect does (below). Inspect keeps its crosshair window. Games pay nothing extra for the native cursor: a zoomed full-screen window is @@ -264,6 +268,12 @@ Tracking (below) can then detach the view from all of these. Inspect (`cursorLockVk`) freezes the cursor and adds a free-look crosshair. It runs in `RunTick`; the mouse hook only swallows clicks. +- **No shift at the toggle (#445).** In a transform session DWM centring stays on after entry + until the look point first moves (`InspectKeepsDwmView`), and leaving without having moved skips + the warp and its 1 px shape jiggle (`InspectExitWarps`). Switching to Wind's own centre at the + toggle moved the view by DWM's 1 px offset (above): measured at 10.5x, entry shifted 10-12 px in + every run, now 0; exit shifted in about half the runs, now in 1 of 3 (DWM relearns its offset + when the pointer is shown again). - **Entry.** The real cursor is frozen in place with a 1 px `ClipCursor` (`t.frozenCursor`) and hidden, so a hover or tooltip under it stays alive. The look point is the mapper centre and pans from ballistics-cooked raw mickeys ([06](06-input.md)). diff --git a/src/main.cpp b/src/main.cpp index 5b59618..68dbb35 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -349,6 +349,7 @@ struct TickState { POINT frozenCursor{}; // where the real cursor is frozen while Inspect is on (virtual px) int clickReleaseTicks = 0; // after a committed click: ticks to keep the freeze clip released so // the synthesized click reaches the look point (then re-freeze) + bool inspectLookMoved = false; // the Inspect look point moved since entry (#445) double inspectPanRemX = 0.0; // sub-pixel carry for the cooked Inspect-mode pan (slow motion not lost) double inspectPanRemY = 0.0; double lockedPanRemX = 0.0; // sub-pixel carry for the locked pan @@ -1585,6 +1586,7 @@ static void RunTick(TickState& t) { // pans by zero. rawDx = 0; rawDy = 0; t.inspectPanRemX = 0.0; t.inspectPanRemY = 0.0; + t.inspectLookMoved = 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 }; @@ -1627,16 +1629,20 @@ static void RunTick(TickState& t) { ClipCursor(nullptr); 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); - t.lastSetVirtual = lp; + const bool warp = wind::InspectExitWarps(t.inspectLookMoved, + dynamic_cast(t.model) != nullptr); + 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). - SetCursorPos(lp.x + 1, lp.y); - SetCursorPos(lp.x, lp.y); + if (warp) { + SetCursorPos(lp.x + 1, lp.y); + SetCursorPos(lp.x, lp.y); + } } 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' @@ -1662,6 +1668,7 @@ static void RunTick(TickState& t) { 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 @@ -2325,7 +2332,8 @@ static void RunTick(TickState& t) { { wind::DwmCentreIn dc; dc.zoomed = lvl > 1.001; - dc.freeCursor = freeCursor && !gameCursor; // a delayed view is Wind's to write (#443) + dc.freeCursor = (freeCursor && !gameCursor) || // a delayed view is Wind's to write (#443) + 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; diff --git a/src/native_cursor.h b/src/native_cursor.h index 7727634..1a32155 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -55,6 +55,22 @@ inline bool NearWall(bool wallArmed, double srcLeft, double srcTop, double level return srcLeft > wall - marginSrc || srcTop > wall - marginSrc; } +// Inspect keeps DWM's view until the look point first moves (#445). DWM centring sits a desktop +// pixel off Wind's own centre (it learns that from Wind's pixel-and-back nudges, which panning needs: +// without them the pointer shook up to 30 px), so switching it off at the Inspect toggle shifted the +// view about 11 screen px at 10x (measured 2026-10-11). With the frozen pointer still at the look point +// DWM's view is the right one; the switch waits for the first look-point motion, which hides it. +inline bool InspectKeepsDwmView(bool inspect, bool lookMoved) { + return inspect && !lookMoved; +} + +// Leaving Inspect warps the pointer to the look point and jiggles it a pixel to refresh its shape. +// In a transform session where the look point never moved the pointer is already there with the +// right shape, and the jiggle is what made DWM re-centre a pixel off on the way out (#445). +inline bool InspectExitWarps(bool lookMoved, bool transformEngine) { + return lookMoved || !transformEngine; +} + inline bool WantDwmCentring(const DwmCentreIn& in) { return in.zoomed && in.freeCursor && !in.viewDetached && !in.wallNeeded && !in.quiesce; } diff --git a/src/version.h b/src/version.h index 3f2834b..f8a9f21 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 29 -#define WIND_VER_PATCH 1 +#define WIND_VER_PATCH 2 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.29.1" +#define WIND_VERSION_STR "0.29.2" diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index 6d5bc74..dbae5f6 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -111,3 +111,20 @@ TEST_CASE("a press held past the click window is a drag and may be nudged") { CHECK(NudgeBlockedByClick(true, 1000, 0) == true); // released, inside the window after it CHECK(NudgeBlockedByClick(true, 1000, 1003) == true); // stamp newer than the clock read } + +TEST_CASE("Inspect keeps DWM's view until the look point moves (#445)") { + CHECK(InspectKeepsDwmView(true, false)); + CHECK_FALSE(InspectKeepsDwmView(true, true)); + CHECK_FALSE(InspectKeepsDwmView(false, false)); + DwmCentreIn in; in.zoomed = true; in.freeCursor = InspectKeepsDwmView(true, false); + CHECK(WantDwmCentring(in)); + in.freeCursor = InspectKeepsDwmView(true, true); + CHECK_FALSE(WantDwmCentring(in)); +} + +TEST_CASE("leaving Inspect warps only when the look point moved or the render engine draws (#445)") { + CHECK_FALSE(InspectExitWarps(false, true)); + CHECK(InspectExitWarps(true, true)); + CHECK(InspectExitWarps(false, false)); + CHECK(InspectExitWarps(true, false)); +} From aeb1b8d7ae0d93c59a6da36787fdf8b4f83525e5 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:56:21 +0200 Subject: [PATCH 2/2] fix(inspect): keep Wind's view after Inspect until the hand moves (#445) Leaving Inspect after moving the look point handed the view back to DWM centring at the toggle, which shifted it by DWM's pixel (field 2026-10-11). Wind now keeps writing its own centre until the pointer first moves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/07-cursor.md | 5 ++++- src/main.cpp | 11 ++++++++--- src/native_cursor.h | 10 ++++++++++ tests/test_native_cursor.cpp | 9 +++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index f9a0c23..f283408 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -270,7 +270,10 @@ the mouse hook only swallows clicks. - **No shift at the toggle (#445).** In a transform session DWM centring stays on after entry until the look point first moves (`InspectKeepsDwmView`), and leaving without having moved skips - the warp and its 1 px shape jiggle (`InspectExitWarps`). Switching to Wind's own centre at the + the warp and its 1 px shape jiggle (`InspectExitWarps`). Once the look point moved, Inspect shows Wind's own + centre, so after the exit Wind keeps writing it until the hand first moves the pointer + (`HoldWindViewAfterInspect`): handing the view back to DWM at the toggle shifted it (field + 2026-10-11), during the first movement it is hidden. Switching to Wind's own centre at the toggle moved the view by DWM's 1 px offset (above): measured at 10.5x, entry shifted 10-12 px in every run, now 0; exit shifted in about half the runs, now in 1 of 3 (DWM relearns its offset when the pointer is shown again). diff --git a/src/main.cpp b/src/main.cpp index 68dbb35..3ea12ce 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -350,6 +350,7 @@ struct TickState { int clickReleaseTicks = 0; // after a committed click: ticks to keep the freeze clip released so // the synthesized click reaches the look point (then re-freeze) bool inspectLookMoved = false; // the Inspect look point moved since entry (#445) + bool postInspectHold = false; // after Inspect: Wind keeps its centre until the hand moves (#445) double inspectPanRemX = 0.0; // sub-pixel carry for the cooked Inspect-mode pan (slow motion not lost) double inspectPanRemY = 0.0; double lockedPanRemX = 0.0; // sub-pixel carry for the locked pan @@ -1445,6 +1446,7 @@ static void RunTick(TickState& t) { // 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.postInspectHold = false; // a new session starts with DWM centring (#445) t.viewDetached = false; t.viewVx = 0; t.viewVy = 0; t.keyPan.reset(); @@ -1587,6 +1589,7 @@ static void RunTick(TickState& t) { 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 }; @@ -1629,8 +1632,9 @@ static void RunTick(TickState& t) { ClipCursor(nullptr); POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, (int)(t.mapper.centerY() + 0.5) + t.mon.y }; - const bool warp = wind::InspectExitWarps(t.inspectLookMoved, - dynamic_cast(t.model) != nullptr); + 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 @@ -1654,6 +1658,7 @@ static void RunTick(TickState& t) { 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) { @@ -2332,7 +2337,7 @@ static void RunTick(TickState& t) { { wind::DwmCentreIn dc; dc.zoomed = lvl > 1.001; - dc.freeCursor = (freeCursor && !gameCursor) || // a delayed view is Wind's to write (#443) + 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); diff --git a/src/native_cursor.h b/src/native_cursor.h index 1a32155..8d77e72 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -71,6 +71,16 @@ inline bool InspectExitWarps(bool lookMoved, bool transformEngine) { return lookMoved || !transformEngine; } +// The way back (#445, field 2026-10-11): once the look point moved, Inspect showed Wind's own centre, +// and handing the view back to DWM at the exit toggle moved it by DWM's pixel. Wind keeps writing +// its centre after the exit until the hand first moves the pointer, which hides the hand-over. +inline bool HoldWindViewAfterInspect(bool lookMoved, bool transformEngine) { + return lookMoved && transformEngine; +} +inline bool PostInspectHoldKeeps(bool held, bool handMoved) { + return held && !handMoved; +} + inline bool WantDwmCentring(const DwmCentreIn& in) { return in.zoomed && in.freeCursor && !in.viewDetached && !in.wallNeeded && !in.quiesce; } diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index dbae5f6..8af04d1 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -128,3 +128,12 @@ TEST_CASE("leaving Inspect warps only when the look point moved or the render en CHECK(InspectExitWarps(false, false)); CHECK(InspectExitWarps(true, false)); } + +TEST_CASE("after Inspect, Wind keeps its centre until the hand moves, only if the look point moved (#445)") { + CHECK(HoldWindViewAfterInspect(true, true)); + CHECK_FALSE(HoldWindViewAfterInspect(false, true)); // DWM kept the view: nothing to hand back + CHECK_FALSE(HoldWindViewAfterInspect(true, false)); // render engine: no DWM centring + CHECK(PostInspectHoldKeeps(true, false)); + CHECK_FALSE(PostInspectHoldKeeps(true, true)); + CHECK_FALSE(PostInspectHoldKeeps(false, false)); +}