diff --git a/CLAUDE.md b/CLAUDE.md index 49e09ea1..8fa87c92 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,13 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. never reload. Add new UI-only keys there. **Cursor (07)** +- In a native-cursor session the lock (`lockApps`, tells) applies only while the pointer is hidden + (`LockApplies`); gates read `t.lockEff`, not `t.detector.locked()`. See 07. +- Native cursor (`txNativeCursor=1`, both sampling modes; `src/native_cursor.h`): DWM draws the real pointer via + Wind's cursor lens (a hidden `WC_MAGNIFIER` window, built on the owner thread at idle) and + centres the view itself (`SetFullscreenMagnifierOffsetsDWMUpdated`). While DWM centres, never + 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. @@ -81,8 +90,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 @@ -113,6 +123,9 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. for the process, not `taskkill`. - Program Files is read-only for the runtime: resolve the ini with `wind::ResolveIniPath()`, logs with `ResolveLogDir`, and keep the explicit WebView2 user-data folder. Never write next to the exe. +- An unreadable ini is not a missing one (another process may be mid-replace). Read the live ini for + a read-modify-write with `wind::ReadLiveIni` and stop when it fails; never write defaults over an + existing file. See 08. ## Toolchain and workflow - Visual Studio is a prerelease channel here; `build.bat` calls vswhere with `-all -prerelease`. diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index 1c75d091..8f9ce28d 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -94,6 +94,8 @@ There is no settings IPC. `WindConfig.exe` writes `magnifier.ini` and the core n kernel transition 144 times a second for a file a human changes. Without a watch handle the loop falls back to a ~1 s timed poll. - Only a changed mtime (`ConfigMTime`) proceeds to a reload. +- An unreadable ini (another process mid-replace) keeps the running settings: the mtime is not + taken and `t.configRetry` re-checks on the next poll. See [08](08-config-profiles.md). **UI-only writes never reload.** A reload rebuilds `ZoomController`, which collapses an active zoom to 1x. `StripUiOnlyKeys` (`src/config.cpp`) drops `uiTheme`, `uiPalette`, `showAdvanced` and diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 5e24020c..7b404b93 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)). 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 @@ -156,17 +177,31 @@ 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-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 + 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 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 @@ -174,9 +209,22 @@ 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. -- 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. + with up to 3 retries. +- 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. +- **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. - 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 27530e05..de782617 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -81,10 +81,67 @@ 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 + +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 + `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. +- **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`). +- **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. +- **No write without its nudge (#381).** The nudge is skipped while a mouse button is held (it made + some clicks fail), so a pan write during a held click would leave Wind's centre on screen until the + next cursor event: drag-selects and held clicks shook. Pan-only writes are held for the click + window instead (`HoldWriteForClick`); level changes and forced writes still go out. +- `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. +- 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** (`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 (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. - **`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 @@ -133,8 +190,16 @@ mickeys. `LockDetector` (`src/lock_detector.*`, pure) decides, with hysteresis. - **A clip is a lock signal only when meaningfully smaller than the monitor.** A machine-wide work-area clip (desktop minus taskbar, ~95%) is common; any-clip ran every desktop session locked. - Tick counts derive from the refresh rate (`setTickRate`). -- **Forced locks go through the detector** (`seedLock()`), because the free-cursor gate reads - `t.detector.locked()`; a tick-local flag once left the view pinned to the warped pointer. +- **Forced locks go through the detector** (`seedLock()`), so the lock persists across ticks; a + tick-local flag once left the view pinned to the warped pointer. +- **A shown pointer is a free pointer in a native-cursor session** (`LockApplies`, + `src/native_cursor.h`; the tick's result is `t.lockEff`, which every gate reads). The locked path + pans from raw mickeys and re-parks the real pointer once per tick; the sprite hid that, but the + native cursor IS the real pointer, so DWM drew it wherever the hand had moved it between ticks. + Field case: DOOM: The Dark Ages menus under `lockApps` (2026-10-07), measured at 4.7x as 22 px + spread slow and 74 px medium with jumps to 118 px, against 0-2 px free. Games show the pointer + in menus and hide it for mouselook, so the lock applies only while `GetCursorInfo` reports it + hidden. The sprite path keeps the old rule. The log line is `lock pointer shown: free`. - `lockForce=1` locks everywhere, for diagnosis only: locked mode has no ballistics or drag-follow. ```mermaid @@ -249,9 +314,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/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 8440fbb3..47d41ca9 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -17,6 +17,18 @@ others are not). - Every writer uses `wind::WriteTextFileAtomic` (`src/profiles_io.h`): write a temp file, then `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`. The temp name embeds the process id, so two writers never clobber each other's temp file. +- **Around each replace the name briefly refuses opens.** Measured 2026-10-07: during a burst of + replaces ~1% of reads failed with `ERROR_ACCESS_DENIED` (the replaced file is delete-pending), and + a replace fails while a reader holds the file. So `ReadTextFileOk` and `WriteTextFileAtomic` + retry sharing and access errors until a deadline (250 ms; the core's tick reads with 20 ms and + re-checks on its next poll), and reads share delete so they never block a replace. +- **An unreadable ini is never a missing one.** Before this, a failed open in `LoadConfig` wrote the + defaults over the user's file, and a failed `ReadTextFile` returned "" to read-modify-write + callers. Field: dragging the tray's Night light slider mid-zoom made the core reload defaults + (zoom keys unbound, so the zoom stuck; `onboarded=0`, so the next start opened the setup). + `LoadConfig` creates the defaults only for a missing file; the hot-reload keeps its settings and + retries (`config ini unreadable on reload`); writers that read the live ini first use + `ReadLiveIni` and stop when it fails. - The only cross-process kernel objects are the single-instance mutexes and the `Local\Wind_QuitRequest` event (quit, restart handshake, installer). A window message would not work: UIPI drops `PostMessage` from a normal process to a UIAccess one. diff --git a/src/config.cpp b/src/config.cpp index 74297d63..1cfdd4cc 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -94,11 +94,14 @@ int EffectiveGpuPriority(const Config& c) { } int EffectiveSamplingMode(int iniValue, bool mpoDisabledAtBoot, bool mpoDisabledInRegistry, - int tdrTest) { + 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) 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; } @@ -234,6 +237,13 @@ 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 == "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 == "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); @@ -628,20 +638,37 @@ std::string DefaultIniText() { // --- File I/O (excluded from the pure test build via WIND_TESTS) ------------ #include #include +#include "profiles_io.h" // ReadTextFileOk / WriteTextFileAtomic: retry through the replace window +#include "logging.h" namespace wind { -Config LoadConfig(const std::wstring& path) { - std::ifstream f(path); - if (!f) { - // Write defaults so the user has something to edit, and run with exactly what was +bool TryLoadConfig(const std::wstring& path, Config& out) { + std::string text; + if (!ReadTextFileOk(path, text)) { + // NEVER write the defaults over an ini that exists (field 2026-10-07): an open that failed + // while the tray replaced the file took this branch and reset every setting mid-zoom. + if (GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES) return false; + const DWORD e = GetLastError(); + if (e != ERROR_FILE_NOT_FOUND && e != ERROR_PATH_NOT_FOUND) return false; + // Missing: write defaults so the user has something to edit, and run with exactly what was // written (issue #274): returning Config{} here let the template and the struct // defaults drift apart silently - the cursorScaleWithZoom trap in another form. - const std::string text = DefaultIniText(); - std::ofstream out(path); - out << text; - return ParseConfig(text); + text = DefaultIniText(); + WriteTextFileAtomic(path, text); + out = ParseConfig(text); + return true; } - std::string text((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - return ParseConfig(text); + // An ini with no settings at all is a half-written or truncated file, not a user's choice. + if (text.find('=') == std::string::npos) return false; + out = ParseConfig(text); + return true; +} +Config LoadConfig(const std::wstring& path) { + Config c; + if (TryLoadConfig(path, c)) return c; + // Unreadable at startup: run on the defaults in memory, leave the file alone. + wind::Log(wind::LogLevel::Warn, "config", "magnifier.ini unreadable at load (err=%lu); running on defaults, file untouched", + GetLastError()); + return ParseConfig(DefaultIniText()); } unsigned long long ConfigMTime(const std::wstring& path) { WIN32_FILE_ATTRIBUTE_DATA d{}; diff --git a/src/config.h b/src/config.h index 5a2d9289..984cbdeb 100644 --- a/src/config.h +++ b/src/config.h @@ -385,6 +385,25 @@ 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): 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; + // 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 = 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 = 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 @@ -649,8 +668,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 @@ -673,6 +694,10 @@ double OutlineDwellSeconds(bool inBand, double prevSeconds, double dt, double th // The first-run ini text; LoadConfig writes it and returns ParseConfig of it (issue #274). std::string DefaultIniText(); Config LoadConfig(const std::wstring& path); +// Hot-reload form: false when the ini exists but could not be read (another process is replacing +// it, or it reads empty). The caller keeps its current Config and tries again later. Only a +// MISSING ini is ever (re)created with the defaults. +bool TryLoadConfig(const std::wstring& path, Config& out); // I/O: last write time as a comparable tick count; 0 if missing. unsigned long long ConfigMTime(const std::wstring& path); } diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index 90ed2e46..1eace8fa 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -212,7 +212,8 @@ static std::string DoSwitchProfile(const std::string& name) { std::string profText; if (!wind::ReadTextFileOk(pp, profText)) return "Could not read the profile file"; { std::string terr = wind::ProfileTextError(profText); if (!terr.empty()) return terr; } - const std::string oldLive = ReadFileUtf8(IniPath()); + std::string oldLive; + if (!wind::ReadLiveIni(IniPath(), oldLive)) return "Could not read the config file"; // Capture hand edits (openIni) into the outgoing profile before the live ini is replaced. wind::MirrorLiveToActiveProfile(IniPath(), oldLive); const std::string newLive = wind::MakeLiveText(profText, oldLive, name); @@ -346,8 +347,9 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { // failed write (AV lock, a sharing violation on the replace) used to vanish - the page // showed the new value while the ini kept the old one. Tell the page, which says so. // setConfig writes the live ini (the session) only; the profile file changes on Save. - if (!WriteFileAtomic(IniPath(), - wind::UpdateIniText(ReadFileUtf8(IniPath()), key, value))) { + std::string live; + if (!wind::ReadLiveIni(IniPath(), live) || + !WriteFileAtomic(IniPath(), wind::UpdateIniText(live, key, value))) { wind::Log(wind::LogLevel::Warn, "config", "setConfig: writing %s=%s failed", key.c_str(), value.c_str()); wv->PostWebMessageAsJson( @@ -523,8 +525,9 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { if (err.empty() && wind::SameProfileName(vals["profile"], from)) { // The pointer update must land or the live ini names a file that no longer exists; // verify the write and roll the rename back if it failed. - if (!wind::WriteTextFileAtomic(IniPath(), - wind::UpdateIniText(ReadFileUtf8(IniPath()), "profile", to))) { + std::string live; + if (!wind::ReadLiveIni(IniPath(), live) || + !wind::WriteTextFileAtomic(IniPath(), wind::UpdateIniText(live, "profile", to))) { MoveFileExW(ProfilePath(to).c_str(), ProfilePath(from).c_str(), 0); err = "Could not update the config file; rename undone"; } diff --git a/src/mag_host.cpp b/src/mag_host.cpp index df90809a..49d99688 100644 --- a/src/mag_host.cpp +++ b/src/mag_host.cpp @@ -62,17 +62,23 @@ 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( + u32 ? GetProcAddress(u32, "SetFullscreenMagnifierOffsetsDWMUpdated") : nullptr); } return initialized_; } 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 @@ -96,6 +102,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 +206,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..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 @@ -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..3d127a26 100644 --- a/src/magnifier_model.h +++ b/src/magnifier_model.h @@ -33,6 +33,12 @@ 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; + // 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 42e111de..ca328c45 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,6 +31,9 @@ #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 "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" @@ -291,7 +294,10 @@ struct TickState { CursorMapper mapper; LockDetector detector; // free vs game-locked cursor bool prevDetLocked = false; // edge-log the detector state (issue #221) + bool lockEff = false; // the lock that APPLIES this tick (LockApplies, native_cursor.h) + bool lockFreed = false; // locked, but freed by a shown pointer (edge-logged) std::string lastCoreIni; // stripped ini fingerprint (skip UI-only reloads) + bool configRetry = false; // last reload found the ini unreadable: check again POINT lastSetVirtual{}; // MEASURED post-present pointer position (virtual px), the // baseline for the next tick's hand delta (issue #169: never // assume the weld landed - measure) @@ -392,7 +398,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) @@ -868,7 +875,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 @@ -882,9 +890,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 = 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); + 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 @@ -1089,6 +1105,7 @@ static void RunTick(TickState& t) { t.sinceCheck += rawDt; if (t.sinceCheck >= 0.25) { t.sinceCheck = 0.0; + if (t.configRetry) checkConfig = true; // the change that failed to read is not re-notified if (WaitForSingleObject(t.configWatch, 0) == WAIT_OBJECT_0) { checkConfig = true; // Re-arm for the next change. If that fails (e.g. the watched dir vanished), close @@ -1107,20 +1124,40 @@ static void RunTick(TickState& t) { if (checkConfig) { unsigned long long m = ConfigMTime(t.iniPath); if (m != t.lastMtime) { - t.lastMtime = m; // Skip the reload when only UI-owned keys changed (uiTheme/uiPalette/showAdvanced/onboarded): // the settings app writes those, the core never reads them, and the reload below // resets the ZoomController - a theme toggle mid-zoom collapsed the zoom to 1x. - std::string stripped = wind::StripUiOnlyKeys(wind::ReadTextFile(t.iniPath)); - if (t.lastCoreIni.empty() || stripped != t.lastCoreIni) { + // An UNREADABLE ini (another process mid-replace, see ReadTextFileOk) is not a change: + // keep the running settings and look again on the next check (lastMtime not taken). + // A MISSING ini is recreated with the defaults, as at startup (TryLoadConfig). + std::string raw; + // Short budget: this is the tick thread, and an unreadable ini is simply re-read next poll. + bool readOk = wind::ReadTextFileOk(t.iniPath, raw, 20) && raw.find('=') != std::string::npos; + if (!readOk && GetFileAttributesW(t.iniPath.c_str()) == INVALID_FILE_ATTRIBUTES) { + Config fresh; + readOk = wind::TryLoadConfig(t.iniPath, fresh) && wind::ReadTextFileOk(t.iniPath, raw, 20); + } + std::string stripped = readOk ? wind::StripUiOnlyKeys(raw) : std::string(); + const bool loaded = readOk && (t.lastCoreIni.empty() || stripped != t.lastCoreIni); + Config nc; + if (loaded) nc = ParseConfig(raw); + if (!readOk) { + if (!t.configRetry) + wind::Log(wind::LogLevel::Warn, "config", "ini unreadable on reload, keeping the running settings"); + t.configRetry = true; + } else { + t.lastMtime = m; + t.configRetry = false; + } + if (loaded) { t.lastCoreIni = stripped; - Config nc = LoadConfig(t.iniPath); // Issue #242: the high-res/MPO option is atomic at restart - while an MPO restart is // pending (registry != boot) the BOOT state's look holds in both directions, and // 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 " @@ -1169,6 +1206,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)); 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 @@ -1344,6 +1383,25 @@ static void RunTick(TickState& t) { // freeze holds it at ~1.01 until the hold expires. const bool quiesceFreeze = QuiesceHoldActive(t) && t.zoom.level() > 1.0; if (!quiesceFreeze) t.zoom.tick(dt < kMaxZoomDt ? dt : kMaxZoomDt); + // 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(); + // AFTER the controller ticked: before it, level() still equals last frame's level, so the + // "still moving" test never fired and the ease-out always ran its slow tail (#375). + 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(); } + } + } + t.prevZoomHeld = held; + } // Recenter on a recenterVk key press (rising edge). bool recenter = false; bool recenterDown = comboHeld(t.cfg.recenterVk, t.cfg.recenterMods); // mods since #307 @@ -1427,7 +1485,7 @@ static void RunTick(TickState& t) { // Keyboard panning (#287): the hook swallows pan keys only while this is set, so at 1x // the pan keys reach the app (e.g. Ctrl+Alt+Left/Right = IntelliJ navigate back/forward). Mouselook games and Inspect // keep them too. Published once per tick, before anything reads the pan keys. - const bool panArmed = lvl > 1.001 && !inspect && !t.detector.locked(); + const bool panArmed = lvl > 1.001 && !inspect && !t.lockEff; g_input.setPanArmed(panArmed); if (!panArmed) t.keyPan.reset(); @@ -1677,6 +1735,25 @@ static void RunTick(TickState& t) { (locked && t.detector.warpLocked()) ? " (warp-anchor)" : "", lvl); t.prevDetLocked = locked; } + // A shown pointer is a free pointer in a native-cursor session (LockApplies): a game's + // menus keep DWM centring, its mouselook (pointer hidden) keeps the locked pan. + { + const bool nativeTx = wind::UseNativeCursor(t.cfg.txNativeCursor) && + dynamic_cast(t.model) != nullptr; + bool showing = false; + if (locked && nativeTx) { + CURSORINFO ci{}; ci.cbSize = sizeof(ci); + showing = GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING) && ci.hCursor != nullptr; + } + const bool applies = wind::LockApplies(locked, nativeTx, showing); + const bool freed = locked && !applies; + if (freed != t.lockFreed) + wind::Log(wind::LogLevel::Info, "lock", "%s lvl=%.2f", + freed ? "pointer shown: free (native cursor)" : "lock applies", lvl); + t.lockFreed = freed; + t.lockEff = applies; + locked = applies; + } if (locked) { // LOCKED pan at the TRUE desktop-cursor speed (issue: "cursor slower zoomed // in DOOM"). Not modelled - MEASURED: free ticks record the OS's own in->out @@ -1762,7 +1839,16 @@ 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 && + // 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())); t.mapper.setMaxSourceLeft(wallNeeded ? kMaxSafeTxMagnitude / lvl : -1.0); @@ -1770,8 +1856,8 @@ 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->setMpoExposed(mpoExposed); + 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 // Launch quiesce: per-tick cover tracking while zoomed (a mid-session takeover by a @@ -1802,7 +1888,7 @@ static void RunTick(TickState& t) { // the view solid; // - a mouselook game clips/recentres the pointer, which is exactly why the locked path // integrates raw deltas instead (issue #3 / #158). - const bool freeCursor = t.cfg.txFreeCursor != 0 && !inspect && !t.detector.locked() && + const bool freeCursor = t.cfg.txFreeCursor != 0 && !inspect && !t.lockEff && dynamic_cast(t.model) != nullptr; // SHELL INPUT PANEL REGIME (issue #283): while the emoji picker (or clipboard history, touch // keyboard) is open, the real pointer replaces the sprite (the shell composes its panels above @@ -1812,7 +1898,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); @@ -1855,12 +1944,12 @@ static void RunTick(TickState& t) { // fsCover (read once above, see the "Foreground facts for this tick" comment) is the // borderless-fullscreen-game tell; reused here rather than a second ForegroundCoversMonitor // call (it is also what fsGame below aliases). - const bool trackEnabled = lvl > 1.001 && !panel && !inspect && !t.detector.locked() && !fsCover && + const bool trackEnabled = lvl > 1.001 && !panel && !inspect && !t.lockEff && !fsCover && (t.cfg.trackCaret != 0 || t.cfg.trackFocus != 0); { wind::SpanScope span_(wind::kSpanTrack); g_track.setActive(trackEnabled, t.cfg.trackCaret != 0, t.cfg.trackFocus != 0, t.cfg.trackLog != 0); } if (t.cfg.trackLog) { // #326: why tracking is on or off, logged on every change const int bits = (lvl > 1.001 ? 1 : 0) | (panel ? 2 : 0) | (inspect ? 4 : 0) | - (t.detector.locked() ? 8 : 0) | (fsCover ? 16 : 0); + (t.lockEff ? 8 : 0) | (fsCover ? 16 : 0); if (bits != t.diagEnableBits) { t.diagEnableBits = bits; wind::Log(wind::LogLevel::Info, "track", "diag enabled=%d zoomed=%d panel=%d inspect=%d locked=%d fsCover=%d lvl=%.2f", @@ -1986,7 +2075,7 @@ static void RunTick(TickState& t) { t.lastSetVirtual = cur; r = wind::DetachedMap(t.viewCx, t.viewCy, px, py, lvl, t.mon.w, t.mon.h); t.viewDetached = edges; // edge mode keeps the view where it is - } else if (t.cfg.mouseAlign == 1 && lvl > 1.001 && !panel && !inspect && !t.detector.locked()) { + } else if (t.cfg.mouseAlign == 1 && lvl > 1.001 && !panel && !inspect && !t.lockEff) { // MOUSE EDGE MODE (issue #276 phase 2): the pointer roams freely inside the view and // the view moves only when it reaches the margin band, just far enough. No weld: // the pointer is real, so clicks are native. Mouselook (locked) and Inspect keep the @@ -2273,7 +2362,24 @@ 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. + { + wind::DwmCentreIn dc; + dc.zoomed = lvl > 1.001; + dc.freeCursor = freeCursor && !panel; + dc.viewDetached = t.viewDetached; + 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); + } // 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 @@ -2977,7 +3083,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 " @@ -3075,6 +3182,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)); tm->setSpriteCapturable(cfg.spriteCapturable != 0); model = std::move(tm); } else { @@ -3089,6 +3197,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)); tm2->setSpriteCapturable(cfg.spriteCapturable != 0); model2 = std::move(tm2); } 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/src/native_cursor.h b/src/native_cursor.h new file mode 100644 index 00000000..d7ef7588 --- /dev/null +++ b/src/native_cursor.h @@ -0,0 +1,125 @@ +#pragma once +// Native cursor (issue #369) - PURE, no , so it is unit-testable. +// +// 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 +// 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). +// 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. +inline bool UseNativeCursor(int txNativeCursor) { + return txNativeCursor != 0; +} + +// A VISIBLE POINTER IS A FREE POINTER (field video 2026-10-07, DOOM: The Dark Ages menus). lockApps +// and the lock tells put a session on the locked path: the view pans from raw mickeys and the weld +// re-parks the real pointer at the view's centre once per tick. With the sprite that was invisible - +// the sprite is drawn at the re-parked point. The native cursor is the REAL pointer, drawn by DWM +// wherever the hand has moved it between ticks, so the locked path made it wander around the centre +// and snap back every tick (measured at 4.7x: 22 px spread slow, 74 px medium, jumps to 118 px; +// free with DWM centring: 0 px). A game shows the pointer only where it is a pointer (menus, +// inventories, maps), and hides it for mouselook - the case the locked path exists for. So in a +// native-cursor session the lock applies only while the pointer is hidden; a shown pointer gets +// DWM centring, exactly what Windows Magnifier does there. The sprite path keeps the old rule. +inline bool LockApplies(bool locked, bool nativeCursor, bool pointerShowing) { + return locked && !(nativeCursor && pointerShowing); +} + +// When DWM may own the pan (DWM centring on). Only where the view is a pure function of the +// real pointer, centred on it - exactly what DWM computes. Everything else needs Wind's offsets. +struct DwmCentreIn { + 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; +} + +// 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; +} + +// 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 +// 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); +} + +// 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; +} + +// NO WRITE WITHOUT ITS NUDGE (issue #381, field report 2026-10-08: zoomed, holding the left button +// for a drag-select or a held click made the view shake; plain panning was fine). The nudge is +// skipped while a click is in progress (it made some clicks fail), but the write it belongs to +// still went out, so during a hold every changed tick put Wind's centre on screen and the next +// cursor event put DWM's back: the view alternated between the two. While DWM centres, a pan-only +// write is held for the click instead; DWM keeps centring on every cursor event, and the first tick +// after the click window writes and nudges as usual. Level changes and forced writes still go out +// (a zoom during a drag must not freeze, and a centring switch needs its write). +inline bool HoldWriteForClick(bool dwmCentring, bool clickInProgress, bool levelMoved, bool forced) { + return dwmCentring && clickInProgress && !levelMoved && !forced; +} + +} // namespace wind diff --git a/src/profiles_io.h b/src/profiles_io.h index 703cf121..f94ba00b 100644 --- a/src/profiles_io.h +++ b/src/profiles_io.h @@ -43,12 +43,55 @@ inline std::vector ListProfileFiles(const std::wstring& dir) { [](const std::wstring& a, const std::wstring& b) { return _wcsicmp(a.c_str(), b.c_str()) < 0; }); return names; } +// REPLACE WINDOW (field 2026-10-07: tray slider drags reset the whole ini to defaults). The writers +// replace the ini atomically (WriteTextFileAtomic), but for a moment around each MoveFileEx the +// name refuses opens: measured on this rig, ~1% of reads during a burst of replaces failed with +// ERROR_ACCESS_DENIED (the replaced file is delete-pending), and a replace fails while a reader +// holds the file. A failed read used to look like an EMPTY or MISSING ini to every caller - the +// core's hot-reload then wrote the defaults over it (unbound zoom keys mid-zoom, onboarded=0, the +// setup at the next start). So reads and replaces retry through that window (sharing violations +// and access-denied on a file that exists) until a DEADLINE, not a count: a background process's +// Sleep(1) can last a whole 15.6 ms timer tick on Windows 11, so a count is no time bound. The core's +// tick thread reads with a short budget (it re-checks on its next poll anyway); the settings apps +// and the tray use the default. +inline bool TransientFileError(DWORD e) { + return e == ERROR_SHARING_VIOLATION || e == ERROR_ACCESS_DENIED || e == ERROR_LOCK_VIOLATION; +} // False when the file exists but could not be opened (locked, permissions) OR is missing; `out` is // only written on success. Callers that must distinguish "missing" pre-check GetFileAttributesW. -inline bool ReadTextFileOk(const std::wstring& path, std::string& out) { - std::ifstream f(path, std::ios::binary); - if (!f) return false; - std::stringstream ss; ss << f.rdbuf(); out = ss.str(); return true; +inline bool ReadTextFileOk(const std::wstring& path, std::string& out, unsigned waitMs = 250) { + const ULONGLONG deadline = GetTickCount64() + waitMs; + for (;;) { + HANDLE h = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) { + if (TransientFileError(GetLastError()) && GetTickCount64() < deadline) { Sleep(1); continue; } + return false; + } + std::string text; + char buf[16384]; + DWORD got = 0; + bool ok = true; + while ((ok = ReadFile(h, buf, sizeof(buf), &got, nullptr) != 0) && got > 0) text.append(buf, got); + CloseHandle(h); + if (!ok) { + if (GetTickCount64() < deadline) { Sleep(1); continue; } + return false; + } + out.swap(text); + return true; + } +} +// For READ-MODIFY-WRITE of the live ini: true with the text, or with "" when the file is MISSING; +// false when it exists but stays unreadable. A caller that writes back must stop on false - an +// unreadable ini read as "" and written back with one key changed is every other setting lost. +inline bool ReadLiveIni(const std::wstring& path, std::string& out) { + if (ReadTextFileOk(path, out)) return true; + if (GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES) return false; + const DWORD e = GetLastError(); + if (e != ERROR_FILE_NOT_FOUND && e != ERROR_PATH_NOT_FOUND) return false; + out.clear(); + return true; } inline std::string ReadTextFile(const std::wstring& path) { std::string out; @@ -62,11 +105,16 @@ inline bool WriteTextFileAtomic(const std::wstring& path, const std::string& tex { std::ofstream f(tmp, std::ios::binary | std::ios::trunc); if (!f) return false; f.write(text.data(), (std::streamsize)text.size()); } - if (!MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + // A reader holding the ini open makes the replace fail for a moment (see ReadTextFileOk). + const ULONGLONG deadline = GetTickCount64() + 250; + for (;;) { + if (MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return true; + const DWORD e = GetLastError(); + if (TransientFileError(e) && GetTickCount64() < deadline) { Sleep(1); continue; } DeleteFileW(tmp.c_str()); + SetLastError(e); return false; } - return true; } // Live-bound contract, switch-time half: capture the CURRENT live settings into the OUTGOING // profile's file before a switch overwrites the live ini. The setConfig mirror covers every write @@ -91,8 +139,9 @@ inline void EnsureProfilesSeeded(const std::wstring& iniPath) { std::wstring dir = ProfilesDirFromIni(iniPath); if (GetFileAttributesW(dir.c_str()) != INVALID_FILE_ATTRIBUTES) return; if (!CreateDirectoryW(dir.c_str(), nullptr)) return; - std::string live = ReadTextFile(iniPath); - if (!WriteTextFileAtomic(dir + L"\\Default.ini", MakeProfileText(live))) { + std::string live; + if (!ReadLiveIni(iniPath, live) || + !WriteTextFileAtomic(dir + L"\\Default.ini", MakeProfileText(live))) { RemoveDirectoryW(dir.c_str()); // dir is still empty; retry the whole seed next launch return; } @@ -135,7 +184,8 @@ inline void ResetSessionToProfile(const std::wstring& iniPath) { DeleteFileW(keep.c_str()); return; } - std::string live = ReadTextFile(iniPath); + std::string live; + if (!ReadLiveIni(iniPath, live)) return; auto vals = ReadIniValues(live); auto it = vals.find("profile"); if (it == vals.end() || it->second.empty()) return; diff --git a/src/transform_model.cpp b/src/transform_model.cpp index c78365b6..f93f96cf 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -8,6 +8,8 @@ #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, NudgeAfterWrite (pure, tested): issue #369 +#include "zoom_ladder.h" // SnapSmoothLevel (pure, tested): issue #369 #include #include #include @@ -34,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); } @@ -45,6 +62,10 @@ 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 + 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; @@ -90,7 +111,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 +122,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; + NudgePointer(np); + } + 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 +289,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 +375,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 +385,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 +416,16 @@ 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). 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; + NudgePointer(np); + } 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 +460,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 @@ -407,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) } @@ -437,6 +500,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; + NudgePointer(np); + } RECT full{ 0, 0, mon_.w, mon_.h }; host_.setInputTransform(false, full, full); // input mapping back to identity at 1x step(6); @@ -458,6 +529,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. @@ -546,12 +638,48 @@ 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. + // 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_; + } else if (lastLevel_ > 1.001 && std::fabs(applyLevel - lastLevel_) <= applyLevel * 1e-9) { + // The request IS the level on screen (RunTick stopped the ease-out there): never re-snap + // it, or the zoom jumps to a neighbouring clean level after it stopped (#375). + ladderReq_ = level; + ladderOut_ = lastLevel_; + applyLevel = lastLevel_; + } 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 + // 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); + // (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); + 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); srcL = o.x; srcT = o.y; } 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 @@ -568,28 +696,29 @@ 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 int wantSampling = cfg.txSamplingMode; + 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; + wantSampling, ok ? 1 : 0, sampleTries_); + appliedSampling_ = wantSampling; } } } 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 @@ -631,6 +760,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 +814,11 @@ 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_; + // A pan write while DWM centres must be followed by its nudge, which a click in progress + // forbids: hold it until the click window ends (HoldWriteForClick, issue #381). + const bool clickHold = HoldWriteForClick(dwmCentreOn_, ClickInProgress(), ci.levelMoved, forceWrite); + const bool writeNow = (ShouldWriteTransform(ci) || forceWrite) && !clickHold; if (writeNow) { lastOffX_ = m.offX; lastOffY_ = m.offY; lastTxX_ = m.txX; lastTxY_ = m.txY; @@ -703,7 +851,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) @@ -737,7 +887,17 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // 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; + if (NudgeAfterWrite(dwmCentreOn_, true)) { + POINT np; + NudgePointer(np); + } + forceWrite_ = false; } warmLevelJitter_ = false; // Input transform. Mode 1 (THE SHIPPED DEFAULT; field-verified 4x-20x, @@ -788,7 +948,12 @@ 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. + // 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; // srcL/srcT, not r.srcLeft/srcTop: when the ramp limiters make applyLevel != level @@ -828,6 +993,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; + NudgePointer(np); + } + if (ok) ixPubLevel_ = applyLevel; if (ok) { ixExpectedValid_ = true; ixExpectedOn_ = enable; @@ -935,6 +1107,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..c0238f78 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). + // 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,19 @@ 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 + 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 int lastClickX_ = 0, lastClickY_ = 0; diff --git a/src/tray_app/engine_dropdown.cpp b/src/tray_app/engine_dropdown.cpp index 5cf6e7fc..41815f86 100644 --- a/src/tray_app/engine_dropdown.cpp +++ b/src/tray_app/engine_dropdown.cpp @@ -63,9 +63,10 @@ bool SetMainEngine(const std::wstring& ini, int picked) { wind::Log(wind::LogLevel::Warn, "tray", "engine pick: relaunch FAILED (rc=%lld haveExe=%d); reverting model", static_cast(rc), (int)haveExe); DeleteFileW(wind::SessionKeepPath().c_str()); - const std::string cur = wind::ReadTextFile(ini); - wind::WriteTextFileAtomic(ini, oldModel.empty() ? wind::UpdateIniText(cur, Flyout::kEngineKey, "hybrid") - : wind::UpdateIniText(cur, Flyout::kEngineKey, oldModel)); + std::string cur; + if (wind::ReadLiveIni(ini, cur)) + wind::WriteTextFileAtomic(ini, oldModel.empty() ? wind::UpdateIniText(cur, Flyout::kEngineKey, "hybrid") + : wind::UpdateIniText(cur, Flyout::kEngineKey, oldModel)); Notify(L"Wind", L"Could not restart Wind; kept the current engine."); return false; } diff --git a/src/tray_app/tray_menu.cpp b/src/tray_app/tray_menu.cpp index 21d32c61..fb8e33c3 100644 --- a/src/tray_app/tray_menu.cpp +++ b/src/tray_app/tray_menu.cpp @@ -106,7 +106,12 @@ void SwitchToProfile(const std::wstring& ini, const std::wstring& nameW) { Notify(L"Wind", L"That profile's file looks corrupt; settings unchanged."); return; } } - const std::string oldLive = wind::ReadTextFile(ini); + std::string oldLive; + if (!wind::ReadLiveIni(ini, oldLive)) { + wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: live ini unreadable"); + Notify(L"Wind", L"Could not switch profile (config file is locked)."); + return; + } // Capture hand edits (openIni) into the outgoing profile before the live ini is replaced. wind::MirrorLiveToActiveProfile(ini, oldLive); const std::string newLive = wind::MakeLiveText(profText, oldLive, wind::NarrowUtf8(nameW)); diff --git a/src/version.h b/src/version.h index 499b3ffa..7d9f9426 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 23 -#define WIND_VER_PATCH 3 +#define WIND_VER_MINOR 24 +#define WIND_VER_PATCH 0 // 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/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). diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h new file mode 100644 index 00000000..c34f16c4 --- /dev/null +++ b/src/zoom_ladder.h @@ -0,0 +1,145 @@ +#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; +} + +// 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. +// 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). +// 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 stepRel = -1.0) { + if (want <= 1.001 || w <= 0 || h <= 0) return want; + 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; + if (dir < 0 && floorLevel > 0.0 && z > floorLevel) return false; + return true; + }; + const double step = want * 1e-5; + for (int pass = 0; pass < 2; ++pass) { + 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; + 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; +} + +// 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; +} + +// 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_config.cpp b/tests/test_config.cpp index 0211a395..fbb6a071 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -415,6 +415,14 @@ 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) == 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); } 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))); +} diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp new file mode 100644 index 00000000..44a45663 --- /dev/null +++ b/tests/test_native_cursor.cpp @@ -0,0 +1,88 @@ +#include "doctest.h" +#include "../src/native_cursor.h" + +using namespace wind; + +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() { + 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("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); +} + +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); +} + +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 +} + +TEST_CASE("A pan write is held while a click forbids its nudge (#381)") { + CHECK(HoldWriteForClick(true, true, false, false) == true); // held button, DWM centres: hold + CHECK(HoldWriteForClick(true, false, false, false) == false); // no click: write and nudge + CHECK(HoldWriteForClick(false, true, false, false) == false); // Wind owns the view: write + CHECK(HoldWriteForClick(true, true, true, false) == false); // a zoom during a drag still writes + CHECK(HoldWriteForClick(true, true, false, true) == false); // a forced write (centring switch) +} + +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 +} + +TEST_CASE("a shown pointer is free in a native-cursor session; mouselook keeps the lock") { + CHECK(LockApplies(true, true, true) == false); // game menu (lockApps): DWM centring + CHECK(LockApplies(true, true, false) == true); // mouselook, pointer hidden: locked pan + CHECK(LockApplies(true, false, true) == true); // sprite path keeps the old rule + CHECK(LockApplies(false, true, false) == false); // nothing locked +} diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp new file mode 100644 index 00000000..440e874a --- /dev/null +++ b/tests/test_zoom_ladder.cpp @@ -0,0 +1,94 @@ +#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 * (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); + 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); + 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); +} + +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)); +} + +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); +} + +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)); + } +} + +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); +} + +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); +} 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 }) => {