diff --git a/CLAUDE.md b/CLAUDE.md index 792286c..cfb6261 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Wind.manifest b/Wind.manifest index 1ad9982..cbfa78c 100644 --- a/Wind.manifest +++ b/Wind.manifest @@ -3,13 +3,10 @@ - + diff --git a/docs/TRACKING-FINDINGS.md b/docs/TRACKING-FINDINGS.md index ffe3ee8..6c59dd4 100644 --- a/docs/TRACKING-FINDINGS.md +++ b/docs/TRACKING-FINDINGS.md @@ -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. diff --git a/docs/architecture/01-overview.md b/docs/architecture/01-overview.md index 9da975d..0200b61 100644 --- a/docs/architecture/01-overview.md +++ b/docs/architecture/01-overview.md @@ -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/` diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index d3b57bb..4205a71 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/docs/architecture/03-engines.md b/docs/architecture/03-engines.md index 00a8270..c645f28 100644 --- a/docs/architecture/03-engines.md +++ b/docs/architecture/03-engines.md @@ -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`. @@ -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`. diff --git a/docs/architecture/04-render-engine.md b/docs/architecture/04-render-engine.md index c4e55c9..39458ad 100644 --- a/docs/architecture/04-render-engine.md +++ b/docs/architecture/04-render-engine.md @@ -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 diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 6222249..5f42042 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -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. @@ -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 diff --git a/docs/architecture/06-input.md b/docs/architecture/06-input.md index 9283866..0ecbb4f 100644 --- a/docs/architecture/06-input.md +++ b/docs/architecture/06-input.md @@ -4,7 +4,7 @@ Wind never has keyboard focus, yet it must see every bind press and every mouse system-wide without breaking input for other programs. This chapter covers the input channels, the hook thread, the swallowing rules, the bind safety rules and the recovery from silently evicted hooks. The code is `src/input_router.*`, the `WM_INPUT` handling in `src/main.cpp`, -`src/keybind_rules.h` and `src/mouse_ballistics.*`. +`src/keybind_rules.h`, `src/swallow_ledger.h` and `src/gain_learner.h`. ## Channels @@ -44,15 +44,21 @@ Bound inputs are eaten so they never also fire in the focused app. - **Balanced down/up.** A DOWN of a bound input is swallowed and recorded (`g_swallowedDown`, `g_kbSwallowedDown`); an UP is swallowed only if its DOWN was. Swallowing an UP whose DOWN the system saw leaves the input held system-wide (the stuck side-button bug, issue #113). -- **No stranded keys.** Records are cleared on every remap (`setButtonBinds`, `setKeys`), and teardown - runs `ReleaseSwallowedButtons`/`ReleaseSwallowedKeys`, which synthesize the missing UP. +- **No stranded keys.** Key records are cleared on a key remap (`setKeys`; `setPanKeys` keeps the + swallow records). Button records are NOT cleared by a remap (`setButtonBinds`, #301): an UP whose + DOWN was swallowed must still be swallowed, or the app sees a lone XBUTTONUP (browser Back). + Each record clears on its own UP. Teardown drops the button records without a synthetic UP (a + lone UP has effects of its own); `ReleaseSwallowedKeys` does synthesize a KEYUP for a swallowed + key, because a lone key-up is harmless. - **Decide once per press.** A key bind is swallowed only when a bind on that key has all its modifiers held (`keyBindMatches`). A VK-only test once ate a plain F1 system-wide for a Ctrl+F1 bind. - **Mask key with Alt or Win.** Swallowing a key while Alt or Win is held injects one mask key (VK 0xE8); otherwise Windows sees the modifier tapped alone and opens Start or the app's menu bar. -- **Wind's own injections** carry `kWindInjectTag` in `dwExtraInfo` and are skipped by the bind - matcher. Other injectors (AutoHotkey) count as real input. +- **Wind's own injections** carry `kWindInjectTag` in `dwExtraInfo`. Only the mouse hook skips + them (Inspect's injected click also carries `LLMHF_INJECTED`). The keyboard hook does not check + the tag, so the mask keystroke counts as a key event there. Other injectors (AutoHotkey) count + as real input. - Hide pointer and hotkey-mode quick zoom are suppressed by `RegisterHotKey`, not the hook. ## Bind rules diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index d7ebb99..572b19e 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -112,8 +112,10 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: `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 + Wind still writes every changed tick (win32k's copy of the view, which pointer-framework + hit-testing reads, only changes on a client write) and sends no warm pulses. Each write is followed + by a pixel-and-back cursor nudge (`NudgeAfterWrite`) so DWM re-centres by its own rule in the same + frame; a pan write is held while a click is in progress (`HoldWriteForClick`, #381). 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 @@ -132,8 +134,8 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: - **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. + DWM centres, every write is followed by a pixel-and-back nudge, so DWM re-centres by its own + rule in the same frame (`NudgeAfterWrite`). 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 @@ -188,7 +190,7 @@ mickeys. `LockDetector` (`src/lock_detector.*`, pure) decides, with hysteresis. | Tell | Rule | When | |---|---|---| | Confined clip | `ClipCursor` rect under 90% of the monitor in either dimension (`ClipRectConfines`) | Always | -| Raw active, cursor frozen | 6 ticks lock (`kLockTicks`); 3 ticks of the cursor tracking input unlock (`kFreeTicks`) | Always | +| Raw active, cursor frozen | 42 ms lock (`kLockMs`, 6 ticks at 144 Hz); 21 ms of the cursor tracking input unlock (`kFreeMs`, 3 ticks) | Always | | Warp anchor | Jumps of 100 px or more landing within 6 px of one anchor, repeatedly; a recent landing blocks unlocking | `warpLock=1` | | Confinement box | 400+ mickeys in ~170 ms while every cursor position stays in a 30 px box | `warpLock=1` | | Hidden-cursor seed | Zoom-in over a covering foreground whose app already hid the cursor calls `seedLock()` | `warpLock=1` | diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 062e380..4ee26b9 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -80,8 +80,12 @@ text is unchanged. `profile` stays in the fingerprint, so a profile switch reloa **Hot or restart follows from how a value is read.** A key read from `t.cfg` per tick or per zoom-in is hot. A key baked into state at initialization needs a restart: `model` (which engines -exist), `gpuPriority` (device build), `zorderBand` and `cursorBandAuto` (overlay and Inspect -crosshair creation). The comment on each `Config` field says which. +exist), `zorderBand` and `cursorBandAuto` (overlay and Inspect crosshair creation), `hdrTonemap` +(render engine init), `fastPan` and `smoothPan` (passed to the `TransformModel` constructor), and +`gpuPriority`, which is only half hot: the device build applies it at init, but the game-pacing +decision in `RunTick` reads it live, so a change mid-session can mismatch the two until a restart. +The comment on each `Config` field says which. `lowGpuPriority` is a legacy alias: `gpuPriority` +wins when set, else `lowGpuPriority=1` means `gpuPriority=-1` (`EffectiveGpuPriority`). Keys of features that were removed (the old sprite cursor and its experiments, the hook write path, the write-rate and level gates, warm modes 2-4, the composite pulse pacing, the wobble cage) are @@ -120,8 +124,8 @@ comments and order. 1. Read and check the profile with `ProfileTextError`, which rejects binary, oversized or unparseable text. A read failure is distinct from an empty file; treating a locked file as empty would reset the user to defaults. -2. Settings asks Save, Discard or Cancel when the session has unsaved changes. The tray does not - prompt. +2. Settings asks Save, Discard or Cancel when the session has unsaved changes. The tray flyout + asks too, through its own `ConfirmSwitch` prompt (`src/tray_app/`). 3. Write `MakeLiveText(profile, oldLive, name)` over the live ini. 4. The core hot-reloads everything except `model`. When the parsed `model` differs, the surface relaunches `Wind.exe`. If the relaunch fails, it writes the old `model` back, so the ini always @@ -201,6 +205,18 @@ Every key works in the ini whether or not Settings shows it. Keys hot-reload unl `showAdvanced`, `trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`. `uiTheme` is a legacy key, ignored. -**Diagnostics.** `diagnostics=1` writes the frame-pacing log; the rest is in -[12](12-instrumentation.md). Transform tuning keys (`tx*`, `ixDecimate`, `mpoBuster`, `tdrTest`) -are documented on their `Config` fields in `src/config.h`. +**Transform and diagnostics.** Hot unless noted; the full text is on the `Config` field. + +| Keys | Meaning | +|---|---| +| `edgeClip` (1) | While a transform session is zoomed, `ClipCursor` 1 px inside the monitor keeps the pointer off the contested outermost pixels (edge cursor-shape flicker); 0 lets the pointer reach the corner pixel. The trade-off is recorded at `Config::edgeClip` in `src/config.h` | +| `lockedBallistics` (1) | Locked games pan with the learned input-to-output gain (`GainLearner`); 0 pans with plain raw mickeys | +| `smoothPan` (0, restart) | Hold the display composited while zoomed (a 1 px pin) so flip-model games keep DWM's pan path | +| `fastPan` (1, restart) | Pan through the private `SetMagnificationDesktopMagnification` channel (finer than the public write) | +| `mpoGuard` (1), `mpoGuardLiftWall` (1), `mpoGuardTest` (0) | The MPO guard effect, whether it lifts the pan walls while plane-free, and a diagnostic that applies it on an MPO-off boot | +| `txRestLevel` (1.0) | Level the transform rests at when idle; above 1.0 parks a hair off identity (costs a composed desktop) | +| `txTrace` (0) | 1 records a per-tick trace and writes `txtrace-.csv` to the log folder at each session end | + +`diagnostics=1` writes the frame-pacing log; the rest is in [12](12-instrumentation.md). The other +transform tuning keys (`tx*`, `ixDecimate`, `mpoBuster`, `tdrTest`) are documented on their `Config` +fields in `src/config.h`. diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index 25d66e8..ee7fba5 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -59,7 +59,7 @@ the reply invalid and the page waits forever. | `saveSession` | reply `sessionSaved` | Write `MakeProfileText(live)` over the profile | | `discardSession` | reply `config` | Rewrite the live ini from the profile | | `ready` | fire | Two frames after mount; the host logs launch-to-paint | -| `window` | fire | `minimize`, `close`, `quitWind`, `restartWind` (writes `session.keep` first) | +| `window` | fire | `minimize`, `maximize` (toggles restore), `close`, `quitWind`, `restartWind` (writes `session.keep` first) | | `dirty` | fire | Unsaved flag, so `WM_CLOSE` can prompt | | `openIni` | fire | Open `magnifier.ini` in the `.ini` handler or Notepad | | `exportDiagnostics` | fire | Zip `%LOCALAPPDATA%\Wind\logs` to the Desktop on a worker thread (the window stays responsive; a repeat click while it runs is ignored), then reveal it in Explorer | @@ -81,7 +81,7 @@ and default. `Settings.svelte` is the shell (title bar, sidebar, banner, save ca | Type | Widget | Notes | |---|---|---| -| `keybind` | `lib/KeybindCapture.svelte` + `controls/Keycaps.svelte` | State lives in sibling keys (`buttonKey`, `vkKey`, `modsKey`); zoom rows take two slots | +| `keybind` | `controls/Bindings.svelte` (onboarding uses `lib/KeybindCapture.svelte`) | State lives in sibling keys (`buttonKey`, `vkKey`, `modsKey`); zoom rows take two slots | | `toggle` | `controls/Toggle.svelte` | `1`/`0` | | `slider` | `controls/Slider.svelte` | `min`, `max`, `step`, `unit` (also `aria-valuetext`) | | `select` | `controls/Select.svelte` | `options` + `optionLabels` | diff --git a/docs/architecture/12-instrumentation.md b/docs/architecture/12-instrumentation.md index fcb1e64..fa1a14b 100644 --- a/docs/architecture/12-instrumentation.md +++ b/docs/architecture/12-instrumentation.md @@ -29,7 +29,9 @@ Details: `tools/testenv/README.md`. Both binaries log through `src/logging.cpp`. The pure half (formatting, rotation, snapshot text) is unit-tested; the Win32 half is excluded from the test build. -- **Files.** `%LOCALAPPDATA%\Wind\logs\` (`ResolveLogDir`): `wind-core.log` from Wind.exe, +- **Files.** The `logs\` folder next to the exe when that folder is writable (dev and portable + builds), else `%LOCALAPPDATA%\Wind\logs\` (the Program Files install; `ResolveLogDir`, the same + rule as the ini): `wind-core.log` from Wind.exe, `wind-config.log` from WindConfig.exe. Rotation at 1 MiB over three generations. A second instance that cannot open the shared log writes `wind-core-.log`; once it holds the single-instance mutex (a restart), `LogClaimBase` moves it onto `wind-core.log`, so it rotates. diff --git a/installer/app.nsh b/installer/app.nsh index d1a3ffd..9b4574a 100644 --- a/installer/app.nsh +++ b/installer/app.nsh @@ -77,7 +77,7 @@ Var LicenceDir ; where "Read the full licence" put its copy, empty until then ; Now wait for the PROCESS. This gate is the whole point of asking politely: killing ; Wind between "released the mutex" and "finished shutting down" would skip the cursor - ; restore, the ClipCursor release and the Magnifier registry restore, which is exactly + ; restore, the ClipCursor release and the zoom reset, which is exactly ; the damage the quit event exists to avoid. Up to 5 s in 250 ms steps, so the normal ; case costs a quarter of a second. StrCpy $3 0 diff --git a/src/config.h b/src/config.h index 39636c0..ce86e94 100644 --- a/src/config.h +++ b/src/config.h @@ -229,8 +229,9 @@ struct Config { // 1 = 1px translation pulse (shipped). The cost is honest and known: the view sits 1px off // the truth for one tick per pulse. It is shipped anyway because the hitch it removes is // worse; 0 turns it off for anyone who disagrees. - // Only views Wind writes itself are warmed (locked mouselook, Inspect, a caret/focus tracked - // view); while DWM centres the view there is nothing to warm. Any value above 1 reads as 1. + // Only views Wind writes itself are warmed (locked mouselook, Inspect); while DWM centres the + // view there is nothing to warm, and a free pointer's detached view (caret, focus, keyboard + // pan) is not warmed either (078adb1). Any value above 1 reads as 1. int txWarmMode = 1; // WARM CADENCE (issue #246, hot). Every warm write is a real source-rect change, so DWM // re-renders the whole magnified screen for it: per-tick warming made a zoomed session diff --git a/src/engine_pick.h b/src/engine_pick.h index 7827338..3dff7c7 100644 --- a/src/engine_pick.h +++ b/src/engine_pick.h @@ -3,14 +3,20 @@ // predicate in the app is unit-tested (issues #148 exclusion, #172 shell desktop) instead of // living inline in two RunTick sites that had to stay identical by hand. // -// transform is picked ONLY for a borderless foreground that covers the PRIMARY target monitor -// (games, F11 video): compositor-internal magnification survives a heavy game's present load. +// Order of the rules (see ShouldPickTransform): capture-protected, renderExclude or rotated output +// -> transform always; then the per-window-category preference; then the Auto rule. +// +// Auto picks transform on the PRIMARY target monitor for (a) a borderless foreground that covers +// the monitor (games, F11 video: compositor-internal magnification survives a heavy game's present +// load) or (b) the desktop, when desktopTransform=1 (shipped) and the input transform is verified. // Everything else gets the render engine: // - a maximized desktop app covers but keeps its caption -> render (documented trap), -// - the shell desktop (Win+D) reads as a borderless cover -> render (issue #172), +// - the shell desktop (Win+D) reads as a borderless cover, so it is never a game -> render +// on the game path (issue #172); only the desktop path may take it, // - excluded exes (fullscreen browser video wants a desktop-style cursor) -> render, // - learned cursor-shape churners -> render, unless the tdrTest harness forces transform, // - any non-primary monitor -> render (no cross-adapter transform chase). +// An explicit Transform category preference skips the churny and input-transform checks. #include namespace wind { diff --git a/src/mag_host.cpp b/src/mag_host.cpp index 0a8934a..a67b6b8 100644 --- a/src/mag_host.cpp +++ b/src/mag_host.cpp @@ -78,9 +78,9 @@ bool MagHost::setSamplingMode(unsigned mode) { // 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 - // a confirmed dwmcore crash trigger over complex (Mica/acrylic) geometry at high zoom - - // a cheaper filter may look smooth without taking the compositor down. + // what the extra three do. They were tried as a cheaper filter than mode 1 (a confirmed dwmcore + // crash trigger over Mica/acrylic at high zoom) and field-tested 2026-08-13: all three render + // identically to nearest (see Config::txSamplingMode), so they are kept for diagnostics only. // The raw setter takes a DWORD POINTER, not a value: passing the value by mistake // dereferences it and access-violates (field crash 2026-08-13). if (mode >= 2) { diff --git a/src/magnifier_model.h b/src/magnifier_model.h index 6357a19..2a5280a 100644 --- a/src/magnifier_model.h +++ b/src/magnifier_model.h @@ -57,7 +57,9 @@ struct IMagnifierModel { // Called every tick while IDLE (not zoomed, no Inspect). The transform model builds its // magnification context and cursor lens here, once, so the first zoom does not pay for them. virtual void idleTick() {} - virtual bool retarget(const MonitorTarget& m) { (void)m; return false; } // render-only; false = unchanged + // Move to another monitor or resolution. Both engines implement it (render also validates the + // adapter and returns false across GPUs); the default is "unchanged". + virtual bool retarget(const MonitorTarget& m) { (void)m; return false; } virtual void present(const MapResult& r, double level, const Config& cfg, const MonitorTarget& mon, const PresentExtras& ex) = 0; // the per-tick draw virtual bool coversShell() const = 0; // whether the magnified view covers the shell diff --git a/src/main.cpp b/src/main.cpp index 0e518a8..9ec8e99 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -438,9 +438,9 @@ static TickState* g_tick = nullptr; static std::set g_churnyApps; static std::wstring ChurnyFilePath() { - std::wstring dir = wind::ResolveLogDir(); // %LOCALAPPDATA%\Wind\logs + std::wstring dir = wind::ResolveLogDir(); // exe-dir\logs, else %LOCALAPPDATA%\Wind\logs size_t cut = dir.find_last_of(L"\\/"); - if (cut != std::wstring::npos) dir.resize(cut); // -> %LOCALAPPDATA%\Wind + if (cut != std::wstring::npos) dir.resize(cut); // -> the log dir's parent return dir + L"\\churny_apps.txt"; } static std::wstring ExeNameOf(HWND h) { @@ -1803,7 +1803,7 @@ static void RunTick(TickState& t) { // 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). + // the far edge is proven on an MPO boot (shipped on: mpoGuardLiftWall=1). // What a TRANSFORM session would get, whichever engine is active now: the mid-zoom // render -> transform switch below hands the same tick to an engine this block did not see. const bool liftIfTransform = !g_mpoDisabled && t.cfg.mpoGuardLiftWall != 0 && @@ -3305,11 +3305,10 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { bool running = true; unsigned long long nextRecoverMs = 0; // device-lost recovery backoff gate (GetTickCount64) bool lostSeen = false; // this device loss already attributed (backstop runs once) - // The transform model does no blocking present, so it can never self-pace via Present(1,0) or - // DwmFlush the way the render model does. It must always be timer-paced (like the idle/1x path), - // or the zoomed loop spins flat out and floods MagSetFullscreenTransform, backing up DWM's - // desktop-transform queue so the view lags ~1-2s behind input. Cache the model kind once. - // hybrid swaps engines per zoom-in: current-engine check is per-iteration in the loop + // Pacing is decided per iteration below (hybrid swaps engines per zoom-in, so the current + // engine is checked every pass): see "Pacing while zoomed". The transform model does no + // blocking present, so while zoomed it is DwmFlush-paced; an unpaced loop would flood + // MagSetFullscreenTransform and back up DWM's desktop-transform queue (~1-2 s of view lag). while (running) { MSG msg; while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { diff --git a/src/render_engine.cpp b/src/render_engine.cpp index 4f8ac86..6709a30 100644 --- a/src/render_engine.cpp +++ b/src/render_engine.cpp @@ -140,7 +140,7 @@ struct RenderEngine::State { ComPtr blendInvert; // invert blend for I-beam-style cursors ComPtr cursorTex; // the ACTIVE cursor's texture (an alias into the cache) ComPtr cursorSRV; - ComPtr crosshairSRV; // Inspect-mode crosshair sprite (32x32, built once) + ComPtr crosshairSRV; // Inspect-mode crosshair sprite (48x48, built once) HCURSOR lastCursor = nullptr; // re-decode only when the OS cursor changes int curW = 0, curH = 0, hotX = 0, hotY = 0; bool cursorReady = false; @@ -1359,7 +1359,7 @@ bool RenderEngine::renderFrame(const RenderFrameParams& p) { } // Render one frame and dump it WITHOUT presenting, so the PNG reflects exactly the drawn -// frame (a FLIP_DISCARD back-buffer read after Present is undefined). Verification only. +// frame (a DISCARD swap-chain back-buffer read after Present is undefined). Verification only. bool RenderEngine::dumpFrame(const RenderFrameParams& p, const wchar_t* path) { if (!s_->ready) return false; s_->render(p); diff --git a/src/render_engine.h b/src/render_engine.h index 4658f30..50c2955 100644 --- a/src/render_engine.h +++ b/src/render_engine.h @@ -150,7 +150,7 @@ class RenderEngine { // Verification only: copy the back-buffer to a 32bpp BGRA PNG. bool dumpBackbufferPng(const wchar_t* path); // Verification only: render one frame and dump it before Present (so the PNG matches the - // drawn frame; a FLIP_DISCARD back-buffer read after Present is undefined). + // drawn frame; a DISCARD swap-chain back-buffer read after Present is undefined). bool dumpFrame(const RenderFrameParams& p, const wchar_t* path); private: diff --git a/src/render_shaders.h b/src/render_shaders.h index d71d864..908695f 100644 --- a/src/render_shaders.h +++ b/src/render_shaders.h @@ -2,7 +2,8 @@ namespace wind { // Constant buffer: source sub-rect UV bounds, output brightness, HDR tonemap params, sharpening -// strength, and the source texel size. 48 bytes (three 16-byte registers). +// strength, the source texel size and the colour matrix. 128 bytes: eight 16-byte registers +// (three scalar registers plus the five colour-matrix rows). // hdrMode: 0 = SDR passthrough, 1 = scRGB (FP16 linear Rec.709) -> SDR. // scRgbScale = 80 / SDR-white-nits (scRGB 1.0 = 80 nits; SDR white maps to 1.0). // sharpness: 0 = off (single tap, cheapest); >0 = adaptive sharpen strength. @@ -13,6 +14,7 @@ struct MagCB { float texelW, texelH, colorOn, pad1; // reg 2 (colorOn: issue #288) float cm[5][4]; // reg 3-7: colour matrix rows (RGBA in, RGBA out) + offsets }; +static_assert(sizeof(MagCB) == 128, "MagCB must match the HLSL cbuffer CB (8 x 16-byte registers)"); // Fullscreen-triangle magnify shader. The VS maps the visible [0,1] screen UV into the // source sub-rect; the PS samples the captured desktop, optionally sharpens (adaptive, clamped to diff --git a/src/transform.h b/src/transform.h index 2d370b6..192b965 100644 --- a/src/transform.h +++ b/src/transform.h @@ -3,7 +3,7 @@ namespace wind { struct OffsetF { double x; double y; }; // Float (sub-pixel) source-region top-left, clamped on screen. level >= 1.0. // center: virtual lens center in screen pixels; screenW/H: monitor size in pixels. -// Used by the own GPU renderer, which pans sub-pixel. +// Used by CursorMapper and the transform model, which pan sub-pixel. OffsetF ComputeOffsetF(double centerX, double centerY, double level, int screenW, int screenH); // The two forms of the fullscreen-magnifier transform, both derived from the sub-pixel source diff --git a/src/tx_warm.h b/src/tx_warm.h index 1a9f382..5845cf8 100644 --- a/src/tx_warm.h +++ b/src/tx_warm.h @@ -25,8 +25,9 @@ // // The 1px translation jitter is the one that works, at a known cost: the view sits 1px off the // truth for one tick per pulse (smooth sampling can render that as shaking; nearest masks it). -// Warm pulses only run where Wind writes the view itself (locked mouselook, Inspect, a caret or -// focus tracked view); DWM's own centring needs none. +// Warm pulses only run where Wind writes the view itself (locked mouselook, Inspect); DWM's own +// centring needs none, and a free pointer's detached view (caret, focus, keyboard pan) is excluded +// because the pulse showed there as a one-pixel shake (078adb1). // // docs/HITCH-FINDINGS.md carries the full write-up and the measured dead ends. namespace wind { diff --git a/src/version.h b/src/version.h index 11c7be6..616d905 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 24 -#define WIND_VER_PATCH 18 +#define WIND_VER_PATCH 19 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.24.18" +#define WIND_VERSION_STR "0.24.19"