diff --git a/.github/release-notes.md b/.github/release-notes.md index b580ec3..dbe582d 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -11,21 +11,17 @@ keeps them unless you say otherwise. Requires 64-bit Windows 10 or 11. -## Two things to know before you download +## One thing to know before you download -**This build is unsigned.** SmartScreen will warn on first run: choose *More info* then +**This installer is unsigned.** SmartScreen will warn on first run: choose *More info* then *Run anyway*. Some browsers and most managed work computers block the download outright, which a signature is the only real fix for; one is being arranged. -**Being unsigned costs two features**, because Windows only grants UIAccess to a signed -binary in a protected folder: - -- zoom shortcuts do not work while an elevated window has focus (Task Manager, regedit, an - elevated terminal) -- the desktop transform path stays off, so the desktop is magnified by the render engine - -Everything else works normally. Wind detects this at startup and picks the right engine on -its own, so there is nothing to configure. +That does not cost you UIAccess, though: Setup signs Wind for UIAccess on your own PC during +install, using a certificate it generates and trusts locally, then deletes right away - so +zoom shortcuts keep working with an elevated window focused (Task Manager, regedit, an +elevated terminal), and the desktop uses the same compositor transform engine as a game. +There is nothing to configure either way. ## What is in it @@ -35,6 +31,8 @@ its own, so there is nothing to configure. - Automatic engine choice per zoom, between a DWM fullscreen transform and its own DXGI + Direct3D 11 renderer - Named settings profiles, and a Settings app with guided first-run setup +- Tracking modes: follow the text caret or the keyboard-focused control instead of the + pointer, with a smooth glide, plus a mouse edge mode - Multi-monitor and HDR aware ## Verify your download diff --git a/README.md b/README.md index f4ffd5c..2268e0d 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ you keep clicking and using the screen while zoomed. - **Zoom lock detection** - games that pin the mouse to the screen center (DOOM-style mouselook) would drag the zoom back with it; listed apps (Settings > Cursor) pan from raw mouse motion instead. +- **Tracking modes** - the view can follow you instead of only the pointer: it recenters on + the text caret as you type (on by default) or on the keyboard-focused control (off by + default), gliding smoothly to each new target; a mouse edge mode keeps the pointer from + reaching the view's border. Settings > Tracking. ## Magnifier models (`model=`) Selected with the `model` ini key or the "Magnifier engine" row in Settings. `model` is @@ -107,20 +111,25 @@ you sign in, and installs the WebView2 runtime if Settings has no browser engine Your settings, profiles and logs stay in `%LOCALAPPDATA%\Wind`, and uninstalling keeps them unless you say otherwise. -**Signing.** Release builds are currently **unsigned**, so Windows SmartScreen will warn on -first run, and the UIAccess-only behaviour above is switched off (Wind detects this at startup -and stays on the render path for the desktop; everything else works normally). Being unsigned -is also why some browsers, and most managed work computers, refuse the download outright. A -certificate is being arranged; the release pipeline already signs when one is configured, via +**Signing.** The installer package itself is currently **unsigned**, so Windows SmartScreen +will warn on first run, and that is also why some browsers, and most managed work computers, +refuse the download outright. A certificate for that is being arranged. + +That does not cost you UIAccess, though. Setup generates a one-time local signing certificate +on each PC it installs to, trusts it there, signs the UIAccess build with it, and deletes the +private key right away - so a normal install gets UIAccess (elevated-window shortcuts keep +working, and the desktop uses the transform engine) without needing a purchased certificate. If +that per-PC signing step ever fails, Setup falls back to the ordinary, non-UIAccess build. + +The release pipeline also signs with a real certificate when one is configured, via `WIND_SIGN_THUMBPRINT`, or `WIND_SIGN_PFX` plus `WIND_SIGN_PASSWORD`: ``` pwsh -File tools\release.ps1 ``` -With a certificate it builds the UIAccess variant, signs both executables and the installer, -and writes `dist\Wind-Setup-x64-.exe`. Without one it builds the ordinary variant and -says so. `src\version.h` is the only place the version is declared. +With a certificate it signs both executables and the installer up front and skips the per-PC +step entirely. `src\version.h` is the only place the version is declared. ## Build Requires Visual Studio 2022+ Build Tools (Desktop development with C++). From any shell: @@ -161,10 +170,17 @@ Profiles (tray -> Profiles, or the Settings titlebar) snapshot the whole file pe - Pacing/perf: `vsync` (default on), `dwmFlush` (default 0), `gameFpsCap`, `gpuPriority`. - `model` - `hybrid` (default) / `render` / `transform` / `magnify`. Restart to switch. - `multiMonitor` - 0 (default, primary only) or 1 (follow the cursor's monitor per zoom-in). -- `desktopTransform` - experimental, ini-only: use the game (compositor) engine on the - desktop too (signed install only, primary monitor only, Auto model). +- `desktopTransform` - default **1**: use the game (compositor) engine on the desktop too + (primary monitor only, Auto model), whenever UIAccess is available; every normal install + gets that from the per-PC signing described above. Set it to `0` to keep the desktop on the + render engine. - `lockApps` - per-app zoom lock detection (Settings > Cursor > "Zoom lock detection"); `warpLock=1` extends the detection heuristics to unlisted games. +- `trackCaret` (default 1) / `trackFocus` (default 0) - follow the text caret or the + keyboard-focused control instead of the pointer; `trackGlideMs` (default 200) sets how fast + the view glides to a new target. `mouseAlign=1` switches ordinary mouse tracking to an edge + mode where the pointer may approach the view's border instead of staying centered. + Settings > Tracking. - Advanced: `zorderBand`, `transformExclude`, `noSwallowApps`, `profile`, `launchQuiesce` (default 1; 0 disables the ~1.5s write hold on a freshly launched fullscreen cover - a test knob for issue #247, it unguards the #187 DWM crash class, do not ship it off). diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 41e3ea6..5d7fdc6 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -4,7 +4,9 @@ **Status:** Issue 1 **FIXED** (UIAccess). Issue 2 root cause confirmed (missing `MagSetInputTransform`) and then refined to a **DPI coordinate-space mismatch** at 225% scale; logical-coordinate fix implemented, pending test. Issue 3 (flicker) **fixed** -(unit-tested), pending user confirmation. Issue 4 unchanged. See per-issue "Resolution". +(unit-tested), pending user confirmation. Issue 4 **resolved** by the transform engine +(native-Magnifier composition parity measured; see docs/HITCH-FINDINGS.md, 2026-09-28), not by +the render-pipeline injection this doc once framed as the only fix. See per-issue "Resolution". **Live-test results (2026-05-25):** - After UIAccess + `MagSetInputTransform`: **Issue 1 fixed** (zoom buttons now work over @@ -32,7 +34,7 @@ problem (view flicker, Issue 3 below), not that FPS ceiling. | 1 | Zoom side-buttons do nothing | Task Manager, some apps | UIPI-class: input not reaching Wind over those windows. UIAccess resolved it. | **FIXED** (UIAccess) | | 2 | Partial / position-dependent clickability while zoomed (which targets work depends on window position) | Any window, when zoomed, at non-100% scale | `MagSetInputTransform` rects were passed in **physical** px, but input maps in **logical** (DPI-scaled) px. At 225% they were 2.25x too large -> click offset grows with screen position. | Logical-coordinate fix implemented; **pending test** | | 3 | Magnified view flickers / jumps while moving the cursor (off-centers and recenters rapidly) | GPU-rendered windows: Windows Terminal, browser, launcher | `Tracker::update` free/locked heuristic flip-flopped between snapping to `GetCursorPos` and integrating raw deltas | **Fixed** (hysteresis lock detector), unit-tested; user confirming | -| 4 | Large FPS drop when panning/zooming in games | Borderless games (KCD2 etc.) | Public API scales in DWM, drops game off the GPU fast path | Unchanged (see PERFORMANCE-FINDINGS.md); direction decision open | +| 4 | Large FPS drop when panning/zooming in games | Borderless games (KCD2 etc.) | Public API scales in DWM, drops game off the GPU fast path | **Resolved** via the transform engine (native-Magnifier composition parity, see HITCH-FINDINGS.md); a smaller residual felt-smoothness gap is still tracked there | Issues **2 and 3 are very likely the same root cause** (the tracker's center diverging from the true cursor), showing up as both a visual symptom (flicker) and an interaction @@ -272,24 +274,30 @@ follows). Commit on `fix/interaction-bugs`. ## Issue 4 - In-game FPS hitching (cross-reference) -Documented and concluded in [`PERFORMANCE-FINDINGS.md`](PERFORMANCE-FINDINGS.md): the -large FPS drop while panning/zooming in borderless games is a ceiling of the public -Magnification API (scaling happens in DWM, dropping the game off its GPU fast path). -Only render-pipeline injection fully fixes it. **The direction decision (accept the -limit and finalize v1, vs. pivot to injection) is still open and not part of this -round.** Listed here only so the four issues live in one place. +Documented in [`PERFORMANCE-FINDINGS.md`](PERFORMANCE-FINDINGS.md), which concluded (for the +Magnification-API engine that existed at the time) that the large FPS drop while +panning/zooming in borderless games was a ceiling of the public API and that only +render-pipeline injection would fully fix it. **Resolved differently**: the transform engine +(issue #148, revived) drives DWM's own magnification channel the way native Magnifier does, +instead of the render engine's DXGI-capture pipeline, and the hybrid model picks it +automatically for fullscreen bordered games. Measured composition-rate parity with native +Magnifier over a real game (docs/HITCH-FINDINGS.md, 2026-09-28); a smaller residual +felt-smoothness gap (cursor handling, micro-holds) is still open there, but the large +architecture-level FPS drop this issue documented is fixed. Listed here only so the four +issues live in one place. --- ## Cross-cutting note: visual-only vs. input transform -Wind magnifies visually but does not remap input (`MagSetInputTransform` is -deliberately unused; it needs UIAccess). This is correct and click-accurate **as long -as the view stays centered on the true cursor**. Issues 2 and 3 both come back to the -center diverging from the true cursor, which breaks that assumption. If we ever do want -true decoupled-lens interaction (clicking the magnified target while the lens is offset -from the real cursor), that would require `MagSetInputTransform` and therefore working -UIAccess. Not needed to fix Issues 2/3 if we keep the center on the cursor. +This described the original Magnification-API engine these issues were filed against, which +magnified visually but never remapped input. It is no longer accurate for the current +transform engine: when UIAccess is available, the transform engine actively publishes +`MagSetInputTransform` per source-rect change (`magInputTransform=1`, default; issue #185, +docs/POINTER-HITTEST-FINDINGS.md) specifically to fix pointer-framework hover dead zones on +the desktop. The render engine still has no input transform and instead keeps the real cursor +welded/synced to the drawn one, so it stays correct and click-accurate only as long as that +weld keeps the view centered on the true cursor. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f819e6e..943787c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,49 +3,51 @@ Items that are agreed direction but not yet scheduled. One line of context each; details live in the referenced issues/specs. -## One default engine (agreed direction, 2026-08-13) +## One default engine (agreed direction, 2026-08-13; desktop half shipped 2026-09-28) Converge on the TRANSFORM engine as the single DEFAULT for every use case; the other models -stay shipped as deliberate alternatives ("second best"), never deleted. Prerequisites before -flipping any default: extended field testing of `desktopTransform=1` (endurance, the #189 perf -levers validated: ixDecimate / txKeepAliveMaxLevel / txMaxStepPct A/Bs), the spriteBand16 -constant-size-cursor verdict, and the launch-quiesce (#187) holding across more game launches. +stay shipped as deliberate alternatives ("second best"), never deleted. The desktop half of +this is done: `desktopTransform=1` shipped as the default (issue #271/#272, owner decision +2026-09-28), so hybrid now picks transform on the desktop whenever the input-transform +availability probe succeeds. What is left before transform becomes the default for GAMES too: +the spriteBand16 constant-size-cursor verdict, and the launch-quiesce (#187) holding across +more game launches (`txKeepAliveMaxLevel` is retired - warm-keeping now runs through +`txWarmMode`/`txWarmHz`, see CLAUDE.md). Spec: `docs/superpowers/specs/2026-08-12-one-model-transform-design.md` (P3/P4); mechanism record: `docs/POINTER-HITTEST-FINDINGS.md`. ## Installer / public release -- **Bundle the NVIDIA MPO mitigation** (issue #148). The transform model's full zoom range over - games requires MPO hardware overlay planes to be OFF on NVIDIA systems; otherwise the driver's - 16-bit plane-programming field overflows (|srcX*level| > 32767) and resets the GPU. Wind - detects the boot state and pan-walls the unsafe strip when MPO is on, but the BEST experience - needs the registry edit. The installer must: - - offer an opt-in step (checked by default on NVIDIA GPUs) that sets - `HKLM\SOFTWARE\Microsoft\Windows\Dwm\OverlayTestMode = DWORD 5` and explains the - reboot-to-apply + how to undo (delete the value); - - never set it silently (system-wide display setting; users must know it exists); - - the uninstaller should offer to remove the value. - Also report the underlying bug to NVIDIA with the minimal repro (issue #148 has the full - forensics: signed UIAccess rig, gl_churn/gl_stress stressors, event-log verdicts). -- **MPO-buster alternative** (unbuilt): a fullscreen alpha-1 click-through layered window shown - only during transform game sessions would force DWM to composite the game (off the hardware - plane), removing the need for the registry edit entirely. Prototype and A/B against the - registry route before the installer ships (evidence it works: the render model's alpha-1 - primeReveal forces exactly this demotion, issue #90). +- **NVIDIA MPO mitigation** (issue #148): shipped, but through Settings rather than the + installer. WindConfig.exe's "Disable MPO" toggle (issue #164) writes + `HKLM\SOFTWARE\Microsoft\Windows\Dwm\OverlayTestMode = DWORD 5` via an elevated `reg.exe` + call, re-reads the real state instead of assuming it applied, and is boot-state aware (DWM + only reads the value at boot, so the UI says a restart is needed rather than implying the + toggle is instant). The installer itself still does not offer this as a first-run step; an + install-time prompt remains open if one is wanted. Also report the underlying bug to NVIDIA + with the minimal repro (issue #148 has the full forensics: signed UIAccess rig, + gl_churn/gl_stress stressors, event-log verdicts). +- **MPO buster** (issue #191): built and shipped, on by default (`mpoBuster=1`). During a + transform game session exposed to the MPO bug, Wind shows a fullscreen alpha-1 + click-through ghost window that forces DWM to composite the game off the hardware overlay + plane, lifting the pan wall once the ghost settles. It runs alongside the registry route + rather than replacing it: the pan wall still applies unconditionally whenever sampling is + `nearest` and MPO is on (issue #243). -## Next session - start here (2026-07-26, updated at checkpoint 8a52040) +## Next session - start here (2026-07-26, updated at checkpoint 8a52040; size decision closed 2026-09-18) **Cursor is DONE and field-verified**: the transform model welds the real OS cursor to the lens -point, so hover, dragging and clicks are all native. Items 1 below is therefore closed; what -remains of the cursor work is the SIZE decision: +point, so hover, dragging and clicks are all native. Items 1 below is therefore closed, and the +SIZE decision below is now closed too: - With the transform engine a pointer can be **correctly placed OR constant size, never both**. DWM magnifies layered windows too, so a screen-space marker lands off-screen once transformed (verified: the pointer vanished at high zoom); in desktop space it sits exactly on target but grows with the zoom, like the native Magnifier. The hardware pointer is the only constant-size surface and it draws at its raw desktop position - the wrong place. -- So the standing "constant on-screen size" rule cannot be met by the transform engine. The - render engine does meet it (it draws its own frame) but runs its own loop at ~92fps with many - hitches while panning, versus 144fps/1 hitch for transform. **Max to decide**: live with a - growing pointer in games, or use render there. +- So the standing "constant on-screen size" rule cannot be met by the transform engine. + **Decided (owner decision, issue #253, 2026-09-18): the cursor grows with the zoom in every + engine**, including games via transform - this replaces the old constant-size rule. + `cursorConstantSize` (default 0) is an opt-in, render-only escape hatch for anyone who wants + the old constant-size look back. ## Superseded (kept for the reasoning) diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index a6f1030..9c17ec6 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -25,10 +25,13 @@ even while the render overlay covers the screen. The tray right-click -> Quit al - [ ] Task Manager: Wind CPU stays near 0% idle-zoomed; low while panning. - [ ] No noticeable stutter added to the game. -## Own GPU renderer (engine=render, the default) +## Own GPU renderer (model=render) -The own capture+Direct3D renderer (DXGI Desktop Duplication). Select with `engine=render` -in magnifier.ini (default); `engine=mag` selects the Magnification-API engine. +The own capture+Direct3D renderer (DXGI Desktop Duplication). Select with `model=render` +in magnifier.ini; the shipped default is `model=hybrid` ("Auto" in the settings UI), which +picks render or the DWM transform engine per zoom-in. `model=magnify` drives the native +Windows Magnifier instead (works over DRM video like Netflix, which Desktop Duplication +captures as black); the old `engine=mag` key was removed (issue #20). **Auto-verified (CI/dev, via render-then-dump PNGs):** - D3D11 device + click-through overlay + flip-swapchain present. @@ -40,14 +43,14 @@ in magnifier.ini (default); `engine=mag` selects the Magnification-API engine. - End-to-end: `WIND_SELFTEST=1 Wind.exe` drives the real path and dumps `wind_selftest.png`. **Human-only checks (please verify when you return):** -- [ ] Zoom in (engine=render): exactly ONE cursor visible (not two). If two, the OS-cursor +- [ ] Zoom in (model=render): exactly ONE cursor visible (not two). If two, the OS-cursor hide needs the documented fallback (see KNOWN-ISSUES "Own renderer"). - [ ] Pan while zoomed: cursor stays centered and BUTTER SMOOTH (no L-pixel hop) - the goal. - [ ] Content pans smoothly at high zoom (8x) - no judder. - [ ] Click something while zoomed: it lands where the centered cursor points. - [ ] DRM video (e.g. Netflix) shows BLACK in the magnified layer (known DDA limit). - [ ] Quit from tray: cursor + screen back to normal everywhere. -- [ ] A/B vs engine=mag and vs Windows Magnifier for smoothness/feel. +- [ ] A/B vs model=magnify and vs Windows Magnifier for smoothness/feel. ## Installer (issue #213) @@ -81,6 +84,9 @@ returns blank on the DIB-into-static drawing), so the screens below are human-on wraps without a visible jump. - [ ] Hover Install, Back, minimise and close: each one lights up, and the hit area matches what it looks like. Drag the caption strip: the window moves. +- [ ] Licence screen: Install/Next does nothing until the accept box is ticked. "Read the + full licence" opens LICENSE.txt in the default text viewer (not elevated). Going Back + to Welcome and forward again keeps the box's state. - [ ] The setup screen shows `C:\Program Files\Wind` in Consolas, in the gap left for it. - [ ] Toggle "Start Wind when I sign in", go forward, come Back: the box kept its state. - [ ] The progress bar sits ON the drawn trough, in Wind's indigo, not the Windows green. @@ -96,7 +102,11 @@ returns blank on the DIB-into-static drawing), so the screens below are human-on - [ ] Tray > Open Settings works after install (proves WebView2 is present or was installed). ## Notes / known v1 behavior -- Editing the config while running re-initializes zoom to 1.0x (rare action). -- Renderer knobs (cursorSensitivity, cursorScaleWithZoom, bilinear) apply on restart. -- v1 magnifies the primary monitor; SDR; desktop-focused (engine=mag still serves games). -- Recenter is unbound by default (recenterVk=0); no keyboard hook is wired in v1. +- Editing the config while running keeps the current zoom level, clamped into a lowered + maxLevel if needed; it no longer collapses to 1.0x (fixed, issue #234/#235). +- Renderer knobs (cursorSensitivity, cursorConstantSize, bilinear) apply on restart. +- Primary monitor by default; `multiMonitor=1` opts into following the cursor's monitor. + `model=magnify` still serves DRM-protected video (Netflix etc.), which Desktop Duplication + captures as black. +- Recenter is unbound by default (recenterVk=0); keyboard zoom/recenter/cursorLock binds go + through a WH_KEYBOARD_LL hook (src/input_router.cpp). diff --git a/docs/architecture/01-overview.md b/docs/architecture/01-overview.md index 7786d30..ae11cf8 100644 --- a/docs/architecture/01-overview.md +++ b/docs/architecture/01-overview.md @@ -71,6 +71,14 @@ locked regime outright, no heuristics, and `warpLock` adds warp-anchor/box/seed pointer-warping mouselook engines (`LockDetector::warpLocked`); the list is the feature, the knob adds the smart tells globally. Details in [The cursor system](07-cursor.md). +**Tracking follows the caret and keyboard focus on the desktop, without ever moving the pointer.** +(Issue #276.) While zoomed on the desktop (not locked, not a fullscreen game, not Inspect), the +view can glide to the text caret and keyboard focus (`trackCaret`, default on; `trackFocus`, +default off) with a critically-damped 200 ms spring (`trackGlideMs`), and an opt-in mouse-edge +mode (`mouseAlign=1`) lets the pointer roam inside the view and pans it only once the pointer +reaches the edge margin. A real mouse move always takes the view back. Settings has its own +Tracking section. Details in [The cursor system](07-cursor.md). + ## Two binaries, one ini **The magnifier core and the settings app are separate processes with zero runtime coupling; the @@ -162,8 +170,12 @@ large fleet of PowerShell measurement probes, see | `cursor_lock.cpp/.h` | Pure Inspect-mode on/off toggle state | | `cursor_mapper.cpp/.h` | Pure centered-lens mapper: integrates per-tick deltas into a float lens center, owns the pan wall | | `cursor_sprite.cpp/.h` | The transform model's layered-window cursor sprite (banded via `band_window.h`) | +| `detached_view.h` | Pure detached-view map (issue #276): draws the view off-pointer while the cursor fields still report the real pointer, so the sprite/cursor scroll with tracked content | +| `edge_pan.h` | Pure mouse-edge-mode geometry (issue #276 phase 2): the view moves only once the pointer leaves a comfort band sized to the cursor's visible body | | `drag_follow.h` | `ShouldDragFollow`: pure decision to suspend the weld during a button-hold (issue #169) | | `engine_pick.h` | Pure hybrid engine-pick predicate, shared by zoom-in pick and mid-zoom switch | +| `focus_track.cpp/.h` | `FocusTracker`: one thread owns WinEvents + UI Automation for caret/keyboard-focus tracking (issue #276); the tick thread only flips `setActive()` and reads `snapshot()` | +| `gain_learner.h` | Pure learned-ballistics gain for locked-regime panning: measures OS-cursor output against raw mickeys while free, instead of modeling Windows' pointer pipeline | | `hdr_info.cpp/.h` | OS query for the live SDR white level per display (issue #160) | | `hdr_scale.h` | Pure HDR-to-SDR tonemap scale, fold-in rule, and re-read throttle | | `hook_geometry.h` | Pure free-cursor source-rect formula, measured to match native Magnifier (issue #206) | @@ -189,12 +201,21 @@ large fleet of PowerShell measurement probes, see | `render_shaders.h` | HLSL sources: magnify/sharpen/tonemap PS, cursor quad, single-pass edge outline | | `resource.h` / `wind.rc` | App/tray icon resources | | `shell_desktop.h` | Pure test: is this window class the shell desktop (Win+D reads as a game otherwise, issue #172) | +| `sprite_layer.h` | Pure rule for which z-band the transform cursor sprite shows in, so it survives the Snipping Tool overlay and shell surfaces (issue #269) | +| `test_telemetry.h` | Per-tick CSV telemetry sample/formatting for the `tools/testenv` proving-ground harness (`WIND_TESTLOG`, issue #225) | +| `tick_stats.h` | Pure ring buffer of recent tick intervals backing the tray's frame-pacing readout | | `transform.cpp/.h` | Pure transform math: anchored offsets, TDR-safe clamps, input-transform rects, foreign-writer detection | | `transform_model.cpp/.h` | The transform engine: sessions, the weld, keep-alive, `txMaxStepPct` rate limit (default 25, i.e. 2.5% per tick) | | `tray.cpp/.h` | Tray icon, balloon, and menu handling | +| `tray_draw.h` | Owner-drawn tray menu: the drawing half, kept out of `tray.cpp` | +| `tray_status.h` | Pure decisions for what the tray menu shows (engine label, status text) from a published tick-loop snapshot | | `tx_cadence.h` | Pure transform write-cadence gates, traced against native Magnifier (issue #204) | +| `tx_warm.h` | Pure transform warm-keeping: the pulsed rest-tick displacement (`txWarmHz`/`txWarmMode`) that keeps DWM's magnification re-render from going cold between pans | | `version.h` | The single source of the version; bumping it cuts a release | +| `view_glide.h` | Pure glide/spring easing toward a tracking target (issue #276): time-based `GlideToward` and a critically-damped `SpringToward` | +| `view_target.h` | Pure view-ownership rules for tracking (issue #276): mouse vs. caret/focus, the click-quiet window, warp-vs-glide handback | | `webview2_probe.h` | Pure rule: is the WebView2 runtime actually installed ("0.0.0.0" leftovers lie) | +| `wobble_cage.cpp/.h` | Dev-only live wobble detector: four boxing bars around the cursor that flash on a screen-space hit, drawn in desktop space so the transform can't displace it out from under itself (issue #229) | | `zoom_controller.cpp/.h` | Pure hold-to-zoom state machine + quick-zoom toggle arithmetic | | `config_ui/main.cpp` | The WebView2 settings host; `HandleWebMessage` owns the bridge message set | | `config_ui/ini_edit.cpp/.h` | Pure in-place ini text editing (preserves comments, order, unknown keys) | diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index 9384612..e3a9115 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -166,6 +166,19 @@ lock edge logs which tell engaged (`"lock"` category, wind-core.log) so field re diagnosable. Crucially, a forced lock goes **through the detector** (`t.detector.seedLock()`), not a tick-local flag, because downstream gates read `t.detector.locked()`. +A fourth case overrides the resolved `MapResult` afterward: **tracking** (issue #276). Once zoomed +on the desktop (not locked, not a fullscreen game, not Inspect) and `trackCaret` and/or +`trackFocus` is on, `FocusTracker`'s snapshot can hand the view to the caret or keyboard focus +instead of the pointer (`wind::StepViewOwner`, src/view_target.h); the view glides there +(`wind::SpringToward`/`GlideToward`, src/view_glide.h) via `wind::DetachedMap` (src/detached_view.h), +which maps the view independently of where the pointer is. A real mouse move always takes the +view back, warping the pointer into frame rather than dragging the view back to it. The same +override also drives mouse-edge mode (`mouseAlign=1`): the pointer roams freely inside the view +and `wind::EdgePanCenter` (src/edge_pan.h) pans the view only once the pointer reaches the margin +band. Whenever tracking has detached the view this tick, `t.viewDetached` is set, and +`PresentExtras.suppressCursorSync` follows it (see below), because tracking never moves the +pointer. + Inside the free regime, `wind::ShouldDragFollow` (src/drag_follow.h) suspends the per-tick weld while a physical mouse button is held and follows the pointer 1:1 unscaled; the weld fighting a drag was the #169 window-drag flicker. And when `txFreeCursor` is on in a transform session, the @@ -204,7 +217,8 @@ handover choreography live in [Engines and the hybrid pick](03-engines.md). The tick then fills a `PresentExtras` (src/magnifier_model.h): the outline visibility (with the low-zoom dwell and the idle-hide fade both computed here from `dt`), the cursor mode, weld -suppression (`suppressCursorSync` when drag-follow or free cursor is active), transform-write +suppression (`suppressCursorSync` when drag-follow, free cursor, or tracking has detached the +view is active), transform-write pausing (around an Inspect click's injected input, and for the launch quiesce), and the game pacing flags. Then `t.model->present(r, lvl, cfg, mon, ex)` runs the engine. Two opt-in game modes can skip the present on some ticks: the reduced-push mode (`gameFpsCap` with vsync) @@ -289,9 +303,10 @@ Device-lost recovery also lives in the main loop, not the tick: when the render removed D3D device, the loop restores the cursor first, cleans Inspect state, marks the churny backstop if a transform game session was live within 30 s, and rebuilds on a 500 ms backoff. -## Threads: hooks vs. the Magnification runtime +## Threads: hooks, the focus tracker, and the Magnification runtime -Wind has exactly two threads that matter, and the split is principled: +Wind has three threads that matter beyond the tick thread itself, each split off for its own +principled reason: **The hook thread** (src/input_router.cpp) exists because `WH_MOUSE_LL` / `WH_KEYBOARD_LL` callbacks must return fast or Windows evicts the hook, and because they stall the *system's* @@ -300,6 +315,14 @@ would hold every keystroke and mouse move on the machine hostage for a frame. Th does minimal work (set atomics, count mickeys, swallow bound keys) and the tick thread reads the results. +**The focus-tracker thread** (`FocusTracker`, src/focus_track.cpp, issue #276) exists because +caret and keyboard-focus tracking needs WinEvents (out-of-context hooks), `GetGUIThreadInfo`, and +UI Automation, all of which either block or want their own message loop / COM MTA, none of which +can run on the tick thread without risking a stall. It starts alongside the hook thread in +`wWinMain` and stops on every teardown path. The tick thread only calls `setActive()` (arming or +disarming it as tracking turns on or off) and reads an atomically published `snapshot()`; it +never waits on UI Automation. + **Magnification API calls are thread-affine.** This was measured, not assumed (the transcript is in the header comment of src/mag_thread.h): only the thread that called `MagInitialize` can drive the transform; a write from any other thread returns FALSE and changes nothing. By default @@ -337,6 +360,9 @@ inline on the caller, degrading exactly to the old single-threaded behavior. - `src/mag_thread.h` / `.cpp`: Magnification runtime thread ownership and marshalling - `src/hook_transform.h` / `.cpp`: the armed hook-write state and the single-writer contract - `src/input_router.cpp`: the hook thread the tick reads from +- `src/focus_track.h` / `.cpp`: the focus-tracker thread and its published snapshot +- `src/view_target.h`, `src/view_glide.h`, `src/detached_view.h`, `src/edge_pan.h`: the pure + tracking decisions (view ownership, glide/spring, the detached map, mouse-edge geometry) - `src/engine_pick.h`, `src/drag_follow.h`, `src/lock_detector.cpp`, `src/inspect_focus.h`: the pure decision helpers the tick calls - Field evidence: [../WOBBLE-CAPTURE-2026-08-21.md](../WOBBLE-CAPTURE-2026-08-21.md), diff --git a/docs/architecture/03-engines.md b/docs/architecture/03-engines.md index 7b57a91..a7ce2e5 100644 --- a/docs/architecture/03-engines.md +++ b/docs/architecture/03-engines.md @@ -75,28 +75,47 @@ The inputs, and who computes them in `main.cpp`: | `tdrHarness` | `cfg.tdrTest > 0`: the #148 field harness bypasses the churny veto | config | | `desktopTransformOptIn` | The `desktopTransform` knob is on (issue #185) | config | | `inputTransformOk` | `MagSetInputTransform` was verified available (needs UIAccess) | `TransformModel::inputTransformAvailable()`, probed at init | +| `pref` | The user's per-window-category engine choice (Auto/Transform/Render) for this foreground's category (Game/Acrylic/Desktop/Other) | `FillCategoryInputs` -> `ClassifyWindow` + `ParseEnginePref(engineGame/engineAcrylic/engineDesktop/engineOther)` (issue #237) | +| `captureProtected` | The foreground (or a child surface) carries display-affinity capture protection (DRM: Netflix, Apple TV, PlayReady) | `RefreshFgCache` -> `IsCaptureProtectedFg`, walking child windows | +| `renderExcluded` | The exe is on `renderExclude`, the manual escape hatch for protected apps the affinity probe misses | `RefreshFgCache` -> `FgExeInList(renderExclude)` | -The logic is four lines: +The pick has three tiers, evaluated in this order (`ShouldPickTransform`, src/engine_pick.h): ```cpp +// 1. DRM always wins: a capture-protected window (or renderExclude) renders as a black +// rectangle on the render engine, so it forces transform even off a game or off transformExclude. +if (in.captureProtected || in.renderExcluded) return true; +// 2. An explicit per-window-category preference (issue #237: engineGame/engineAcrylic/ +// engineDesktop/engineOther, each Auto/Transform/Render). Render is honored outright; +// Transform is still refused off the primary monitor or on an excluded exe. +if (in.pref == EnginePref::Render) return false; +if (in.pref == EnginePref::Transform) return in.primaryMonitor && !in.excluded; +// 3. Auto (the historical, unchanged default - an untouched install is all-Auto): const bool game = in.coversMonitor && in.borderless && !in.shellDesktop; const bool desktop = in.desktopTransformOptIn && in.inputTransformOk; return (game || desktop) && in.primaryMonitor && !in.excluded && (in.tdrHarness || !in.churny); ``` -Reading it as intent: the transform is picked for the **game path** (a borderless cover that is +Reading it as intent: DRM protection overrides everything, because a black rectangle is a total +failure and the things transform is otherwise vetoed for (transformExclude, the churny list) are +lesser risks by comparison. Next, a user's explicit per-window-category choice (Settings lets you +pin Games, Acrylic desktop apps, plain desktop, or Other to Transform or Render) wins over the +automatic reads, short of the primary-monitor and exclusion correctness limits. Only then does +Auto run: the transform is picked for the **game path** (a borderless cover that is not the shell desktop, i.e. a real fullscreen game or F11 video) or the **desktop path** (the user has `desktopTransform` on, the default since issue #271, AND the source-rect input transform verifiably works, because without it pointer-input frameworks like Explorer and Settings get hard hover dead zones under a welded cursor, root-caused in [../POINTER-HITTEST-FINDINGS.md](../POINTER-HITTEST-FINDINGS.md)). -Either path additionally requires the primary monitor (no cross-adapter transform chase), and both -are vetoed by the exclusion list and the learned churny list. Everything that fails the predicate -gets the render engine, including the documented trap that a maximized desktop app covers the -monitor but keeps its caption, so it correctly stays on render. - -**The hybrid pick, as ShouldPickTransform evaluates it (src/engine_pick.h):** +Either Auto path additionally requires the primary monitor (no cross-adapter transform chase), and +both are vetoed by the exclusion list and the learned churny list. Everything that falls through +all three tiers gets the render engine, including the documented trap that a maximized desktop app +covers the monitor but keeps its caption, so it correctly stays on render. + +**The Auto tier of the hybrid pick (the historical, unchanged default), as `ShouldPickTransform` +evaluates it once DRM protection and any per-window preference have already been resolved +(src/engine_pick.h):** ```mermaid flowchart TD diff --git a/docs/architecture/04-render-engine.md b/docs/architecture/04-render-engine.md index f9be216..c8f6696 100644 --- a/docs/architecture/04-render-engine.md +++ b/docs/architecture/04-render-engine.md @@ -2,8 +2,13 @@ The render engine is Wind's own magnifier: it captures the desktop with DXGI Desktop Duplication, scales a sub-pixel source rectangle on the GPU with Direct3D 11, and presents the result onto a -fullscreen, click-through, capture-excluded overlay window. It is the default engine for desktop -sessions and the fallback for everything the transform engine cannot handle. Almost every design +fullscreen, click-through, capture-excluded overlay window. Since issue #272 it is the fallback engine for desktop sessions (the transform engine is now +the default there too, `desktopTransform=1`) and for everything else the transform engine is +refused: non-primary monitors (no cross-adapter transform chase), apps on `transformExclude`, +learned churny apps (unless the tdr test harness forces transform), and any desktop session +without a verified input-transform publish (no UIAccess). Capture-protected (DRM) content and +apps on `renderExclude` still get the transform engine even then, since Desktop Duplication +returns black for protected content and the render engine would show nothing at all. Almost every design decision in `src/render_engine.cpp` exists because the obvious alternative was tried and failed in a measurable way; this chapter treats those hard-won rules as first-class architecture, not trivia. diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index e543374..5f49fce 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -20,8 +20,9 @@ games repeatedly put the two in the same class, and after the #219 cadence work better than native on every protocol tried (`../PERF-ACRYLIC-PARITY-2026-08-21.md`). The hybrid model ([chapter 03](03-engines.md)) therefore picks the transform for game sessions -(borderless fullscreen cover on the primary), and optionally for the desktop too -(`desktopTransform=1`, gated on the input transform being available, see below). +(borderless fullscreen cover on the primary), and, since issue #272, on the desktop too by +default (`desktopTransform=1` ships on since the 2026-09-28 owner decision; an explicit +`desktopTransform=0` opts back to render), gated on the input transform being available, see below. ## The shared-runtime law @@ -254,7 +255,9 @@ nearest, so there is no cheaper middle filter; and the raw user32 (field crash 2026-08-13). `txSamplingMode` ships 0 (nearest): a slightly blocky image is the correct trade against a compositor that dies. The flag is DWM-global and survives our process until DWM restarts, which is why smoothing appeared to come and go between builds; the model -re-applies the configured mode once per context (`samplingApplied_`). +re-applies the configured mode per context (`appliedSampling_`), with a bounded retry (up to 3 +attempts, 1 s apart) since the setter's return value is not reliable - mode 0 via ordinal 1 +reports FALSE on every call on this rig while still working (issue #274/#275). ## The input transform diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index c9edb5f..7051768 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -141,13 +141,13 @@ is a plain object naming its ini key, row type, label, description, and default. | Row type | Widget | Notes | |---|---|---| -| `keybind` | `ui/src/lib/KeybindCapture.svelte` | Stores state under `buttonKey`/`vkKey`/`modsKey` sibling ini keys, not `row.key` (which is a `__`-prefixed placeholder) | +| `keybind` | `ui/src/lib/KeybindCapture.svelte` | Stores state under `buttonKey`/`vkKey`/`modsKey` sibling ini keys, not `row.key` (which is a `__`-prefixed placeholder); the zoom-in/zoom-out rows also carry a second `buttonKey2`/`vkKey2`/`modsKey2` slot so either binding fires the action (the core OR-combines them) | | `toggle` | animated SVG checkbox | Writes `1`/`0` | | `slider` | `input type=range` | `min`/`max`/`step`/`unit`; `unit` also feeds `aria-valuetext` | | `select` | `ui/src/lib/CustomSelect.svelte` | `options` + `optionLabels` (e.g. `hybrid` shown as "Auto") | | `segmented` | ARIA radiogroup with roving tabindex | No live rows use it after the 2026-08-21 cleanup, but the widget remains | | `applist` | summary + "Manage list" dialog (`ui/src/lib/AppListModal.svelte`) | One comma-separated ini string; the host's `pickExe` feeds it bare exe names | -| `mpo` | checkbox bound to `extra`, not `values` | Reflects a registry value; keeping it out of `values` prevents Apply from writing a junk key into the ini | +| `highres` | checkbox bound to `values` (the `txSamplingMode` ini key) | The combined high-resolution-cursor/MPO toggle (issue #242); only the separate "Requires restart" chip comes from `extra.mpoNeedsRestart`, since that reflects a registry value with no ini key of its own | | `about` | logo hero | Label-less; also gives the last section enough height for the scrollspy | | `color`, `button` | supported by `Row.svelte` | Currently unused by the schema | @@ -158,8 +158,10 @@ Row *visibility and gating* are schema flags, all evaluated in the render condit staged `values`, so flipping it reveals rows before Apply). - `requires: 'key'` shows the row only while another value is `1` (the alternate-keybind rows require `altKeybinds`); `requiresNot` is the inverse. -- `showIf: {key, eq}` shows the row only when another value equals a literal (historically used - for model-specific display rows; the cleanup removed the last users, the mechanism remains). +- `showIf: {key, eq}` shows the row only when another value equals a literal: the per-window- + engine rows (`engineGame`, `engineAcrylic`, `engineDesktop`, `engineOther`, `renderExclude`) + use it to hide themselves unless `model` is `hybrid`, where a pinned single engine would make + them no-ops. - `dependsOn: 'key'` renders the row but disables it when the dependency is off. This is why adding a setting is normally a one-line schema edit plus a core-side `ParseConfig` @@ -188,6 +190,14 @@ that pin the mouse to the screen center, which would otherwise pin the zoomed vi listed apps get the view unlocked from the pointer and panned from raw mouse motion (see [The cursor system](07-cursor.md)). +### Tracking (issue #276/#277) + +A Tracking section, sitting between Cursor and Display, was added after the 2026-08-21 cleanup: +`trackCaret` (on by default), `trackFocus` (off by default), `trackAlign`/`mouseAlign` (Centred +vs Within the edges selects), and `mouseMarginPct` (the edge-mode margin slider). None of these +rows carry an `advanced` flag, so they show unconditionally. The caret/focus/edge-mode +mechanics they drive are covered in [The cursor system](07-cursor.md). + ## Staged Apply, live keybinds Settings state is two dictionaries in `Settings.svelte`: `values` (what the page shows) and diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index 5197ee7..d4be42d 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -89,12 +89,13 @@ flowchart TD The moving parts: -- **`src/version.h` is the only version declaration.** The workflow regex-reads `WIND_VER_MAJOR/MINOR/PATCH` from it. Bumping it is what cuts a NEW release (a new tag `v`); a push that leaves it alone refreshes the existing release's asset in place with `gh release upload --clobber`, which is what keeps the download matching `main` without a version per commit. As of this writing the tree is at 0.2.0. -- **Docs never trigger it.** `paths-ignore` skips `**.md`, `docs/**`, and issue templates, since they cannot change the installer. +- **`src/version.h` is the only version declaration.** The workflow regex-reads `WIND_VER_MAJOR/MINOR/PATCH` from it. Bumping it is what cuts a NEW release (a new tag `v`); a push that leaves it alone refreshes the existing release's asset in place with `gh release upload --clobber`, which is what keeps the download matching `main` without a version per commit. As of this writing the tree is at 0.10.4. +- **Docs, the licence text and the test harness never trigger it.** `paths-ignore` skips `**.md`, `docs/**`, issue templates, `LICENSE`, and `tools/testenv/**`: none of them can change the installer, and skipping the last two keeps a licence or test-harness edit from resetting the published asset's hash for nothing. - **Tests gate the build.** `build.bat test` runs before the installer is built; a red doctest suite blocks the release. -- **`tools/release.ps1` is the shared build driver**, used identically by CI and by a local release. Signing is environment-driven (`WIND_SIGN_THUMBPRINT` or `WIND_SIGN_PFX`/`WIND_SIGN_PASSWORD`) so no certificate detail enters the repo. With a cert it builds and signs the `uiaccess` variant, and it signs the PAYLOAD before makensis packs it, because signing the installer does not sign what is inside it and UIAccess is granted on Wind.exe's own signature. Without a cert it builds the ordinary `uiAccess=false` variant, on the reasoning in its header: shipping a manifest that asks for a privilege Windows will refuse is noise in a public artifact, and the app already degrades correctly. -- **CI ships unsigned `uiAccess=false`, and that is correct**, not a gap. No certificate is configured in CI, and the self-signed dev cert must never go there: it is trusted by nobody and would look worse than no signature. +- **`tools/release.ps1` is the shared build driver**, used identically by CI and by a local release. Signing is environment-driven (`WIND_SIGN_THUMBPRINT` or `WIND_SIGN_PFX`/`WIND_SIGN_PASSWORD`) so no certificate detail enters the repo. With a cert it builds and signs the `uiaccess` variant, and it signs the PAYLOAD before makensis packs it, because signing the installer does not sign what is inside it and UIAccess is granted on Wind.exe's own signature. Without a cert it builds BOTH variants: the ordinary `uiAccess=false` Wind.exe, and the uiaccess build as `WindUA.exe`, which the installer then signs on each PC it installs to with a locally trusted, per-machine certificate whose private key is deleted right after signing (issue #261/#262, `installer/local-sign.ps1`); if that local signing fails, setup keeps the ordinary build. +- **CI ships both variants unsigned, and that is correct**, not a gap. No certificate is configured in CI, and the self-signed dev cert must never go there: it is trusted by nobody and would look worse than no signature. Local, per-PC signing at install time is what gives most machines a UIAccess-capable build without a real certificate. - **A concurrency group serializes runs** (`group: release`, no cancel-in-progress) so two quick pushes cannot race to upload the same asset, and the publish step hand-checks `gh` exit codes because Actions' pwsh turns the normal "tag does not exist yet" exit 1 from `gh release view` into a thrown error otherwise. +- **The asset can publish to a separate public repo.** While this repo stays private, the workflow targets `Maxaubert/Wind-releases` whenever `RELEASES_TOKEN` (a fine-grained PAT scoped to that repo) is set, so the installer is publicly downloadable without authenticating against a private repo. Without that secret it falls back to publishing the release here instead. Check `Maxaubert/Wind-releases` first when looking for a published installer. The corollary standing rule: never hand-upload a release artifact. The workflow owns the assets, and a manual upload is precisely how the public download drifts from `main` again. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f7a1451..5804975 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -6,7 +6,7 @@ guided tour, or jump straight to the subsystem you are touching. Historical desi field investigations are linked from each chapter as evidence; when this book and an old spec disagree, the book (and above it, the code) wins. -Current as of v0.2.0. +Current as of v0.10.3. ## The system at a glance @@ -51,7 +51,7 @@ flowchart LR | 04 | [The render engine](04-render-engine.md) | Own capture + GPU scale, and the compositor rules learned the hard way | | 05 | [The transform engine](05-transform-engine.md) | Magnifying inside DWM: channels, cadence, MPO, the input transform | | 06 | [The input pipeline](06-input.md) | Hooks, Raw Input, key swallowing and its limits | -| 07 | [The cursor system](07-cursor.md) | Free cursor, the weld, the sprite, lock detection, Inspect mode | +| 07 | [The cursor system](07-cursor.md) | Free cursor, the weld, the sprite, lock detection, Inspect mode, tracking (caret/focus/mouse edge) | | 08 | [Config and profiles](08-config-profiles.md) | The ini as the single source of truth, and profiles on top | | 09 | [The settings UI](09-settings-ui.md) | The WebView2 host, the schema-driven Svelte app, the bridge | | 10 | [The magnify model](10-magnify-model.md) | Driving the native Magnifier, and the measured dead ends | diff --git a/installer/README.md b/installer/README.md index 0ae67c5..7445328 100644 --- a/installer/README.md +++ b/installer/README.md @@ -23,12 +23,23 @@ whose wrong answers are quiet. | `make-loop.mjs` | turns a source clip into the frame sequence, and makes it loop | | `kit.nsh` | the frameless window: size, DPI, GDI+, unpacking | | `video.nsh` | the player: decode, composite, hover, clicks, dragging | -| `screens.nsh` | the four screens, and what each click means | +| `screens.nsh` | the five screens, and what each click means | | `over.nsh` | generated: control rectangles in 640x480 units | | `media//` | generated: `v/` frames, `o/` overlays. Not hand-edited. | | `media//o/back.png` | generated: the shade and the caption scrim, drawn under every screen | | `MicrosoftEdgeWebview2Setup.exe` | Microsoft's ~1.7 MB Evergreen bootstrapper stub | +## The licence screen (issue #258) + +Two of the five screens are the licence page, before and after its box is ticked (`over.html` +screens 4 and 5; `windLicenceCreate`/`windLicenceLeave` in `screens.nsh`). Install/Next does +nothing until the box is checked. "Read the full licence" does not open a copy from +`$PLUGINSDIR` (an elevated NSIS locks that folder to Administrators, so a de-elevated viewer +would be refused); it copies `LICENSE.txt` into a fresh folder made with `GetTempFileName` in +the user's own temp directory, opens it through `explorer.exe` so the viewer itself is not +elevated, and deletes that copy on `.onGUIEnd`. A silent install (`/S`) skips the page like +every other one, and `LICENSE.txt` is installed next to `Wind.exe` either way. + ## Building it ``` diff --git a/tools/testenv/README.md b/tools/testenv/README.md index 97c8a2f..1cc0049 100644 --- a/tools/testenv/README.md +++ b/tools/testenv/README.md @@ -68,8 +68,8 @@ Metric notes: ## Benchmark mode (bench.ps1): compare magnifiers head to head - powershell -File tools estenvench.ps1 # Wind vs Windows Magnifier - powershell -File tools estenvench.ps1 -Drivers wind,native,external -ExternalSpec zt.json + powershell -File tools\testenv\bench.ps1 # Wind vs Windows Magnifier + powershell -File tools\testenv\bench.ps1 -Drivers wind,native,external -ExternalSpec zt.json Drives DIFFERENT magnifiers through the SAME scenarios (solid pan/fast, heavy acrylic zigzag over a solid underlay, heavy acrylic pan over a video-like animated underlay, and a response