diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index a32ed98..36e9724 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -131,6 +131,16 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: session-end reset of `ixPubLevel_` keeps a quick zoom back to the same level from skipping the publish nudge, and the hide-cursor hotkey's show-again transition nudges after the blanker restore like the zoom-out does. +- **Game-drawn cursor (#443, `gameCursorLagMs`, -1 = one display frame, 0 = off).** Some games + hide the pointer and draw their own cursor at its position (Cyberpunk 2077 menus). That cursor + reaches the screen about a display frame after the pointer moved, while DWM centring shows the + newest pointer, so the game's cursor swung behind every movement and flipped side on each change of + direction: rms 54 screen px at 6.5x and 1000 desktop px/s (2026-10-11, 3 runs). While a covering + foreground app hides the pointer (`GameDrawsCursor`, `src/game_cursor.h`; not Wind's own hiding), + DWM centring is off and the view follows the pointer `gameCursorLagMs` late, read back from a + per-tick `PointerHistory`: rms 15-23 px at 1000 px/s, 6 px at 400 (29 before). Wind's own write + without the delay measured the same as DWM centring, so the gain is the delay. The rest is the + game's frame timing. A mouselook game that recentres the pointer is locked and unaffected. - **Pan glide (#430, `panGlideMaxPx`, 0 = off).** A soft stop: when a mouse movement stops, the POINTER eases on at the hand's speed and slows to rest within `panGlideMaxPx` SCREEN px at any zoom (`src/pan_glide.h`). One setting since #434: the ease's time constant follows the diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index dfdbc47..1745ed3 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -176,6 +176,7 @@ Every key works in the ini whether or not Settings shows it. Keys hot-reload unl |---|---| | `maxLevel` (12), `zoomInSpeed`, `zoomOutSpeed`, `zoomEaseOutMs`, `smoothZoom*` | Range and feel of the zoom | | `cursorSensitivity` (1.0), `panSpeed` (1.0), `panGlideMaxPx` (0) | Mouse and arrow-key pan speed; the pan glide's distance, which also sets its ease (#430, #434) | +| `gameCursorLagMs` (-1) | How late the view follows the pointer while a full-screen game hides it and draws its own cursor; -1 = one display frame, 0 = off (#443). Ini only | | `mouseAlign` (0 centred, 1 within the edges), `mouseMarginPct` | Where the pointer sits while the view moves | | `trackCaret` (1), `trackFocus` (0), `trackAlign`, `trackGlideMs` (200) | Follow the text caret and keyboard focus | | `lockApps`, `warpLock` (0) | Games whose sessions pan from raw mouse motion; heuristics for unlisted games | diff --git a/src/config.cpp b/src/config.cpp index ebd77d3..0ed402a 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -211,6 +211,7 @@ Config ParseConfig(const std::string& text) { else if (key == "diagnostics") c.diagnostics = ParseIntStrict(val); else if (key == "cursorSensitivity") c.cursorSensitivity = ParseDoubleStrict(val); else if (key == "panGlideMaxPx") c.panGlideMaxPx = ParseIntStrict(val); + else if (key == "gameCursorLagMs") c.gameCursorLagMs = ParseIntStrict(val); else if (key == "cursorConstantSize") c.cursorConstantSize = ParseIntStrict(val); else if (key == "trayPinned") c.trayPinned = ParseIntStrict(val); else if (key == "cursorVisibility") c.cursorVisibility = val; @@ -305,6 +306,8 @@ Config ParseConfig(const std::string& text) { if (c.txRampMinStep > 100) c.txRampMinStep = 100; if (c.txRampMinFrom < 1.0) c.txRampMinFrom = 1.0; if (c.panGlideMaxPx > 400) c.panGlideMaxPx = 400; + if (c.gameCursorLagMs < -1) c.gameCursorLagMs = -1; + if (c.gameCursorLagMs > 100) c.gameCursorLagMs = 100; c.sharpness = clampd(c.sharpness, 0.0, 1.0); c.brightness = clampd(c.brightness, 0.5, 1.5); c.colorWarmPct = (int)clampd(c.colorWarmPct, 0, 100); @@ -526,6 +529,10 @@ std::string DefaultIniText() { "; panGlideMaxPx: when a mouse movement stops the pointer eases on at most this many screen\n" "; px before it rests (Transform engine, zoomed); 0=off. A longer glide also eases longer\n" "panGlideMaxPx=0\n" + "; gameCursorLagMs: a full-screen game that hides the pointer and draws its own cursor shows it\n" + "; a frame late; the zoomed view follows the pointer this many ms late to stay on it.\n" + "; -1=one display frame (default), 0=off\n" + "gameCursorLagMs=-1\n" "; cursorConstantSize: 0=the cursor grows with the zoom (default); 1=keep it at normal\n" "; desktop size at every zoom (render engine only). Replaces cursorScaleWithZoom (ignored).\n" "cursorConstantSize=0\n" diff --git a/src/config.h b/src/config.h index 875f9a7..6768dc5 100644 --- a/src/config.h +++ b/src/config.h @@ -391,6 +391,10 @@ struct Config { // (0 = off, the default). The ease's time follows the distance (PanGlideTauS, one setting since // #434; the old panGlideMs key is ignored). Hot. Replaces cursorSmoothing (ignored). int panGlideMaxPx = 0; + // Game-drawn cursor (issue #443, src/game_cursor.h): while a covering foreground app hides the + // pointer and draws its own cursor, the view follows the pointer this many ms late so it stays + // on the game's cursor. -1 (default) = one display frame, 0 = off (DWM centring). Hot. + int gameCursorLagMs = -1; // 0 (default) = the render engine's cursor grows with the zoom, matching the transform // engine (DWM magnifies its sprite); 1 = opt-in constant desktop-size pointer. Replaces // cursorScaleWithZoom (issue #253), which is IGNORED: the default template wrote it as an diff --git a/src/game_cursor.h b/src/game_cursor.h new file mode 100644 index 0000000..279db03 --- /dev/null +++ b/src/game_cursor.h @@ -0,0 +1,63 @@ +#pragma once +// Game-drawn cursor (issue #443) - PURE, no , tests/test_game_cursor.cpp. +// +// Some games hide the Windows pointer and draw their own cursor at the pointer's position (Cyberpunk +// 2077 menus). Their cursor reaches the screen a display frame or so after the pointer moved, while +// DWM centring puts the view on the newest pointer position, so the game's cursor swung behind every +// movement and flipped side on each change of direction: rms 54 screen px at 6.5x and 1000 desktop +// px/s (measured 2026-10-11). While a covering foreground app hides the pointer, Wind writes the view +// itself and follows the pointer gameCursorLagMs late (default one display frame): rms 11-35 px. +// Following without the delay (Wind's write, no DWM centring) measured the same as DWM centring, so +// the gain is the delay. The rest is the game's own frame timing. +namespace wind { + +// A game draws its own cursor: a free-pointer transform session over a foreground that covers the +// monitor, with the pointer hidden by that app (not by Wind's hide-cursor hotkey or setting). +inline bool GameDrawsCursor(bool freeCursor, bool fsCover, bool pointerShowing, bool hiddenByWind) { + return freeCursor && fsCover && !pointerShowing && !hiddenByWind; +} + +// The delay in ms: cfg < 0 means one display frame at hz, 0 turns the delay off (DWM centring as for +// any other window), anything else is used as given (capped at 100 ms). +inline double GameCursorLagMs(int cfgMs, double hz) { + if (cfgMs == 0) return 0.0; + if (cfgMs > 0) return cfgMs > 100 ? 100.0 : double(cfgMs); + return hz > 0.0 ? 1000.0 / hz : 1000.0 / 60.0; +} + +// The pointer's recent positions, one sample per tick, read back at an earlier time. +struct PointerHistory { + static constexpr int kN = 64; // 440 ms at 144 Hz, far more than any delay + struct S { double ms, x, y; }; + S s[kN] = {}; + int head = 0, count = 0; + + void push(double ms, double x, double y) { + s[head] = { ms, x, y }; + head = (head + 1) % kN; + if (count < kN) ++count; + } + void clear() { head = 0; count = 0; } + // The position at time ms, interpolated between the two samples around it. Before the oldest + // sample it is the oldest one; with no samples it reports false. + bool at(double ms, double& x, double& y) const { + if (count == 0) return false; + const S* newer = &s[(head - 1 + kN) % kN]; + if (newer->ms <= ms) { x = newer->x; y = newer->y; return true; } + for (int k = 2; k <= count; ++k) { + const S* older = &s[(head - k + kN) % kN]; + if (older->ms <= ms) { + const double span = newer->ms - older->ms; + const double f = span > 0.0 ? (ms - older->ms) / span : 1.0; + x = older->x + (newer->x - older->x) * f; + y = older->y + (newer->y - older->y) * f; + return true; + } + newer = older; + } + x = newer->x; y = newer->y; + return true; + } +}; + +} // namespace wind diff --git a/src/main.cpp b/src/main.cpp index 90a0067..5b59618 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -46,7 +46,8 @@ #include "cursor_decode.h" // edge mode measures the cursor body #include "focus_track.h" // tracking: caret/focus watcher thread #include "tray_host.h" // WindTray.exe owns the icon and menu (#291) -#include "pan_glide.h" // momentum after a flick (#430) +#include "pan_glide.h" +#include "game_cursor.h" // momentum after a flick (#430) #include "gain_learner.h" // learned pointer ballistics: locked pan at TRUE desktop speed #include "tray_ipc.h" // the status block shared with WindTray.exe #include "pointer_binds.h" // kWindInjectTag: tag our own injected clicks (#285) @@ -273,6 +274,8 @@ struct TickState { bool prevDetLocked = false; // edge-log the detector state (issue #221) bool lockEff = false; // the lock that APPLIES this tick (LockApplies, native_cursor.h) bool lockFreed = false; // locked, but freed by a shown pointer (edge-logged) + wind::PointerHistory ptrHist; // the pointer per tick, for a game-drawn cursor (#443) + bool gameCursor = false; // a game draws its own cursor (edge-logged) std::string lastCoreIni; // stripped ini fingerprint (skip UI-only reloads) bool configRetry = false; // last reload found the ini unreadable: check again POINT lastSetVirtual{}; // MEASURED post-present pointer position (virtual px), the @@ -1888,11 +1891,34 @@ static void RunTick(TickState& t) { } } } + // 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::GameDrawsCursor(freeCursor, fsCover, showing, byWind); + } + 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)) { - t.mapper.reset(double(cp.x - t.mon.x), double(cp.y - t.mon.y)); + 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); // --- Tracking (issue #276): caret / focus own the view; the pointer is never moved. --- @@ -2299,7 +2325,7 @@ static void RunTick(TickState& t) { { wind::DwmCentreIn dc; dc.zoomed = lvl > 1.001; - dc.freeCursor = freeCursor; + dc.freeCursor = freeCursor && !gameCursor; // a delayed view is Wind's to write (#443) dc.viewDetached = t.viewDetached; dc.wallNeeded = wind::NearWall(wallNeeded, r.srcLeft, r.srcTop, lvl, kMaxSafeTxMagnitude, 64.0); dc.quiesce = quiesceHold; diff --git a/src/version.h b/src/version.h index 3437c2b..3f2834b 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 0 +#define WIND_VER_PATCH 1 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.29.0" +#define WIND_VERSION_STR "0.29.1" diff --git a/tests/test_config.cpp b/tests/test_config.cpp index cf0d814..8413729 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -185,6 +185,13 @@ TEST_CASE("numeric fields are clamped to documented ranges") { CHECK(ParseConfig("maxLevel=8\n").maxLevel == doctest::Approx(8.0)); CHECK(ParseConfig("panGlideMaxPx=40\n").panGlideMaxPx == 40); } +TEST_CASE("gameCursorLagMs defaults to one frame (-1) and clamps to -1..100 (#443)") { + CHECK(ParseConfig("").gameCursorLagMs == -1); + CHECK(ParseConfig("gameCursorLagMs=0\n").gameCursorLagMs == 0); + CHECK(ParseConfig("gameCursorLagMs=12\n").gameCursorLagMs == 12); + CHECK(ParseConfig("gameCursorLagMs=-7\n").gameCursorLagMs == -1); + CHECK(ParseConfig("gameCursorLagMs=500\n").gameCursorLagMs == 100); +} TEST_CASE("multiMonitor can be set") { CHECK(ParseConfig("multiMonitor=0\n").multiMonitor == 0); CHECK(ParseConfig("multiMonitor=1\n").multiMonitor == 1); diff --git a/tests/test_game_cursor.cpp b/tests/test_game_cursor.cpp new file mode 100644 index 0000000..20cd9b5 --- /dev/null +++ b/tests/test_game_cursor.cpp @@ -0,0 +1,45 @@ +#include "doctest.h" +#include "../src/game_cursor.h" + +using namespace wind; + +TEST_CASE("a game draws its cursor only when a covering app hides the pointer (#443)") { + CHECK(GameDrawsCursor(true, true, false, false)); + CHECK_FALSE(GameDrawsCursor(true, true, true, false)); // pointer shown: DWM centring as usual + CHECK_FALSE(GameDrawsCursor(true, false, false, false)); // not covering the monitor + CHECK_FALSE(GameDrawsCursor(false, true, false, false)); // locked game or Inspect + CHECK_FALSE(GameDrawsCursor(true, true, false, true)); // Wind hid it (hotkey, setting) +} + +TEST_CASE("the lag is one display frame by default, 0 turns it off, explicit values are capped") { + CHECK(GameCursorLagMs(-1, 144.0) == doctest::Approx(1000.0 / 144.0)); + CHECK(GameCursorLagMs(-1, 0.0) == doctest::Approx(1000.0 / 60.0)); + CHECK(GameCursorLagMs(0, 144.0) == 0.0); + CHECK(GameCursorLagMs(12, 144.0) == doctest::Approx(12.0)); + CHECK(GameCursorLagMs(500, 144.0) == doctest::Approx(100.0)); +} + +TEST_CASE("the pointer history reads back an earlier position, interpolated") { + PointerHistory h; + double x = -1, y = -1; + CHECK_FALSE(h.at(0.0, x, y)); + h.push(0.0, 100, 10); + h.push(10.0, 200, 20); + h.push(20.0, 300, 30); + CHECK(h.at(25.0, x, y)); CHECK(x == doctest::Approx(300)); CHECK(y == doctest::Approx(30)); // newest + CHECK(h.at(20.0, x, y)); CHECK(x == doctest::Approx(300)); + CHECK(h.at(15.0, x, y)); CHECK(x == doctest::Approx(250)); CHECK(y == doctest::Approx(25)); + CHECK(h.at(3.0, x, y)); CHECK(x == doctest::Approx(130)); + CHECK(h.at(-5.0, x, y)); CHECK(x == doctest::Approx(100)); // older than the oldest: the oldest + h.clear(); + CHECK_FALSE(h.at(0.0, x, y)); +} + +TEST_CASE("the pointer history wraps and keeps the newest samples") { + PointerHistory h; + for (int i = 0; i < PointerHistory::kN + 10; ++i) h.push(i * 7.0, i, 0); + double x = 0, y = 0; + const double newest = (PointerHistory::kN + 9) * 7.0; + CHECK(h.at(newest - 7.0 * 1.5, x, y)); CHECK(x == doctest::Approx(PointerHistory::kN + 9 - 1.5)); + CHECK(h.at(0.0, x, y)); CHECK(x == doctest::Approx(10)); // the oldest kept sample +}