Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2e17567
feat(cursor): native cursor for High resolution cursor - DWM-drawn po…
Maxaubert Oct 7, 2026
daee1f2
feat(cursor): native cursor at both sampling modes; fix the smoothing…
Maxaubert Oct 7, 2026
edb6f53
fix(cursor): native pointer visible through zoom-in; zoom-shake exper…
Maxaubert Oct 7, 2026
1baef8f
revert(cursor): drop the nearest-while-zooming experiment; smooth is …
Maxaubert Oct 7, 2026
840f30d
fix(cursor): one centre during zoom - nudge after level writes while …
Maxaubert Oct 7, 2026
c5b1860
feat(cursor): MPO nearest guard - invisible colour effect keeps plane…
Maxaubert Oct 7, 2026
59bb605
fix(cursor): keep writing the view while DWM centres - taskbar hover …
Maxaubert Oct 7, 2026
8a7d5d1
feat(cursor): smooth-zoom ladder - zoom through levels DWM rounds cle…
Maxaubert Oct 7, 2026
3651e35
fix(cursor): no pointer nudge during a click; MPO guard A/B and wall-…
Maxaubert Oct 7, 2026
9e1ffc9
fix(cursor): DWM centring yields only near an armed MPO wall (#369)
Maxaubert Oct 7, 2026
0ffacdb
fix(cursor): smooth-zoom ladder no longer reads as an endless ramp (#…
Maxaubert Oct 7, 2026
37794b5
fix(cursor): smooth-zoom ladder keeps the middle of the screen still,…
Maxaubert Oct 7, 2026
0adad40
fix(cursor): no ghost or walls in plane-free sessions; zoom ladder ne…
Maxaubert Oct 7, 2026
f584930
fix(cursor): zoom ladder search window grows with zoom (#369)
Maxaubert Oct 7, 2026
fba3f08
fix(cursor): zoom ladder never settles after the key is released (#369)
Maxaubert Oct 7, 2026
a3158fa
fix(cursor): the zoom ease-out stops before a DWM rounding step (#369)
Maxaubert Oct 7, 2026
6c74a87
fix(cursor): smooth-zoom release stops at the level on screen (#369)
Maxaubert Oct 7, 2026
4e0f95c
feat(cursor): truncated ease-out keeps the user's zoom inertia with s…
Maxaubert Oct 7, 2026
ca53762
fix(cursor): hover follows the zoom ease-out (#369)
Maxaubert Oct 7, 2026
3be69ba
feat(settings): High resolution cursor applies live, no MPO staging o…
Maxaubert Oct 7, 2026
443a096
docs(cursor): state what the plane-free MPO lift has and has not been…
Maxaubert Oct 7, 2026
9cec9d4
docs(cursor): MPO guard field-tested in the far corner above 10x (#369)
Maxaubert Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -52,6 +54,11 @@ Chapter numbers refer to `docs/architecture/NN-*.md`.
never reload. Add new UI-only keys there.

**Cursor (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.
Expand Down Expand Up @@ -81,8 +88,9 @@ Chapter numbers refer to `docs/architecture/NN-*.md`.
stay 0); do not gate the sprite on "the view moved".
- Keep the 2 px right/bottom clamp and the 1-texel left/top floor in `ComputeMagTransform` (TDR and
grey-edge classes).
- MPO on + nearest sampling overflows a 16-bit driver field above ~9.3x at the far right: walls
always. Never offer nearest with MPO on.
- MPO on + nearest sampling overflows a 16-bit driver field above ~9.3x at the far right unless the
MPO guard (`src/mpo_guard.h`, default on) keeps apps off planes while zoomed. Never turn the guard
off with nearest on an MPO boot; walls come back only when it is off.
- `txWarmMode`/`txWarmHz`: every warm write is a full DWM re-render; composition-rate metrics miss
the pan-start hitch. Field-verify in a game.
- Publish the source-rect input transform on every change (needs UIAccess); identity or none gives
Expand Down
80 changes: 64 additions & 16 deletions docs/architecture/05-transform-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -156,27 +177,54 @@ 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
access-violates.
- 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.

Expand Down
65 changes: 59 additions & 6 deletions docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,63 @@ 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.
- `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
Expand Down Expand Up @@ -249,9 +302,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
Expand Down
14 changes: 12 additions & 2 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading