From 2e1756797c717bbab82a07f669a40ac3c0a34cf3 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:46:29 +0200 Subject: [PATCH 01/22] feat(cursor): native cursor for High resolution cursor - DWM-drawn pointer, DWM centring (#369) With High resolution cursor on (smooth sampling), transform sessions now use the pointer Windows Magnifier uses instead of the sprite: - A cursor lens (hidden WC_MAGNIFIER window, MS_SHOWMAGNIFIEDCURSOR toggled only while zoomed) makes DWM draw the real pointer into the magnified frame: sharp, above every band (emoji panel, thumbnails, menus, UAC), latched with the view. No sprite, no blanker, no cursor swaps, no shell-panel freeze. - SetFullscreenMagnifierOffsetsDWMUpdated(TRUE) lets DWM re-centre the view on every cursor update where the view is a pure function of the pointer; Wind keeps the pan for caret, focus, keys, edge mode, Inspect, locked games and reachable MPO walls. Same-level writes and warm pulses stop while DWM centres. - The lens is built at idle after launch and kept warm with its style off: measured free for games (Independent Flip, 0 spikes with a pointer-toggling app), where the composed pointer at 1x costs 19 spikes of 20-42 ms. Avoids the 200-260 ms public write Magnification.dll would block on. Off keeps today's sprite path. txNativeCursor=0 is the kill switch. Version 0.24.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- CLAUDE.md | 11 +- docs/architecture/05-transform-engine.md | 35 +++++-- docs/architecture/07-cursor.md | 48 +++++++-- src/config.cpp | 1 + src/config.h | 4 + src/mag_host.cpp | 56 ++++++++++ src/mag_host.h | 25 +++++ src/magnifier_model.h | 3 + src/main.cpp | 22 +++- src/native_cursor.h | 57 ++++++++++ src/transform_model.cpp | 126 +++++++++++++++++++++-- src/transform_model.h | 17 +++ src/version.h | 2 +- tests/test_native_cursor.cpp | 54 ++++++++++ ui/src/settings-schema.js | 2 +- 15 files changed, 438 insertions(+), 25 deletions(-) create mode 100644 src/native_cursor.h create mode 100644 tests/test_native_cursor.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 49e09ea1..03f2719e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,8 +37,10 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. - Never call `MagInitialize`/`MagUninitialize` directly; use `wind::MagApiAcquire()`/`MagApiRelease()` (`src/mag_host.*`). Independent pairs break each other: two cursors, or transform writes returning FALSE. Keep every hold symmetric. -- A live magnification context taxes every cursor change any app makes, even at level 1.0; only - releasing the runtime leaves that mode. So: no warm-up write at launch, context only around sessions. +- The COMPOSED pointer (DWM drawing it into the magnified frame) taxes every cursor change any app + makes; a context alone does not (measured 2026-10-07). Sprite path: no warm-up write at launch, + context only around sessions. Native cursor: context + cursor lens kept warm, lens style ON only + while zoomed. See 05 and 07. - Colour filters (warmth/brightness) hold the runtime at 1x and pay that tax. Measure a cursor-toggling game with colour on before blaming anything else. See 04. - Magnification calls are thread-affine: only the owning thread's writes take effect. @@ -52,6 +54,11 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. never reload. Add new UI-only keys there. **Cursor (07)** +- High resolution cursor on = native cursor (`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 + write a same-level transform or warm pulse. Never use a public write to get the composed pointer: + it blocks 200-260 ms. `MagGetFullscreenTransform` cannot see DWM's centring. - The cursor grows with the zoom in every engine (#253). Do not restore constant size; `cursorConstantSize=1` is the render-only opt-in. Test defaults on a wiped `%LOCALAPPDATA%\Wind`: the dev ini differs from a clean install. diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 5e24020c..e7729553 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -24,13 +24,17 @@ independent pairs break each other: Holds must be symmetric: take one when you need it, drop it the moment you stop. -**A live context taxes every cursor change any app makes.** While a magnification context exists, -DWM composites magnification-aware, and each cursor visibility or shape change costs a +**The composed pointer taxes every cursor change any app makes.** While DWM draws the pointer into +the magnified frame (a show-magnified-cursor lens: Magnification.dll's own after a public write +above 1x, or Wind's cursor lens with its style on), each cursor visibility or shape change costs a re-composite. Measured in a game that toggles its pointer on middle-click: 17 spike frames per 14 -clicks with a live context, 0 without. Writing level 1.0 does not leave this mode; only releasing -the runtime does. So the context lives only around real sessions, and there is no warm-up write at -launch (24 spike frames with one, 0 without). Colour filters hold the runtime at 1x and pay this -tax ([04](04-render-engine.md)). +clicks with that state live, 0 without; and on 2026-10-07 with a full-screen app blinking its +pointer: 19 spikes of 20-42 ms in 6 s with the composed pointer at 1x, 0 with a context alone, a +context after a private-channel zoom, or a context plus the cursor lens with its style off (all +three kept Independent Flip). So the sprite path keeps the context only around real sessions, and +there is no warm-up write at launch; the native cursor keeps context and lens warm with the style +off at 1x ([07](07-cursor.md#native-cursor-high-resolution-cursor-on)). Colour filters hold the +runtime at 1x ([04](04-render-engine.md)). **Calls are thread-affine.** Only the thread that called `MagInitialize` can drive the transform; a write from another thread returns FALSE and changes nothing (`src/mag_thread.h`). Every entry @@ -112,11 +116,28 @@ stateDiagram-v2 - `setActive(false)` parks DWM at identity at once. Returning to identity costs a ~150 ms compositor stall, so it is paid during the zoom-out motion, not seconds later in a game. - `idleTick()` releases the context once `txIdleReleaseMs` (default 1200, hot) passes, long enough - that quick zoom flicks skip the ~36 ms rebuild. + that quick zoom flicks skip the ~36 ms rebuild. Native-cursor mode never releases at idle: it + builds the context and the cursor lens at 1x after launch and keeps them, style off. +- Native-cursor sessions skip the blanker and the sprite stand-up at zoom-in (no cursor swaps) and + only switch the cursor lens style; zoom-out switches it off after the identity park and nudges + the pointer so the hardware plane repaints. - `teardownMag` restores cursor state **first** (`MagShowSystemCursor(TRUE)` needs a live context), then `resetTransformState()` forgets every cached value, so the next session does not skip writes DWM no longer holds. +## DWM centring (native cursor) + +`MagHost::setDwmCentring` wraps `SetFullscreenMagnifierOffsetsDWMUpdated` (user32, undocumented, +resolved by name): TRUE,0,0 hands the pan to DWM, which re-centres on every cursor update; FALSE,0.8,0.8 +gives it back. Rules (`src/native_cursor.h`, tested): + +- On only where the view is a pure function of the pointer and no MPO wall is in reach + (`WallBinding`: above ~9.3x on a 3840 wide monitor, 15.8x on 2160 high, when armed). +- While on, only level changes are written; warm pulses stop. DWM keeps the factor of the write + that follows a TRUE call, so every switch forces one write (`forceWrite_`, survives paused ticks). +- `MagGetFullscreenTransform` does not see DWM's own moves: win32k's copy keeps Wind's last write. + Judge centring on screen, not by read-back. + ## Clamping **Right and bottom: a 2 px margin, or TDR.** The mapper clamps the float source to diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 27530e05..d2f8a958 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -81,10 +81,46 @@ desktop space and DWM magnifies it; the render engine scales its drawn cursor to cursor kept at desktop size read as tiny next to the transform. `cursorConstantSize=1` is the render-only opt-in for the old constant size. `cursorScaleWithZoom` is retired and ignored. -## Hiding the real pointer: blanker and sprite - -In a zoomed transform session the real pointer would draw unmagnified at its raw position, so it -is hidden and a stand-in drawn. +## Native cursor (High resolution cursor on) + +With **High resolution cursor** on (`txSamplingMode=1`) and `txNativeCursor=1` (default), transform +sessions use the pointer Windows Magnifier uses: the real pointer, drawn by DWM into the magnified +frame. Pure rules: `src/native_cursor.h` (tested). + +- **The cursor lens.** `MagHost::createCursorLens` makes a hidden window of the documented + magnifier control class (`WC_MAGNIFIER`) on the runtime's owner thread. With + `MS_SHOWMAGNIFIEDCURSOR` set, win32k hands the pointer to DWM: magnified, sampled like the content + (smooth = sharp), drawn above every band (thumbnails, Start, the emoji panel, menus, UAC, the + Snipping Tool), latched in the same composition pass as the view. The style is ON only while + zoomed (`setActive`), 0.2 ms per toggle. +- **Built at idle, kept warm.** The lens build costs 60-125 ms, so `idleTick` builds it at 1x right + after launch and the context is never released at idle (no `txIdleReleaseMs` in this mode). + Measured: context + lens with the style OFF costs a pointer-toggling full-screen app nothing + (Independent Flip, 0 spike frames); the style ON at 1x costs it 19 spikes of 20-42 ms in 6 s. So + the cursor-change tax belongs to the composed pointer, not to the context. +- **No public prime.** Magnification.dll builds the same lens itself on the first public + `MagSetFullscreenTransform` above 1x, which blocks 200-260 ms. Owning the lens avoids that write; + it stays only as a fallback when the lens cannot be built. +- **DWM centring.** Where the view is a pure function of the pointer (free cursor, mouse owns the + view, no reachable MPO wall, no launch quiesce: `WantDwmCentring`), the model calls + `SetFullscreenMagnifierOffsetsDWMUpdated(TRUE, 0, 0)` and DWM re-centres the view on every cursor + update. Measured per displayed frame at 3x: the pointer stays on one screen point at every speed, + where a tick-paced write drifts 18-24 px at medium speed and up to 96 px fast. While DWM centres, + Wind sends only level changes (`SendWrite`) and no warm pulses: a same-level write would put a + tick-old offset on screen. Caret, focus, keyboard pan, edge mode, Inspect and locked games switch + it off and Wind writes the view as before; each switch forces one write. +- **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. +- No sprite, no blanker, no shell-panel freeze (`panelPointer` is skipped). The hide-cursor hotkey + blanks the pointer the way Inspect does. Inspect keeps its crosshair sprite. +- **Off** (nearest sampling, or `txNativeCursor=0`): the sprite path below. It suits games: a + composed pointer costs a visible-cursor full-screen app its Independent Flip while zoomed. + +## Hiding the real pointer: blanker and sprite (High resolution cursor off) + +In a zoomed sprite-path transform session the real pointer would draw unmagnified at its raw +position, so it is hidden and a stand-in drawn. - **`CursorBlanker`** (`src/cursor_blanker.*`) swaps the 14 system cursors for transparent ones and keeps the originals. It first reloads the user's scheme, so a previously killed Wind's blanks are @@ -249,9 +285,9 @@ turns held pan keys into a view delta in screen space, so the feel does not chan - While panning, KeyPan owns the view ahead of caret events. The delta is clamped to the monitor and the MPO wall. The pointer does not move; the next mouse move places it in the view. -## Shell input panels +## Shell input panels (sprite path) -The emoji picker, clipboard history and touch keyboard are composed above every window band, so +Native-cursor sessions need none of this: DWM's pointer is already above the panels. The emoji picker, clipboard history and touch keyboard are composed above every window band, so the sprite goes under them. While one is open (`FocusTracker::shellPanelOpen`, from TextInputHost cloak events) and `panelPointer=1` (default), the transform hides the sprite, restores the real pointer and makes one public `MagSetFullscreenTransform` write, after which DWM draws the pointer diff --git a/src/config.cpp b/src/config.cpp index 74297d63..3a2aa1c2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -234,6 +234,7 @@ Config ParseConfig(const std::string& text) { else if (key == "txWarmLevelEps") c.txWarmLevelEps = std::stod(val); else if (key == "txWriteHz") c.txWriteHz = std::stoi(val); else if (key == "txFreeCursor") c.txFreeCursor = std::stoi(val); + else if (key == "txNativeCursor") c.txNativeCursor = std::stoi(val); else if (key == "lockedBallistics") c.lockedBallistics = std::stoi(val); else if (key == "edgeClip") c.edgeClip = std::stoi(val); else if (key == "txPace") c.txPace = std::stoi(val); diff --git a/src/config.h b/src/config.h index 5a2d9289..e351f2e2 100644 --- a/src/config.h +++ b/src/config.h @@ -385,6 +385,10 @@ struct Config { int txHookWrite = 0; int panelPointer = 1; // #283: real magnified pointer, frozen and moved by Wind, while a shell input panel is open int txFreeCursor = 1; + // Native cursor (issue #369, src/native_cursor.h): with High resolution cursor on (smooth + // sampling), transform sessions use DWM's own magnified pointer and DWM's own centring instead + // of the sprite. 0 = always the sprite (A/B and kill switch). Read at zoom-in (hot). + int txNativeCursor = 1; // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write // makes DWM redo work proportional to the zoom. Turning it on scored well in the automated diff --git a/src/mag_host.cpp b/src/mag_host.cpp index df90809a..5c536004 100644 --- a/src/mag_host.cpp +++ b/src/mag_host.cpp @@ -66,6 +66,8 @@ bool MagHost::initialize() { magDll ? GetProcAddress(magDll, MAKEINTRESOURCEA(1)) : nullptr); setSamplingRaw_ = reinterpret_cast( u32 ? GetProcAddress(u32, "SetMagnificationDesktopSamplingMode") : nullptr); + setDwmUpdated_ = reinterpret_cast( + u32 ? GetProcAddress(u32, "SetFullscreenMagnifierOffsetsDWMUpdated") : nullptr); } return initialized_; } @@ -96,6 +98,59 @@ bool MagHost::setSamplingMode(unsigned mode) { }); } +bool MagHost::setDwmCentring(bool on) { + if (!initialized_ || !setDwmUpdated_) return false; + // Not a Magnification-context call (it goes straight to DWM for the caller's desktop), but it + // pairs with the transform writes, so it runs on the same owner thread for ordering. + auto fn = setDwmUpdated_; + return MagThreadInvoke([fn, on]() -> bool { + return fn(on ? TRUE : FALSE, on ? 0.0f : 0.8f, on ? 0.0f : 0.8f) != FALSE; + }); +} + +bool MagHost::createCursorLens() { + if (!initialized_) return false; + if (lens_) return true; + // On the owner thread: the lens registers with the CALLING thread's magnification context. + HWND host = nullptr, lens = nullptr; + const bool ok = MagThreadInvoke([&host, &lens]() -> bool { + HINSTANCE inst = GetModuleHandleW(nullptr); + host = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, L"Static", L"Wind cursor lens", + WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, inst, nullptr); + if (!host) return false; + lens = CreateWindowExW(0, WC_MAGNIFIERW, L"", WS_CHILD, 0, 0, 0, 0, host, nullptr, inst, nullptr); + if (!lens) { DestroyWindow(host); host = nullptr; return false; } + return true; + }); + if (!ok) return false; + lensHost_ = host; + lens_ = lens; + return true; +} + +bool MagHost::setCursorLens(bool on) { + if (!lens_) return false; + HWND lens = lens_; + return MagThreadInvoke([lens, on]() -> bool { + const LONG_PTR st = GetWindowLongPtrW(lens, GWL_STYLE); + const LONG_PTR want = on ? (st | MS_SHOWMAGNIFIEDCURSOR) : (st & ~(LONG_PTR)MS_SHOWMAGNIFIEDCURSOR); + if (want != st) SetWindowLongPtrW(lens, GWL_STYLE, want); + return true; + }); +} + +void MagHost::destroyCursorLens() { + if (!lens_ && !lensHost_) return; + HWND lens = lens_, host = lensHost_; + MagThreadInvoke([lens, host]() -> bool { + if (lens) DestroyWindow(lens); + if (host) DestroyWindow(host); + return true; + }); + lens_ = nullptr; + lensHost_ = nullptr; +} + bool MagHost::setTransform(float zoom, int offX, int offY, int tx, int ty, bool fastPan) { if (!initialized_) return false; SpanScope span(kSpanTxWrite); // includes the marshal to the owner thread @@ -147,6 +202,7 @@ bool MagHost::getInputTransform(bool& active, RECT& src, RECT& dst) { void MagHost::shutdown() { if (!initialized_) return; + destroyCursorLens(); // before MagUninitialize, which unregisters the window class // Reset and release as ONE marshalled unit: split across two invokes another thread could slip // a write in between the identity reset and the release. MagThreadInvoke([]() -> bool { diff --git a/src/mag_host.h b/src/mag_host.h index 35bf49cc..dc518213 100644 --- a/src/mag_host.h +++ b/src/mag_host.h @@ -43,6 +43,27 @@ class MagHost { // NOTE: the state is not ours alone - it survives our process and is reset when DWM restarts, // which is why smoothing appeared to come and go across builds. Set it every session. bool setSamplingMode(unsigned mode); + // user32!SetFullscreenMagnifierOffsetsDWMUpdated (undocumented, resolved by name; issue #369). + // TRUE,0,0 = DWM re-centres the view on the pointer itself at every cursor update, in the + // same composition pass that draws the pointer (Magnify.exe's centred mode). FALSE,0.8,0.8 = + // the client owns the offsets (Magnify.exe's other modes). A TRUE call makes DWM keep the + // factor of the NEXT write, so always write the transform right after switching it on. + bool setDwmCentring(bool on); + // CURSOR LENS (issue #369). A hidden window of the documented magnifier control class + // (WC_MAGNIFIER) registers a window lens with win32k; with MS_SHOWMAGNIFIEDCURSOR set, win32k + // switches the pointer to DWM's composition, so DWM draws the REAL pointer into the magnified + // frame (above every band, sampled like the content). Measured on this PC: + // - creating the lens costs 60-125 ms on the owner thread (once); a lens created on any other + // thread registers nothing; + // - toggling MS_SHOWMAGNIFIEDCURSOR costs 0.2 ms and switches the composed pointer at once; + // - with the style OFF a live context + lens costs nothing: a pointer-toggling full-screen app + // keeps Independent Flip with 0 spike frames. With the style ON at 1x the same app drops to + // composed with 19 spikes of 20-42 ms in 6 s (the "cursor-change tax"); so ON only while zoomed. + // Magnification.dll builds the same lens itself on the first PUBLIC write above 1x, which is + // why that write blocks for 200-260 ms; owning the lens avoids that write entirely. + bool createCursorLens(); + bool setCursorLens(bool on); + bool cursorLensReady() const { return lens_ != nullptr; } void shutdown(); private: bool initialized_ = false; @@ -50,5 +71,9 @@ class MagHost { int (__stdcall* setMagDesktop_)(double, int, int) = nullptr; int (__stdcall* setBitmapSmoothing_)(int) = nullptr; int (__stdcall* setSamplingRaw_)(DWORD*) = nullptr; // modes 2-4 (undocumented) + BOOL (__stdcall* setDwmUpdated_)(BOOL, float, float) = nullptr; + HWND lensHost_ = nullptr; + HWND lens_ = nullptr; + void destroyCursorLens(); }; } diff --git a/src/magnifier_model.h b/src/magnifier_model.h index e8638787..d9b0013c 100644 --- a/src/magnifier_model.h +++ b/src/magnifier_model.h @@ -33,6 +33,9 @@ struct PresentExtras { // draws above its panels) instead of the sprite, and prime one public-API write so DWM draws // that pointer magnified. Transform model only. bool realPointer = false; + // The view is a pure function of the real pointer this tick (free cursor, mouse-owned, no + // walls, no quiesce): a native-cursor session may hand the pan to DWM (issue #369). + bool dwmCentre = false; // Colour filter for the render engine's pixel shader (issue #288). Its capture already // contains the DWM colour effect, so RunTick clears that effect during a render session and // hands the matrix here instead. Transform and 1x use the DWM effect. diff --git a/src/main.cpp b/src/main.cpp index 42e111de..86fd47b9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,6 +31,7 @@ #include "hdr_info.h" // issue #288 #include "cursor_tint.h" // tinted pointer at 1x (#288) #include "transform_model.h" +#include "native_cursor.h" // UseNativeCursor, WantDwmCentring (issue #369) #include "hitch_record.h" // hitch recorder (#361) #include "tick_span.h" #include "input_router.h" @@ -1169,6 +1170,8 @@ static void RunTick(TickState& t) { if (auto* tmHot = dynamic_cast( t.mTransform ? t.mTransform : t.model)) tmHot->setIdleReleaseMs(nc.txIdleReleaseMs); + if (auto* tmHot = dynamic_cast(t.mTransform ? t.mTransform : t.model)) + tmHot->setNativeCursorPref(wind::UseNativeCursor(nc.txNativeCursor, nc.txSamplingMode)); t.cfg = nc; // pick up renderer knobs (smoothing, filter, cursor scale, zoom speed) // transformExclude / renderExclude / the per-window-type engine keys may all have // changed: drop the cache so every exe-derived predicate is re-resolved. Without this @@ -1812,7 +1815,10 @@ static void RunTick(TickState& t) { // write: the hand's motion arrives as ballistics-cooked raw input (the Inspect machinery). // Hook-thread writes were tried first and rejected: owning the runtime there marshals every // write onto the input thread (field: hitches). Tracking and edge mode pause meanwhile. - const bool panel = t.cfg.panelPointer != 0 && freeCursor && lvl > 1.001 && g_track.shellPanelOpen(); + // A native-cursor session (issue #369) needs none of this: DWM's pointer is already drawn + // above the panels and DWM keeps it centred, so the hand moves the pointer directly. + const bool panel = t.cfg.panelPointer != 0 && freeCursor && lvl > 1.001 && g_track.shellPanelOpen() && + !(tmWall && tmWall->nativeSession()); if (panel && !t.panelFreeze) { GetClipCursor(&t.panelSavedClip); POINT p{}; GetCursorPos(&p); @@ -2274,6 +2280,18 @@ static void RunTick(TickState& t) { const bool quiesceHold = QuiesceHoldActive(t); ex.pauseWrites = t.clickPauseTicks > 0 || quiesceHold; if (quiesceHold) ex.suppressCursorSync = true; + // Native cursor (issue #369): DWM may own the pan only where the view is a pure function of + // the pointer. The model applies it only in a native-cursor session. + { + wind::DwmCentreIn dc; + dc.zoomed = lvl > 1.001; + dc.freeCursor = freeCursor && !panel; + dc.viewDetached = t.viewDetached; + dc.wallNeeded = wind::WallBinding(wallNeeded, lvl, t.mon.w, t.mon.h, kMaxSafeTxMagnitude); + dc.quiesce = quiesceHold; + dc.hookWrite = hookWrite; + ex.dwmCentre = wind::WantDwmCentring(dc); + } // Our tray menu is open (in WindTray.exe, flagged through the shared block): the pointer // belongs to the USER (they are aiming at menu items), // so the weld must not re-park it - at full tick rate it pins the cursor outright @@ -3075,6 +3093,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { cfg.cursorSprite != 0, cfg.zorderBand, cfg.spriteBand16 != 0, cfg.cursorBandAuto != 0); tm->setIdleReleaseMs(cfg.txIdleReleaseMs); + tm->setNativeCursorPref(wind::UseNativeCursor(cfg.txNativeCursor, cfg.txSamplingMode)); tm->setSpriteCapturable(cfg.spriteCapturable != 0); model = std::move(tm); } else { @@ -3089,6 +3108,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { cfg.cursorSprite != 0, cfg.zorderBand, cfg.spriteBand16 != 0, cfg.cursorBandAuto != 0); tm2->setIdleReleaseMs(cfg.txIdleReleaseMs); + tm2->setNativeCursorPref(wind::UseNativeCursor(cfg.txNativeCursor, cfg.txSamplingMode)); tm2->setSpriteCapturable(cfg.spriteCapturable != 0); model2 = std::move(tm2); } diff --git a/src/native_cursor.h b/src/native_cursor.h new file mode 100644 index 00000000..49c0f6e9 --- /dev/null +++ b/src/native_cursor.h @@ -0,0 +1,57 @@ +#pragma once +// Native cursor (issue #369) - PURE, no , so it is unit-testable. +// +// With High resolution cursor on (smooth sampling), transform sessions use the pointer Windows +// Magnifier uses instead of Wind's sprite: +// - ONE public MagSetFullscreenTransform write makes DWM draw the REAL pointer into the +// magnified frame: magnified, above every window band (thumbnails, emoji panel, menus, UAC, +// the Snipping Tool), sampled like the content (smooth = high res). Later private writes keep +// it. No sprite, no cursor blanking, so no cursor swaps at zoom-in or zoom-out. +// - SetFullscreenMagnifierOffsetsDWMUpdated(TRUE, 0, 0) makes DWM re-centre the view itself on +// every cursor update, latched in the same composition pass as the pointer. Measured per +// displayed frame at 3x: the pointer sits at one fixed screen point at every speed (native, +// DWM alone), where a tick-paced write drifts 18-24 px at medium speed and up to 96 px fast. +// DWM re-learns a small hotspot offset only when the cursor HANDLE changes; a learn taken +// mid-jump can sit a few px off until the next shape change (native has the same). +// Off (nearest sampling) keeps the sprite path: the cheaper choice for games, because a composed +// pointer costs a visible-cursor game its Independent Flip. +namespace wind { + +// Whether a transform session uses the native cursor. Decided once at zoom-in; sampling is a +// restart-staged setting, so it cannot change under a session anyway. +inline bool UseNativeCursor(int txNativeCursor, int effectiveSamplingMode) { + return txNativeCursor != 0 && effectiveSamplingMode == 1; +} + +// 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 { + bool zoomed = false; // level above 1x + bool freeCursor = false; // free-cursor transform session (not Inspect, not locked) + bool viewDetached = false; // caret, focus, keyboard pan or mouse edge mode own the view + bool wallNeeded = false; // an MPO pan wall is in reach (WallBinding): DWM would pan past it + bool quiesce = false; // launch quiesce: no magnification activity at all + bool hookWrite = false; // the mouse hook owns transform writes (txHookWrite) +}; + +// Whether an armed MPO pan wall can actually stop the view at this level: the wall caps the source +// origin at maxSafe/level, and the view can only travel to w - w/level. Below ~9.3x on a 3840 wide +// monitor (15.8x on 2160 high) the wall is out of reach, so DWM's own pan cannot cross it. +inline bool WallBinding(bool wallArmed, double level, int w, int h, double maxSafe) { + if (!wallArmed || level <= 1.0) return false; + return (w * level - w) > maxSafe || (h * level - h) > maxSafe; +} + +inline bool WantDwmCentring(const DwmCentreIn& in) { + return in.zoomed && in.freeCursor && !in.viewDetached && !in.wallNeeded && !in.quiesce && + !in.hookWrite; +} + +// While DWM centres, a write at an unchanged level would put Wind's tick-old offset back on +// screen until the next cursor update (a one-frame twitch per write), so only level changes and +// the one write that follows a centring switch go out. The bookkeeping still advances. +inline bool SendWrite(bool dwmCentring, bool levelMoved, bool forceWrite) { + return !dwmCentring || levelMoved || forceWrite; +} + +} // namespace wind diff --git a/src/transform_model.cpp b/src/transform_model.cpp index c78365b6..65f093b9 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -8,6 +8,7 @@ #include "sprite_layer.h" // PickSpriteLayer (pure, tested): issue #269 #include "config_path.h" // ResolveLogDir #include "tick_span.h" // per-tick spans (#361) +#include "native_cursor.h" // UseNativeCursor, SendWrite (pure, tested): issue #369 #include #include #include @@ -45,6 +46,8 @@ static const unsigned long long kSettleMs = 100; void TransformModel::resetTransformState() { panelPrimed_ = false; // a rebuilt context needs its own public prime (#283, review #284) + nativePrimed_ = false; // ...and so does the native cursor (#369) + forceWrite_ = false; // lastLevel_ = 0 already forces the next write // Everything the write path caches must be forgotten across a teardown, or the next session // compares against values DWM no longer holds and skips the writes that would re-apply them. lastLevel_ = 0.0; lastRequestedLevel_ = 0.0; @@ -90,7 +93,8 @@ void TransformModel::teardownMag() { if (cursorHidden_) { ShowSystemCursorMarshalled(TRUE); cursorHidden_ = false; } if (sprite_) sprite_->hide(); cage_.hide(); - if (blanker_) blanker_->restore(); + if (blanker_ && (!nativeSession_ || blanker_->blanked())) blanker_->restore(); + setDwmCentre(false); host_.setTransform(1.0f, 0, 0, 0, 0, false); // leave DWM at identity before releasing RECT full{ 0, 0, mon_.w, mon_.h }; host_.setInputTransform(false, full, full); @@ -100,6 +104,15 @@ void TransformModel::teardownMag() { idleSinceMs_ = 0; identityParked_ = false; resetTransformState(); + if (nativeSession_ && active_) { + // Released mid-session (shutdown, model swap): the pointer leaves DWM's composition for the + // hardware plane, which Windows repaints only on the next cursor EVENT, so nudge it a pixel + // and back (the same trick the zoom-out uses). + POINT np; + if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + } + nativeSession_ = false; + lensFailed_ = false; // a fresh context may build the lens QueryPerformanceCounter(&b); wind::Log(wind::LogLevel::Info, "transform", "magnification context released in %.1fms", double(b.QuadPart - a.QuadPart) * 1000.0 / fr.QuadPart); @@ -258,6 +271,23 @@ void TransformModel::writeTransform(float lvl, int offX, int offY, int tx, int t noteWrite(double(b.QuadPart - a.QuadPart) * 1000.0 / fr.QuadPart, ok); } +// DWM centring switch (issue #369). Turning it on hands the pan to DWM; turning it off hands it +// back. Either way the caller forces the next transform write: after TRUE, DWM keeps the factor of +// the next write (it would otherwise centre with a stale one), and after FALSE the view must be +// put back on Wind's own offset. +void TransformModel::setDwmCentre(bool on) { + if (on == dwmCentreOn_) return; + if (on && dwmCentreBroken_) return; + const bool ok = host_.setDwmCentring(on); + if (on && !ok) { + dwmCentreBroken_ = true; + wind::Log(wind::LogLevel::Warn, "transform", + "DWM centring unavailable (SetFullscreenMagnifierOffsetsDWMUpdated) - Wind keeps the pan"); + return; + } + dwmCentreOn_ = on; +} + void TransformModel::hideSystemCursor(bool hide) { if (!useSprite_ || !blanker_) return; if (hide && !ensureMag()) return; // MagShowSystemCursor needs a live context @@ -327,6 +357,9 @@ void TransformModel::setActive(bool active) { LARGE_INTEGER zf, z0, z1, z2; // zoom timeline split (#310): a few QPC reads, always on QueryPerformanceFrequency(&zf); QueryPerformanceCounter(&z0); lastEnter_.wasWarm = magUp_; + // Native cursor (issue #369): DWM draws the real pointer, so there is nothing to stand + // up and nothing to blank - no cursor swaps at zoom-in at all. + nativeSession_ = nativePref_; // Blank the system cursor set BEFORE the magnification context exists (issue #189): the // blanker swaps 14 system cursors, and under a LIVE context every cursor change costs a // DWM re-composite (the documented per-change tax) - running the burst inside the fresh @@ -334,7 +367,7 @@ void TransformModel::setActive(bool active) { // zoom-in hitch. Plain SetSystemCursor needs no context, so it is free out here. The // present() hide branch keeps its blank() call as the fallback (idempotent) and still // owns MagShowSystemCursor + cursorHidden_ bookkeeping. - if (useSprite_ && blanker_) { + if (useSprite_ && blanker_ && !nativeSession_) { // Bridge the swap gap (issue #221 field report: zoom-in BLINKS the cursor): the // blank hides the real pointer instantly, but the sprite's first present is a // context build (~36ms) plus a reveal away - a visible cursor-less gap. Stand the @@ -365,6 +398,9 @@ void TransformModel::setActive(bool active) { // per 3 cycles vs 2, and it added zoom-out spikes. Entering magnification costs ~36ms // once per zoom-in regardless - that is DWM building its machinery.) ensureMag(); + // Native cursor: switch the pointer to DWM's composition (0.2 ms once the lens exists; a + // first zoom before the idle warm-up pays the lens build here, once). + if (nativeSession_ && host_.createCursorLens()) host_.setCursorLens(true); QueryPerformanceCounter(&z2); lastEnter_.bridgeMs = double(z1.QuadPart - z0.QuadPart) * 1000.0 / zf.QuadPart; lastEnter_.ensureMagMs = double(z2.QuadPart - z1.QuadPart) * 1000.0 / zf.QuadPart; @@ -399,7 +435,9 @@ void TransformModel::setActive(bool active) { // Unconditional (and idempotent): setActive(true) pre-blanks BEFORE the context exists, so // a session that never entered the draw branch (cursorVisibility=never, hide-hotkey) still // has blanked system cursors to give back even though cursorHidden_ never went true. - if (blanker_) { + // Native sessions never blanked, so there is nothing to restore (and no scheme reload). + setDwmCentre(false); // before the identity park, so DWM does not re-centre against it + if (blanker_ && (!nativeSession_ || blanker_->blanked())) { // Windows repaints the pointer plane only on the next cursor EVENT, so a restored-but- // still pointer stays invisible until the hand moves (field-verified). A 1px nudge and // back generates that event invisibly. It must FOLLOW the restore, which runs on the @@ -437,6 +475,14 @@ void TransformModel::setActive(bool active) { const double parkMs = double(pb.QuadPart - pa.QuadPart) * 1000.0 / fr.QuadPart; if (parkMs > 5.0) wind::Log(wind::LogLevel::Info, "transform", "identity park took %.1fms", parkMs); + if (nativeSession_ && host_.cursorLensReady()) { + // Native cursor: back to the hardware pointer at 1x (the composed one taxes every cursor + // change a game makes). The hardware plane repaints only on the next cursor EVENT, so + // nudge the pointer a pixel and back. + host_.setCursorLens(false); + POINT np; + if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + } RECT full{ 0, 0, mon_.w, mon_.h }; host_.setInputTransform(false, full, full); // input mapping back to identity at 1x step(6); @@ -458,6 +504,27 @@ void TransformModel::setActive(bool active) { } void TransformModel::idleTick() { + // NATIVE CURSOR (issue #369): keep the context and the cursor lens alive at 1x instead of + // releasing them. With the lens style OFF this costs nothing (measured: a pointer-toggling + // full-screen app keeps Independent Flip, 0 spike frames), and it moves the one-time 60-125 ms + // lens build off the zoom path: it runs here, at 1x, shortly after launch. The old release + // existed for the cursor-change tax, which only the composed pointer (style ON) causes. + if (nativePref_ && !active_) { + if ((!magUp_ || !host_.cursorLensReady()) && !lensFailed_) { + LARGE_INTEGER fr, a, b; + QueryPerformanceFrequency(&fr); QueryPerformanceCounter(&a); + const bool ok = ensureMag() && host_.createCursorLens(); + QueryPerformanceCounter(&b); + lensFailed_ = !ok; // no 100 ms retry loop; zoom-in falls back to the public prime + if (!lensLogged_) { + lensLogged_ = true; + wind::Log(ok ? wind::LogLevel::Info : wind::LogLevel::Warn, "transform", + "cursor lens %s in %.1fms (kept warm at 1x)", ok ? "ready" : "FAILED", + double(b.QuadPart - a.QuadPart) * 1000.0 / fr.QuadPart); + } + } + return; + } if (!magUp_ || active_ || idleSinceMs_ == 0) return; // Holding a non-identity rest level is pointless if the context is then released: the release // returns DWM to identity anyway. So the two go together. @@ -552,6 +619,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg srcL = o.x; srcT = o.y; } idleReleaseMs_ = cfg.txIdleReleaseMs; // hot-reloadable release window + nativePref_ = UseNativeCursor(cfg.txNativeCursor, cfg.txSamplingMode); // next zoom-in (#369) restLevel_ = cfg.txRestLevel; // hot if (!ensureMag()) return; // lazy context: the session's first write brings DWM up // Bitmap smoothing (issue #197/#227), once per magnification context. The smooth filter @@ -631,6 +699,21 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg if (m.txX < -32000) m.txX = -32000; } bool txWroteThisTick = false; // the sprite follows the VIEW, not the tick (see below) + // DWM CENTRING (issue #369, src/native_cursor.h). In a native-cursor session where the view is + // a pure function of the pointer (RunTick's ex.dwmCentre), DWM re-centres the view itself on + // every cursor update, latched with the pointer it draws: no tick-to-frame drift at any speed. + // Switching off is allowed on a paused tick (it stops DWM moving the view); switching on waits + // for a tick that may write, because the switch needs the forced write that follows it. + // The owed write survives paused ticks (forceWrite_), so a switch-off during a pause still puts + // Wind's offset back on the first tick that may write. + { + const bool want = nativeSession_ && ex.dwmCentre && applyLevel > 1.001; + if (want != dwmCentreOn_ && (!want || !ex.pauseWrites)) { + setDwmCentre(want); + forceWrite_ = true; + wind::Log(wind::LogLevel::Info, "transform", "DWM centring %s", dwmCentreOn_ ? "ON" : "off"); + } + } // Trace inputs, captured here and appended at the END of present() so the sprite position // recorded is this tick's, not the previous one's. bool trChanged = false, trRamping = ramping, trWarm = false; @@ -670,7 +753,8 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg ci.writeHz = cfg.txWriteHz; ci.minOffsetPx = cfg.txMinOffsetPx; ci.settleMs = kSettleMs; - const bool writeNow = ShouldWriteTransform(ci); + const bool forceWrite = forceWrite_; + const bool writeNow = ShouldWriteTransform(ci) || forceWrite; if (writeNow) { lastOffX_ = m.offX; lastOffY_ = m.offY; lastTxX_ = m.txX; lastTxY_ = m.txY; @@ -703,7 +787,9 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg wi.warmHz = cfg.txWarmHz; wi.pulseOpen = keepAliveTick_ != 0; wi.sinceLastWarmMs = nowMs - (lastWarmMs_ > lastChangeMs_ ? lastWarmMs_ : lastChangeMs_); - switch (WarmAction(wi)) { + // No warm pulses while DWM centres: each one would put Wind's offset back on screen for a + // frame, and DWM's own per-cursor-update moves are the pan (native has no warm-keeping). + switch (dwmCentreOn_ ? TxWarm::None : WarmAction(wi)) { case TxWarm::Jitter1px: keepAliveTick_ ^= 1; txJitter = keepAliveTick_; // BOTH parities must write (the return-to-true half too) @@ -732,12 +818,19 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // anyway (measured), so skipping is free; the next changed/keep-alive tick writes as before. // suppressTransformWrite: the mouse hook is the single writer this session (issue #206). The // state above is still maintained, so turning the hook path off mid-session resumes cleanly. - if ((changedAndWriting || keepAliveActive) && !ex.suppressTransformWrite) { + if ((changedAndWriting || keepAliveActive) && !ex.suppressTransformWrite && + SendWrite(dwmCentreOn_, ci.levelMoved, forceWrite)) { // warmLevelJitter_ (mode 4) perturbs only the LEVEL, and only on warm ticks - a real // write always sends the true level. See the mode 4 note above for why it has to change // at all and why this is the cheapest honest thing to change. const double lvlOut = warmLevelJitter_ ? applyLevel * (1.0 + cfg.txWarmLevelEps) : applyLevel; - writeTransform((float)lvlOut, m.offX, m.offY, m.txX + txJitter, m.txY, fastPan_, false); + // Native cursor (#369): the context's first zoomed write goes through the PUBLIC API, which + // is what makes DWM draw the real pointer magnified; the private channel keeps it after. + // (Fallback only: normally the cursor lens does this without the 200-260 ms public write.) + const bool prime = nativeSession_ && !nativePrimed_ && !host_.cursorLensReady() && lvlOut > 1.001; + writeTransform((float)lvlOut, m.offX, m.offY, m.txX + txJitter, m.txY, fastPan_ && !prime, false); + if (prime) nativePrimed_ = true; + forceWrite_ = false; } warmLevelJitter_ = false; // Input transform. Mode 1 (THE SHIPPED DEFAULT; field-verified 4x-20x, @@ -935,6 +1028,25 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg lastSpriteY_ = spriteBand16_ ? (int)(r.cursorScreenY + 0.5) + mon_.y : r.clickDesktopY + mon_.y; sprite_->keepOnTop(); + } else if (nativeSession_ && level > 1.001) { + // NATIVE CURSOR (issue #369): DWM draws the real pointer into the magnified frame, above + // every band, so there is no sprite. Only the hide-cursor hotkey / cursorVisibility=never + // hides it, the same way Inspect does (blanker for standard shapes, MagShowSystemCursor + // for app-custom ones). + spriteShown_ = false; + panelPrimed_ = false; + if (sprite_) sprite_->hide(); + if (!ex.drawCursor) { + if (!cursorHidden_ && blanker_) { + blanker_->blank(); + ShowSystemCursorMarshalled(FALSE); + cursorHidden_ = true; + } + } else if (cursorHidden_) { + ShowSystemCursorMarshalled(TRUE); + if (blanker_) blanker_->restore(); + cursorHidden_ = false; + } } else if (useSprite_ && sprite_ && ex.realPointer && ex.drawCursor && level > 1.001) { // SHELL INPUT PANEL (issue #283). The emoji picker and its siblings are composed by the shell // above every window band, so the sprite goes under them. The real pointer is the one thing diff --git a/src/transform_model.h b/src/transform_model.h index 8ce38df7..0d6a6028 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -24,6 +24,12 @@ class TransformModel : public IMagnifierModel { zorderBand_(zorderBand), spriteBand16_(spriteBand16), cursorBandAuto_(cursorBandAuto) {} // Before initialize(). The dualcursor rig measures the sprite from captures (issue #269). void setSpriteCapturable(bool on) { spriteCapturable_ = on; } + // Native cursor preference (issue #369, src/native_cursor.h): UseNativeCursor(txNativeCursor, + // effective sampling). Latched into the session at zoom-in; present() keeps it current. + void setNativeCursorPref(bool on) { nativePref_ = on; } + // This session draws DWM's own pointer (no sprite, no blanking). RunTick skips the shell-panel + // pointer freeze for it: DWM's pointer is already above the panels. + bool nativeSession() const { return active_ && nativeSession_; } bool initialize(const MonitorTarget& monitor) override; // MONITOR GEOMETRY CAN CHANGE UNDER A LIVE SESSION (issue #230). mon_ feeds the clamp bounds in // ComputeMagTransform, the sprite's placement offsets and the MagSetInputTransform rects, and @@ -148,6 +154,17 @@ class TransformModel : public IMagnifierModel { // zoomed). So the context lives only around real zoom sessions. bool magUp_ = false; bool panelPrimed_ = false; // #283: public write done for this panel + // Native cursor (issue #369). nativePrimed_: the context's one public write that makes DWM + // draw the real pointer magnified. dwmCentreOn_: DWM owns the pan (DWMUpdated TRUE). + bool nativePref_ = false; + bool nativeSession_ = false; + bool nativePrimed_ = false; + bool dwmCentreOn_ = false; + bool dwmCentreBroken_ = false; // the export is missing or refused: never retry + bool forceWrite_ = false; // a centring switch owes DWM one real write + bool lensLogged_ = false; // one-shot log of the cursor-lens warm-up + bool lensFailed_ = false; // the idle lens build failed: do not retry per tick + void setDwmCentre(bool on); bool cursorHidden_ = false; // we called MagShowSystemCursor(FALSE) bool haveLastClick_ = false; // dedup the per-tick cursor weld int lastClickX_ = 0, lastClickY_ = 0; diff --git a/src/version.h b/src/version.h index 499b3ffa..c08692be 100644 --- a/src/version.h +++ b/src/version.h @@ -7,4 +7,4 @@ #define WIND_VER_PATCH 3 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.23.3" +#define WIND_VERSION_STR "0.24.0" diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp new file mode 100644 index 00000000..39537728 --- /dev/null +++ b/tests/test_native_cursor.cpp @@ -0,0 +1,54 @@ +#include "doctest.h" +#include "../src/native_cursor.h" + +using namespace wind; + +TEST_CASE("native cursor needs the knob AND smooth sampling") { + CHECK(UseNativeCursor(1, 1) == true); + CHECK(UseNativeCursor(1, 0) == false); // high resolution cursor off: the sprite path + CHECK(UseNativeCursor(0, 1) == false); // kill switch + CHECK(UseNativeCursor(0, 0) == false); +} + +static DwmCentreIn Mouse() { + DwmCentreIn in; + in.zoomed = true; + in.freeCursor = true; + return in; +} + +TEST_CASE("DWM centres a plain zoomed free-cursor session") { + CHECK(WantDwmCentring(Mouse()) == true); +} + +TEST_CASE("DWM centring is off wherever Wind must own the offset") { + DwmCentreIn in = Mouse(); + in.zoomed = false; + CHECK(WantDwmCentring(in) == false); + in = Mouse(); in.freeCursor = false; // Inspect, locked mouselook + CHECK(WantDwmCentring(in) == false); + in = Mouse(); in.viewDetached = true; // caret, focus, keys, edge mode + CHECK(WantDwmCentring(in) == false); + in = Mouse(); in.wallNeeded = true; // MPO walls: DWM would pan past them + CHECK(WantDwmCentring(in) == false); + in = Mouse(); in.quiesce = true; // launch quiesce + CHECK(WantDwmCentring(in) == false); + in = Mouse(); in.hookWrite = true; + CHECK(WantDwmCentring(in) == false); +} + +TEST_CASE("while DWM centres, only level changes and forced writes go out") { + CHECK(SendWrite(false, false, false) == true); // Wind owns the pan: every write + CHECK(SendWrite(true, false, false) == false); // pan-only write would twitch the view + CHECK(SendWrite(true, true, false) == true); // zoom ramp + CHECK(SendWrite(true, false, true) == true); // the write after a centring switch +} + +TEST_CASE("an armed MPO wall only blocks DWM centring where the view can reach it") { + // 3840x2160, wall at |src*level| <= 32000. + CHECK(WallBinding(false, 20.0, 3840, 2160, 32000.0) == false); // not armed + CHECK(WallBinding(true, 3.0, 3840, 2160, 32000.0) == false); // 3x: max reach 7680 + CHECK(WallBinding(true, 9.0, 3840, 2160, 32000.0) == false); // 30720: still inside + CHECK(WallBinding(true, 9.5, 3840, 2160, 32000.0) == true); // 32640: reachable + CHECK(WallBinding(true, 1.0, 3840, 2160, 32000.0) == false); +} diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 81414629..ec0c232d 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -111,7 +111,7 @@ export const groups = [ // turning high-res OFF also stages MPO-disable and turning it ON stages MPO re-enable, both // atomic at the Windows restart (the core holds the boot state's look until then). { key: 'txSamplingMode', type: 'highres', label: 'High resolution cursor', keywords: ['sharp', 'crisp', 'smooth', 'blurry', 'pixelated', 'hidpi', 'sampling', 'quality', 'nearest', 'mpo', 'overlay', 'cursor', 'pointer', 'resolution', '4k', 'antialiasing', 'restart', 'registry'], - desc: 'A sharper pointer and image at high zoom.', def: 0 }, + desc: 'A sharp pointer, drawn by Windows above every panel, and a smoother image. Off is lighter for games.', def: 0 }, // Zoom lock detection (#221): games like DOOM pin the mouse to the screen centre, which would // pin the zoom view there too. Listed apps get the view UNLOCKED from the pointer. { key: 'lockApps', type: 'applist', adv: true, label: 'Mouse-locked games', keywords: ['lock', 'locked', 'fps', 'shooter', 'first person', 'mouselook', 'mouse look', 'camera', 'game', 'exe', 'program', 'unlock', 'exception', 'apps', 'pinned', 'centre'], From daee1f22c96bb6aac57f063a9fc18decd2429167 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:00:22 +0200 Subject: [PATCH 02/22] feat(cursor): native cursor at both sampling modes; fix the smoothing setter lookup (#369) - The native cursor no longer needs High resolution cursor: DWM samples the pointer like the content, so nearest gives the same pixelated, shimmer-free look the sprite had, now as the real pointer above every panel with DWM centring. High resolution cursor only picks the filter. Measured at nearest: pointer on one screen point per frame at every speed, drawn over the emoji panel, 0 late frames at zoom-in and zoom-out, Independent Flip with 0 spikes at 1x. - MagSetFullscreenUseBitmapSmoothing was resolved by ordinal 1, which does not exist (the export ordinals start at 100), so Wind never set the filter and showed whatever state another process left in DWM. Resolved by name now; from a smooth DWM state Wind at nearest now really is nearest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- CLAUDE.md | 2 +- docs/architecture/05-transform-engine.md | 12 ++++++++---- docs/architecture/07-cursor.md | 17 ++++++++++------- src/config.h | 6 +++--- src/mag_host.cpp | 8 ++++++-- src/mag_host.h | 4 ++-- src/main.cpp | 6 +++--- src/native_cursor.h | 16 ++++++++-------- src/transform_model.cpp | 8 ++++---- src/transform_model.h | 4 ++-- tests/test_native_cursor.cpp | 8 +++----- ui/src/settings-schema.js | 2 +- 12 files changed, 51 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 03f2719e..4e327543 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. never reload. Add new UI-only keys there. **Cursor (07)** -- High resolution cursor on = native cursor (`src/native_cursor.h`): DWM draws the real pointer via +- 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 write a same-level transform or warm pulse. Never use a public write to get the composed pointer: diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index e7729553..20820c8f 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -33,7 +33,7 @@ pointer: 19 spikes of 20-42 ms in 6 s with the composed pointer at 1x, 0 with a context after a private-channel zoom, or a context plus the cursor lens with its style off (all three kept Independent Flip). So the sprite path keeps the context only around real sessions, and there is no warm-up write at launch; the native cursor keeps context and lens warm with the style -off at 1x ([07](07-cursor.md#native-cursor-high-resolution-cursor-on)). Colour filters hold the +off at 1x ([07](07-cursor.md#native-cursor)). Colour filters hold the runtime at 1x ([04](04-render-engine.md)). **Calls are thread-affine.** Only the thread that called `MagInitialize` can drive the transform; @@ -186,8 +186,12 @@ Defences (wall arming in `RunTick`, write clamp in `TransformModel::present`): ## Bitmap smoothing DWM magnifies with nearest neighbour unless something calls -`MagSetFullscreenUseBitmapSmoothing` (Magnification.dll ordinal 1, undocumented, resolved by -ordinal). `txSamplingMode`: 0 nearest (default), 1 smooth. +`MagSetFullscreenUseBitmapSmoothing` (Magnification.dll, undocumented, resolved BY NAME). +`txSamplingMode`: 0 nearest (default), 1 smooth. + +- Until 0.24.0 it was resolved by ordinal 1, which does not exist (the export ordinals start at + 100), so Wind never set the filter: the image showed whatever state another process had left. + Measured 2026-10-07: from a smooth DWM state Wind at nearest stayed smooth; by name it switches. - The flag is the whole quality gap to the built-in Magnifier, image and cursor alike. - The raw user32 `SetMagnificationDesktopSamplingMode` takes a DWORD **pointer**; a by-value call @@ -195,7 +199,7 @@ ordinal). `txSamplingMode`: 0 nearest (default), 1 smooth. - Modes 2–4, which the kernel accepts, render as nearest. There is no middle filter. - The flag is DWM-global and outlives the process that set it until DWM restarts, so a stale smooth state can make a build look smooth that is not. The model re-applies its mode per context - with up to 3 retries; the setter's return value is unreliable. + with up to 3 retries. - Under smooth, level ramps shimmer slightly (the filter re-interpolates each scale step); pans are clean. Swapping to nearest during ramps shifted the image 1–2 px per swap and was rejected. - Smoothing once crashed dwm.exe over Mica and acrylic at high zoom; it did not reproduce on a diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index d2f8a958..3bac6a1f 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -81,11 +81,13 @@ desktop space and DWM magnifies it; the render engine scales its drawn cursor to cursor kept at desktop size read as tiny next to the transform. `cursorConstantSize=1` is the render-only opt-in for the old constant size. `cursorScaleWithZoom` is retired and ignored. -## Native cursor (High resolution cursor on) +## Native cursor -With **High resolution cursor** on (`txSamplingMode=1`) and `txNativeCursor=1` (default), transform -sessions use the pointer Windows Magnifier uses: the real pointer, drawn by DWM into the magnified -frame. Pure rules: `src/native_cursor.h` (tested). +With `txNativeCursor=1` (default), transform sessions use the pointer Windows Magnifier uses: the +real pointer, drawn by DWM into the magnified frame. It works at either sampling mode, because DWM +samples it like the content: High resolution cursor (smooth) makes it sharp but it shimmers +slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: +`src/native_cursor.h` (tested). - **The cursor lens.** `MagHost::createCursorLens` makes a hidden window of the documented magnifier control class (`WC_MAGNIFIER`) on the runtime's owner thread. With @@ -114,10 +116,11 @@ frame. Pure rules: `src/native_cursor.h` (tested). until the next shape change. Windows Magnifier has the same behaviour. - No sprite, no blanker, no shell-panel freeze (`panelPointer` is skipped). The hide-cursor hotkey blanks the pointer the way Inspect does. Inspect keeps its crosshair sprite. -- **Off** (nearest sampling, or `txNativeCursor=0`): the sprite path below. It suits games: a - composed pointer costs a visible-cursor full-screen app its Independent Flip while zoomed. +- **Off** (`txNativeCursor=0`): the sprite path below, kept as a fallback. Games pay nothing extra + for the native cursor: a zoomed full-screen window is composed anyway, and at 1x the lens style + is off. -## Hiding the real pointer: blanker and sprite (High resolution cursor off) +## Hiding the real pointer: blanker and sprite (txNativeCursor=0) In a zoomed sprite-path transform session the real pointer would draw unmagnified at its raw position, so it is hidden and a stand-in drawn. diff --git a/src/config.h b/src/config.h index e351f2e2..57f93549 100644 --- a/src/config.h +++ b/src/config.h @@ -385,9 +385,9 @@ struct Config { int txHookWrite = 0; int panelPointer = 1; // #283: real magnified pointer, frozen and moved by Wind, while a shell input panel is open int txFreeCursor = 1; - // Native cursor (issue #369, src/native_cursor.h): with High resolution cursor on (smooth - // sampling), transform sessions use DWM's own magnified pointer and DWM's own centring instead - // of the sprite. 0 = always the sprite (A/B and kill switch). Read at zoom-in (hot). + // Native cursor (issue #369, src/native_cursor.h): transform sessions use DWM's own magnified + // pointer and DWM's own centring instead of the sprite, at either sampling mode. 0 = the sprite + // (A/B and kill switch). Read at zoom-in (hot). int txNativeCursor = 1; // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write diff --git a/src/mag_host.cpp b/src/mag_host.cpp index 5c536004..49d99688 100644 --- a/src/mag_host.cpp +++ b/src/mag_host.cpp @@ -62,8 +62,12 @@ bool MagHost::initialize() { setMagDesktop_ = reinterpret_cast( u32 ? GetProcAddress(u32, "SetMagnificationDesktopMagnification") : nullptr); HMODULE magDll = GetModuleHandleW(L"Magnification.dll"); + // BY NAME (issue #369). It was resolved by ordinal 1, which does not exist: the export + // table's ordinal base is 100 (this function is 104 on 26200), so the lookup returned NULL + // and every setSamplingMode call failed - Wind never set the filter at all, and the image + // showed whatever smoothing state another process (Windows Magnifier) had left in DWM. setBitmapSmoothing_ = reinterpret_cast( - magDll ? GetProcAddress(magDll, MAKEINTRESOURCEA(1)) : nullptr); + magDll ? GetProcAddress(magDll, "MagSetFullscreenUseBitmapSmoothing") : nullptr); setSamplingRaw_ = reinterpret_cast( u32 ? GetProcAddress(u32, "SetMagnificationDesktopSamplingMode") : nullptr); setDwmUpdated_ = reinterpret_cast( @@ -74,7 +78,7 @@ bool MagHost::initialize() { bool MagHost::setSamplingMode(unsigned mode) { if (!initialized_) return false; - // Modes 0/1 go through Magnification.dll ordinal 1 (the documented-shape BOOL wrapper that + // Modes 0/1 go through MagSetFullscreenUseBitmapSmoothing (the documented-shape BOOL wrapper that // native Magnifier uses). Modes 2-4 exist only on the raw user32 setter: the kernel accepts // and round-trips 0..4 though the wrapper exposes just two, and nothing is published about // what the extra three do. They are worth trying because mode 1's edge-preserving filter is diff --git a/src/mag_host.h b/src/mag_host.h index dc518213..fdc6fbf6 100644 --- a/src/mag_host.h +++ b/src/mag_host.h @@ -34,8 +34,8 @@ class MagHost { // republish. Also the truth for publish success: on this rig MagSetInputTransform can return // FALSE while the publish lands, so the return value alone must never be trusted. bool getInputTransform(bool& active, RECT& src, RECT& dst); - // Magnification bitmap smoothing: MagSetFullscreenUseBitmapSmoothing, Magnification.dll - // ORDINAL 1 (undocumented, no header - Magnify.exe imports it; it is what the "smooth edges + // Magnification bitmap smoothing: MagSetFullscreenUseBitmapSmoothing, exported BY NAME from + // Magnification.dll (undocumented, no header - Magnify.exe imports it; it is what the "smooth edges // of images and text" option flips). A session that never sets it samples NEAREST NEIGHBOUR, // which is the blocky magnified image/cursor. Callable without UIAccess, needs a live // MagInitialize. NEVER call the raw user32 SetMagnificationDesktopSamplingMode instead - it diff --git a/src/main.cpp b/src/main.cpp index 86fd47b9..0a268f01 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1171,7 +1171,7 @@ static void RunTick(TickState& t) { t.mTransform ? t.mTransform : t.model)) tmHot->setIdleReleaseMs(nc.txIdleReleaseMs); if (auto* tmHot = dynamic_cast(t.mTransform ? t.mTransform : t.model)) - tmHot->setNativeCursorPref(wind::UseNativeCursor(nc.txNativeCursor, nc.txSamplingMode)); + tmHot->setNativeCursorPref(wind::UseNativeCursor(nc.txNativeCursor)); t.cfg = nc; // pick up renderer knobs (smoothing, filter, cursor scale, zoom speed) // transformExclude / renderExclude / the per-window-type engine keys may all have // changed: drop the cache so every exe-derived predicate is re-resolved. Without this @@ -3093,7 +3093,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { cfg.cursorSprite != 0, cfg.zorderBand, cfg.spriteBand16 != 0, cfg.cursorBandAuto != 0); tm->setIdleReleaseMs(cfg.txIdleReleaseMs); - tm->setNativeCursorPref(wind::UseNativeCursor(cfg.txNativeCursor, cfg.txSamplingMode)); + tm->setNativeCursorPref(wind::UseNativeCursor(cfg.txNativeCursor)); tm->setSpriteCapturable(cfg.spriteCapturable != 0); model = std::move(tm); } else { @@ -3108,7 +3108,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { cfg.cursorSprite != 0, cfg.zorderBand, cfg.spriteBand16 != 0, cfg.cursorBandAuto != 0); tm2->setIdleReleaseMs(cfg.txIdleReleaseMs); - tm2->setNativeCursorPref(wind::UseNativeCursor(cfg.txNativeCursor, cfg.txSamplingMode)); + tm2->setNativeCursorPref(wind::UseNativeCursor(cfg.txNativeCursor)); tm2->setSpriteCapturable(cfg.spriteCapturable != 0); model2 = std::move(tm2); } diff --git a/src/native_cursor.h b/src/native_cursor.h index 49c0f6e9..ac4d8226 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -1,8 +1,9 @@ #pragma once // Native cursor (issue #369) - PURE, no , so it is unit-testable. // -// With High resolution cursor on (smooth sampling), transform sessions use the pointer Windows -// Magnifier uses instead of Wind's sprite: +// Transform sessions use the pointer Windows Magnifier uses instead of Wind's sprite, at either +// sampling mode (DWM samples the pointer like the content: smooth = sharp but shimmers during zoom +// ramps, nearest = pixelated and steady): // - ONE public MagSetFullscreenTransform write makes DWM draw the REAL pointer into the // magnified frame: magnified, above every window band (thumbnails, emoji panel, menus, UAC, // the Snipping Tool), sampled like the content (smooth = high res). Later private writes keep @@ -13,14 +14,13 @@ // DWM alone), where a tick-paced write drifts 18-24 px at medium speed and up to 96 px fast. // DWM re-learns a small hotspot offset only when the cursor HANDLE changes; a learn taken // mid-jump can sit a few px off until the next shape change (native has the same). -// Off (nearest sampling) keeps the sprite path: the cheaper choice for games, because a composed -// pointer costs a visible-cursor game its Independent Flip. +// txNativeCursor=0 keeps the old sprite path as a fallback. Games pay nothing extra: a zoomed +// full-screen window is composed anyway, and at 1x the lens style is off (hardware pointer). namespace wind { -// Whether a transform session uses the native cursor. Decided once at zoom-in; sampling is a -// restart-staged setting, so it cannot change under a session anyway. -inline bool UseNativeCursor(int txNativeCursor, int effectiveSamplingMode) { - return txNativeCursor != 0 && effectiveSamplingMode == 1; +// Whether a transform session uses the native cursor. Decided once at zoom-in. +inline bool UseNativeCursor(int txNativeCursor) { + return txNativeCursor != 0; } // When DWM may own the pan (DWM centring on). Only where the view is a pure function of the diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 65f093b9..9f48ede0 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -619,7 +619,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg srcL = o.x; srcT = o.y; } idleReleaseMs_ = cfg.txIdleReleaseMs; // hot-reloadable release window - nativePref_ = UseNativeCursor(cfg.txNativeCursor, cfg.txSamplingMode); // next zoom-in (#369) + nativePref_ = UseNativeCursor(cfg.txNativeCursor); // next zoom-in (#369) restLevel_ = cfg.txRestLevel; // hot if (!ensureMag()) return; // lazy context: the session's first write brings DWM up // Bitmap smoothing (issue #197/#227), once per magnification context. The smooth filter @@ -636,9 +636,9 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // The flag is DWM-global and dies with a DWM restart, hence per-context re-apply. // A failed apply is retried, a little (issue #274): the call used to be recorded as applied // before it ran, so a wrong-thread failure left the filter unapplied for the whole context. - // BOUNDED, because the setter's return value is not a reliable success signal: mode 0 via - // ordinal 1 has reported FALSE on every call in this rig's logs (245 of 245) while working, - // so an unbounded retry would re-issue it every tick. Up to 3 attempts, 1 s apart, then + // BOUNDED, so a failing setter can never re-issue every tick. (The "FALSE on every call" seen + // in this rig's logs, 245 of 245, was the setter never being found: it was resolved by a + // non-existent ordinal until #369.) Up to 3 attempts, 1 s apart, then // accept. The mode is DWM-global state, so a genuine miss is also re-tried per context. if (cfg.txSamplingMode >= 0 && appliedSampling_ != cfg.txSamplingMode) { const unsigned long long now = GetTickCount64(); diff --git a/src/transform_model.h b/src/transform_model.h index 0d6a6028..52303c86 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -24,8 +24,8 @@ class TransformModel : public IMagnifierModel { zorderBand_(zorderBand), spriteBand16_(spriteBand16), cursorBandAuto_(cursorBandAuto) {} // Before initialize(). The dualcursor rig measures the sprite from captures (issue #269). void setSpriteCapturable(bool on) { spriteCapturable_ = on; } - // Native cursor preference (issue #369, src/native_cursor.h): UseNativeCursor(txNativeCursor, - // effective sampling). Latched into the session at zoom-in; present() keeps it current. + // Native cursor preference (issue #369, src/native_cursor.h): UseNativeCursor(txNativeCursor). + // Latched into the session at zoom-in; present() keeps it current. void setNativeCursorPref(bool on) { nativePref_ = on; } // This session draws DWM's own pointer (no sprite, no blanking). RunTick skips the shell-panel // pointer freeze for it: DWM's pointer is already above the panels. diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index 39537728..cb747977 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -3,11 +3,9 @@ using namespace wind; -TEST_CASE("native cursor needs the knob AND smooth sampling") { - CHECK(UseNativeCursor(1, 1) == true); - CHECK(UseNativeCursor(1, 0) == false); // high resolution cursor off: the sprite path - CHECK(UseNativeCursor(0, 1) == false); // kill switch - CHECK(UseNativeCursor(0, 0) == false); +TEST_CASE("native cursor follows the knob alone, at either sampling mode") { + CHECK(UseNativeCursor(1) == true); + CHECK(UseNativeCursor(0) == false); // kill switch: the sprite path } static DwmCentreIn Mouse() { diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index ec0c232d..90c39a56 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -111,7 +111,7 @@ export const groups = [ // turning high-res OFF also stages MPO-disable and turning it ON stages MPO re-enable, both // atomic at the Windows restart (the core holds the boot state's look until then). { key: 'txSamplingMode', type: 'highres', label: 'High resolution cursor', keywords: ['sharp', 'crisp', 'smooth', 'blurry', 'pixelated', 'hidpi', 'sampling', 'quality', 'nearest', 'mpo', 'overlay', 'cursor', 'pointer', 'resolution', '4k', 'antialiasing', 'restart', 'registry'], - desc: 'A sharp pointer, drawn by Windows above every panel, and a smoother image. Off is lighter for games.', def: 0 }, + desc: 'A smoother image and pointer at high zoom. Off keeps them pixel-sharp and steady while zooming.', def: 0 }, // Zoom lock detection (#221): games like DOOM pin the mouse to the screen centre, which would // pin the zoom view there too. Listed apps get the view UNLOCKED from the pointer. { key: 'lockApps', type: 'applist', adv: true, label: 'Mouse-locked games', keywords: ['lock', 'locked', 'fps', 'shooter', 'first person', 'mouselook', 'mouse look', 'camera', 'game', 'exe', 'program', 'unlock', 'exception', 'apps', 'pinned', 'centre'], From edb6f53c46d526ed1f674b8d4278cea937f99e57 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:00:22 +0200 Subject: [PATCH 03/22] fix(cursor): native pointer visible through zoom-in; zoom-shake experiment (#369) - win32k hands the cursor mode to DWM only on the next pointer update, so after switching the lens style on, zoom-in nudges the pointer a pixel and back. Before, the small hardware pointer stayed for the whole zoom-in while the hand was still (no composed pointer in any ramp frame). - A scale-changing MagSetInputTransform publish stops DWM drawing the composed pointer until the next cursor event: native sessions hold the publish while the level ramps (as Windows Magnifier does) and nudge after a scale-changing publish. Pure rules, tested. - txDwmCentre=0 kill switch for DWM centring. - Experiment txRampNearest (off, MPO-off only): nearest while the level moves, smooth at rest. The zoom shake is DWM's xBR smooth scaler re-shaping edges per scale step: tip jitter 8-10 px p95 at smooth vs max 4 px with the experiment, no pop at the switch back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 11 ++++-- docs/architecture/07-cursor.md | 8 +++++ src/config.cpp | 3 ++ src/config.h | 9 +++++ src/main.cpp | 2 +- src/native_cursor.h | 18 ++++++++++ src/transform_model.cpp | 44 +++++++++++++++++++----- src/transform_model.h | 2 ++ tests/test_native_cursor.cpp | 13 +++++++ 9 files changed, 98 insertions(+), 12 deletions(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 20820c8f..3a90eaa1 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -200,8 +200,15 @@ DWM magnifies with nearest neighbour unless something calls - The flag is DWM-global and outlives the process that set it until DWM restarts, so a stale smooth state can make a build look smooth that is not. The model re-applies its mode per context with up to 3 retries. -- Under smooth, level ramps shimmer slightly (the filter re-interpolates each scale step); pans are - clean. Swapping to nearest during ramps shifted the image 1–2 px per swap and was rejected. +- Smooth is DWM's xBR scaler (internal resample mode 2; mode 1 is Lanczos, selectable only by the + `ResampleModeOverride` DWM registry value read at DWM start). xBR re-shapes edges at every new + scale, which is the zoom "shake": measured 2026-10-07, cursor tip jitter 8-10 px p95 with 33-39 + direction reversals per zoom-in at smooth, 2 px and 3-7 at nearest, for the sprite and the native + pointer alike. Pans are clean. +- `txRampNearest=1` (experiment, off, MPO-off boots only): nearest while the level moves, smooth + once it has been still for `txRampNearestSettleMs` (100). Measured: max tip step 4 px, no jump + over 6 px including the switch back. The older "swap shifted the image 1-2 px, rejected" verdict + predates the working setter (see above) and is void. - Smoothing once crashed dwm.exe over Mica and acrylic at high zoom; it did not reproduce on a newer driver. If dwm.exe crashes return, set `txSamplingMode=0` first. diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 3bac6a1f..ef4bd745 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -111,6 +111,14 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: Wind sends only level changes (`SendWrite`) and no warm pulses: a same-level write would put a tick-old offset on screen. Caret, focus, keyboard pan, edge mode, Inspect and locked games switch it off and Wind writes the view as before; each switch forces one write. +- **Cursor events, not style flips, switch DWM.** win32k sends the new cursor mode to DWM only on + the next pointer update, so zoom-in nudges the pointer a pixel and back right after turning the + lens style on (otherwise the small hardware pointer stayed for the whole zoom while the hand was + still). A `MagSetInputTransform` publish that changes the scale also stops DWM drawing the + pointer until the next cursor event, so native sessions hold the publish while the level ramps + (Windows Magnifier does the same) and nudge after a scale-changing publish (`HoldInputPublish`, + `NudgeAfterPublish`). +- `txDwmCentre=0` keeps the native pointer but lets Wind write the view itself (kill switch). - **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. diff --git a/src/config.cpp b/src/config.cpp index 3a2aa1c2..b4a9c650 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -235,6 +235,9 @@ Config ParseConfig(const std::string& text) { else if (key == "txWriteHz") c.txWriteHz = std::stoi(val); else if (key == "txFreeCursor") c.txFreeCursor = std::stoi(val); else if (key == "txNativeCursor") c.txNativeCursor = std::stoi(val); + else if (key == "txDwmCentre") c.txDwmCentre = std::stoi(val); + else if (key == "txRampNearest") c.txRampNearest = std::stoi(val); + else if (key == "txRampNearestSettleMs") c.txRampNearestSettleMs = std::stoi(val); else if (key == "lockedBallistics") c.lockedBallistics = std::stoi(val); else if (key == "edgeClip") c.edgeClip = std::stoi(val); else if (key == "txPace") c.txPace = std::stoi(val); diff --git a/src/config.h b/src/config.h index 57f93549..3eff153d 100644 --- a/src/config.h +++ b/src/config.h @@ -389,6 +389,15 @@ struct Config { // pointer and DWM's own centring instead of the sprite, at either sampling mode. 0 = the sprite // (A/B and kill switch). Read at zoom-in (hot). int txNativeCursor = 1; + // DWM centring for native-cursor sessions (issue #369): 1 = DWM re-centres on every cursor + // update where the view is a pure function of the pointer; 0 = Wind always writes the view (hot). + int txDwmCentre = 1; + // EXPERIMENT (issue #369): with smooth sampling, use nearest while the level moves and switch + // back to smooth once it has been still for txRampNearestSettleMs. Smooth is DWM's xBR scaler, + // whose edges re-shape at every new scale (the zoom "shake": 10 px p95 tip jitter vs 2 px at + // nearest). Only when MPO is off at boot (nearest + MPO is the TDR combo). 0 = off (hot). + int txRampNearest = 0; + int txRampNearestSettleMs = 100; // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write // makes DWM redo work proportional to the zoom. Turning it on scored well in the automated diff --git a/src/main.cpp b/src/main.cpp index 0a268f01..a0755d3d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2290,7 +2290,7 @@ static void RunTick(TickState& t) { dc.wallNeeded = wind::WallBinding(wallNeeded, lvl, t.mon.w, t.mon.h, kMaxSafeTxMagnitude); dc.quiesce = quiesceHold; dc.hookWrite = hookWrite; - ex.dwmCentre = wind::WantDwmCentring(dc); + ex.dwmCentre = t.cfg.txDwmCentre != 0 && wind::WantDwmCentring(dc); } // Our tray menu is open (in WindTray.exe, flagged through the shared block): the pointer // belongs to the USER (they are aiming at menu items), diff --git a/src/native_cursor.h b/src/native_cursor.h index ac4d8226..2c9d3845 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -54,4 +54,22 @@ inline bool SendWrite(bool dwmCentring, bool levelMoved, bool forceWrite) { return !dwmCentring || levelMoved || forceWrite; } +// INPUT TRANSFORM vs the composed pointer (measured 2026-10-07). A MagSetInputTransform publish that +// changes the SCALE makes DWM stop drawing the composed pointer until the next cursor event: zooming +// in with a still mouse left no pointer in any frame of the ramp (2 of 225 frames), and with the +// publish off it was in every frame (225 of 225). Pan-only publishes do not do it. Windows +// Magnifier never publishes during a zoom animation, only once it ends, and from inside its mouse +// hook, before the event that repaints the pointer. So in a native-cursor session: +// - hold the publish while the level ramps (a foreign stomp still forces it); +// - after a publish whose level differs from the last published one, nudge the pointer a pixel +// and back so DWM draws it again. +inline bool HoldInputPublish(bool nativeSession, bool ramping, bool stomped) { + return nativeSession && ramping && !stomped; +} + +inline bool NudgeAfterPublish(bool nativeSession, double publishedLevel, double previousLevel) { + const double d = publishedLevel - previousLevel; + return nativeSession && (d > 1e-4 || d < -1e-4); +} + } // namespace wind diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 9f48ede0..0650e82b 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -48,6 +48,7 @@ void TransformModel::resetTransformState() { panelPrimed_ = false; // a rebuilt context needs its own public prime (#283, review #284) nativePrimed_ = false; // ...and so does the native cursor (#369) forceWrite_ = false; // lastLevel_ = 0 already forces the next write + ixPubLevel_ = 0.0; // Everything the write path caches must be forgotten across a teardown, or the next session // compares against values DWM no longer holds and skips the writes that would re-apply them. lastLevel_ = 0.0; lastRequestedLevel_ = 0.0; @@ -399,8 +400,15 @@ void TransformModel::setActive(bool active) { // once per zoom-in regardless - that is DWM building its machinery.) ensureMag(); // Native cursor: switch the pointer to DWM's composition (0.2 ms once the lens exists; a - // first zoom before the idle warm-up pays the lens build here, once). - if (nativeSession_ && host_.createCursorLens()) host_.setCursorLens(true); + // first zoom before the idle warm-up pays the lens build here, once). win32k sends the + // new cursor mode to DWM only on the next pointer update (move or shape), so without a + // cursor event the small hardware pointer stays on screen for the whole zoom-in while the + // hand is still (measured: no composed pointer in any ramp frame). Nudge a pixel and back. + if (nativeSession_ && host_.createCursorLens()) { + host_.setCursorLens(true); + POINT np; + if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + } QueryPerformanceCounter(&z2); lastEnter_.bridgeMs = double(z1.QuadPart - z0.QuadPart) * 1000.0 / zf.QuadPart; lastEnter_.ensureMagMs = double(z2.QuadPart - z1.QuadPart) * 1000.0 / zf.QuadPart; @@ -640,17 +648,25 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // in this rig's logs, 245 of 245, was the setter never being found: it was resolved by a // non-existent ordinal until #369.) Up to 3 attempts, 1 s apart, then // accept. The mode is DWM-global state, so a genuine miss is also re-tried per context. - if (cfg.txSamplingMode >= 0 && appliedSampling_ != cfg.txSamplingMode) { + int wantSampling = cfg.txSamplingMode; + if (cfg.txRampNearest != 0 && wantSampling == 1 && !mpoExposed_) { + // Experiment (issue #369, see config.h): nearest while the level moves. + const unsigned long long nowS = GetTickCount64(); + if (!rampStopped || applyLevel != lastLevel_) lastLevelMoveMs_ = nowS; + if (nowS - lastLevelMoveMs_ < (unsigned long long)cfg.txRampNearestSettleMs) wantSampling = 0; + } + if (wantSampling >= 0 && appliedSampling_ != wantSampling) { const unsigned long long now = GetTickCount64(); - if (sampleTryMode_ != cfg.txSamplingMode) { sampleTryMode_ = cfg.txSamplingMode; sampleTries_ = 0; } + if (sampleTryMode_ != wantSampling) { sampleTryMode_ = wantSampling; sampleTries_ = 0; } if (sampleTries_ == 0 || now - sampleLastTryMs_ >= 1000) { - const bool ok = host_.setSamplingMode((unsigned)cfg.txSamplingMode); + const bool ok = host_.setSamplingMode((unsigned)wantSampling); ++sampleTries_; sampleLastTryMs_ = now; if (ok || sampleTries_ >= 3) { - wind::Log(wind::LogLevel::Info, "transform", "bitmap smoothing %d applied=%d (tries %d)", - cfg.txSamplingMode, ok ? 1 : 0, sampleTries_); - appliedSampling_ = cfg.txSamplingMode; + if (cfg.txRampNearest == 0) + wind::Log(wind::LogLevel::Info, "transform", "bitmap smoothing %d applied=%d (tries %d)", + wantSampling, ok ? 1 : 0, sampleTries_); + appliedSampling_ = wantSampling; } } } @@ -881,7 +897,10 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg const bool rest = !changed; // warmIxOnly bypasses the decimation for the same reason a stomp does: the publish IS the // work here, and decimating it away would defeat the whole mode. - if (ixForce || warmIxOnly || rest || ++ixTick_ >= cfg.ixDecimate) { + // Native cursor (#369): no publish while the level ramps (it hides the composed pointer); + // the pending flag carries it to the first settled tick. + const bool hold = HoldInputPublish(nativeSession_, ramping, ixForce); + if (!hold && (ixForce || warmIxOnly || rest || ++ixTick_ >= cfg.ixDecimate)) { ixTick_ = 0; ixPending_ = false; // srcL/srcT, not r.srcLeft/srcTop: when the ramp limiters make applyLevel != level @@ -921,6 +940,13 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg ok = true; } noteIxWrite(double(b.QuadPart - a.QuadPart) * 1000.0 / fr.QuadPart, ok); + if (ok && NudgeAfterPublish(nativeSession_, applyLevel, ixPubLevel_)) { + // A scale-changing publish stops DWM drawing the composed pointer until the next + // cursor event: give it one, a pixel and back. + POINT np; + if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + } + if (ok) ixPubLevel_ = applyLevel; if (ok) { ixExpectedValid_ = true; ixExpectedOn_ = enable; diff --git a/src/transform_model.h b/src/transform_model.h index 52303c86..3a36d725 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -164,6 +164,8 @@ class TransformModel : public IMagnifierModel { bool forceWrite_ = false; // a centring switch owes DWM one real write bool lensLogged_ = false; // one-shot log of the cursor-lens warm-up bool lensFailed_ = false; // the idle lens build failed: do not retry per tick + double ixPubLevel_ = 0.0; // level of the last input-transform publish (#369) + unsigned long long lastLevelMoveMs_ = 0; // txRampNearest experiment (#369) void setDwmCentre(bool on); bool cursorHidden_ = false; // we called MagShowSystemCursor(FALSE) bool haveLastClick_ = false; // dedup the per-tick cursor weld diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index cb747977..045b361c 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -50,3 +50,16 @@ TEST_CASE("an armed MPO wall only blocks DWM centring where the view can reach i CHECK(WallBinding(true, 9.5, 3840, 2160, 32000.0) == true); // 32640: reachable CHECK(WallBinding(true, 1.0, 3840, 2160, 32000.0) == false); } + +TEST_CASE("input transform: held while a native session ramps, unless a foreign writer stomped it") { + CHECK(HoldInputPublish(true, true, false) == true); + CHECK(HoldInputPublish(true, true, true) == false); // correctness of the mapping wins + CHECK(HoldInputPublish(true, false, false) == false); // settled: publish + CHECK(HoldInputPublish(false, true, false) == false); // sprite path: unchanged behaviour +} + +TEST_CASE("pointer nudge only after a scale-changing publish in a native session") { + CHECK(NudgeAfterPublish(true, 3.0, 1.0) == true); + CHECK(NudgeAfterPublish(true, 3.0, 3.0) == false); // pan-only publish + CHECK(NudgeAfterPublish(false, 3.0, 1.0) == false); +} From 1baef8fb1a4ed748fc247d326410774c93be7773 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:16:49 +0200 Subject: [PATCH 04/22] revert(cursor): drop the nearest-while-zooming experiment; smooth is Lanczos (#369) The switch from pixel to smooth at rest is plainly visible (user test), so the experiment goes. Docs: DWM's smooth path is Lanczos on a normal system (disassembly), not xBR; the shake is measured and its suspected cause noted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 19 ++++++++++--------- src/config.cpp | 2 -- src/config.h | 6 ------ src/transform_model.cpp | 13 +++---------- src/transform_model.h | 1 - 5 files changed, 13 insertions(+), 28 deletions(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 3a90eaa1..fd0fcfda 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -200,15 +200,16 @@ DWM magnifies with nearest neighbour unless something calls - The flag is DWM-global and outlives the process that set it until DWM restarts, so a stale smooth state can make a build look smooth that is not. The model re-applies its mode per context with up to 3 retries. -- Smooth is DWM's xBR scaler (internal resample mode 2; mode 1 is Lanczos, selectable only by the - `ResampleModeOverride` DWM registry value read at DWM start). xBR re-shapes edges at every new - scale, which is the zoom "shake": measured 2026-10-07, cursor tip jitter 8-10 px p95 with 33-39 - direction reversals per zoom-in at smooth, 2 px and 3-7 at nearest, for the sprite and the native - pointer alike. Pans are clean. -- `txRampNearest=1` (experiment, off, MPO-off boots only): nearest while the level moves, smooth - once it has been still for `txRampNearestSettleMs` (100). Measured: max tip step 4 px, no jump - over 6 px including the switch back. The older "swap shifted the image 1-2 px, rejected" verdict - predates the working setter (see above) and is void. +- Smooth renders the magnified subtree into a scratch target at source resolution and scales it + with Lanczos (`CResampleLayer::RenderLanczos`; the DWM registry value `ResampleModeOverride=1` + would force xBR instead, any other value is an error). Zoom "shake", measured 2026-10-07: cursor + tip jitter 8-10 px p95, jumps up to 18-26 px, 33-39 direction reversals per zoom-in at smooth; + 2 px and 3-7 at nearest; same for the sprite and the native pointer. Pans are clean. Suspected + cause (untested): the scratch target snaps to whole source pixels while the private channel + positions the view in screen pixels, so the image can jump up to one source pixel times the zoom. +- Nearest while the level moves and smooth at rest was tried: steady, but the switch from pixel to + smooth is plainly visible, so it was rejected (2026-10-07). The older "swap shifted the image + 1-2 px" verdict predates the working setter and is void. - Smoothing once crashed dwm.exe over Mica and acrylic at high zoom; it did not reproduce on a newer driver. If dwm.exe crashes return, set `txSamplingMode=0` first. diff --git a/src/config.cpp b/src/config.cpp index b4a9c650..81ac6c1a 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -236,8 +236,6 @@ Config ParseConfig(const std::string& text) { else if (key == "txFreeCursor") c.txFreeCursor = std::stoi(val); else if (key == "txNativeCursor") c.txNativeCursor = std::stoi(val); else if (key == "txDwmCentre") c.txDwmCentre = std::stoi(val); - else if (key == "txRampNearest") c.txRampNearest = std::stoi(val); - else if (key == "txRampNearestSettleMs") c.txRampNearestSettleMs = std::stoi(val); else if (key == "lockedBallistics") c.lockedBallistics = std::stoi(val); else if (key == "edgeClip") c.edgeClip = std::stoi(val); else if (key == "txPace") c.txPace = std::stoi(val); diff --git a/src/config.h b/src/config.h index 3eff153d..965efed3 100644 --- a/src/config.h +++ b/src/config.h @@ -392,12 +392,6 @@ struct Config { // DWM centring for native-cursor sessions (issue #369): 1 = DWM re-centres on every cursor // update where the view is a pure function of the pointer; 0 = Wind always writes the view (hot). int txDwmCentre = 1; - // EXPERIMENT (issue #369): with smooth sampling, use nearest while the level moves and switch - // back to smooth once it has been still for txRampNearestSettleMs. Smooth is DWM's xBR scaler, - // whose edges re-shape at every new scale (the zoom "shake": 10 px p95 tip jitter vs 2 px at - // nearest). Only when MPO is off at boot (nearest + MPO is the TDR combo). 0 = off (hot). - int txRampNearest = 0; - int txRampNearestSettleMs = 100; // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write // makes DWM redo work proportional to the zoom. Turning it on scored well in the automated diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 0650e82b..52eda0f9 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -648,13 +648,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // in this rig's logs, 245 of 245, was the setter never being found: it was resolved by a // non-existent ordinal until #369.) Up to 3 attempts, 1 s apart, then // accept. The mode is DWM-global state, so a genuine miss is also re-tried per context. - int wantSampling = cfg.txSamplingMode; - if (cfg.txRampNearest != 0 && wantSampling == 1 && !mpoExposed_) { - // Experiment (issue #369, see config.h): nearest while the level moves. - const unsigned long long nowS = GetTickCount64(); - if (!rampStopped || applyLevel != lastLevel_) lastLevelMoveMs_ = nowS; - if (nowS - lastLevelMoveMs_ < (unsigned long long)cfg.txRampNearestSettleMs) wantSampling = 0; - } + const int wantSampling = cfg.txSamplingMode; if (wantSampling >= 0 && appliedSampling_ != wantSampling) { const unsigned long long now = GetTickCount64(); if (sampleTryMode_ != wantSampling) { sampleTryMode_ = wantSampling; sampleTries_ = 0; } @@ -663,9 +657,8 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg ++sampleTries_; sampleLastTryMs_ = now; if (ok || sampleTries_ >= 3) { - if (cfg.txRampNearest == 0) - wind::Log(wind::LogLevel::Info, "transform", "bitmap smoothing %d applied=%d (tries %d)", - wantSampling, ok ? 1 : 0, sampleTries_); + wind::Log(wind::LogLevel::Info, "transform", "bitmap smoothing %d applied=%d (tries %d)", + wantSampling, ok ? 1 : 0, sampleTries_); appliedSampling_ = wantSampling; } } diff --git a/src/transform_model.h b/src/transform_model.h index 3a36d725..b3b19e2b 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -165,7 +165,6 @@ class TransformModel : public IMagnifierModel { bool lensLogged_ = false; // one-shot log of the cursor-lens warm-up bool lensFailed_ = false; // the idle lens build failed: do not retry per tick double ixPubLevel_ = 0.0; // level of the last input-transform publish (#369) - unsigned long long lastLevelMoveMs_ = 0; // txRampNearest experiment (#369) void setDwmCentre(bool on); bool cursorHidden_ = false; // we called MagShowSystemCursor(FALSE) bool haveLastClick_ = false; // dedup the per-tick cursor weld From 840f30d1d819c1d2031155ca016ac07c22392cdb Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:33:54 +0200 Subject: [PATCH 05/22] fix(cursor): one centre during zoom - nudge after level writes while DWM centres (#369) Wind's level writes centre exactly on the pointer, DWM centres on its learned hotspot offset; at ~5x the view sat 5-10 px off during a zoom and snapped back when it stopped. A pixel-and-back cursor event after each level write lets DWM re-centre by its own rule in the same frame. Field-verified: the shift is gone and pans stay steady. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/07-cursor.md | 8 +++++++- src/native_cursor.h | 10 ++++++++++ src/transform_model.cpp | 4 ++++ tests/test_native_cursor.cpp | 7 +++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index ef4bd745..6230888a 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -118,7 +118,13 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: pointer until the next cursor event, so native sessions hold the publish while the level ramps (Windows Magnifier does the same) and nudge after a scale-changing publish (`HoldInputPublish`, `NudgeAfterPublish`). -- `txDwmCentre=0` keeps the native pointer but lets Wind write the view itself (kill switch). +- **One centre during zoom.** DWM centres on its cursor point plus a learned hotspot offset that can + sit 1-2 desktop px off Wind's exact centre; a level write puts the view on Wind's centre, the next + cursor event back on DWM's (a 5-10 px shift at ~5x that snapped back when the zoom stopped). While + DWM centres, every level write is followed by a pixel-and-back nudge, so DWM re-centres by its own + rule in the same frame (`NudgeAfterLevelWrite`). Field-verified: shift gone, pans steady. +- `txDwmCentre=0` keeps the native pointer but lets Wind write the view itself (kill switch; pans + then wobble by speed x tick x zoom). - **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. diff --git a/src/native_cursor.h b/src/native_cursor.h index 2c9d3845..f2ee1516 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -72,4 +72,14 @@ inline bool NudgeAfterPublish(bool nativeSession, double publishedLevel, double return nativeSession && (d > 1e-4 || d < -1e-4); } +// ONE CENTRE DURING ZOOM (user field test 2026-10-07). DWM centres on its own cursor point plus a +// learned hotspot offset that can be 1-2 desktop px off Wind's exact centre; a level write puts the +// view on Wind's centre and the next cursor event puts it back on DWM's, so a zoom with a still hand +// sat 5-10 px off at ~5x and snapped back when the zoom stopped (gone with txDwmCentre=0). So while +// DWM centres, every level write is followed by a cursor event (a pixel and back), which makes DWM +// re-centre by its own rule in the same frame: one centre throughout. +inline bool NudgeAfterLevelWrite(bool dwmCentring, bool levelMoved, bool wrote) { + return dwmCentring && levelMoved && wrote; +} + } // namespace wind diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 52eda0f9..2e4e261d 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -839,6 +839,10 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg const bool prime = nativeSession_ && !nativePrimed_ && !host_.cursorLensReady() && lvlOut > 1.001; writeTransform((float)lvlOut, m.offX, m.offY, m.txX + txJitter, m.txY, fastPan_ && !prime, false); if (prime) nativePrimed_ = true; + if (NudgeAfterLevelWrite(dwmCentreOn_, ci.levelMoved, true)) { + POINT np; + if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + } forceWrite_ = false; } warmLevelJitter_ = false; diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index 045b361c..5aa7f2af 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -63,3 +63,10 @@ TEST_CASE("pointer nudge only after a scale-changing publish in a native session CHECK(NudgeAfterPublish(true, 3.0, 3.0) == false); // pan-only publish CHECK(NudgeAfterPublish(false, 3.0, 1.0) == false); } + +TEST_CASE("while DWM centres, a level write is followed by a cursor event (one centre during zoom)") { + CHECK(NudgeAfterLevelWrite(true, true, true) == true); + CHECK(NudgeAfterLevelWrite(true, false, true) == false); // pan-only: DWM already owns it + CHECK(NudgeAfterLevelWrite(false, true, true) == false); // Wind owns the view + CHECK(NudgeAfterLevelWrite(true, true, false) == false); // nothing written +} From c5b18607082996eea1d679bc3cdd8e7b9ece81a0 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:40:56 +0200 Subject: [PATCH 06/22] feat(cursor): MPO nearest guard - invisible colour effect keeps planes off while zoomed (#369) Nearest sampling on an MPO boot is the NVIDIA 16-bit TDR combo because flip-model surfaces stay plane candidates under the zoom. A colour transform on the scaled desktop visual forces DWM's external layer (as smooth's resample property does), and nothing under it becomes a plane. While zoomed at nearest on an MPO boot Wind now applies a 0.998 RGB effect (invisible). mpoNearestGuard=1 lets nearest run on MPO boots (default off until verified on one); mpoGuardTest=1 forces the effect on an MPO-off boot to check its look. Pan walls stay armed (fail-closed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 7 +++++ src/config.cpp | 6 ++-- src/config.h | 10 ++++++- src/main.cpp | 24 ++++++++++++---- src/mpo_guard.h | 36 ++++++++++++++++++++++++ tests/test_config.cpp | 6 ++++ tests/test_mpo_guard.cpp | 27 ++++++++++++++++++ 7 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 src/mpo_guard.h create mode 100644 tests/test_mpo_guard.cpp diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index fd0fcfda..31983085 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -182,6 +182,13 @@ Defences (wall arming in `RunTick`, write clamp in `TransformModel::present`): Nearest with MPO on is never offered (`EffectiveSamplingMode` keeps the boot state's mode until the reboot lands). - `tdrTest` is the field harness: 2 probes the clamp, 4 lifts the wall. +- **MPO nearest guard** (`src/mpo_guard.h`, issue #369). Zoomed at nearest on an MPO boot, Wind + applies an invisible colour effect (0.998 on R, G, B). A colour transform, like the resample + property, makes the scaled desktop visual require an external layer, and nothing under such a + visual is recorded as a plane candidate, so no plane can carry the overflowing translation. + `mpoNearestGuard=1` (default 0 until verified) lets nearest run on MPO boots with the guard; + `mpoGuardTest=1` forces the effect on an MPO-off boot to check its look. The pan walls stay + armed for nearest either way until an MPO-on boot proves the guard (fail-closed). ## Bitmap smoothing diff --git a/src/config.cpp b/src/config.cpp index 81ac6c1a..6157e55d 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -94,11 +94,11 @@ int EffectiveGpuPriority(const Config& c) { } int EffectiveSamplingMode(int iniValue, bool mpoDisabledAtBoot, bool mpoDisabledInRegistry, - int tdrTest) { + int tdrTest, bool nearestGuard) { if (tdrTest != 0) return iniValue; if (mpoDisabledAtBoot != mpoDisabledInRegistry) // restart pending: hold the boot look return mpoDisabledAtBoot ? 0 : 1; - if (iniValue == 0 && !mpoDisabledAtBoot) return 1; // crisp on an MPO boot = the TDR combo + if (iniValue == 0 && !mpoDisabledAtBoot && !nearestGuard) return 1; // crisp on an MPO boot = the TDR combo return iniValue; } @@ -236,6 +236,8 @@ Config ParseConfig(const std::string& text) { else if (key == "txFreeCursor") c.txFreeCursor = std::stoi(val); else if (key == "txNativeCursor") c.txNativeCursor = std::stoi(val); else if (key == "txDwmCentre") c.txDwmCentre = std::stoi(val); + else if (key == "mpoNearestGuard") c.mpoNearestGuard = std::stoi(val); + else if (key == "mpoGuardTest") c.mpoGuardTest = std::stoi(val); else if (key == "lockedBallistics") c.lockedBallistics = std::stoi(val); else if (key == "edgeClip") c.edgeClip = std::stoi(val); else if (key == "txPace") c.txPace = std::stoi(val); diff --git a/src/config.h b/src/config.h index 965efed3..9882e800 100644 --- a/src/config.h +++ b/src/config.h @@ -392,6 +392,12 @@ struct Config { // DWM centring for native-cursor sessions (issue #369): 1 = DWM re-centres on every cursor // update where the view is a pure function of the pointer; 0 = Wind always writes the view (hot). int txDwmCentre = 1; + // MPO nearest guard (issue #369, src/mpo_guard.h): 1 = nearest sampling is allowed on an MPO-on + // boot, with an invisible colour effect while zoomed so DWM composes the desktop itself (no + // plane, no 16-bit overflow). 0 (default until verified on an MPO boot) = nearest on MPO boots + // is turned into smooth as before. The pan walls stay armed either way. Restart to apply. + int mpoNearestGuard = 0; + int mpoGuardTest = 0; // diagnostic: apply the MPO guard effect even on an MPO-off boot (hot) // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write // makes DWM redo work proportional to the zoom. Turning it on scored well in the automated @@ -656,8 +662,10 @@ int EffectiveGpuPriority(const Config& c); // The ini always keeps the user's intent. A deliberate steady smooth+MPO-off config (legacy // setups) is untouched by rule 1 because nothing is pending. Applied at EVERY config load. // tdrTest != 0 bypasses (the field harness must be able to repro nearest+MPO deliberately). +// nearestGuard (mpoNearestGuard, issue #369): nearest is allowed on an MPO boot because the MPO +// guard (src/mpo_guard.h) forces DWM's external layer while zoomed. int EffectiveSamplingMode(int iniValue, bool mpoDisabledAtBoot, bool mpoDisabledInRegistry, - int tdrTest); + int tdrTest, bool nearestGuard = false); // Pure: whether the edge outline should show at this zoom level, given the master `outline` // toggle and the optional low-zoom cutoff. (The "are we zoomed" level > 1.0 gate stays in the diff --git a/src/main.cpp b/src/main.cpp index a0755d3d..26c20210 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -32,6 +32,7 @@ #include "cursor_tint.h" // tinted pointer at 1x (#288) #include "transform_model.h" #include "native_cursor.h" // UseNativeCursor, WantDwmCentring (issue #369) +#include "mpo_guard.h" // WantMpoGuard, GuardedColorMatrix (issue #369) #include "hitch_record.h" // hitch recorder (#361) #include "tick_span.h" #include "input_router.h" @@ -869,7 +870,8 @@ static bool RenderOverlayShown(TickState& t) { return rm && rm->visible(); } static void UpdateColorFilter(TickState& t, bool zoomedNow, bool renderSession, PresentExtras* ex) { - (void)zoomedNow; // applies zoomed and at 1x alike (owner decision 2026-09-29) + // The user's filter applies zoomed and at 1x alike (owner decision 2026-09-29); zoomedNow gates + // only the MPO guard below. const double w = t.cfg.colorWarmPct / 100.0, d = t.cfg.colorDimPct / 100.0; // Toggling HDR is not guaranteed to raise WM_DISPLAYCHANGE (and may settle after it), so while a // filter is on the state is re-read once a second. A DisplayConfig query is microseconds (the @@ -883,9 +885,17 @@ static void UpdateColorFilter(TickState& t, bool zoomedNow, bool renderSession, const wind::ColorMatrix enc = wind::BuildColorMatrix(w, d, false); const bool inShader = renderSession && !wind::IsIdentity(enc); if (ex) { ex->colorOn = inShader; ex->color = enc; } - const wind::ColorMatrix dwm = inShader ? wind::IdentityColorMatrix() - : wind::BuildColorMatrix(w, d, g_hdrOn.load(std::memory_order_relaxed)); - g_color.apply(dwm, !wind::IsIdentity(dwm)); + wind::ColorMatrix dwm = inShader ? wind::IdentityColorMatrix() + : wind::BuildColorMatrix(w, d, g_hdrOn.load(std::memory_order_relaxed)); + // MPO guard (issue #369, src/mpo_guard.h): zoomed at nearest on an MPO boot, an invisible + // non-identity effect makes DWM compose the desktop itself, so no plane can take the 16-bit + // translation. The engine's runtime carries it (zoomed only), so it never needs our own hold. + const bool mpoGuard = wind::WantMpoGuard(zoomedNow, dynamic_cast(t.model) != nullptr, + !g_mpoDisabled || t.cfg.mpoGuardTest != 0, + t.cfg.txSamplingMode); + const bool userFilter = !wind::IsIdentity(dwm); + dwm = wind::GuardedColorMatrix(dwm, mpoGuard); + g_color.apply(dwm, userFilter); } // Tinted pointer at 1x (spec 2026-09-30-cursor-tint-design.md): the hardware pointer is out of the @@ -1121,7 +1131,8 @@ static void RunTick(TickState& t) { // crisp never runs on an MPO-enabled boot (the 16-bit TDR combo; covers profile // switches and hand edits too). The ini keeps the user's intent. if (int eff = EffectiveSamplingMode(nc.txSamplingMode, g_mpoDisabled, - wind::MpoDisabledInRegistry(), nc.tdrTest); + wind::MpoDisabledInRegistry(), nc.tdrTest, + nc.mpoNearestGuard != 0); eff != nc.txSamplingMode) { wind::Log(wind::LogLevel::Info, "config", "sampling %d deferred, running %d: MPO restart pending or MPO-enabled " @@ -2995,7 +3006,8 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // an MPO-enabled boot (the 16-bit TDR combo). The ini keeps the user's intent. Mirrored at // the hot-reload site in RunTick, which also covers profile switches and hand edits. if (int eff = EffectiveSamplingMode(cfg.txSamplingMode, g_mpoDisabled, - wind::MpoDisabledInRegistry(), cfg.tdrTest); + wind::MpoDisabledInRegistry(), cfg.tdrTest, + cfg.mpoNearestGuard != 0); eff != cfg.txSamplingMode) { wind::Log(wind::LogLevel::Info, "config", "sampling %d deferred, running %d: MPO restart pending or MPO-enabled boot " diff --git a/src/mpo_guard.h b/src/mpo_guard.h new file mode 100644 index 00000000..8608957a --- /dev/null +++ b/src/mpo_guard.h @@ -0,0 +1,36 @@ +#pragma once +// MPO nearest guard (issue #369) - PURE, no , tests/test_mpo_guard.cpp. +// +// Nearest sampling with MPO on is the NVIDIA 16-bit TDR combo (05-transform-engine.md): with no +// layer above it, DWM keeps flip-model surfaces (games, video, browsers) as hardware-plane +// candidates while zoomed, and a candidate's destination rectangle (about -source offset x zoom) +// overflows a 16-bit driver field past ~32767. Smooth sampling never hits it because the resample +// property makes the scaled desktop visual require an EXTERNAL LAYER, and nothing under such a +// visual is ever recorded as a plane candidate (COcclusionContext::PreSubgraph / +// CheckAndRecordOverlayCandidate). A colour transform on that visual does the same. So while zoomed +// at nearest with MPO on, Wind applies a colour effect that is visibly a no-op but not identity: +// DWM composes the zoomed desktop itself, keeps nearest sampling, and no plane can overflow. +// UNVERIFIED on an MPO-on boot: Wind's pan walls stay armed until it is (fail-closed), and nearest +// on an MPO boot needs mpoNearestGuard=1. +#include "color_matrix.h" +namespace wind { + +// 0.998 on R, G and B: at most half an 8-bit step darker, below what a viewer can see, and far +// outside any exact-identity test (DWM sends the matrix as-is; IsIdentity tolerates 1e-6). +inline ColorMatrix MpoGuardMatrix() { + ColorMatrix m = IdentityColorMatrix(); + m.m[0][0] = m.m[1][1] = m.m[2][2] = 0.998f; + return m; +} + +// Only while zoomed: at 1x a colour transform would cost every full-screen app its Independent Flip. +inline bool WantMpoGuard(bool zoomed, bool transformSession, bool mpoOnAtBoot, int effectiveSampling) { + return zoomed && transformSession && mpoOnAtBoot && effectiveSampling == 0; +} + +// The matrix DWM should get: a real filter already forces the layer, so it is used unchanged. +inline ColorMatrix GuardedColorMatrix(const ColorMatrix& wanted, bool guard) { + return (guard && IsIdentity(wanted)) ? MpoGuardMatrix() : wanted; +} + +} // namespace wind diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 0211a395..41e6e480 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -415,6 +415,12 @@ TEST_CASE("the high-res/MPO option is atomic at restart (issue #242)") { // The field harness must be able to repro nearest+MPO deliberately. CHECK(EffectiveSamplingMode(0, false, false, 2) == 0); CHECK(EffectiveSamplingMode(0, false, true, 4) == 0); + // MPO nearest guard (issue #369): nearest may run on an MPO boot when the guard is on; a pending + // restart still holds the boot look. + CHECK(EffectiveSamplingMode(0, false, false, 0, true) == 0); + CHECK(EffectiveSamplingMode(0, false, true, 0, true) == 1); + CHECK(ParseConfig("").mpoNearestGuard == 0); + CHECK(ParseConfig("mpoNearestGuard=1\n").mpoNearestGuard == 1); } TEST_CASE("cursorBandAuto defaults on and parses off (issue #269)") { diff --git a/tests/test_mpo_guard.cpp b/tests/test_mpo_guard.cpp new file mode 100644 index 00000000..fa6fc32d --- /dev/null +++ b/tests/test_mpo_guard.cpp @@ -0,0 +1,27 @@ +#include "doctest.h" +#include "../src/mpo_guard.h" + +using namespace wind; + +TEST_CASE("MPO guard: only zoomed, transform, MPO on and nearest") { + CHECK(WantMpoGuard(true, true, true, 0) == true); + CHECK(WantMpoGuard(false, true, true, 0) == false); // 1x: keep Independent Flip + CHECK(WantMpoGuard(true, false, true, 0) == false); // render engine: no DWM zoom + CHECK(WantMpoGuard(true, true, false, 0) == false); // MPO off: no plane to overflow + CHECK(WantMpoGuard(true, true, true, 1) == false); // smooth already forces the layer +} + +TEST_CASE("MPO guard matrix is not identity but visually a no-op") { + const ColorMatrix g = MpoGuardMatrix(); + CHECK(IsIdentity(g) == false); + for (int i = 0; i < 3; ++i) CHECK((1.0f - g.m[i][i]) * 255.0f < 1.0f); // under one 8-bit step + CHECK(g.m[3][3] == 1.0f); // alpha untouched + CHECK(g.m[4][0] == 0.0f); // no offsets +} + +TEST_CASE("a real colour filter is kept; identity becomes the guard only when wanted") { + ColorMatrix warm = IdentityColorMatrix(); warm.m[2][2] = 0.7f; + CHECK(SameMatrix(GuardedColorMatrix(warm, true), warm)); + CHECK(SameMatrix(GuardedColorMatrix(IdentityColorMatrix(), true), MpoGuardMatrix())); + CHECK(IsIdentity(GuardedColorMatrix(IdentityColorMatrix(), false))); +} From 59bb6059173d1b04907d1397f47813fcd99fa517 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:01:53 +0200 Subject: [PATCH 07/22] fix(cursor): keep writing the view while DWM centres - taskbar hover follows the zoom (#369) DWM's own centring moves only DWM's copy of the view; win32k's copy changes only on a client write and pointer-framework hit-testing (taskbar, XAML) maps points with it. With only level writes it froze and taskbar hover landed on the neighbouring icon, worse with zoom (field video). Like Windows Magnifier, every changed tick is written now, each followed by a pixel-and-back cursor event so DWM re-centres by its own rule in the same frame. DWM's centring was measured to match Wind's formula to under 1 px at every screen edge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/native_cursor.h | 29 +++++++++++++++-------------- src/transform_model.cpp | 7 +++---- tests/test_native_cursor.cpp | 15 ++++----------- 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/native_cursor.h b/src/native_cursor.h index f2ee1516..1dca882d 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -47,12 +47,14 @@ inline bool WantDwmCentring(const DwmCentreIn& in) { !in.hookWrite; } -// While DWM centres, a write at an unchanged level would put Wind's tick-old offset back on -// screen until the next cursor update (a one-frame twitch per write), so only level changes and -// the one write that follows a centring switch go out. The bookkeeping still advances. -inline bool SendWrite(bool dwmCentring, bool levelMoved, bool forceWrite) { - return !dwmCentring || levelMoved || forceWrite; -} +// KEEP WRITING WHILE DWM CENTRES (user field test 2026-10-07). DWM's own centring moves only DWM's +// copy of the view; win32k's copy (what MagGetFullscreenTransform returns) changes only on a client +// write, and pointer-framework hit-testing (the taskbar, XAML, Chromium) maps points with it. When +// Wind sent only level changes, that copy froze at the last write and taskbar hover landed on the +// neighbouring icon, worse with zoom (gone with txDwmCentre=0). Windows Magnifier writes the view on +// every mouse event even in centred mode. So every changed tick is written, and each write is +// followed by a cursor event so DWM re-centres by its own rule in the same frame (NudgeAfterWrite). +// (Measured 2026-10-07: DWM's centring matches Wind's formula to under 1 px at every edge.) // INPUT TRANSFORM vs the composed pointer (measured 2026-10-07). A MagSetInputTransform publish that // changes the SCALE makes DWM stop drawing the composed pointer until the next cursor event: zooming @@ -72,14 +74,13 @@ inline bool NudgeAfterPublish(bool nativeSession, double publishedLevel, double return nativeSession && (d > 1e-4 || d < -1e-4); } -// ONE CENTRE DURING ZOOM (user field test 2026-10-07). DWM centres on its own cursor point plus a -// learned hotspot offset that can be 1-2 desktop px off Wind's exact centre; a level write puts the -// view on Wind's centre and the next cursor event puts it back on DWM's, so a zoom with a still hand -// sat 5-10 px off at ~5x and snapped back when the zoom stopped (gone with txDwmCentre=0). So while -// DWM centres, every level write is followed by a cursor event (a pixel and back), which makes DWM -// re-centre by its own rule in the same frame: one centre throughout. -inline bool NudgeAfterLevelWrite(bool dwmCentring, bool levelMoved, bool wrote) { - return dwmCentring && levelMoved && wrote; +// ONE CENTRE (user field test 2026-10-07). DWM centres on its own cursor point plus a learned +// hotspot offset that can be 1-2 desktop px off Wind's exact centre; a Wind write puts the view on +// Wind's centre and the next cursor event puts it back on DWM's, so a zoom with a still hand sat +// 5-10 px off at ~5x and snapped back when the zoom stopped. So while DWM centres, every write is +// followed by a cursor event (a pixel and back): DWM re-centres by its own rule in the same frame. +inline bool NudgeAfterWrite(bool dwmCentring, bool wrote) { + return dwmCentring && wrote; } } // namespace wind diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 2e4e261d..a7a9d007 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -8,7 +8,7 @@ #include "sprite_layer.h" // PickSpriteLayer (pure, tested): issue #269 #include "config_path.h" // ResolveLogDir #include "tick_span.h" // per-tick spans (#361) -#include "native_cursor.h" // UseNativeCursor, SendWrite (pure, tested): issue #369 +#include "native_cursor.h" // UseNativeCursor, NudgeAfterWrite (pure, tested): issue #369 #include #include #include @@ -827,8 +827,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // anyway (measured), so skipping is free; the next changed/keep-alive tick writes as before. // suppressTransformWrite: the mouse hook is the single writer this session (issue #206). The // state above is still maintained, so turning the hook path off mid-session resumes cleanly. - if ((changedAndWriting || keepAliveActive) && !ex.suppressTransformWrite && - SendWrite(dwmCentreOn_, ci.levelMoved, forceWrite)) { + if ((changedAndWriting || keepAliveActive) && !ex.suppressTransformWrite) { // warmLevelJitter_ (mode 4) perturbs only the LEVEL, and only on warm ticks - a real // write always sends the true level. See the mode 4 note above for why it has to change // at all and why this is the cheapest honest thing to change. @@ -839,7 +838,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg const bool prime = nativeSession_ && !nativePrimed_ && !host_.cursorLensReady() && lvlOut > 1.001; writeTransform((float)lvlOut, m.offX, m.offY, m.txX + txJitter, m.txY, fastPan_ && !prime, false); if (prime) nativePrimed_ = true; - if (NudgeAfterLevelWrite(dwmCentreOn_, ci.levelMoved, true)) { + if (NudgeAfterWrite(dwmCentreOn_, true)) { POINT np; if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } } diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index 5aa7f2af..68d47c92 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -35,12 +35,6 @@ TEST_CASE("DWM centring is off wherever Wind must own the offset") { CHECK(WantDwmCentring(in) == false); } -TEST_CASE("while DWM centres, only level changes and forced writes go out") { - CHECK(SendWrite(false, false, false) == true); // Wind owns the pan: every write - CHECK(SendWrite(true, false, false) == false); // pan-only write would twitch the view - CHECK(SendWrite(true, true, false) == true); // zoom ramp - CHECK(SendWrite(true, false, true) == true); // the write after a centring switch -} TEST_CASE("an armed MPO wall only blocks DWM centring where the view can reach it") { // 3840x2160, wall at |src*level| <= 32000. @@ -64,9 +58,8 @@ TEST_CASE("pointer nudge only after a scale-changing publish in a native session CHECK(NudgeAfterPublish(false, 3.0, 1.0) == false); } -TEST_CASE("while DWM centres, a level write is followed by a cursor event (one centre during zoom)") { - CHECK(NudgeAfterLevelWrite(true, true, true) == true); - CHECK(NudgeAfterLevelWrite(true, false, true) == false); // pan-only: DWM already owns it - CHECK(NudgeAfterLevelWrite(false, true, true) == false); // Wind owns the view - CHECK(NudgeAfterLevelWrite(true, true, false) == false); // nothing written +TEST_CASE("while DWM centres, every write is followed by a cursor event (one centre)") { + CHECK(NudgeAfterWrite(true, true) == true); + CHECK(NudgeAfterWrite(false, true) == false); // Wind owns the view + CHECK(NudgeAfterWrite(true, false) == false); // nothing written } From 8a7d5d18e4a9a56718391eaa1887ff84ec33583d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:55:18 +0200 Subject: [PATCH 08/22] feat(cursor): smooth-zoom ladder - zoom through levels DWM rounds cleanly (#369) DWM's smooth path rounds its scratch image's size and origin to whole pixels every frame; during a continuous zoom that shakes the image (measured 11 px p95, jumps to 42 px at 10-25x). Two closed terms predict the shift per level; with smooth sampling the applied level now snaps to the nearest level predicting under 1 px (never backwards in a ramp, held once settled). Standalone: jitter 0.7-0.8 px p95, jumps 1-2 px, as steady as nearest. txSmoothLadder=0 turns it off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 5 ++ src/config.cpp | 1 + src/config.h | 4 ++ src/transform_model.cpp | 18 +++++++ src/transform_model.h | 1 + src/zoom_ladder.h | 63 ++++++++++++++++++++++++ tests/test_zoom_ladder.cpp | 40 +++++++++++++++ 7 files changed, 132 insertions(+) create mode 100644 src/zoom_ladder.h create mode 100644 tests/test_zoom_ladder.cpp diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 31983085..c8f2c3e7 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -214,6 +214,11 @@ DWM magnifies with nearest neighbour unless something calls 2 px and 3-7 at nearest; same for the sprite and the native pointer. Pans are clean. Suspected cause (untested): the scratch target snaps to whole source pixels while the private channel positions the view in screen pixels, so the image can jump up to one source pixel times the zoom. +- **Smooth-zoom ladder** (`txSmoothLadder=1`, `src/zoom_ladder.h`): the smooth path's scratch image + has its size and origin rounded to whole pixels every frame, and two closed terms predict the + resulting shift per level. While smooth, the applied level snaps to the nearest level predicting + under 1 px (never backwards in a ramp; held once the zoom settles). Measured standalone at + 3840x2160: 10-25x jitter 11 px -> 0.7 px p95, worst jump 42 px -> 2 px; 2-10x 4.7 px -> 0.8 px. - Nearest while the level moves and smooth at rest was tried: steady, but the switch from pixel to smooth is plainly visible, so it was rejected (2026-10-07). The older "swap shifted the image 1-2 px" verdict predates the working setter and is void. diff --git a/src/config.cpp b/src/config.cpp index 6157e55d..f69e5319 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -237,6 +237,7 @@ Config ParseConfig(const std::string& text) { else if (key == "txNativeCursor") c.txNativeCursor = std::stoi(val); else if (key == "txDwmCentre") c.txDwmCentre = std::stoi(val); else if (key == "mpoNearestGuard") c.mpoNearestGuard = std::stoi(val); + else if (key == "txSmoothLadder") c.txSmoothLadder = std::stoi(val); else if (key == "mpoGuardTest") c.mpoGuardTest = std::stoi(val); else if (key == "lockedBallistics") c.lockedBallistics = std::stoi(val); else if (key == "edgeClip") c.edgeClip = std::stoi(val); diff --git a/src/config.h b/src/config.h index 9882e800..dfc3c2fb 100644 --- a/src/config.h +++ b/src/config.h @@ -397,6 +397,10 @@ struct Config { // plane, no 16-bit overflow). 0 (default until verified on an MPO boot) = nearest on MPO boots // is turned into smooth as before. The pan walls stay armed either way. Restart to apply. int mpoNearestGuard = 0; + // Smooth-zoom ladder (issue #369, src/zoom_ladder.h): with smooth sampling, zoom only through + // levels where DWM's per-frame scratch rounding predicts under 1 px of movement (the zoom shake: + // 11 px p95 -> 0.7 px measured at 10-25x). 0 = off (hot). + int txSmoothLadder = 1; int mpoGuardTest = 0; // diagnostic: apply the MPO guard effect even on an MPO-off boot (hot) // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write diff --git a/src/transform_model.cpp b/src/transform_model.cpp index a7a9d007..2ef716ff 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -9,6 +9,7 @@ #include "config_path.h" // ResolveLogDir #include "tick_span.h" // per-tick spans (#361) #include "native_cursor.h" // UseNativeCursor, NudgeAfterWrite (pure, tested): issue #369 +#include "zoom_ladder.h" // SnapSmoothLevel (pure, tested): issue #369 #include #include #include @@ -49,6 +50,7 @@ void TransformModel::resetTransformState() { nativePrimed_ = false; // ...and so does the native cursor (#369) forceWrite_ = false; // lastLevel_ = 0 already forces the next write ixPubLevel_ = 0.0; + ladderReq_ = 0.0; ladderOut_ = 0.0; // Everything the write path caches must be forgotten across a teardown, or the next session // compares against values DWM no longer holds and skips the writes that would re-apply them. lastLevel_ = 0.0; lastRequestedLevel_ = 0.0; @@ -621,6 +623,22 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg if (snapped < 1.0) snapped = 1.0; applyLevel = snapped; } + // SMOOTH-ZOOM LADDER (issue #369, src/zoom_ladder.h): with smooth sampling DWM rounds its scratch + // image's size and origin every frame, which shakes a continuous zoom; snap to the nearest level + // whose predicted rounding error is under 1 px (never backwards in the ramp). Once the zoom has + // settled the chosen level is held: panning at a fixed level does not shake, re-snapping would. + if (cfg.txSmoothLadder != 0 && cfg.txSamplingMode == 1 && applyLevel > 1.001) { + if (applyLevel == level && level == ladderReq_ && ladderOut_ > 0.0) { + applyLevel = ladderOut_; + } else { + const int dir = applyLevel > lastLevel_ ? 1 : (applyLevel < lastLevel_ ? -1 : 0); + const double snapped = SnapSmoothLevel(applyLevel, r.centerX, r.centerY, mon_.w, mon_.h, + lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir); + ladderReq_ = level; + ladderOut_ = snapped; + applyLevel = snapped; + } + } double srcL = r.srcLeft, srcT = r.srcTop; if (applyLevel != level) { OffsetF o = ComputeOffsetF(r.centerX, r.centerY, applyLevel, mon_.w, mon_.h); diff --git a/src/transform_model.h b/src/transform_model.h index b3b19e2b..c0238f78 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -165,6 +165,7 @@ class TransformModel : public IMagnifierModel { bool lensLogged_ = false; // one-shot log of the cursor-lens warm-up bool lensFailed_ = false; // the idle lens build failed: do not retry per tick double ixPubLevel_ = 0.0; // level of the last input-transform publish (#369) + double ladderReq_ = 0.0, ladderOut_ = 0.0; // smooth-zoom ladder: settled request -> held level void setDwmCentre(bool on); bool cursorHidden_ = false; // we called MagShowSystemCursor(FALSE) bool haveLastClick_ = false; // dedup the per-tick cursor weld diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h new file mode 100644 index 00000000..38115d7c --- /dev/null +++ b/src/zoom_ladder.h @@ -0,0 +1,63 @@ +#pragma once +// Smooth-zoom ladder (issue #369) - PURE, no , tests/test_zoom_ladder.cpp. +// +// DWM's smooth sampling renders the zoomed subtree into a scratch image whose SIZE and ORIGIN are +// rounded to whole pixels every frame (CResampleLayer::Create, PushEffects/PixelAlign; see the +// compositor notes), then stretches it to the screen. During a continuous zoom that rounding moves +// the image by a different amount each frame: the zoom "shake". Measured live (3840x2160, pointer +// fixed): cursor-tip jitter 4.7 px p95 at 2-10x and 11-12 px at 10-25x, jumps up to 42 px. Two closed +// terms predict it per axis, with no fitted factors: +// error = c * (S/z - n)/n - (round(o) - o) * z, n = round(S/z) +// (S screen extent, z level, o the clamped source origin, c the pointer's screen position). Zooming +// only through levels where both axes predict under 1 px measured 0.5-0.8 px p95 and 1-2 px jumps at +// 2-25x, as steady as nearest sampling. Such levels lie under 0.9 % apart up to 12x (about 4 % at +// 12-25x), finer than a zoom tick, so a ramp snapped to them still reads as continuous. +#include +namespace wind { + +inline double LadderOrigin(double centre, double z, int extent) { + double o = centre - extent / (2.0 * z); + const double hi = extent - extent / z; + if (o > hi) o = hi; + if (o < 0.0) o = 0.0; + return o; +} + +// Predicted smooth-sampling displacement (screen px) on one axis for a view centred on `centre`. +inline double SmoothRoundingError(double z, int extent, double centre) { + double n = std::floor(extent / z + 0.5); + if (n < 1.0) n = 1.0; + const double o = LadderOrigin(centre, z, extent); + const double c = (centre - o) * z; // pointer screen position + return c * (extent / z - n) / n - (std::floor(o + 0.5) - o) * z; +} + +// The level nearest `want` (within +-maxRel) whose predicted error is under tol on both axes; +// `want` itself when none is found. Never steps behind `floorLevel` in the ramp's direction +// (dir > 0 zooming in, < 0 out, 0 settled), so a snapped ramp cannot visibly reverse. +inline double SnapSmoothLevel(double want, double centreX, double centreY, int w, int h, + double floorLevel = 0.0, int dir = 0, + double tol = 1.0, double maxRel = 0.02) { + if (want <= 1.001 || w <= 0 || h <= 0) return want; + auto ok = [&](double z) { + return std::fabs(SmoothRoundingError(z, w, centreX)) < tol && + std::fabs(SmoothRoundingError(z, h, centreY)) < tol; + }; + auto allowed = [&](double z) { + if (z <= 1.001) return false; + if (dir > 0 && floorLevel > 0.0 && z < floorLevel) return false; + if (dir < 0 && floorLevel > 0.0 && z > floorLevel) return false; + return true; + }; + const double step = want * 1e-5; + const int steps = (int)(maxRel / 1e-5); + if (allowed(want) && ok(want)) return want; + for (int i = 1; i <= steps; ++i) { + const double a = want + i * step, b = want - i * step; + if (allowed(a) && ok(a)) return a; + if (allowed(b) && ok(b)) return b; + } + return want; +} + +} // namespace wind diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp new file mode 100644 index 00000000..8125a6ff --- /dev/null +++ b/tests/test_zoom_ladder.cpp @@ -0,0 +1,40 @@ +#include "doctest.h" +#include "../src/zoom_ladder.h" +#include + +using namespace wind; + +TEST_CASE("smooth rounding error: zero where the scratch size and origin are whole") { + // 3840x2160 at 4x, pointer centred: 3840/4 = 960, origin 1920 - 480 = 1440 (whole). + CHECK(std::fabs(SmoothRoundingError(4.0, 3840, 1920.0)) < 1e-9); + CHECK(std::fabs(SmoothRoundingError(4.0, 2160, 1080.0)) < 1e-9); +} + +TEST_CASE("smooth rounding error grows at awkward levels") { + double worst = 0; + for (double z = 10.0; z < 25.0; z += 0.01) { + const double e = std::fabs(SmoothRoundingError(z, 3840, 1920.0)); + if (e > worst) worst = e; + } + CHECK(worst > 5.0); // the measured shake class (11-12 px p95 at 10-25x) +} + +TEST_CASE("snapped levels are low-error, close to the request, and never reverse a ramp") { + for (double want = 2.0; want < 25.0; want *= 1.013) { + const double z = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160); + CHECK(std::fabs(z - want) <= want * 0.0201); + if (z != want) { + CHECK(std::fabs(SmoothRoundingError(z, 3840, 1920.0)) < 1.0); + CHECK(std::fabs(SmoothRoundingError(z, 2160, 1080.0)) < 1.0); + } + const double up = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160, want, +1); + CHECK(up >= want); + const double dn = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160, want, -1); + CHECK(dn <= want); + } +} + +TEST_CASE("1x and degenerate inputs pass through") { + CHECK(SnapSmoothLevel(1.0, 1920.0, 1080.0, 3840, 2160) == 1.0); + CHECK(SnapSmoothLevel(3.0, 1920.0, 1080.0, 0, 0) == 3.0); +} From 3651e35b46f65a39e8627fb84ec02fd0c7f21459 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:48:45 +0200 Subject: [PATCH 09/22] fix(cursor): no pointer nudge during a click; MPO guard A/B and wall-lift switches (#369) - The pixel-and-back nudge could land between button-down and button-up and some clicks did not register (field report). No nudge while the left, right or middle button is held or within 250 ms of it (side buttons stay exempt: they are the zoom keys). - mpoGuard=0 turns the guard effect off for A/B. Measured on an MPO boot at nearest: without the guard a full-screen flip app stays on a hardware plane while zoomed (Hardware Composed: Independent Flip); with it the app goes Composed: Flip at zoom-in and keeps presenting. - mpoGuardLiftWall=1 (default 0) drops the pan walls and the write clamp while the guard is on, for the far-edge proof; with it DWM centring also stays on above ~9.3x. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/config.cpp | 2 ++ src/config.h | 2 ++ src/main.cpp | 11 ++++++++--- src/transform_model.cpp | 28 ++++++++++++++++++++++------ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index f69e5319..b3503801 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -239,6 +239,8 @@ Config ParseConfig(const std::string& text) { else if (key == "mpoNearestGuard") c.mpoNearestGuard = std::stoi(val); else if (key == "txSmoothLadder") c.txSmoothLadder = std::stoi(val); else if (key == "mpoGuardTest") c.mpoGuardTest = std::stoi(val); + else if (key == "mpoGuard") c.mpoGuard = std::stoi(val); + else if (key == "mpoGuardLiftWall") c.mpoGuardLiftWall = std::stoi(val); else if (key == "lockedBallistics") c.lockedBallistics = std::stoi(val); else if (key == "edgeClip") c.edgeClip = std::stoi(val); else if (key == "txPace") c.txPace = std::stoi(val); diff --git a/src/config.h b/src/config.h index dfc3c2fb..4c406c59 100644 --- a/src/config.h +++ b/src/config.h @@ -402,6 +402,8 @@ struct Config { // 11 px p95 -> 0.7 px measured at 10-25x). 0 = off (hot). int txSmoothLadder = 1; int mpoGuardTest = 0; // diagnostic: apply the MPO guard effect even on an MPO-off boot (hot) + int mpoGuard = 1; // diagnostic: 0 = never apply the guard effect (A/B on an MPO boot; hot) + int mpoGuardLiftWall = 0; // 1 = no pan walls / write clamp while the guard is on (needs proof; hot) // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write // makes DWM redo work proportional to the zoom. Turning it on scored well in the automated diff --git a/src/main.cpp b/src/main.cpp index 26c20210..e034dbfd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -890,7 +890,7 @@ static void UpdateColorFilter(TickState& t, bool zoomedNow, bool renderSession, // MPO guard (issue #369, src/mpo_guard.h): zoomed at nearest on an MPO boot, an invisible // non-identity effect makes DWM compose the desktop itself, so no plane can take the 16-bit // translation. The engine's runtime carries it (zoomed only), so it never needs our own hold. - const bool mpoGuard = wind::WantMpoGuard(zoomedNow, dynamic_cast(t.model) != nullptr, + const bool mpoGuard = t.cfg.mpoGuard != 0 && wind::WantMpoGuard(zoomedNow, dynamic_cast(t.model) != nullptr, !g_mpoDisabled || t.cfg.mpoGuardTest != 0, t.cfg.txSamplingMode); const bool userFilter = !wind::IsIdentity(dwm); @@ -1776,7 +1776,12 @@ static void RunTick(TickState& t) { // So: MPO on + nearest = walls, ALWAYS. Smooth keeps the #191 ghost-gated lift (shown + // settled >=350ms + rect intact; fail-closed). tdrTest=4 is the field harness override. const bool nearestSampling = t.cfg.txSamplingMode == 0; - const bool wallNeeded = mpoExposed && t.cfg.tdrTest != 4 && + // MPO guard lift (issue #369, src/mpo_guard.h): with the guard effect on, DWM composes the zoomed + // desktop and no plane carries the translation, so neither the walls nor the write clamp are + // needed. Behind mpoGuardLiftWall until an MPO-on boot proves it at the far edge (default off). + const bool guardLift = mpoExposed && nearestSampling && t.cfg.mpoGuard != 0 && + t.cfg.mpoGuardLiftWall != 0; + const bool wallNeeded = mpoExposed && t.cfg.tdrTest != 4 && !guardLift && (nearestSampling || !(t.cfg.mpoBuster != 0 && tmWall->mpoGhostSettled())); t.mapper.setMaxSourceLeft(wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0); @@ -1785,7 +1790,7 @@ static void RunTick(TickState& t) { t.mapper.setMaxSourceTop(wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0); if (tmWall) { tmWall->setMpoBusterWanted(mpoExposed && t.cfg.mpoBuster != 0); - tmWall->setMpoExposed(mpoExposed); + tmWall->setMpoExposed(mpoExposed && !guardLift); // no write clamp under the guard lift } if (transformGame) t.lastTransformGameMs = GetTickCount64(); // device-lost backstop window // Launch quiesce: per-tick cover tracking while zoomed (a mid-session takeover by a diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 2ef716ff..05caff06 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -36,6 +36,21 @@ static bool ShowSystemCursorMarshalled(BOOL show) { if (!ok) g_showCursorFails.fetch_add(1, std::memory_order_relaxed); return ok; } +// The pixel-and-back cursor event the native cursor relies on (issue #369). Never while a click is +// in progress: a nudge between button-down and button-up made some clicks fail to register (field +// report), so nothing is injected while the left, right or middle button is held or for 250 ms +// after it was last seen down. The side buttons are exempt: they are Wind's zoom keys. +static unsigned long long g_lastButtonMs = 0; +static bool ClickInProgress() { + const unsigned long long now = GetTickCount64(); + if ((GetAsyncKeyState(VK_LBUTTON) | GetAsyncKeyState(VK_RBUTTON) | GetAsyncKeyState(VK_MBUTTON)) & 0x8000) + g_lastButtonMs = now; + return g_lastButtonMs != 0 && now - g_lastButtonMs < 250; +} +static void NudgePointer(POINT& np) { + if (ClickInProgress()) return; + if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } +} unsigned long long TransformCursorHideFailures() { return g_showCursorFails.load(std::memory_order_relaxed); } @@ -112,7 +127,7 @@ void TransformModel::teardownMag() { // hardware plane, which Windows repaints only on the next cursor EVENT, so nudge it a pixel // and back (the same trick the zoom-out uses). POINT np; - if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + NudgePointer(np); } nativeSession_ = false; lensFailed_ = false; // a fresh context may build the lens @@ -409,7 +424,7 @@ void TransformModel::setActive(bool active) { if (nativeSession_ && host_.createCursorLens()) { host_.setCursorLens(true); POINT np; - if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + NudgePointer(np); } QueryPerformanceCounter(&z2); lastEnter_.bridgeMs = double(z1.QuadPart - z0.QuadPart) * 1000.0 / zf.QuadPart; @@ -455,7 +470,7 @@ void TransformModel::setActive(bool active) { // the nudge rides along on the worker too. blanker_->restore([] { POINT np; - if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + NudgePointer(np); }); step(1); // system cursor restore queued (it was the whole teardown cost) } @@ -491,7 +506,7 @@ void TransformModel::setActive(bool active) { // nudge the pointer a pixel and back. host_.setCursorLens(false); POINT np; - if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + NudgePointer(np); } RECT full{ 0, 0, mon_.w, mon_.h }; host_.setInputTransform(false, full, full); // input mapping back to identity at 1x @@ -646,6 +661,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg } idleReleaseMs_ = cfg.txIdleReleaseMs; // hot-reloadable release window nativePref_ = UseNativeCursor(cfg.txNativeCursor); // next zoom-in (#369) + ClickInProgress(); // keep the click window current between nudges restLevel_ = cfg.txRestLevel; // hot if (!ensureMag()) return; // lazy context: the session's first write brings DWM up // Bitmap smoothing (issue #197/#227), once per magnification context. The smooth filter @@ -858,7 +874,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg if (prime) nativePrimed_ = true; if (NudgeAfterWrite(dwmCentreOn_, true)) { POINT np; - if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + NudgePointer(np); } forceWrite_ = false; } @@ -958,7 +974,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // A scale-changing publish stops DWM drawing the composed pointer until the next // cursor event: give it one, a pixel and back. POINT np; - if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + NudgePointer(np); } if (ok) ixPubLevel_ = applyLevel; if (ok) { From 9e1ffc91fdb8fff5b2c608969eb0da29ac6cedb9 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:01:21 +0200 Subject: [PATCH 10/22] fix(cursor): DWM centring yields only near an armed MPO wall (#369) It switched off whenever a wall was reachable at the level (above ~9.3x on 3840 wide), and the switch made the view jump and the pan wobble anywhere on screen at high zoom (field video). Now it yields only when the view is within 64 source px of the wall. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/main.cpp | 2 +- src/native_cursor.h | 13 +++++++++++++ tests/test_native_cursor.cpp | 8 ++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index e034dbfd..e8812ba0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2303,7 +2303,7 @@ static void RunTick(TickState& t) { dc.zoomed = lvl > 1.001; dc.freeCursor = freeCursor && !panel; dc.viewDetached = t.viewDetached; - dc.wallNeeded = wind::WallBinding(wallNeeded, lvl, t.mon.w, t.mon.h, kMaxSafeTxMagnitude); + dc.wallNeeded = wind::NearWall(wallNeeded, r.srcLeft, r.srcTop, lvl, kMaxSafeTxMagnitude, 64.0); dc.quiesce = quiesceHold; dc.hookWrite = hookWrite; ex.dwmCentre = t.cfg.txDwmCentre != 0 && wind::WantDwmCentring(dc); diff --git a/src/native_cursor.h b/src/native_cursor.h index 1dca882d..bf36d67c 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -42,6 +42,19 @@ inline bool WallBinding(bool wallArmed, double level, int w, int h, double maxSa return (w * level - w) > maxSafe || (h * level - h) > maxSafe; } +// Whether the view is close enough to an armed MPO pan wall that DWM's own (unclamped) centring +// could cross it before Wind's next tick. Only then does Wind take the pan back. The earlier rule +// (WallBinding: the wall is reachable at this level at all) switched centring off everywhere above +// ~9.3x, and the switch made the view jump and the pan wobble in the middle of the screen (field +// video 2026-10-07). marginSrc covers one tick of fast hand motion; the 32000 limit itself already +// sits under the real 32767 field. +inline bool NearWall(bool wallArmed, double srcLeft, double srcTop, double level, double maxSafe, + double marginSrc) { + if (!wallArmed || level <= 1.0) return false; + const double wall = maxSafe / level; + return srcLeft > wall - marginSrc || srcTop > wall - marginSrc; +} + inline bool WantDwmCentring(const DwmCentreIn& in) { return in.zoomed && in.freeCursor && !in.viewDetached && !in.wallNeeded && !in.quiesce && !in.hookWrite; diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index 68d47c92..39d08d45 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -63,3 +63,11 @@ TEST_CASE("while DWM centres, every write is followed by a cursor event (one cen CHECK(NudgeAfterWrite(false, true) == false); // Wind owns the view CHECK(NudgeAfterWrite(true, false) == false); // nothing written } + +TEST_CASE("DWM centring yields only near an armed wall, not everywhere it is reachable") { + // 12x: wall at 32000/12 = 2666.7 source px. + CHECK(NearWall(true, 1000.0, 500.0, 12.0, 32000.0, 64.0) == false); // middle of the screen + CHECK(NearWall(true, 2610.0, 500.0, 12.0, 32000.0, 64.0) == true); // within a tick of the wall + 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 +} From 0ffacdbda9526c3972d8e5aef48d04a648f33a04 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:23:26 +0200 Subject: [PATCH 11/22] fix(cursor): smooth-zoom ladder no longer reads as an endless ramp (#369) The ladder holds a level slightly off the requested one at rest, and 'ramping' compared the two, so the native session held its input-transform publish forever: hover dead zones with smooth sampling. Ramping is judged on the level before the snap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/transform_model.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 05caff06..ad2b5a0a 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -642,6 +642,10 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // image's size and origin every frame, which shakes a continuous zoom; snap to the nearest level // whose predicted rounding error is under 1 px (never backwards in the ramp). Once the zoom has // settled the chosen level is held: panning at a fixed level does not shake, re-snapping would. + // The ladder's held level differs from the requested one on purpose, so "still ramping" must be + // judged on the level BEFORE the snap: comparing the snapped level with `level` read as a ramp + // that never ended, which held the input-transform publish forever (hover dead zones). + const double preLadderLevel = applyLevel; if (cfg.txSmoothLadder != 0 && cfg.txSamplingMode == 1 && applyLevel > 1.001) { if (applyLevel == level && level == ladderReq_ && ladderOut_ > 0.0) { applyLevel = ladderOut_; @@ -700,7 +704,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg traceOn_ = cfg.txTrace != 0; cfgWobbleCage_ = cfg.txWobbleCage; // diagnostic cage + threshold px (issue #229) if (level > sessionMaxLevel_) sessionMaxLevel_ = level; - const bool ramping = applyLevel != level || (applyLevel != lastLevel_ && lastLevel_ > 0.0); + const bool ramping = preLadderLevel != level || (applyLevel != lastLevel_ && lastLevel_ > 0.0); // Edge sampling margin (see transform.h) applied to the SOURCE, not just to the written // transform: srcL/srcT go on to feed the input-transform publish below, and a visual rect // that sat one texel inside a published rect that did not would put the pointer framework's From 37794b58c163e6eff7ab450835bb4b7825a93ec5 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:31:11 +0200 Subject: [PATCH 12/22] fix(cursor): smooth-zoom ladder keeps the middle of the screen still, not just the pointer (#369) The rounding error grows with screen position, so levels clean at the pointer still moved content elsewhere (field: a horizontal line shook; predicted p95 3.2 px in the middle half, 6.4 px at the edges). Levels are now required clean (1 px, growing slowly above 12x) at the pointer and across the middle half of the screen: predicted p95 1.0 / 2.0 px, clean levels at most 1.1 % apart up to 12x, 3.1 % at 12-20x, 4.7 % above. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/zoom_ladder.h | 43 +++++++++++++++++++++++++++++++------- tests/test_zoom_ladder.cpp | 20 +++++++++++++++--- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 38115d7c..669f7a8d 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -32,16 +32,45 @@ inline double SmoothRoundingError(double z, int extent, double centre) { return c * (extent / z - n) / n - (std::floor(o + 0.5) - o) * z; } -// The level nearest `want` (within +-maxRel) whose predicted error is under tol on both axes; -// `want` itself when none is found. Never steps behind `floorLevel` in the ramp's direction -// (dir > 0 zooming in, < 0 out, 0 settled), so a snapped ramp cannot visibly reverse. +// Worst predicted displacement on one axis over the pointer and the middle half of the screen. +// The size-ratio term grows linearly with the screen position, so a level that is clean at the +// pointer can still move content elsewhere by several px (field: a horizontal line shook while +// the cursor was steady; up to 6 px predicted). The middle half is where the eye is; requiring +// the whole screen leaves 5-11 % gaps between clean levels above 12x. Predicted p95 over a sweep: +// pointer-only rule 3.2 px in the middle half / 6.4 px at the edges; this rule 1.0 / 2.0 px. +inline double SmoothRoundingErrorArea(double z, int extent, double centre) { + double n = std::floor(extent / z + 0.5); + if (n < 1.0) n = 1.0; + const double o = LadderOrigin(centre, z, extent); + const double slope = (extent / z - n) / n; + const double shift = (std::floor(o + 0.5) - o) * z; + const double cPtr = (centre - o) * z; + double worst = std::fabs(slope * cPtr - shift); + const double lo = std::fabs(slope * extent * 0.25 - shift), hi = std::fabs(slope * extent * 0.75 - shift); + if (lo > worst) worst = lo; + if (hi > worst) worst = hi; + return worst; +} + +// Allowed predicted error: 1 px up to 12x (clean levels at most 1.1 % apart), then growing slowly +// (1.4 px at 20x, 2 px at 32x) so the clean levels stay at most 3.1 % apart at 12-20x and 4.7 % above, +// where one source pixel already spans 12-30 screen px. +inline double SmoothLadderTolerance(double z) { + const double t = 0.05 * z + 0.4; + return (z <= 12.0 || t < 1.0) ? 1.0 : t; +} + +// The level nearest `want` (within +-maxRel) whose predicted error over the pointer and the middle +// half of the screen is under tolerance on both axes; `want` itself when none is found. Never +// steps behind `floorLevel` in the ramp's direction (dir > 0 zooming in, < 0 out, 0 settled), so a +// snapped ramp cannot visibly reverse. inline double SnapSmoothLevel(double want, double centreX, double centreY, int w, int h, - double floorLevel = 0.0, int dir = 0, - double tol = 1.0, double maxRel = 0.02) { + double floorLevel = 0.0, int dir = 0, double maxRel = 0.05) { if (want <= 1.001 || w <= 0 || h <= 0) return want; auto ok = [&](double z) { - return std::fabs(SmoothRoundingError(z, w, centreX)) < tol && - std::fabs(SmoothRoundingError(z, h, centreY)) < tol; + const double tol = SmoothLadderTolerance(z); + return SmoothRoundingErrorArea(z, w, centreX) < tol && + SmoothRoundingErrorArea(z, h, centreY) < tol; }; auto allowed = [&](double z) { if (z <= 1.001) return false; diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 8125a6ff..73667f46 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -22,10 +22,10 @@ TEST_CASE("smooth rounding error grows at awkward levels") { TEST_CASE("snapped levels are low-error, close to the request, and never reverse a ramp") { for (double want = 2.0; want < 25.0; want *= 1.013) { const double z = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160); - CHECK(std::fabs(z - want) <= want * 0.0201); + CHECK(std::fabs(z - want) <= want * 0.0501); if (z != want) { - CHECK(std::fabs(SmoothRoundingError(z, 3840, 1920.0)) < 1.0); - CHECK(std::fabs(SmoothRoundingError(z, 2160, 1080.0)) < 1.0); + CHECK(SmoothRoundingErrorArea(z, 3840, 1920.0) < SmoothLadderTolerance(z)); + CHECK(SmoothRoundingErrorArea(z, 2160, 1080.0) < SmoothLadderTolerance(z)); } const double up = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160, want, +1); CHECK(up >= want); @@ -38,3 +38,17 @@ TEST_CASE("1x and degenerate inputs pass through") { CHECK(SnapSmoothLevel(1.0, 1920.0, 1080.0, 3840, 2160) == 1.0); CHECK(SnapSmoothLevel(3.0, 1920.0, 1080.0, 0, 0) == 3.0); } + +TEST_CASE("the area error covers more than the pointer: clean at the centre is not enough") { + // Some levels are clean at the pointer (centre) yet move content at the quarter lines by over 1 px. + int centreOnly = 0; + for (double z = 2.0; z < 30.0; z += 0.001) { + const bool atPtr = std::fabs(SmoothRoundingError(z, 3840, 1920.0)) < 1.0; + const bool inArea = SmoothRoundingErrorArea(z, 3840, 1920.0) < 1.0; + if (atPtr && !inArea) ++centreOnly; + } + CHECK(centreOnly > 0); + CHECK(SmoothLadderTolerance(5.0) == 1.0); + CHECK(SmoothLadderTolerance(12.0) == 1.0); + CHECK(SmoothLadderTolerance(20.0) == doctest::Approx(1.4)); +} From 0adad4077bc5de9281fc89aa206bac271490a3bd Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:42:02 +0200 Subject: [PATCH 13/22] fix(cursor): no ghost or walls in plane-free sessions; zoom ladder never jumps (#369) - Smooth sampling and the MPO guard both keep flip apps off hardware planes while zoomed (measured on an MPO boot: Hardware Composed: Independent Flip -> Composed: Flip at zoom-in). With mpoGuardLiftWall those sessions now skip the MPO ghost too, whose hide cost 5-7 ms at every zoom-out (16-21 ms landing stalls), and drop the walls for smooth as well as for the guard. - The smooth-zoom ladder considers only levels within 0.5 % of the request (clean over the middle half first, then at the pointer, else the request), and keeps the level on screen when the zoom stops. Snapping up to 5 % away made zooms jump forwards and back and shift on key-up (field). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/main.cpp | 16 ++++++++++------ src/transform_model.cpp | 7 +++++++ src/zoom_ladder.h | 25 ++++++++++++++++++------- tests/test_zoom_ladder.cpp | 11 +++++++---- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index e8812ba0..a20624ff 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1776,11 +1776,15 @@ static void RunTick(TickState& t) { // So: MPO on + nearest = walls, ALWAYS. Smooth keeps the #191 ghost-gated lift (shown + // settled >=350ms + rect intact; fail-closed). tdrTest=4 is the field harness override. const bool nearestSampling = t.cfg.txSamplingMode == 0; - // MPO guard lift (issue #369, src/mpo_guard.h): with the guard effect on, DWM composes the zoomed - // desktop and no plane carries the translation, so neither the walls nor the write clamp are - // needed. Behind mpoGuardLiftWall until an MPO-on boot proves it at the far edge (default off). - const bool guardLift = mpoExposed && nearestSampling && t.cfg.mpoGuard != 0 && - t.cfg.mpoGuardLiftWall != 0; + // PLANE-FREE SESSION (issue #369): smooth sampling (the resample property) and the MPO guard + // effect both make the scaled desktop visual need an external layer, so DWM composes the zoomed + // desktop and no hardware plane carries the translation. Measured on an MPO boot: a full-screen + // flip app goes from Hardware Composed: Independent Flip to Composed: Flip at zoom-in in both + // cases. Then the pan walls, the write clamp and the MPO ghost are all unnecessary; the ghost + // alone cost 5-7 ms at every zoom-out (16-21 ms landing stalls). Behind mpoGuardLiftWall until + // the far edge is proven on an MPO boot (default off). + const bool guardLift = mpoExposed && t.cfg.mpoGuardLiftWall != 0 && + (!nearestSampling || t.cfg.mpoGuard != 0); const bool wallNeeded = mpoExposed && t.cfg.tdrTest != 4 && !guardLift && (nearestSampling || !(t.cfg.mpoBuster != 0 && tmWall->mpoGhostSettled())); @@ -1789,7 +1793,7 @@ static void RunTick(TickState& t) { // strip above ~16.2x on 2160 was reachable-lethal with the X-only wall. t.mapper.setMaxSourceTop(wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0); if (tmWall) { - tmWall->setMpoBusterWanted(mpoExposed && t.cfg.mpoBuster != 0); + tmWall->setMpoBusterWanted(mpoExposed && t.cfg.mpoBuster != 0 && !guardLift); tmWall->setMpoExposed(mpoExposed && !guardLift); // no write clamp under the guard lift } if (transformGame) t.lastTransformGameMs = GetTickCount64(); // device-lost backstop window diff --git a/src/transform_model.cpp b/src/transform_model.cpp index ad2b5a0a..a070cdb7 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -649,6 +649,13 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg if (cfg.txSmoothLadder != 0 && cfg.txSamplingMode == 1 && applyLevel > 1.001) { if (applyLevel == level && level == ladderReq_ && ladderOut_ > 0.0) { applyLevel = ladderOut_; + } else if (rampStopped && applyLevel == level && lastLevel_ > 1.001 && + std::fabs(lastLevel_ - level) <= level * 0.005) { + // The zoom just stopped: keep the level already on screen rather than re-snapping, so + // releasing the key never nudges the zoom in or out (field 2026-10-07). + ladderReq_ = level; + ladderOut_ = lastLevel_; + applyLevel = lastLevel_; } else { const int dir = applyLevel > lastLevel_ ? 1 : (applyLevel < lastLevel_ ? -1 : 0); const double snapped = SnapSmoothLevel(applyLevel, r.centerX, r.centerY, mon_.w, mon_.h, diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 669f7a8d..6ef5c3e6 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -64,14 +64,22 @@ inline double SmoothLadderTolerance(double z) { // half of the screen is under tolerance on both axes; `want` itself when none is found. Never // steps behind `floorLevel` in the ramp's direction (dir > 0 zooming in, < 0 out, 0 settled), so a // snapped ramp cannot visibly reverse. +// Tiered (field 2026-10-07: snapping up to 5 % away made zooms jump forwards and back and changed +// the level on key-up): only levels within +-maxRel (0.5 %) of the request are considered. First +// choice clean over the middle half of the screen, second clean at the pointer, else the request +// itself (that frame may shake a little, but the zoom never visibly jumps). inline double SnapSmoothLevel(double want, double centreX, double centreY, int w, int h, - double floorLevel = 0.0, int dir = 0, double maxRel = 0.05) { + double floorLevel = 0.0, int dir = 0, double maxRel = 0.005) { if (want <= 1.001 || w <= 0 || h <= 0) return want; - auto ok = [&](double z) { + auto areaOk = [&](double z) { const double tol = SmoothLadderTolerance(z); return SmoothRoundingErrorArea(z, w, centreX) < tol && SmoothRoundingErrorArea(z, h, centreY) < tol; }; + auto ptrOk = [&](double z) { + return std::fabs(SmoothRoundingError(z, w, centreX)) < 1.0 && + std::fabs(SmoothRoundingError(z, h, centreY)) < 1.0; + }; auto allowed = [&](double z) { if (z <= 1.001) return false; if (dir > 0 && floorLevel > 0.0 && z < floorLevel) return false; @@ -80,11 +88,14 @@ inline double SnapSmoothLevel(double want, double centreX, double centreY, int w }; const double step = want * 1e-5; const int steps = (int)(maxRel / 1e-5); - if (allowed(want) && ok(want)) return want; - for (int i = 1; i <= steps; ++i) { - const double a = want + i * step, b = want - i * step; - if (allowed(a) && ok(a)) return a; - if (allowed(b) && ok(b)) return b; + for (int pass = 0; pass < 2; ++pass) { + auto ok = [&](double z) { return pass == 0 ? areaOk(z) : ptrOk(z); }; + if (allowed(want) && ok(want)) return want; + for (int i = 1; i <= steps; ++i) { + const double a = want + i * step, b = want - i * step; + if (allowed(a) && ok(a)) return a; + if (allowed(b) && ok(b)) return b; + } } return want; } diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 73667f46..0964168e 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -22,10 +22,13 @@ TEST_CASE("smooth rounding error grows at awkward levels") { TEST_CASE("snapped levels are low-error, close to the request, and never reverse a ramp") { for (double want = 2.0; want < 25.0; want *= 1.013) { const double z = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160); - CHECK(std::fabs(z - want) <= want * 0.0501); - if (z != want) { - CHECK(SmoothRoundingErrorArea(z, 3840, 1920.0) < SmoothLadderTolerance(z)); - CHECK(SmoothRoundingErrorArea(z, 2160, 1080.0) < SmoothLadderTolerance(z)); + CHECK(std::fabs(z - want) <= want * 0.00501); + if (z != want) { // a snap is always clean at least at the pointer + const bool area = SmoothRoundingErrorArea(z, 3840, 1920.0) < SmoothLadderTolerance(z) && + SmoothRoundingErrorArea(z, 2160, 1080.0) < SmoothLadderTolerance(z); + const bool ptr = std::fabs(SmoothRoundingError(z, 3840, 1920.0)) < 1.0 && + std::fabs(SmoothRoundingError(z, 2160, 1080.0)) < 1.0; + CHECK((area || ptr)); } const double up = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160, want, +1); CHECK(up >= want); From f584930722393dadb0a97dd5a7281acd0eb5e6ad Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:19 +0200 Subject: [PATCH 14/22] fix(cursor): zoom ladder search window grows with zoom (#369) A flat 0.5 % window found no clean level at 20-31x, so the shake came back there (field; predicted 15 px p95). The pointer pass now searches 0.5 % up to 12x, growing to 1.2 % at 24x (predicted 0 px at the pointer, under 3 px over the middle half at 20-31x); the hold-on-release uses the same window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/transform_model.cpp | 2 +- src/zoom_ladder.h | 21 +++++++++++++++------ tests/test_zoom_ladder.cpp | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/transform_model.cpp b/src/transform_model.cpp index a070cdb7..88d841d0 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -650,7 +650,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg if (applyLevel == level && level == ladderReq_ && ladderOut_ > 0.0) { applyLevel = ladderOut_; } else if (rampStopped && applyLevel == level && lastLevel_ > 1.001 && - std::fabs(lastLevel_ - level) <= level * 0.005) { + std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5)) { // The zoom just stopped: keep the level already on screen rather than re-snapping, so // releasing the key never nudges the zoom in or out (field 2026-10-07). ladderReq_ = level; diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 6ef5c3e6..a254e2d1 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -64,12 +64,20 @@ inline double SmoothLadderTolerance(double z) { // half of the screen is under tolerance on both axes; `want` itself when none is found. Never // steps behind `floorLevel` in the ramp's direction (dir > 0 zooming in, < 0 out, 0 settled), so a // snapped ramp cannot visibly reverse. -// Tiered (field 2026-10-07: snapping up to 5 % away made zooms jump forwards and back and changed -// the level on key-up): only levels within +-maxRel (0.5 %) of the request are considered. First -// choice clean over the middle half of the screen, second clean at the pointer, else the request -// itself (that frame may shake a little, but the zoom never visibly jumps). +// How far from the request a snap may go. Field 2026-10-07: up to 5 % made zooms jump forwards and +// back; a flat 0.5 % found no clean level at 20-31x (predicted shake back to 15 px p95). Clean +// levels thin out with zoom, so the window grows with it: 0.5 % up to 12x, then to 1.2 % at 24x and +// above (predicted at 20-31x: 0 px at the pointer, under 3 px over the middle half). +inline double SnapWindow(double z) { + if (z <= 12.0) return 0.005; + const double w = 0.005 + (z - 12.0) * (0.007 / 12.0); + return w > 0.012 ? 0.012 : w; +} + +// Tiered: the middle half of the screen clean within 0.5 %, else the pointer clean within +// SnapWindow, else the request itself (that frame may shake a little; the zoom never jumps). inline double SnapSmoothLevel(double want, double centreX, double centreY, int w, int h, - double floorLevel = 0.0, int dir = 0, double maxRel = 0.005) { + double floorLevel = 0.0, int dir = 0, double maxRel = -1.0) { if (want <= 1.001 || w <= 0 || h <= 0) return want; auto areaOk = [&](double z) { const double tol = SmoothLadderTolerance(z); @@ -87,8 +95,9 @@ inline double SnapSmoothLevel(double want, double centreX, double centreY, int w return true; }; const double step = want * 1e-5; - const int steps = (int)(maxRel / 1e-5); for (int pass = 0; pass < 2; ++pass) { + const double rel = maxRel > 0.0 ? maxRel : (pass == 0 ? 0.005 : SnapWindow(want)); + const int steps = (int)(rel / 1e-5); auto ok = [&](double z) { return pass == 0 ? areaOk(z) : ptrOk(z); }; if (allowed(want) && ok(want)) return want; for (int i = 1; i <= steps; ++i) { diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 0964168e..4ea0c86a 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -22,7 +22,7 @@ TEST_CASE("smooth rounding error grows at awkward levels") { TEST_CASE("snapped levels are low-error, close to the request, and never reverse a ramp") { for (double want = 2.0; want < 25.0; want *= 1.013) { const double z = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160); - CHECK(std::fabs(z - want) <= want * 0.00501); + CHECK(std::fabs(z - want) <= want * (SnapWindow(want) + 1e-5)); if (z != want) { // a snap is always clean at least at the pointer const bool area = SmoothRoundingErrorArea(z, 3840, 1920.0) < SmoothLadderTolerance(z) && SmoothRoundingErrorArea(z, 2160, 1080.0) < SmoothLadderTolerance(z); @@ -55,3 +55,16 @@ TEST_CASE("the area error covers more than the pointer: clean at the centre is n CHECK(SmoothLadderTolerance(12.0) == 1.0); CHECK(SmoothLadderTolerance(20.0) == doctest::Approx(1.4)); } + +TEST_CASE("the snap window grows with zoom and high zoom still finds pointer-clean levels") { + CHECK(SnapWindow(5.0) == 0.005); + CHECK(SnapWindow(12.0) == 0.005); + CHECK(SnapWindow(30.0) == doctest::Approx(0.012)); + int found = 0, total = 0; + for (double want = 20.0; want < 31.0; want *= 1.007, ++total) { + const double z = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160); + if (std::fabs(SmoothRoundingError(z, 3840, 1920.0)) < 1.0 && + std::fabs(SmoothRoundingError(z, 2160, 1080.0)) < 1.0) ++found; + } + CHECK(found >= total * 9 / 10); +} From fba3f088a17ff9ceeb6429b011d145cb7422fc12 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:01:25 +0200 Subject: [PATCH 15/22] fix(cursor): zoom ladder never settles after the key is released (#369) During the ease-out after release the ladder held a level and hopped to the next, which read as the zoom settling into place (field). Snaps are now capped at the zoom's own motion this frame: fast zooms keep their clean levels, a decelerating zoom glides out exactly and stops where it is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/transform_model.cpp | 5 ++++- src/zoom_ladder.h | 10 ++++++++-- tests/test_zoom_ladder.cpp | 8 ++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 88d841d0..aea72022 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -658,8 +658,11 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg applyLevel = lastLevel_; } else { const int dir = applyLevel > lastLevel_ ? 1 : (applyLevel < lastLevel_ ? -1 : 0); + // Snap no further than the zoom moves this frame (min 0.02 %, so a ramp start still snaps). + const double stepRel = lastLevel_ > 1.0 ? std::fabs(applyLevel - lastLevel_) / applyLevel : 1.0; const double snapped = SnapSmoothLevel(applyLevel, r.centerX, r.centerY, mon_.w, mon_.h, - lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir); + lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir, -1.0, + stepRel > 0.0002 ? stepRel : 0.0002); ladderReq_ = level; ladderOut_ = snapped; applyLevel = snapped; diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index a254e2d1..74732d0d 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -76,8 +76,13 @@ inline double SnapWindow(double z) { // Tiered: the middle half of the screen clean within 0.5 %, else the pointer clean within // SnapWindow, else the request itself (that frame may shake a little; the zoom never jumps). +// stepRel (> 0) caps both windows at the zoom's own motion this frame: a fast zoom has room to +// pick clean levels, a decelerating one almost none, so the ease-out after the key is released +// glides out exactly instead of holding a level and hopping to the next (field 2026-10-07: the +// zoom visibly "settled" after the key was released). inline double SnapSmoothLevel(double want, double centreX, double centreY, int w, int h, - double floorLevel = 0.0, int dir = 0, double maxRel = -1.0) { + double floorLevel = 0.0, int dir = 0, double maxRel = -1.0, + double stepRel = -1.0) { if (want <= 1.001 || w <= 0 || h <= 0) return want; auto areaOk = [&](double z) { const double tol = SmoothLadderTolerance(z); @@ -96,7 +101,8 @@ inline double SnapSmoothLevel(double want, double centreX, double centreY, int w }; const double step = want * 1e-5; for (int pass = 0; pass < 2; ++pass) { - const double rel = maxRel > 0.0 ? maxRel : (pass == 0 ? 0.005 : SnapWindow(want)); + double rel = maxRel > 0.0 ? maxRel : (pass == 0 ? 0.005 : SnapWindow(want)); + if (stepRel > 0.0 && stepRel < rel) rel = stepRel; const int steps = (int)(rel / 1e-5); auto ok = [&](double z) { return pass == 0 ? areaOk(z) : ptrOk(z); }; if (allowed(want) && ok(want)) return want; diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 4ea0c86a..1b7555d6 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -68,3 +68,11 @@ TEST_CASE("the snap window grows with zoom and high zoom still finds pointer-cle } CHECK(found >= total * 9 / 10); } + +TEST_CASE("a slow (easing) zoom is barely snapped: the snap never exceeds the frame's own motion") { + for (double want = 3.0; want < 30.0; want *= 1.01) { + const double step = 0.0003; // ease-out: 0.03 % this frame + const double z = SnapSmoothLevel(want, 1920.0, 1080.0, 3840, 2160, 0.0, 0, -1.0, step); + CHECK(std::fabs(z - want) <= want * (step + 1e-6)); + } +} From a3158fa3456bc72fbfb679e9055a5dccaa9f557f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:32:26 +0200 Subject: [PATCH 16/22] fix(cursor): the zoom ease-out stops before a DWM rounding step (#369) After the zoom key was released the decelerating zoom either hopped between clean levels or, when not snapped, crossed one of DWM's whole-pixel rounding steps, which jumped the image (the view and cursor shifted once the zoom settled; gone with txSmoothLadder=0, so not DWM centring). The ease-out now follows the request only within the current rounding cell and stops before leaving it. Held keys, buttons and explicit targets (wheel, quick zoom) keep the ladder as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/magnifier_model.h | 3 +++ src/main.cpp | 5 +++++ src/transform_model.cpp | 7 +++++++ src/zoom_ladder.h | 17 +++++++++++++++++ tests/test_zoom_ladder.cpp | 9 +++++++++ 5 files changed, 41 insertions(+) diff --git a/src/magnifier_model.h b/src/magnifier_model.h index d9b0013c..3d127a26 100644 --- a/src/magnifier_model.h +++ b/src/magnifier_model.h @@ -36,6 +36,9 @@ struct PresentExtras { // The view is a pure function of the real pointer this tick (free cursor, mouse-owned, no // walls, no quiesce): a native-cursor session may hand the pan to DWM (issue #369). bool dwmCentre = false; + // A zoom key or button is held, or the controller is heading for an explicit target (wheel, + // quick zoom). False while the zoom eases out after a release (issue #369 ladder). + bool zoomDriven = true; // Colour filter for the render engine's pixel shader (issue #288). Its capture already // contains the DWM colour effect, so RunTick clears that effect during a render session and // hands the matrix here instead. Transform and 1x use the DWM effect. diff --git a/src/main.cpp b/src/main.cpp index a20624ff..e435320a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2299,6 +2299,11 @@ static void RunTick(TickState& t) { // (the old tick countdown only decremented while zoomed, which did exactly that). const bool quiesceHold = QuiesceHoldActive(t); ex.pauseWrites = t.clickPauseTicks > 0 || quiesceHold; + { + auto& zs = g_input.state(); + ex.zoomDriven = zs.inHeld.load() || zs.outHeld.load() || g_input.anyBoundKeyPressed() || + t.zoom.hasTarget(); + } if (quiesceHold) ex.suppressCursorSync = true; // Native cursor (issue #369): DWM may own the pan only where the view is a pure function of // the pointer. The model applies it only in a native-cursor session. diff --git a/src/transform_model.cpp b/src/transform_model.cpp index aea72022..657ad271 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -649,6 +649,13 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg if (cfg.txSmoothLadder != 0 && cfg.txSamplingMode == 1 && applyLevel > 1.001) { if (applyLevel == level && level == ladderReq_ && ladderOut_ > 0.0) { applyLevel = ladderOut_; + } else if (!ex.zoomDriven && !rampStopped && lastLevel_ > 1.001) { + // EASE-OUT after the key was released: follow the request exactly while it stays in + // the current rounding cell, stop just before it would cross into the next one. + if (!SameRoundingCell(applyLevel, lastLevel_, r.centerX, r.centerY, mon_.w, mon_.h)) + applyLevel = lastLevel_; + ladderReq_ = level; + ladderOut_ = applyLevel; } else if (rampStopped && applyLevel == level && lastLevel_ > 1.001 && std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5)) { // The zoom just stopped: keep the level already on screen rather than re-snapping, so diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 74732d0d..6b3d9159 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -115,4 +115,21 @@ inline double SnapSmoothLevel(double want, double centreX, double centreY, int w return want; } +// THE EASE-OUT MAY NOT CROSS A ROUNDING STEP (field 2026-10-07). After the zoom key is released the +// zoom decelerates; snapping there hopped between clean levels (read as settling), and not snapping +// let the slow zoom cross one of DWM's whole-pixel rounding steps, which jumps the image by up to +// the level in px (read as the view and cursor shifting once the zoom settled). Within one rounding +// cell (same scratch size n and same rounded origin on both axes) the image moves continuously, so +// the ease-out follows the request while it stays in the cell and stops just before it would leave. +inline bool SameRoundingCell(double z1, double z2, double centreX, double centreY, int w, int h) { + auto cell = [](double z, int extent, double centre, double& n, double& o) { + n = std::floor(extent / z + 0.5); + o = std::floor(LadderOrigin(centre, z, extent) + 0.5); + }; + double n1x, o1x, n2x, o2x, n1y, o1y, n2y, o2y; + cell(z1, w, centreX, n1x, o1x); cell(z2, w, centreX, n2x, o2x); + cell(z1, h, centreY, n1y, o1y); cell(z2, h, centreY, n2y, o2y); + return n1x == n2x && o1x == o2x && n1y == n2y && o1y == o2y; +} + } // namespace wind diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 1b7555d6..d72be12a 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -76,3 +76,12 @@ TEST_CASE("a slow (easing) zoom is barely snapped: the snap never exceeds the fr CHECK(std::fabs(z - want) <= want * (step + 1e-6)); } } + +TEST_CASE("rounding cells: a tiny step usually stays inside, a long glide leaves") { + int same = 0, total = 0; + for (double z = 3.0; z < 20.0; z *= 1.003, ++total) + if (SameRoundingCell(z, z * 1.00005, 1920.0, 1080.0, 3840, 2160)) ++same; + CHECK(same > total * 8 / 10); + CHECK(SameRoundingCell(4.0, 6.0, 1920.0, 1080.0, 3840, 2160) == false); + CHECK(SameRoundingCell(5.0, 5.0, 1920.0, 1080.0, 3840, 2160) == true); +} From 6c74a87916d599cccd4c6a1e3fe9e7c7148cd67e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:36:35 +0200 Subject: [PATCH 17/22] fix(cursor): smooth-zoom release stops at the level on screen (#369) With smooth sampling and the ladder, every slow ease-out crosses DWM rounding steps (an image jump of about the level in px each); snapping hopped and holding froze then caught up (field). On the release edge of the zoom keys/buttons the controller is set to the level on screen and its glide stopped, so the zoom ends exactly where it was let go. Wheel and quick-zoom targets are untouched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/main.cpp | 19 ++++++++++++++++++- src/zoom_controller.h | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index e435320a..0de44ce5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -394,7 +394,8 @@ struct TickState { DWORD quiescedPid = 0; // fires at most once per process instance HWND lastCoverFg = nullptr; // edge-detect cover-takeover foregrounds unsigned long long lastCoverProbeMs = 0; - double prevTickLevel = 0.0; // hook-write arming: only while the level is settled (#206) // throttles the idle-tick cover watch to ~4Hz + double prevTickLevel = 0.0; + bool prevZoomHeld = false; // #369: release edge of the zoom keys/buttons // hook-write arming: only while the level is settled (#206) // throttles the idle-tick cover watch to ~4Hz IMagnifierModel* wantModel = nullptr; // hybrid stickiness: candidate engine and how long it unsigned long long wantSinceMs = 0; // has been the candidate (debounces foreground reads) unsigned long long kbHookDivergentSinceMs = 0; // LL keyboard-hook watchdog dwell (issue #156) @@ -1357,6 +1358,22 @@ static void RunTick(TickState& t) { // smooth ramp, merely delayed. Gated on level > 1.0 so the session still enters and the // freeze holds it at ~1.01 until the hold expires. const bool quiesceFreeze = QuiesceHoldActive(t) && t.zoom.level() > 1.0; + // SMOOTH-ZOOM RELEASE (issue #369). With smooth sampling and the zoom ladder, a slow ease-out + // must cross DWM's whole-pixel rounding steps, and each crossing jumps the image by about the + // level in px; snapping it instead hopped, holding it froze then caught up (all field-tested). + // So the zoom stops where it is on screen at the moment the key or button is released. + { + auto& zs = g_input.state(); + const bool held = zs.inHeld.load() || zs.outHeld.load() || g_input.anyBoundKeyPressed(); + if (t.prevZoomHeld && !held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && + t.cfg.txSmoothLadder != 0) { + if (auto* tmStop = dynamic_cast(t.model)) { + const double shown = tmStop->writtenLevel(); + if (shown > 1.001) { t.zoom.setLevel(shown); t.zoom.stopGlide(); } + } + } + t.prevZoomHeld = held; + } if (!quiesceFreeze) t.zoom.tick(dt < kMaxZoomDt ? dt : kMaxZoomDt); // Recenter on a recenterVk key press (rising edge). bool recenter = false; diff --git a/src/zoom_controller.h b/src/zoom_controller.h index 77ca7a11..d8f398d5 100644 --- a/src/zoom_controller.h +++ b/src/zoom_controller.h @@ -26,6 +26,7 @@ class ZoomController { double level() const { return level_; } void reset(); // level=min, dir=None, held cleared void setLevel(double l); // instant snap to a level (clamped to [min,max]); dir_ untouched + void stopGlide() { rate_ = 0.0; } // end the ease-out now (issue #369: smooth-zoom release) // Scroll-wheel zoom (#285): move a TARGET level by x(1+step) per step (negative = out), clamped; // tick() glides the level to it. Steps stack on the target, so fast scrolling reads as one // continuous zoom. A held zoom direction takes over at once (the target is dropped). From 4e0f95cabdda50919a2e09bc2fc80ed524c77ff0 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:40:17 +0200 Subject: [PATCH 18/22] feat(cursor): truncated ease-out keeps the user's zoom inertia with smoothing (#369) The dead stop on release removed the user's ease-out. Now the ease-out runs, snapped to clean levels like a held zoom, until it moves less per frame than clean levels are apart (0.6 x the snap window); then the zoom stops on the level on screen. Only the barely-moving tail, where every earlier attempt hopped, shifted or froze, is cut. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/main.cpp | 14 ++++++++------ src/transform_model.cpp | 13 ++----------- src/zoom_ladder.h | 10 ++++++++++ tests/test_zoom_ladder.cpp | 7 +++++++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0de44ce5..a7b5557f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -33,6 +33,7 @@ #include "transform_model.h" #include "native_cursor.h" // UseNativeCursor, WantDwmCentring (issue #369) #include "mpo_guard.h" // WantMpoGuard, GuardedColorMatrix (issue #369) +#include "zoom_ladder.h" // EaseOutShouldStop (issue #369) #include "hitch_record.h" // hitch recorder (#361) #include "tick_span.h" #include "input_router.h" @@ -1358,15 +1359,16 @@ static void RunTick(TickState& t) { // smooth ramp, merely delayed. Gated on level > 1.0 so the session still enters and the // freeze holds it at ~1.01 until the hold expires. const bool quiesceFreeze = QuiesceHoldActive(t) && t.zoom.level() > 1.0; - // SMOOTH-ZOOM RELEASE (issue #369). With smooth sampling and the zoom ladder, a slow ease-out - // must cross DWM's whole-pixel rounding steps, and each crossing jumps the image by about the - // level in px; snapping it instead hopped, holding it froze then caught up (all field-tested). - // So the zoom stops where it is on screen at the moment the key or button is released. + // SMOOTH-ZOOM RELEASE (issue #369, src/zoom_ladder.h EaseOutShouldStop). With smooth sampling and + // the zoom ladder, a slow zoom must cross DWM's whole-pixel rounding steps, each an image jump of + // about the level in px; snapping the slow tail hopped, holding it froze then caught up (field). + // So after a release the user's ease-out runs (snapped like a held zoom) until it moves less per + // frame than clean levels are apart, then the zoom stops on the level on screen. { auto& zs = g_input.state(); const bool held = zs.inHeld.load() || zs.outHeld.load() || g_input.anyBoundKeyPressed(); - if (t.prevZoomHeld && !held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && - t.cfg.txSmoothLadder != 0) { + if (!held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && t.cfg.txSmoothLadder != 0 && + t.zoom.level() != t.prevLvl && wind::EaseOutShouldStop(t.zoom.level(), t.prevLvl)) { if (auto* tmStop = dynamic_cast(t.model)) { const double shown = tmStop->writtenLevel(); if (shown > 1.001) { t.zoom.setLevel(shown); t.zoom.stopGlide(); } diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 657ad271..195af033 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -649,13 +649,6 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg if (cfg.txSmoothLadder != 0 && cfg.txSamplingMode == 1 && applyLevel > 1.001) { if (applyLevel == level && level == ladderReq_ && ladderOut_ > 0.0) { applyLevel = ladderOut_; - } else if (!ex.zoomDriven && !rampStopped && lastLevel_ > 1.001) { - // EASE-OUT after the key was released: follow the request exactly while it stays in - // the current rounding cell, stop just before it would cross into the next one. - if (!SameRoundingCell(applyLevel, lastLevel_, r.centerX, r.centerY, mon_.w, mon_.h)) - applyLevel = lastLevel_; - ladderReq_ = level; - ladderOut_ = applyLevel; } else if (rampStopped && applyLevel == level && lastLevel_ > 1.001 && std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5)) { // The zoom just stopped: keep the level already on screen rather than re-snapping, so @@ -665,11 +658,9 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg applyLevel = lastLevel_; } else { const int dir = applyLevel > lastLevel_ ? 1 : (applyLevel < lastLevel_ ? -1 : 0); - // Snap no further than the zoom moves this frame (min 0.02 %, so a ramp start still snaps). - const double stepRel = lastLevel_ > 1.0 ? std::fabs(applyLevel - lastLevel_) / applyLevel : 1.0; + // (The slow tail of an ease-out never reaches here: RunTick stops the glide first.) const double snapped = SnapSmoothLevel(applyLevel, r.centerX, r.centerY, mon_.w, mon_.h, - lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir, -1.0, - stepRel > 0.0002 ? stepRel : 0.0002); + lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir); ladderReq_ = level; ladderOut_ = snapped; applyLevel = snapped; diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 6b3d9159..c34f16c4 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -132,4 +132,14 @@ inline bool SameRoundingCell(double z1, double z2, double centreX, double centre return n1x == n2x && o1x == o2x && n1y == n2y && o1y == o2y; } +// TRUNCATED EASE-OUT (field 2026-10-07). After a release the user's ease-out runs, snapped to +// clean levels like a held zoom; once it moves less per frame than clean levels are apart, the +// ladder could only hop or jump, so the zoom stops there on the level on screen. The fast part of +// the glide (nearly all of it) survives; only the barely-moving tail is cut. +inline bool EaseOutShouldStop(double level, double prevLevel) { + if (level <= 1.001 || prevLevel <= 1.001) return false; + const double step = std::fabs(level - prevLevel) / level; + return step < 0.6 * SnapWindow(level); +} + } // namespace wind diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index d72be12a..440e874a 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -85,3 +85,10 @@ TEST_CASE("rounding cells: a tiny step usually stays inside, a long glide leaves CHECK(SameRoundingCell(4.0, 6.0, 1920.0, 1080.0, 3840, 2160) == false); CHECK(SameRoundingCell(5.0, 5.0, 1920.0, 1080.0, 3840, 2160) == true); } + +TEST_CASE("the ease-out runs while it moves faster than the clean-level spacing, then stops") { + CHECK(EaseOutShouldStop(5.0, 5.0 / 1.02) == false); // 2 % per frame: still gliding + CHECK(EaseOutShouldStop(5.0, 5.0 / 1.001) == true); // 0.1 % per frame: the tail, stop + CHECK(EaseOutShouldStop(25.0, 25.0 / 1.004) == true); // 0.4 % at 25x (window 1.2 %) + CHECK(EaseOutShouldStop(1.0, 1.0) == false); +} From ca53762151d2278231dcb3af67809e2d5fdb416d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:49:12 +0200 Subject: [PATCH 19/22] fix(cursor): hover follows the zoom ease-out (#369) The input-transform publish was held for the whole ramp, and with a long ease-out (300 ms) hover only landed once the glide ended (field video: tab hover). It is now held only while a zoom key/button drives the ramp. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/transform_model.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 195af033..40063f7d 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -941,7 +941,9 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // work here, and decimating it away would defeat the whole mode. // Native cursor (#369): no publish while the level ramps (it hides the composed pointer); // the pending flag carries it to the first settled tick. - const bool hold = HoldInputPublish(nativeSession_, ramping, ixForce); + // Held only while a zoom key/button drives the ramp: during the release ease-out hover must + // follow (field 2026-10-07: tab hover waited for the whole 300 ms glide to end). + const bool hold = HoldInputPublish(nativeSession_, ramping && ex.zoomDriven, ixForce); if (!hold && (ixForce || warmIxOnly || rest || ++ixTick_ >= cfg.ixDecimate)) { ixTick_ = 0; ixPending_ = false; From 3be69ba424aa4347d667e6d99603fbe60c33a9d3 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:23:27 +0200 Subject: [PATCH 20/22] feat(settings): High resolution cursor applies live, no MPO staging or restart (#369) Smooth sampling and the MPO guard both keep apps off hardware planes while zoomed (measured on an MPO boot), so either look is safe with MPO on. The toggle no longer writes the MPO registry value or prompts for a restart; mpoNearestGuard and mpoGuardLiftWall default to 1 (no walls, write clamp or ghost in plane-free sessions), and EffectiveSamplingMode runs the ini value as is with the guard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- CLAUDE.md | 5 +-- docs/architecture/05-transform-engine.md | 10 +++--- src/config.cpp | 3 ++ src/config.h | 4 +-- tests/test_config.cpp | 6 ++-- ui/src/Settings.svelte | 45 ++++-------------------- ui/src/settings-schema.js | 8 ++--- ui/tests/session.spec.js | 21 ++++------- 8 files changed, 34 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4e327543..079b6ec7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,8 +88,9 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. stay 0); do not gate the sprite on "the view moved". - Keep the 2 px right/bottom clamp and the 1-texel left/top floor in `ComputeMagTransform` (TDR and grey-edge classes). -- MPO on + nearest sampling overflows a 16-bit driver field above ~9.3x at the far right: walls - always. Never offer nearest with MPO on. +- MPO on + nearest sampling overflows a 16-bit driver field above ~9.3x at the far right unless the + MPO guard (`src/mpo_guard.h`, default on) keeps apps off planes while zoomed. Never turn the guard + off with nearest on an MPO boot; walls come back only when it is off. - `txWarmMode`/`txWarmHz`: every warm write is a full DWM re-render; composition-rate metrics miss the pan-start hitch. Field-verify in a game. - Publish the source-rect input transform on every change (needs UIAccess); identity or none gives diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index c8f2c3e7..3a79ed1b 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -177,10 +177,12 @@ Defences (wall arming in `RunTick`, write clamp in `TransformModel::present`): plane. - A write-site clamp backs the walls up when the session is exposed and the ghost is not settled, because the walls divide by the controller level while the write uses the step-capped level. -- Settings couples the two: the **High resolution cursor** option sets smooth sampling and stages - MPO re-enable; turning it off sets nearest and stages MPO-disable, both applied at the restart. - Nearest with MPO on is never offered (`EffectiveSamplingMode` keeps the boot state's mode until - the reboot lands). +- Since #369 the **High resolution cursor** option only switches sampling, live: smooth (resample + layer) and nearest with the MPO guard (colour layer) are both plane-free while zoomed, so the page + no longer stages MPO or asks for a restart. `mpoNearestGuard=0` restores the old rule + (`EffectiveSamplingMode` then keeps the boot state's mode until a reboot). +- Plane-free sessions (`mpoGuardLiftWall=1`, default) also drop the pan walls, the write clamp and + the MPO ghost. Field-run on an MPO boot at up to 31x, far right and bottom: no driver reset. - `tdrTest` is the field harness: 2 probes the clamp, 4 lifts the wall. - **MPO nearest guard** (`src/mpo_guard.h`, issue #369). Zoomed at nearest on an MPO boot, Wind applies an invisible colour effect (0.998 on R, G, B). A colour transform, like the resample diff --git a/src/config.cpp b/src/config.cpp index b3503801..7325c445 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -96,6 +96,9 @@ int EffectiveGpuPriority(const Config& c) { int EffectiveSamplingMode(int iniValue, bool mpoDisabledAtBoot, bool mpoDisabledInRegistry, int tdrTest, bool nearestGuard) { if (tdrTest != 0) return iniValue; + // With the MPO guard both looks are safe on any boot (#369): the ini value runs as is, no + // restart-pending hold. + if (nearestGuard) return iniValue; if (mpoDisabledAtBoot != mpoDisabledInRegistry) // restart pending: hold the boot look return mpoDisabledAtBoot ? 0 : 1; if (iniValue == 0 && !mpoDisabledAtBoot && !nearestGuard) return 1; // crisp on an MPO boot = the TDR combo diff --git a/src/config.h b/src/config.h index 4c406c59..bb5921e9 100644 --- a/src/config.h +++ b/src/config.h @@ -396,14 +396,14 @@ struct Config { // boot, with an invisible colour effect while zoomed so DWM composes the desktop itself (no // plane, no 16-bit overflow). 0 (default until verified on an MPO boot) = nearest on MPO boots // is turned into smooth as before. The pan walls stay armed either way. Restart to apply. - int mpoNearestGuard = 0; + int mpoNearestGuard = 1; // Smooth-zoom ladder (issue #369, src/zoom_ladder.h): with smooth sampling, zoom only through // levels where DWM's per-frame scratch rounding predicts under 1 px of movement (the zoom shake: // 11 px p95 -> 0.7 px measured at 10-25x). 0 = off (hot). int txSmoothLadder = 1; int mpoGuardTest = 0; // diagnostic: apply the MPO guard effect even on an MPO-off boot (hot) int mpoGuard = 1; // diagnostic: 0 = never apply the guard effect (A/B on an MPO boot; hot) - int mpoGuardLiftWall = 0; // 1 = no pan walls / write clamp while the guard is on (needs proof; hot) + int mpoGuardLiftWall = 1; // no pan walls / write clamp / ghost while the session is plane-free (hot) // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write // makes DWM redo work proportional to the zoom. Turning it on scored well in the automated diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 41e6e480..fbb6a071 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -418,8 +418,10 @@ TEST_CASE("the high-res/MPO option is atomic at restart (issue #242)") { // MPO nearest guard (issue #369): nearest may run on an MPO boot when the guard is on; a pending // restart still holds the boot look. CHECK(EffectiveSamplingMode(0, false, false, 0, true) == 0); - CHECK(EffectiveSamplingMode(0, false, true, 0, true) == 1); - CHECK(ParseConfig("").mpoNearestGuard == 0); + CHECK(EffectiveSamplingMode(0, false, true, 0, true) == 0); // no restart-pending hold + CHECK(EffectiveSamplingMode(1, true, false, 0, true) == 1); + CHECK(ParseConfig("").mpoNearestGuard == 1); + CHECK(ParseConfig("mpoNearestGuard=0\n").mpoNearestGuard == 0); CHECK(ParseConfig("mpoNearestGuard=1\n").mpoNearestGuard == 1); } diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index e4fea1b2..94814a8f 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -10,8 +10,8 @@ import './design/themes.css'; import { groups } from './settings-schema.js'; import { getSession, setConfig, setConfigPersist, saveSession, discardSession, openIni, - exportDiagnostics, openRepo, pickExe, windowControl, onMessage, getMpoState, - setMpoDisabled, rebootNow, setDirty, switchProfile, createProfile, deleteProfile } from './bridge.js'; + exportDiagnostics, openRepo, pickExe, windowControl, onMessage, + setDirty, switchProfile, createProfile, deleteProfile } from './bridge.js'; import { fill, changedKeys, GLOBAL_KEYS } from './session.js'; import { themes, normalizePalette } from './design/themes.js'; import { droppedBinds } from './lib/keybindRules.js'; @@ -75,25 +75,10 @@ loaded = true; } - // --- MPO (issue #164, #242) ----------------------------------------------------------------- - // High resolution cursor couples to the registry. The registry write needs UAC, so it runs the - // moment the toggle moves; a dismissed prompt reverts the toggle. mpoBoot is what DWM loaded at - // boot and alone decides whether a restart is required. - let mpoLive = $state(false), mpoBoot = $state(false), mpoKnown = $state(false); - let mpoRestartPrompt = $state(false), mpoFailed = $state(false); - const mpoNeedsRestart = $derived(mpoKnown && mpoLive !== mpoBoot); - async function syncMpo(prevTx) { - if (!mpoKnown) return; - const want = Number(values.txSamplingMode) !== 1; // crisp sampling -> MPO disabled - if (want === mpoLive) return; - const res = await setMpoDisabled(want); - mpoLive = res.disabled; - if (!res.ok || res.disabled !== want) { - mpoFailed = true; - // The ini half must not land alone: crisp sampling with MPO still on is the driver-crash combo. - values = { ...values, txSamplingMode: prevTx }; setConfig('txSamplingMode', prevTx); - } else if (res.disabled !== mpoBoot) mpoRestartPrompt = true; - } + // High resolution cursor no longer touches MPO (issue #369): smooth sampling and the MPO guard + // (an invisible colour effect while zoomed at nearest) both keep apps off hardware planes, so + // either look is safe with MPO on and the toggle applies live, with no registry write or restart. + const mpoNeedsRestart = false; // --- Changes -------------------------------------------------------------------------------- function change(key, val) { @@ -102,7 +87,6 @@ const prev = values[key]; values = { ...values, [key]: val }; setConfig(key, val); - if (key === 'txSamplingMode') syncMpo(prev); } // Keybind captures: live AND saved at once, so the hook stops swallowing the old binding and a // later Save or Discard cannot lose them. @@ -136,10 +120,6 @@ ]; (async () => { await load(); - const s = await getMpoState(); - mpoLive = s.disabled; mpoKnown = true; - // No record for this boot -> assume the registry is what DWM loaded. - mpoBoot = s.bootKnown ? s.atBoot : s.disabled; })(); return () => { offs.forEach((o) => o()); }; }); @@ -352,19 +332,6 @@ buttons={[{ label: 'Cancel', onClick: () => (deleteTarget = '') }, { label: 'Delete', kind: 'danger', onClick: () => { const name = deleteTarget; deleteTarget = ''; profileAction('delete', { name }); } }]} /> {/if} - {#if mpoRestartPrompt} - (mpoRestartPrompt = false)} - buttons={[{ label: 'Cancel', onClick: () => (mpoRestartPrompt = false) }, - { label: 'Restart now', kind: 'primary', onClick: () => { mpoRestartPrompt = false; rebootNow(); } }]} /> - {/if} - {#if mpoFailed} - (mpoFailed = false)} - buttons={[{ label: 'Close', kind: 'primary', onClick: () => (mpoFailed = false) }]} /> - {/if} {#if restartError} window.__live.model)).toBe('hybrid'); }); -test('High resolution cursor: the registry write follows the toggle and a dismissed UAC reverts it', async ({ page }) => { - await page.addInitScript(() => { - const orig = window.chrome.webview.postMessage; - window.chrome.webview.postMessage = (msg) => { - orig(msg); - if (msg.type === 'setMpoDisabled') - window.__hostSend({ type: 'mpoApplied', ok: false, disabled: false }); - }; - }); +test('High resolution cursor applies live: no MPO registry write, no restart prompt (#369)', async ({ page }) => { await page.goto('/'); await go(page, 'view'); const sw = page.locator('[data-key="txSamplingMode"]').getByRole('switch'); - await sw.check({ force: true }); // high resolution on: MPO already enabled, no registry write - expect(await sent(page, 'setMpoDisabled')).toHaveLength(0); - await sw.click({ force: true }); // crisp: needs MPO disabled, the admin prompt is dismissed - await expect(page.getByRole('dialog', { name: 'MPO change not applied' })).toBeVisible(); - expect(await sent(page, 'setMpoDisabled')).toHaveLength(1); + await sw.check({ force: true }); expect(await page.evaluate(() => window.__live.txSamplingMode)).toBe('1'); + await sw.click({ force: true }); + expect(await page.evaluate(() => window.__live.txSamplingMode)).toBe('0'); + expect(await sent(page, 'setMpoDisabled')).toHaveLength(0); + await expect(page.getByRole('dialog', { name: 'Restart to finish' })).toHaveCount(0); + await expect(page.locator('[data-key="txSamplingMode"]').getByText('Requires restart')).toHaveCount(0); }); test('the tray switching profile reloads the session', async ({ page }) => { From 443a096822b554f633f57f711d3ea6f827deff6a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:24:24 +0200 Subject: [PATCH 21/22] docs(cursor): state what the plane-free MPO lift has and has not been tested on (#369) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 3a79ed1b..a5b5a7a5 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -182,7 +182,8 @@ Defences (wall arming in `RunTick`, write clamp in `TransformModel::present`): no longer stages MPO or asks for a restart. `mpoNearestGuard=0` restores the old rule (`EffectiveSamplingMode` then keeps the boot state's mode until a reboot). - Plane-free sessions (`mpoGuardLiftWall=1`, default) also drop the pan walls, the write clamp and - the MPO ghost. Field-run on an MPO boot at up to 31x, far right and bottom: no driver reset. + the MPO ghost. In daily use on an MPO boot (up to 31x) without a driver reset so far; the + far-right/bottom corner above ~9.3x has not been tested deliberately yet. - `tdrTest` is the field harness: 2 probes the clamp, 4 lifts the wall. - **MPO nearest guard** (`src/mpo_guard.h`, issue #369). Zoomed at nearest on an MPO boot, Wind applies an invisible colour effect (0.998 on R, G, B). A colour transform, like the resample From 9cec9d4ef104336e6681e1a06809c0bc11c6eb3a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:28:14 +0200 Subject: [PATCH 22/22] docs(cursor): MPO guard field-tested in the far corner above 10x (#369) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index a5b5a7a5..7b404b93 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -182,8 +182,8 @@ Defences (wall arming in `RunTick`, write clamp in `TransformModel::present`): no longer stages MPO or asks for a restart. `mpoNearestGuard=0` restores the old rule (`EffectiveSamplingMode` then keeps the boot state's mode until a reboot). - Plane-free sessions (`mpoGuardLiftWall=1`, default) also drop the pan walls, the write clamp and - the MPO ghost. In daily use on an MPO boot (up to 31x) without a driver reset so far; the - far-right/bottom corner above ~9.3x has not been tested deliberately yet. + the MPO ghost. Field-tested 2026-10-07 on an MPO boot (RTX 5090): nearest with the guard, panned + into the far-right and bottom-right corner above 10x, no driver reset; daily use up to 31x. - `tdrTest` is the field harness: 2 probes the clamp, 4 lifts the wall. - **MPO nearest guard** (`src/mpo_guard.h`, issue #369). Zoomed at nearest on an MPO boot, Wind applies an invisible colour effect (0.998 on R, G, B). A colour transform, like the resample