Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 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,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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Loading
Loading