Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/architecture/08-config-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions src/game_cursor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#pragma once
// Game-drawn cursor (issue #443) - PURE, no <windows.h>, 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
32 changes: 29 additions & 3 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. ---
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
7 changes: 7 additions & 0 deletions tests/test_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions tests/test_game_cursor.cpp
Original file line number Diff line number Diff line change
@@ -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
}
Loading