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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,15 @@ Chapter numbers refer to `docs/architecture/NN-*.md`.
- A clip is a lock signal only when under 90% of the monitor (`ClipRectConfines`); work-area clips
are common.
- Tracking never moves the pointer: detached views (`t.viewDetached`); the pointer comes to the view
on a mouse move. Never poll the Java Access Bridge; load only Authenticode-signed bridge DLLs.
on a mouse move. Never poll the Java Access Bridge at a fixed rate (reads follow bridge events,
a window switch or a backed-off retry); load only Authenticode-signed bridge DLLs.
- Shell input panels need nothing special in the transform engine: DWM draws its pointer above them.
Do not bring back pointer freezing or hook-thread view writes.

**Input (06)**
- Bound keys are swallowed by LL hooks with balanced down/up; release swallowed keys on teardown.
- The keyboard hook is the authority for bound-key state; the hook skips Wind's own injections
(`kWindInjectTag`).
- The keyboard hook is the authority for bound-key state; only the mouse hook skips Wind's own
injections (`kWindInjectTag`), the keyboard hook counts the Alt/Win mask key.
- Bind rules live in `src/keybind_rules.h` and `ui/src/lib/keybindRules.js`, both tested against
`tests/fixtures/keybind_cases.txt`: change both.
- LL hooks cannot block Raw Input, so bound keys still reach raw-input games. No driver-based fix.
Expand Down
11 changes: 4 additions & 7 deletions Wind.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<!-- uiAccess=true is REQUIRED for MagSetInputTransform, which routes mouse input
to the magnified element (fixes clicks/I-beam misaligning while zoomed). The
OS only honours this for a code-signed binary run from a secure location
(C:\Program Files\Wind). Build, then run tools\uiaccess_setup.ps1 (elevated)
to sign + deploy. The unsigned dev build in the repo will NOT launch with
this set ("A referral was returned from the server") - that is expected;
run the deployed copy. -->
<!-- The PLAIN manifest: asInvoker, uiAccess=false, so the build runs from anywhere
(build.bat, and always WindTray.exe). The uiAccess=true variant, needed for
MagSetInputTransform and the optional high z-order band, is Wind.uiaccess.manifest
(build.bat uiaccess; it only launches signed from C:\Program Files\Wind). -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
Expand Down
6 changes: 4 additions & 2 deletions docs/TRACKING-FINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@ follows only the terminal caret there.
- Java apps expose the caret only through the bridge (the built-in Magnifier does not follow them
either). Reads take 1–12 ms (outlier 134 ms) and work only while the Java window is active.
- UIPI drops the JVM's handshake messages to a UIAccess process, so Wind allows exactly the bridge
protocol's messages on the bridge's own hidden windows. Reads happen only after bridge callbacks,
never on the poll; hung Java windows (`IsHungAppWindow`) are skipped.
protocol's messages on the bridge's own hidden windows. Reads follow bridge callbacks and window
switches; there is no fixed-rate poll. While the last read found nothing, a backed-off retry
(250 ms doubling to 4 s, reset by any Java event or window switch) reads again. Hung Java windows
(`IsHungAppWindow`) are skipped.
- The client DLL and any `vcruntime140.dll` beside it must carry a valid Authenticode signature and
are held open against replacement. Wind enables the bridge in
`%USERPROFILE%\.accessibility.properties`, rewriting the file only after a clean read.
Expand Down
12 changes: 6 additions & 6 deletions docs/architecture/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,17 @@ build. See [11](11-build-test-release.md).
| Loop and session state | `main.cpp` (`wWinMain`, `RunTick`), `idle_policy.h`, `sched_priority.h`, `tick_stats.h` |
| Engine contract and pick | `magnifier_model.h`, `engine_pick.h`, `shell_desktop.h`, `launch_quiesce.h` |
| Render engine | `render_engine.*`, `render_model.*`, `render_shaders.h`, `hdr_info.*`, `hdr_scale.h`, `band_window.h`, `png_dump.*` |
| Transform engine | `transform_model.*`, `transform.*`, `mag_host.*`, `tx_warm.h`, `comp_pin.*`, `mpo_boot.h`, `native_cursor.h`, `zoom_ladder.h` |
| Colour | `color_filter.*`, `color_matrix.h`, `cursor_tint.*` |
| Input | `input_router.*`, `keybind_rules.h`, `pointer_binds.h`, `keyboard_pan.h`, `typing_key.h` |
| Transform engine | `transform_model.*`, `transform.*`, `mag_host.*`, `tx_warm.h`, `comp_pin.*`, `mpo_boot.h`, `mpo_guard.h`, `native_cursor.h`, `zoom_ladder.h`, `dwm_watch.*` |
| Colour | `color_filter.*`, `color_matrix.h`, `cursor_tint.*`, `cursor_tint_pixels.h` |
| Input | `input_router.*`, `keybind_rules.h`, `pointer_binds.h`, `keyboard_pan.h`, `typing_key.h`, `event_order.h`, `swallow_ledger.h` |
| Cursor and lock | `cursor_mapper.*`, `lock_detector.*`, `drag_follow.h`, `gain_learner.h`, `cursor_sprite.*`, `cursor_blanker.*`, `cursor_decode.*`, `sprite_layer.h`, `crosshair.*`, `cursor_lock.*`, `inspect_focus.h` |
| Tracking | `focus_track.*`, `view_target.h`, `view_glide.h`, `detached_view.h`, `edge_pan.h`, `caret_rect.h`, `track_filter.h`, `java_bridge*` |
| Tracking | `focus_track.*`, `view_target.h`, `view_glide.h`, `detached_view.h`, `edge_pan.h`, `caret_rect.h`, `track_filter.h`, `java_bridge*`, `focus_identity.h` |
| Zoom | `zoom_controller.*` |
| Config and profiles | `config.*`, `config_path.h`, `profiles.*`, `profiles_io.h` |
| Tray | `tray_host.*`, `tray_ipc.h`, `tray_items.*`, `tray_status.h`, `tray_app/` |
| Settings host | `config_ui/` (`main.cpp`, `ini_edit.*`, `mpo.h`, `wind_watchdog.h`, `webview_recover.h`) |
| Logging and diagnostics | `logging.*`, `test_telemetry.h`, `hitch_record.*`, `tick_span.h` |
| Installer support | `installer_state.h` (ParseVersion), `webview2_probe.h`, `version.h` |
| Logging and diagnostics | `logging.*`, `log_queue.h`, `test_telemetry.h`, `hitch_record.*`, `tick_span.h`, `com_util.h`, `reload_gate.h` |
| Installer support | `installer_state.h`, `webview2_probe.h`, `version.h`, `resource.h`, `wind.rc` |

Other top-level folders: `ui/` (Svelte settings app and Playwright tests), `tests/` (doctest),
`tools/` (deploy, release and measurement scripts, [12](12-instrumentation.md)), `installer/`
Expand Down
55 changes: 42 additions & 13 deletions docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ All state that feeds the view is read and written on the tick thread, in one pas
across threads makes the view and the cursor sample different instants, which shows as a visible
beat (the wobble class in [../NATIVE-MAGNIFIER-STOMP.md](../NATIVE-MAGNIFIER-STOMP.md)).

`RunTick` never sleeps or waits; the caller paces it (see [Pacing](#pacing)).
`RunTick` does not pace itself; the caller does (see [Pacing](#pacing)). It can still block
briefly: a config reload reads the ini with a short retry budget (up to 20 ms, see
[Config hot-reload](#config-hot-reload)), and the game pacing modes wait on a vblank inside it.

## The phases of a tick

Expand Down Expand Up @@ -52,13 +54,17 @@ flowchart TD
active at 1x (`active = zoomed || inspect`). Details in [07](07-cursor.md).
6. **Pan delta.** One of three regimes, see [below](#pan-delta-three-regimes).
7. **Foreground facts and the pan wall.** `GetForegroundWindow`, `ForegroundCoversMonitor` and the
borderless check are read once per tick into locals (`fgTick`, `fsCover`, `fgBorderless`), so
no two reads in one tick disagree. They feed the MPO pan wall
borderless check are read once into locals (`fgTick`, `fsCover`, `fgBorderless`) for the engine
pick, the instant switch and the pacing levers. Earlier phases of the same tick read the
foreground on their own (the `noSwallowApps` hook suspension, the `lockApps` match, the
activation pick, Inspect's entry), so those reads can differ from `fgTick` if the foreground
changes mid-tick. The locals feed the MPO pan wall
(`setMaxSourceLeft`/`setMaxSourceTop`, see [05](05-transform-engine.md)), the churny backstop,
the launch quiesce and the game pacing levers.
8. **Activation pick and instant switch.** On the idle-to-active edge the tick retargets to the
cursor's monitor when `multiMonitor=1` (and re-reads its refresh rate), then runs the engine
pick. The same pick runs every zoomed tick; a changed result hands over with controller and
monitor the session will use (the cursor's monitor when `multiMonitor=1`, else the primary one;
`multiMonitor` picks which monitor, not whether a changed geometry is noticed) in both engines,
re-reads its refresh rate, then runs the engine pick. The same pick runs every zoomed tick; a changed result hands over with controller and
mapper untouched. The switch needs a stable candidate for 350 ms and is frozen while a
transient overlay or the game-inspect focus stealer holds foreground. See [03](03-engines.md).
9. **Present.** The tick fills `PresentExtras` (`src/magnifier_model.h`): outline visibility and
Expand All @@ -82,7 +88,8 @@ flowchart TD
**Teardown and idle.** On the active-to-idle edge the overlay deactivates, the cursor is restored,
Inspect residue (clip, swallowed clicks, foreground steal) is cleaned and pending reveals are
cancelled. While idle the tick still calls `idleTick()` on the model, which is how the transform
releases its magnification context ~1.2 s after a zoom ends. The tick ends with the stuck-input
builds its magnification context and cursor lens at 1x and keeps them warm (#369, see
[05](05-transform-engine.md)); it no longer releases them after a zoom. The tick ends with the stuck-input
timeline and, under `diagnostics=1`, the 2 s frame-pacing window.

## Config hot-reload
Expand All @@ -95,33 +102,38 @@ 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.
- The read is `ReadTextFileOk` with a 20 ms retry budget, which can sleep on the tick thread.
- 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`,
**UI-only writes never reload.** A reload rebuilds `ZoomController` and `CursorMapper`, which is
wasted work and risks a visible hitch mid-zoom (the live level and mapper centre are carried over
since #234, so the view no longer collapses to 1x). `StripUiOnlyKeys` (`src/config.cpp`) drops `uiTheme`, `uiPalette`, `showAdvanced`,
`onboarded` and the five tray layout keys (`trayPerf`, `traySliders`, `traySliderOrder`,
`trayToggles`, `trayToggleOrder`), and the result is compared with the fingerprint of the last applied config
(`t.lastCoreIni`). An identical fingerprint skips the reload. The fingerprint is seeded at startup;
an empty one would make the first Settings write of a session reload.

A real reload re-binds the hook's buttons and swallowed keys (`g_input.setButtonBinds`/`setKeys`),
re-registers the hotkeys, invalidates the foreground cache, and rebuilds `ZoomController` and `CursorMapper` with the mapper's centre kept.
Engine-shaped keys (`model`) need a restart: they decide which models exist.
re-registers the hotkeys, invalidates the foreground cache, and rebuilds `ZoomController` (the live
zoom level is kept and clamped into the new `maxLevel`, #234) and `CursorMapper` (centre kept).
Engine-shaped keys (`model`) and the other restart-only keys need a restart, see
[08](08-config-profiles.md#hot-reload-and-the-ui-only-fingerprint).

## Pan delta: three regimes

| Regime | Source of truth | Delta |
|---|---|---|
| Free (desktop) | The OS cursor | `GetCursorPos - lastSetVirtual`, times `cursorSensitivity` |
| Locked (game holds the mouse) | Raw Input mickeys | `rawDx/rawDy * cursorSensitivity` |
| Locked (game holds the mouse) | Raw Input mickeys | `rawDx/rawDy * learned gain * cursorSensitivity` (`GainLearner`; plain `rawDx/rawDy * cursorSensitivity` when `lockedBallistics=0`) |
| Inspect (cursor frozen) | Raw mickeys x learned gain | `GainLearner::gainFor` with a sub-pixel carry |

- **Free** reads the cursor's own movement since Wind last placed it, so Windows' pointer
acceleration is already applied.
- **Locked** applies when `LockDetector` says a game owns the pointer, see [07](07-cursor.md). A
forced lock (`lockApps`, the `warpLock` zoom-in seed) goes through the detector
(`seedLock()`), because downstream gates read `t.detector.locked()`.
(`seedLock()`). Free ticks teach `GainLearner` the OS's real input-to-output pointer ratio per
speed, and the locked pan replays it, so a locked game pans at desktop-cursor speed.
- **Tracking** overrides the result afterwards: caret, focus or mouse-edge mode can detach the view
from the pointer (`t.viewDetached`), see [07](07-cursor.md).
- `ShouldDragFollow` (`src/drag_follow.h`) suspends the render engine's weld while a mouse button is
Expand All @@ -141,7 +153,7 @@ The main loop in `wWinMain` paces the tick:
| Active, no blocking present | High-resolution waitable timer at the detected refresh rate |
| Render, vsync (default) | `Present(1,0)` blocks to the refresh; the timer is skipped |
| Render, `dwmFlush=1` | `Present(0,0)`, then `DwmFlush()` after the tick |
| Transform | Always `DwmFlush`; an unpaced loop floods DWM's transform queue until the view lags |
| Transform | Always `DwmFlush` while zoomed (the write and the pointer land in one composite); an unpaced loop would flood DWM's transform queue until the view lags. Idle at 1x uses the timer or the idle sleep |
| Game pacing modes | Paced inside `RunTick` (vblank waits or the present accumulator) |

`DetectRefreshHz` reads the current monitor's real rate and is re-read on retarget. **Tick counts
Expand Down Expand Up @@ -184,6 +196,23 @@ composition. It does not help when the GPU is the bottleneck.

## Threads

The core process runs these threads. Named threads show up by name in WPA and debuggers.

| Thread | Name | Source | Job |
|---|---|---|---|
| Tick | `Wind tick` | `src/main.cpp` | The loop and every Magnification call. Highest priority, see above |
| Input hooks | `Wind input hooks` | `src/input_router.cpp` | LL mouse and keyboard hooks, Raw Input registration |
| Focus tracker | (WinEvent loop) | `src/focus_track.cpp` | Caret and focus tracking, own COM apartment |
| Focus lookup | `Wind focus lookup` | `src/focus_track.cpp` | One UIA focus query at a time, detached on timeout |
| Cursor swaps | `Wind cursor swaps` | `src/cursor_blanker.cpp` | System cursor hide, tint and restore swaps, off the tick |
| DWM watch | `Wind DWM watch` | `src/dwm_watch.cpp` | Polls for a dwm.exe restart once a second, bumps a generation |
| Log writer | `Wind log writer` | `src/logging.cpp` | Drains the log queue to disk (1000 ms wait) |
| Tray supervisor | (unnamed) | `src/tray_host.cpp` | Keeps `WindTray.exe` running, rate limited |
| Trace dump | (short lived) | `src/transform_model.cpp` | Writes the `txTrace=1` CSV at session end, below normal priority |

WindConfig has its own short-lived export thread, and the timer-polling threads (DWM watch, log
writer) wake on a timer even at 1x, a known cost of the idle design.

- **Hook thread** (`src/input_router.cpp`). LL hook callbacks must return fast or Windows evicts the
hook, and they stall system input while running, so they cannot share a thread that blocks in
`Present`. The hook thread sets atomics, counts mickeys and swallows bound keys. See
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/03-engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ whatever `TickState::model` points at:
| `setActive(bool)` | Show or hide the magnified view (render: layer alpha; transform: enable/disable the DWM transform). |
| `onActivate()` | Idle-to-active edge: grab a live frame, not a cached one. |
| `present(...)` | The per-tick draw, with the mapper's `MapResult`, the level, config, monitor and `PresentExtras`. |
| `idleTick()` | Every idle tick. The transform releases its Magnification context here, see [05](05-transform-engine.md). |
| `retarget(MonitorTarget)` | Render only, for `multiMonitor`. |
| `idleTick()` | Every idle tick. The transform builds its Magnification context and cursor lens here and keeps them warm, see [05](05-transform-engine.md). |
| `retarget(MonitorTarget)` | Both engines, at activation (`multiMonitor` picks the monitor; a changed resolution is followed either way). Render returns false across adapters. |

`model` in `magnifier.ini` takes `hybrid`, `render` or `transform` and is read once at launch, so
changing it restarts Wind. Anything else, including an old `model=magnify`, reads as `hybrid`.
Expand All @@ -40,7 +40,7 @@ changing it restarts Wind. Anything else, including an old `model=magnify`, read
|---|---|---|
| `hybrid` ("Auto") | `TickState` holds a `RenderModel` (`mRender`) and a `TransformModel` (`mTransform`) and points `model` at one per session | The default |
| `render` | DXGI Desktop Duplication + D3D11 onto a click-through, capture-excluded overlay (`src/render_engine.*`) | Sub-pixel pan, cursor in the same frame, shell coverage. [04](04-render-engine.md) |
| `transform` | The DWM fullscreen transform, no presents of its own (`src/transform_model.cpp`) | Games and protected video. [05](05-transform-engine.md) |
| `transform` | The DWM fullscreen transform, no presents of its own (`src/transform_model.cpp`) | Games, protected video and rotated outputs, plus the desktop when `desktopTransform=1` and the input transform is verified. [05](05-transform-engine.md) |

If the transform half fails to initialize, Auto logs a warning and runs render only; every pick
site checks `t.mTransform`.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/04-render-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ engage by default on the desktop.

## Multi-monitor and device loss

- `retarget` moves the engine to the cursor's monitor at zoom-in (`multiMonitor=1`). It validates
- `retarget` moves the engine to the session's monitor at zoom-in (the cursor's monitor with
`multiMonitor=1`, else the primary; the transform half is retargeted too). It validates
first: the output must be on the D3D device's adapter (`selectOutput` by GDI device name), or it
returns false and the session stays put. The fallible `ResizeBuffers` runs before the window
moves. The pipeline works in monitor-local pixels; the origin offset is applied only at
Expand Down
11 changes: 7 additions & 4 deletions docs/architecture/05-transform-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ catch-up snap.

- It caps up-steps only. A down clamp made a quick re-zoom start backwards (the session-start
bounce).
- `setActive(false)` writes identity outside `writeTransform`, so it sets `lastLevel_ = 1.0`
explicitly. Forgetting that is the same bounce.
- `setActive(false)` writes the rest level (identity, 1.0 as shipped; `txRestLevel`) outside
`writeTransform`, so it sets `lastLevel_ = restLevel_` explicitly. Forgetting that is the same
bounce.
- When the applied level trails the requested one, the source rect is recomputed for the applied
level, so geometry and level never disagree.

Expand Down Expand Up @@ -134,8 +135,10 @@ gives it back. Rules (`src/native_cursor.h`, tested):
- On only where the view is a pure function of the pointer and no armed MPO wall is within a tick
of the view (`NearWall`, a 64 source px margin: DWM's own pan is unclamped and could cross a wall
before Wind's next tick; above ~9.3x on a 3840 wide monitor, 15.8x on 2160 high).
- 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).
- While on, Wind still writes every changed tick (win32k's copy feeds pointer-framework
hit-testing) and each write is followed by a pixel-and-back nudge (`NudgeAfterWrite`); 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.
- When the export is missing or refuses the call, `dwmCentreBroken_` latches (one Warn) and
Expand Down
Loading
Loading