diff --git a/CLAUDE.md b/CLAUDE.md index 079b6ec7..749c0947 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,8 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. never reload. Add new UI-only keys there. **Cursor (07)** +- In a native-cursor session the lock (`lockApps`, tells) applies only while the pointer is hidden + (`LockApplies`); gates read `t.lockEff`, not `t.detector.locked()`. See 07. - Native cursor (`txNativeCursor=1`, both sampling modes; `src/native_cursor.h`): DWM draws the real pointer via Wind's cursor lens (a hidden `WC_MAGNIFIER` window, built on the owner thread at idle) and centres the view itself (`SetFullscreenMagnifierOffsetsDWMUpdated`). While DWM centres, never diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 6230888a..4eaaee84 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -186,8 +186,16 @@ mickeys. `LockDetector` (`src/lock_detector.*`, pure) decides, with hysteresis. - **A clip is a lock signal only when meaningfully smaller than the monitor.** A machine-wide work-area clip (desktop minus taskbar, ~95%) is common; any-clip ran every desktop session locked. - Tick counts derive from the refresh rate (`setTickRate`). -- **Forced locks go through the detector** (`seedLock()`), because the free-cursor gate reads - `t.detector.locked()`; a tick-local flag once left the view pinned to the warped pointer. +- **Forced locks go through the detector** (`seedLock()`), so the lock persists across ticks; a + tick-local flag once left the view pinned to the warped pointer. +- **A shown pointer is a free pointer in a native-cursor session** (`LockApplies`, + `src/native_cursor.h`; the tick's result is `t.lockEff`, which every gate reads). The locked path + pans from raw mickeys and re-parks the real pointer once per tick; the sprite hid that, but the + native cursor IS the real pointer, so DWM drew it wherever the hand had moved it between ticks. + Field case: DOOM: The Dark Ages menus under `lockApps` (2026-10-07), measured at 4.7x as 22 px + spread slow and 74 px medium with jumps to 118 px, against 0-2 px free. Games show the pointer + in menus and hide it for mouselook, so the lock applies only while `GetCursorInfo` reports it + hidden. The sprite path keeps the old rule. The log line is `lock pointer shown: free`. - `lockForce=1` locks everywhere, for diagnosis only: locked mode has no ballistics or drag-follow. ```mermaid diff --git a/src/main.cpp b/src/main.cpp index b5f84979..f864b5a5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -294,6 +294,8 @@ struct TickState { CursorMapper mapper; LockDetector detector; // free vs game-locked cursor 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) std::string lastCoreIni; // stripped ini fingerprint (skip UI-only reloads) POINT lastSetVirtual{}; // MEASURED post-present pointer position (virtual px), the // baseline for the next tick's hand delta (issue #169: never @@ -1462,7 +1464,7 @@ static void RunTick(TickState& t) { // Keyboard panning (#287): the hook swallows pan keys only while this is set, so at 1x // the pan keys reach the app (e.g. Ctrl+Alt+Left/Right = IntelliJ navigate back/forward). Mouselook games and Inspect // keep them too. Published once per tick, before anything reads the pan keys. - const bool panArmed = lvl > 1.001 && !inspect && !t.detector.locked(); + const bool panArmed = lvl > 1.001 && !inspect && !t.lockEff; g_input.setPanArmed(panArmed); if (!panArmed) t.keyPan.reset(); @@ -1712,6 +1714,25 @@ static void RunTick(TickState& t) { (locked && t.detector.warpLocked()) ? " (warp-anchor)" : "", lvl); t.prevDetLocked = locked; } + // A shown pointer is a free pointer in a native-cursor session (LockApplies): a game's + // menus keep DWM centring, its mouselook (pointer hidden) keeps the locked pan. + { + const bool nativeTx = wind::UseNativeCursor(t.cfg.txNativeCursor) && + dynamic_cast(t.model) != nullptr; + bool showing = false; + if (locked && nativeTx) { + CURSORINFO ci{}; ci.cbSize = sizeof(ci); + showing = GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING) && ci.hCursor != nullptr; + } + const bool applies = wind::LockApplies(locked, nativeTx, 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 @@ -1846,7 +1867,7 @@ static void RunTick(TickState& t) { // 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 = t.cfg.txFreeCursor != 0 && !inspect && !t.detector.locked() && + const bool freeCursor = t.cfg.txFreeCursor != 0 && !inspect && !t.lockEff && dynamic_cast(t.model) != nullptr; // SHELL INPUT PANEL REGIME (issue #283): while the emoji picker (or clipboard history, touch // keyboard) is open, the real pointer replaces the sprite (the shell composes its panels above @@ -1902,12 +1923,12 @@ static void RunTick(TickState& t) { // 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 && !panel && !inspect && !t.detector.locked() && !fsCover && + const bool trackEnabled = lvl > 1.001 && !panel && !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) | (panel ? 2 : 0) | (inspect ? 4 : 0) | - (t.detector.locked() ? 8 : 0) | (fsCover ? 16 : 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 panel=%d inspect=%d locked=%d fsCover=%d lvl=%.2f", @@ -2033,7 +2054,7 @@ static void RunTick(TickState& t) { 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 && !panel && !inspect && !t.detector.locked()) { + } else if (t.cfg.mouseAlign == 1 && lvl > 1.001 && !panel && !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 diff --git a/src/native_cursor.h b/src/native_cursor.h index bf36d67c..905caef3 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -23,6 +23,20 @@ inline bool UseNativeCursor(int txNativeCursor) { return txNativeCursor != 0; } +// A VISIBLE POINTER IS A FREE POINTER (field video 2026-10-07, DOOM: The Dark Ages menus). lockApps +// and the lock tells put a session on the locked path: the view pans from raw mickeys and the weld +// re-parks the real pointer at the view's centre once per tick. With the sprite that was invisible - +// the sprite is drawn at the re-parked point. The native cursor is the REAL pointer, drawn by DWM +// wherever the hand has moved it between ticks, so the locked path made it wander around the centre +// and snap back every tick (measured at 4.7x: 22 px spread slow, 74 px medium, jumps to 118 px; +// free with DWM centring: 0 px). A game shows the pointer only where it is a pointer (menus, +// inventories, maps), and hides it for mouselook - the case the locked path exists for. So in a +// native-cursor session the lock applies only while the pointer is hidden; a shown pointer gets +// DWM centring, exactly what Windows Magnifier does there. The sprite path keeps the old rule. +inline bool LockApplies(bool locked, bool nativeCursor, bool pointerShowing) { + return locked && !(nativeCursor && pointerShowing); +} + // When DWM may own the pan (DWM centring on). Only where the view is a pure function of the // real pointer, centred on it - exactly what DWM computes. Everything else needs Wind's offsets. struct DwmCentreIn { diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index 39d08d45..fde7dd35 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -71,3 +71,10 @@ TEST_CASE("DWM centring yields only near an armed wall, not everywhere it is rea CHECK(NearWall(true, 1000.0, 2620.0, 12.0, 32000.0, 64.0) == true); // bottom wall CHECK(NearWall(false, 2650.0, 2650.0, 12.0, 32000.0, 64.0) == false); // walls off } + +TEST_CASE("a shown pointer is free in a native-cursor session; mouselook keeps the lock") { + CHECK(LockApplies(true, true, true) == false); // game menu (lockApps): DWM centring + CHECK(LockApplies(true, true, false) == true); // mouselook, pointer hidden: locked pan + CHECK(LockApplies(true, false, true) == true); // sprite path keeps the old rule + CHECK(LockApplies(false, true, false) == false); // nothing locked +}