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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 26 additions & 5 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<TransformModel*>(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
Expand Down Expand Up @@ -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<TransformModel*>(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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/native_cursor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions tests/test_native_cursor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading