diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 997725fa..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "permissions": { - "allow": [ - "Bash(./build.bat*)", - "Bash(cmd //c build.bat*)", - "PowerShell(Start-Process powershell -Verb RunAs -Wait -PassThru -WorkingDirectory 'C:\\Users\\Admin\\Documents\\Claude\\Github\\Wind' -ArgumentList '-ExecutionPolicy','Bypass','-File','C:\\Users\\Admin\\Documents\\Claude\\Github\\Wind\\tools\\uiaccess_setup.ps1'*)", - "PowerShell(Start-Process \"C:\\Program Files\\Wind\\Wind.exe\"*)" - ] - } -} diff --git a/.github/release-notes.md b/.github/release-notes.md index 2dee922c..7cae6d77 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -1,43 +1,22 @@ -A lightweight fullscreen magnifier for Windows. Smooth zoom that keeps tracking the mouse -even when a game hides or locks the cursor. +Fullscreen magnifier for Windows with smooth zoom that keeps tracking the mouse in games. ## Install Download **Wind-Setup-x64-__VERSION__.exe** below and run it (`Wind-Setup-x64.exe` is the same -installer under a stable name: https://github.com/Maxaubert/Wind/releases/latest/download/Wind-Setup-x64.exe -always serves the newest release). Setup installs to -`C:\Program Files\Wind` and asks for administrator rights, offers to start Wind when you -sign in, and installs the WebView2 runtime if the Settings window has no browser engine to -run in. Your settings, profiles and logs live in `%LOCALAPPDATA%\Wind`, and uninstalling -keeps them unless you say otherwise. +installer under a name that always points at the latest release). Setup needs 64-bit Windows 10 or +11 and administrator rights, installs to `C:\Program Files\Wind`, offers to start Wind when you +sign in, and adds the WebView2 runtime if it is missing. Settings, profiles and logs stay in +`%LOCALAPPDATA%\Wind`; uninstalling keeps them unless you choose otherwise. -Requires 64-bit Windows 10 or 11. +## Unsigned installer -## One thing to know before you download +The installer is not signed, so SmartScreen warns on first run: choose **More info**, then +**Run anyway**. Some browsers and managed work computers block the download. During setup, Wind is +signed locally with a certificate created for your PC, whose private key is deleted straight away; +this lets zoom keys work over elevated windows. If that step fails, setup installs a build without +that ability. -**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. - -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 - -- Hold-to-zoom on the mouse side buttons, and configurable keybinds -- Keeps tracking the cursor in games that hide or lock it, using raw HID input -- Inspect mode: freeze the pointer and free-look around the magnified view -- 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 +## Verify SHA-256 of `Wind-Setup-x64-__VERSION__.exe` (and its copy `Wind-Setup-x64.exe`): @@ -45,7 +24,4 @@ SHA-256 of `Wind-Setup-x64-__VERSION__.exe` (and its copy `Wind-Setup-x64.exe`): __SHA256__ ``` ---- - -Built from `__COMMIT__` by the release workflow. The installer on this page is rebuilt and -replaced on every push to `main`, so it always matches the current source. +Built from `__COMMIT__`. diff --git a/.gitignore b/.gitignore index f4908205..7ec31690 100644 --- a/.gitignore +++ b/.gitignore @@ -4,46 +4,41 @@ *.pdb *.ilk *.lib -# ...but the VENDORED WebView2 import library is not build output, it is part of the SDK we -# ship in-tree, and WindConfig.exe cannot link without it. Ignoring it worked for years only -# because every build was local; CI failed on the first run with LNK1181. -!third_party/webview2/x64/*.lib *.exp *.res build/ +dist/ +# Vendored binaries that are part of the tree, not build output +!third_party/webview2/x64/*.lib +!installer/MicrosoftEdgeWebview2Setup.exe +# installer/media/ stays committed: CI cannot regenerate it (see installer/README.md) + # Editor / OS .vs/ *.user Thumbs.db -# Claude local (machine-specific) settings and worktrees + +# Claude Code: machine-specific settings, worktrees, plans and brainstorm output +.claude/settings.json .claude/settings.local.json +.claude/worktrees/ .worktrees/ -# Runtime config copy (generated next to the exe by LoadConfig on first run) +.superpowers/ +docs/superpowers/ + +# Runtime and diagnostic output /magnifier.ini -# Diagnostic logs *.log -# PresentMon / perf captures *.csv -# WebView2 user-data folder (created next to WindConfig.exe at runtime) *.WebView2/ -# Self-test / diagnostic dumps wind_selftest.png wind_hdr_diag.txt -# Stray build-log redirects build_*.txt -.superpowers/ -# graphify knowledge-graph output (generated) +# Generated graphify-out/ -# Installer release artifacts. The generated media under installer/media/ is NOT ignored: -# CI builds the installer on every push to main and cannot regenerate frames and overlays -# without ffmpeg, ImageMagick, Playwright and the source clip. It is ~13 MB and changes only -# when the footage or the copy does. Regenerate with installer/make-loop.mjs + make-over.mjs. -dist/ -# ...but the WebView2 bootstrapper stub IS committed; it is a Microsoft -# redistributable the installer packs, not build output. Overrides *.exe above. -!installer/MicrosoftEdgeWebview2Setup.exe +# Test harness tools/testenv/results/ tools/testenv/gamesim.exe tools/testenv/*.obj diff --git a/CLAUDE.md b/CLAUDE.md index e076d936..49e09ea1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,628 +1,139 @@ -# Wind - fullscreen magnifier +# Wind -Lightweight standalone Windows fullscreen magnifier replacing Magnify.exe. -Design spec: `docs/superpowers/specs/2026-05-24-magnifier-design.md`. -Plan: `docs/superpowers/plans/2026-05-24-wind-magnifier.md`. -Developer book (readable, canonical): `docs/architecture/` - keep it in step with changes here. +Windows fullscreen magnifier (C++17, MSVC, DXGI/D3D11, DWM). Developer docs: `docs/architecture/` +(start at its README). Keep them in step with the code; the code wins when they disagree. +Plans and brainstorm output are not committed. ## Commands -- Build app: `build.bat` (locates MSVC via vswhere, emits `Wind.exe`; uiAccess=false, runs anywhere) -- Build + run tests: `build.bat test` (runs the doctest binary; exit 0 = pass) -- Build UIAccess variant: `build.bat uiaccess` (uiAccess=true manifest; must be signed + run - from `C:\Program Files\Wind` - deploy via `tools\uiaccess_setup.ps1` elevated). Needed only - to OPTIONALLY cover the Start menu / taskbar / tray (overlay uses `CreateWindowInBand`, - opt-in `zorderBand=16`; shipped default is 0, see the band note below). -- Build config UI: `build.bat config` (npm-builds the Svelte app under `ui/` to `ui/dist/`, then - compiles `src/config_ui/*.cpp` against the vendored WebView2 SDK -> `WindConfig.exe` next to - `Wind.exe`). Also run by `tools\uiaccess_setup.ps1`, which deploys `WindConfig.exe` + `ui/dist` - alongside the signed `Wind.exe`. -- The app and `uiaccess` targets also build `WindTray.exe` (`build.bat tray` alone), ALWAYS with the - plain manifest: it must never be uiAccess (see "Three binaries"). -- Build the installer: `build.bat installer` (needs NSIS: `winget install NSIS.NSIS`; compiles - `installer\wind.nsi` then runs `tools\installer_check.ps1`). Release artifact: - `pwsh -File tools\release.ps1` -> `dist\Wind-Setup-x64-.exe` (CI also uploads it as `Wind-Setup-x64.exe`, #343: - `releases/latest/download/Wind-Setup-x64.exe` is the always-latest link). Setup is a custom-drawn - video screen modelled on Prism's; see `installer\README.md` and the spec - `docs/superpowers/specs/2026-08-20-installer-design.md`. -- Deploy UIAccess build (elevated; from a normal shell): - `Start-Process powershell -Verb RunAs -ArgumentList '-NoExit','-ExecutionPolicy','Bypass','-File','tools\uiaccess_setup.ps1'` +- `build.bat`: `Wind.exe` + `WindTray.exe` (uiAccess=false, runs anywhere). +- `build.bat test`: builds and runs the doctest binary; exit 0 = pass. +- `build.bat check`: compile-only pass over `src\*.cpp`. +- `build.bat uiaccess`: the uiAccess=true build; works only signed and run from `C:\Program Files\Wind`. +- `build.bat config`: builds the Svelte UI (`ui/` -> `ui/dist/`) and `WindConfig.exe`. +- `build.bat tray`: `WindTray.exe` alone. It always uses the plain manifest; never make it uiAccess. +- `build.bat installer`: NSIS setup (needs `winget install NSIS.NSIS`), then `tools\installer_check.ps1`. +- `pwsh -File tools\release.ps1`: release installer into `dist\` (what CI runs). +- UI tests: `npx playwright test` in `ui/` (run when anything under `ui/` changes). -## Stack -C++17, MSVC cl.exe. DXGI Desktop Duplication + Direct3D 11 (own renderer); Raw Input, -`WH_MOUSE_LL`, DWM (`Dwmapi.lib`), WIC, `MagShowSystemCursor` (`Magnification.lib`, just to -hide the OS cursor). Tests: vendored `third_party/doctest.h`. +## Stack and map +- Three binaries: `Wind.exe` (core, UIAccess when installed), `WindConfig.exe` (WebView2 settings), + `WindTray.exe` (tray flyout, never UIAccess). Settings travel only through `magnifier.ini`. See 01. +- Engines behind `IMagnifierModel`, chosen by `model=` (restart to switch): `hybrid` (Auto, default) + picks `render` or `transform` per session via the pure `src/engine_pick.h`. `model=magnify` is + parsed as `hybrid`; the magnify engine is retired, do not revive it. See 03, 04, 05. +- Profiles: `profiles\.ini` next to the live ini; the live ini is the session, the profile file + the saved state. See 08. +- Settings UI: schema-driven Svelte (`ui/src/settings-schema.js`); `HandleWebMessage` in + `src/config_ui/main.cpp` is the authoritative bridge message set; extend the Playwright mock when + adding one. See 09. +- **Pure-logic files must not include ``.** The test build compiles only the pure `.cpp` + files listed in `build.bat :test`, with `WIND_TESTS` defined. See 11. -## Architecture -Pure logic (no ``): `src/transform` (float `ComputeOffsetF`), -`src/zoom_controller`, `src/cursor_mapper`, parse half of `src/config`. -Win32 I/O: `render_engine`, `input_router`, `tray`, `main`. +Chapter numbers refer to `docs/architecture/NN-*.md`. -One paced tick loop; models behind `IMagnifierModel` (`model=` ini key, restart to switch): -`hybrid` (DEFAULT, "Auto" in the UI) constructs render + transform and picks per zoom-in at the -idle->active edge - transform when the foreground covers the monitor AND is borderless (games, -F11 video) on the primary, else render; while ZOOMED it re-picks instantly (always on) when the -foreground changes, preserving level/lens (controller+mapper untouched; the OUTGOING engine -rests a few ticks AFTER the incoming one is live - restAfterReveal - so a handover never -composites a bare unmagnified frame). `transform` (revived issue #148) = DWM fullscreen -transform via MagSet/private channel: compositor-internal, the only path that stays smooth over -a heavy game (native-Magnifier parity measured); continuous per-tick level (big discrete jumps -are what cost ~30-50ms game frames - do NOT re-quantize ramps), PAN-START HITCH (FIXED 2026-08-27): the first move after any pause hitched, worst at a -side-to-side reversal. Cause is DWM's magnification RE-RENDER going cold, NOT the compositor: -the trace shows a resting tick at 7.50ms (panel at full rate) followed by 25.01ms on the FIRST -REAL pan write. `txWarmMode` (SHIPS 1) alternates the translation 1px on rest ticks, which is a -REAL source-rect change and is what keeps that path warm. Mode 4 (level epsilon 2e-5) held -composition at a flat 6.94ms and scored 0.00 stalls in 15/15 automated rounds and the user STILL -felt the spike - sub-pixel is not a real change, so DO NOT trust composition-rate metrics here. -Cost: the view sits 1px off on alternate rest ticks (the #204 shimmer); txWarmMode=0 disables. -EVERY WARM WRITE IS A FULL DWM RE-RENDER (#246): per-tick warming cost dwm.exe 16% GPU with the -mouse STILL (native at rest: 0.2%) - the whole field-reported GPU gap; panning costs both ~the -same (Wind 16%, native 12%). So it is a PULSE on `txWarmHz` (ships 12: rest cost 4.6%; 0 = per -tick), one displacement + return per period; an open pulse always closes first. The hitch never -reproduces on the desktop, so the cadence floor is a game verdict (`tools/warm_cadence_sweep.ps1`, -`tools/gpu_ab.ps1` = per-process dwm/Wind GPU vs native on the solid target). -MEASURED HARMFUL, do not re-enable: `txWriteHz`/`txMinOffsetPx` (2px view steps = wobble at low -zoom; 60Hz view = low fps at high zoom) and gating the cursor sprite on "the view moved" (freezes -the drawn cursor in the edge zones, worst bottom-left). See docs/HITCH-FINDINGS.md. -launch warm-up 1.001, rest at TRUE 1.0. -maxLevel is ONE SHARED setting across models (no per-model cap; the old 12x cap guarded what -turned out to be the MPO bug below). TRANSFORM ON THE DESKTOP (root-caused AND solved -2026-08-12, docs/POINTER-HITTEST-FINDINGS.md): pointer-input frameworks (XAML/DirectUI - -Explorer, Settings, shell) hit-test through the fullscreen transform and produce hard hover -dead zones under a welded cursor UNLESS the SOURCE-RECT input transform is published -per change (`MagSetInputTransform(TRUE, srcRect, monitorRect)` - what native Magnifier does -continuously; MSDN's "pen/touch only" scoping is wrong, mouse pointer hit-testing consumes -it). Field-verified 4x-20x: weld + source-rect input transform = correct hover everywhere, -legacy apps unaffected. `magInputTransform=1`. HARD DEPENDENCY: needs UIAccess - where absent -(dev builds) the publish fails and transform desktop sessions keep the dead zones, so render -stays the desktop engine unless the publish verifiably succeeds. Identity or no publish = -dead zones (measured); do not "simplify" the source rect away. `desktopTransform=1` (hot; -the DEFAULT since issue #271, owner decision 2026-09-28) lets hybrid pick the transform on the -DESKTOP too (primary -monitor only - multiMonitor secondaries stay on render), gated on the -availability probe (`TransformModel::inputTransformAvailable`, probed at init via a -teardown-shaped acquire/release). `spriteBand16=1` (restart) = the P2 constant-size cursor -experiment (band-16 SCREEN-space sprite; two historical layered-window measurements -contradict each other, so it needs one field verdict). -TRANSFORM CURSOR: WELDED (re-test of the #148 weld, commit 8a52040; supersedes the retired -FOLLOW+FREEZE design - git history has that machinery). The transform welds the REAL cursor to -the lens point per tick (transform_model.cpp; deduped, suspended by drag-follow), exposes -`weldedLastFrame()` so RunTick's #169 measured baseline treats it exactly like the render park, -and hides the raw pointer behind the sprite while zoomed >1.001x. The original weld-TDR -verdicts were measured with MPO enabled AND native Magnifier running - both since eliminated - -so the weld is being re-tested rather than engineered around; if driver resets return, the weld -is the FIRST suspect (docs/HITCH-FINDINGS.md has the bisect). Still true regardless of design: -ClipCursor re-asserts are DEDUPED via GetClipCursor reads, Inspect's injected absolute clicks -pause transform writes for ~3 ticks (ex.pauseWrites), and ComputeMagTransform clamps offsets -AND private-channel translations with a 2px right/bottom margin (rounding overshoot = TDR class). -NVIDIA MPO BUG (issue #148; REFINED 2026-08-29, issue #242): the driver packs DWM's -magnification translation into a 16-bit field ON THE NEAREST-SAMPLING PATH; |srcX*level| > -32767 (the far-right strip above ~9.3x on 3840, and the same for Y above ~16.2x on 2160) wraps -and TDRs - both API channels. #148 believed it needed a game on an overlay plane; the field -disproved that twice: a Mica browser (#197), then three PLAIN-DESKTOP TDRs at 25x bottom-right -(LiveKernelEvent 117 + nvlddmkm storms, tick traces show txX=-92110) with the #191 MPO ghost -verifiably shown AND settled - so the ghost-settled wall lift is FALSIFIED for nearest and the -field lives in the nearest magnification path itself, which no window geometry can demote. -SMOOTH sampling (txSamplingMode=1) takes the shader/float path: the same corner at 25x survived -repeated A/B/A, and native Magnifier (smooth by default) survives it too. With MPO disabled -(HKLM\SOFTWARE\Microsoft\Windows\Dwm OverlayTestMode=5 DWORD, reboot) everything is clean at -full range. The walls (mapper setMaxSourceLeft/Top + the write-site clamp in transform_model) -therefore key on MPO boot state + SAMPLING MODE: MPO on + nearest = walls ALWAYS (no ghost -lift); MPO on + smooth = the #191 ghost-gated lift; MPO off = full range. UI COUPLING (#242): -the Settings "High resolution cursor" toggle is ONE option driving BOTH txSamplingMode and the -MPO registry state (crisp stages MPO-disable via the old UAC+restart flow, high-res stages -re-enable) - crisp+MPO-on is never offered because it is the crash combo; the wall guards the -interim until the reboot lands. CHURNY APPS: the live GetCursorInfo churn -valve was retired (it mis-fired on our own cursor work); what remains is the DEVICE-LOST -BACKSTOP - a render device-lost within 30s of a transform game session marks that session's exe -in %LOCALAPPDATA%\Wind\churny_apps.txt, and future zoom-ins over it pick render (one crash, -never two). `tdrTest` ini knob (hot) = the #148 field harness: >0 forces transform past the -churny list; mode 2 = |tx| clamp probe, mode 4 = pan wall off; modes 1/3 are retired. The -engine pick itself is PURE (`src/engine_pick.h`, doctested) and shared by the zoom-in pick and -the mid-zoom instant switch. RTSS tell while zoomed: doubled overlay = render session, single = -transform. `swapModelVk` is fully retired: the ini key is IGNORED (nothing binds or reads it). -`model=render`: `render_engine` = own DXGI Desktop -Duplication capture + D3D11: magnifies a sub-pixel float source rect to a click-through, -capture-excluded (`WDA_EXCLUDEFROMCAPTURE`) fullscreen overlay; draws the real cursor -(`GetCursorInfo`) centered via `cursor_mapper`; hides the OS cursor (`MagShowSystemCursor`) and -syncs `SetCursorPos` for clicks. Sub-pixel pan + smooth centered cursor. -`model=magnify` (issue #146; NO LONGER SELECTABLE since 0.18.0, Max 2026-10-02: the pickers drop it and config parse maps it to hybrid, so the code below is dormant): drives the NATIVE Windows Magnifier - the DRM-safe fallback -(Netflix etc. blanks under Desktop Duplication). FINAL DESIGN = maximum simplicity: Wind holds -NO zoom state and never touches the transform. `selfDrivenZoom()` on the model interface makes -RunTick bypass the ENTIRE level pipeline (ZoomController pinned at 1x, overlay never activates, -quick zoom / mapper / Inspect never run) and instead call `nativeZoomTick(dir, cfg)` every tick: -while a zoom button is held it injects Ctrl+Alt+wheel notches (Magnifier's own wheel-zoom -shortcut) every 60 ms; Magnifier does everything else natively (stepping by `magnifyStep` -> -its ZoomIncrement, easing each notch, panning, cursor). `magnifyStep` (ini + Settings UI, -5..400, default 50) is written to ZoomIncrement live on change. Init preps fullscreen mode + -toolbar minimized + Magnification=100 and launches Magnify.exe; shutdown/model-swap injects -Win+Esc and restores the user's Magnifier registry from a one-shot snapshot -(`%LOCALAPPDATA%\Wind\magnifier_backup.ini`, written before first modify, kept across crashes -so we never "restore" our own values). The KEYBOARD HOOK SKIPS INJECTED EVENTS in magnify mode -(`setIgnoreInjectedKeys`) so our Ctrl/Alt/Esc chords are never swallowed by a user bind. -DO NOT RE-ATTEMPT the smarter drives - all measured dead ends (probes + amendments in the -spec): injected Win+Plus chord bursts drop ~half and animate each survivor (lag + zoom-after- -release); streaming the `Magnification` registry faster than its ~280 ms animation window -degenerates into ~40% snaps (ONE write eases beautifully - that part is real); injected -Win+wheel is INERT (Ctrl+Alt+wheel is the real channel); driving MagSetFullscreenTransform -ourselves during ramps IS glass-smooth and sticks while Magnify.exe runs, but Magnifier stomps -its stale belief within ~7 ms of any wake with queued mouse moves, its registry handler -animates from a STALE cached actual for writes queued while suspended, and the whole hybrid -collapsed into flicker/racy release levels; suspending Magnify.exe mid-ramp is measurable-safe -for input latency yet still lost the belief-sync races. Also: Magnification writes above 1600 -are silently IGNORED (not clamped), and a SAME-VALUE registry write fires no notification. -The old Magnification-API `engine=mag` fallback was removed (issue #20). History: `model= -transform` was removed for issue #146, then REVIVED as a first-class model for issue #148 - -there is NO transform->magnify aliasing; a `model=transform` ini value runs the real transform -model. Missing/unknown model values fall back to `hybrid` (the product default, "Auto"). -Specs: `docs/superpowers/specs/2026-05-25-own-renderer-design.md` (render, issue #4), -`docs/superpowers/specs/2026-07-22-magnify-model-design.md` (magnify). +## Gotchas -**Profiles** (issue #178, spec `docs/superpowers/specs/2026-08-12-profiles-design.md`): named -full-snapshot settings profiles (keybinds included). Each is `profiles\.ini` next to the -resolved `magnifier.ini`; the live ini stays the single config both exes use, plus `profile=`. -GLOBAL keys never travel with a profile: `profile`, `onboarded`, `uiTheme`, `uiPalette`, `showAdvanced` -(`IsGlobalProfileKey`, src/profiles.* pure + tested; I/O in src/profiles_io.h). Switching = -`MakeLiveText` (profile keys over live, globals preserved) + hot-reload; a `model` change relaunches -Wind via the eviction handshake. An EMPTY profile file = factory defaults (absent keys fall back to -ParseConfig defaults) - that is how "create new profile" works. Settings use a SESSION model (0.16.0, #303): every -change writes only the live ini (instant, hot-reloaded); the profile file is the SAVED state and -changes on Save (`saveSession`) or on a keybind capture (`setConfigPersist`). Unsaved = -`SessionDiffers(live, profile)`. At Wind start `ResetSessionToProfile` rewrites live from the -profile (unsaved changes never survive), unless `%LOCALAPPDATA%\Wind\session.keep` marks a -self-triggered restart. Tray Quit prompts from the files. First run seeds -`Default` from current settings (`EnsureProfilesSeeded`). Surfaces: tray `Profiles` submenu -(switch only, IDs 1100..1131) and the Settings General page (switch/create/rename/ -duplicate/delete; bridge messages `listProfiles`/`switchProfile`/`createProfile`/`renameProfile`/ -`duplicateProfile`/`deleteProfile`, each replying the refreshed list). +**Magnification runtime (05)** +- Never call `MagInitialize`/`MagUninitialize` directly; use `wind::MagApiAcquire()`/`MagApiRelease()` + (`src/mag_host.*`). Independent pairs break each other: two cursors, or transform writes returning + FALSE. Keep every hold symmetric. +- A live magnification context taxes every cursor change any app makes, even at level 1.0; only + releasing the runtime leaves that mode. So: no warm-up write at launch, context only around sessions. +- Colour filters (warmth/brightness) hold the runtime at 1x and pay that tax. Measure a + cursor-toggling game with colour on before blaming anything else. See 04. +- Magnification calls are thread-affine: only the owning thread's writes take effect. -**Three binaries.** `WindTray.exe` (`src/tray_app/`, issue #291) owns the tray icon and menu -WITHOUT UIAccess: a UIAccess process's popup menu stacks above the cursor sprite and the Snipping -Tool overlay, an ordinary process's menu does not. Wind starts it with `ShellExecuteExW` (never -CreateProcess: no inherited UIAccess token) passing `--wind-pid`, restarts it if it dies (at most 3 -launches a minute, `src/tray_host.cpp`), and it exits when that Wind exits. The only coupling is the -shared block `Local\Wind_TrayState_v1` (`src/tray_ipc.h`: status, frame-pacing ring, `menuOpen`) -plus `Local\Wind_QuitRequest` for Quit. Since 0.17.0 (#313) the tray menu is a Direct2D flyout window (`src/tray_app/flyout_*`, no HMENU) with user-chosen quick sliders and toggles; the choice lives in global ini keys `tray*` (`src/tray_items.*`, Settings > Tray menu, `ui/src/tray/`). Spec: `docs/superpowers/specs/2026-10-01-tray-flyout-design.md`. Since 0.18.0 (#315) the toggles are ONE stretched segmented group (full content width whatever the count, 1 px separators) with an ENGINE DROPDOWN row below it (full width, current engine as text, chevron; mockup v02, Max 2026-10-02, no zoom readout) that picks the MAIN engine `model` (Auto/Render/Transform, the Settings row's order and labels; System was dropped from both pickers by Max 2026-10-02 and the core reads model=magnify as Auto): `src/tray_app/engine_dropdown.cpp` writes the live ini (a session change like Settings, the profile is untouched), drops `session.keep`, then relaunches Wind.exe with no prompt; the group geometry is `LayoutSegments` in `src/tray_app/flyout_tools.h`, the engine list is as wide as the dropdown. Spec `docs/superpowers/specs/2026-10-02-tray-tools-design.md`; Max REJECTED Mouse lock, Pass keys and Pause Wind chips (2026-10-02), so none exist and unknown keys in `trayToggles` are dropped. The `keepEdges` segment is labelled "Keep cursor centred" (Max 2026-10-02): ON = mouseAlign AND trackAlign both 0 (centred), the key name is kept for saved layouts. Tray item names follow the renamed Settings rows (Arrow key speed, Engine; the ini keys are unchanged). `WindTray.exe --render-test out.png` renders it headless for visual checks. `Wind.exe` is the always-running magnifier (the -perf-critical core described above). `WindConfig.exe` is an on-demand settings GUI: a thin C++ WebView2 host -(`src/config_ui/main.cpp`) that loads a built Svelte app from `ui/dist/` and talks to the core -only by writing `magnifier.ini` (the core dir-watches and hot-reloads it - no IPC). First -launch also runs a short guided onboarding (wind-trails-into-logo intro -> set zoom keys -> -done; sets `onboarded=1` so it never auto-opens again). The config process is non-admin, runs -in a separate exe entirely, and has zero perf coupling to the magnifier loop. Settings spec: -`docs/superpowers/specs/2026-05-27-config-ui-polish-onboarding-design.md`. UI source: `ui/src/` -(Svelte + Vite). Bridge messages: `getConfig`, `setConfig`, `window` (minimize/close/quitWind/ -restartWind), `dirty`, `openIni`, `exportDiagnostics`, `pickExe`, `mpoState`, `setMpoDisabled`, -`rebootNow`, and the six profile messages (`listProfiles`/`switchProfile`/`createProfile`/ -`renameProfile`/`duplicateProfile`/`deleteProfile`) - see `HandleWebMessage` in -`src/config_ui/main.cpp` for the authoritative set. Settings apply instantly to the live ini; the -floating Save capsule shows unsaved state (Save / Discard), keybinds persist at once. See -`docs/architecture/09-settings-ui.md`. +**Idle and the loop (02)** +- The 1x loop sleeps. Anything that must react at 1x needs a wake: an LL-hook edge calls `WakeMain()` + after publishing state, a window message in the wait mask, or `IdleNow` keeping the loop awake. + Never add `QS_RAWINPUT`/`QS_INPUT` to the mask. +- Tick counts depend on the refresh rate; derive new ones through `TicksAtHz`/`setTickRate`. +- A config reload rebuilds `ZoomController`; UI-only keys are stripped by `StripUiOnlyKeys` so they + never reload. Add new UI-only keys there. -## IMPORTANT gotchas -- THE MAGNIFICATION RUNTIME IS PROCESS-SCOPED AND SHARED. Both models use it (transform: the - fullscreen transform; render: `MagShowSystemCursor`), so NEVER call `MagInitialize` / - `MagUninitialize` directly - go through `wind::MagApiAcquire()` / `MagApiRelease()` - (src/mag_host.*), which refcounts it. Independent pairs break each other silently: the - transform's idle release killed render's cursor hiding (real pointer reappears beside the - drawn one = TWO CURSORS), and render's teardown killed the transform context (every write - returns FALSE - the magnifier "stops zooming" while the cursor still moves, since the tick - loop is healthy). Holds must be symmetric: take one when you start needing it, drop it the - moment you stop, or an idle desktop keeps DWM in magnification-aware compositing (issue #148). -- A LIVE MAGNIFICATION CONTEXT TAXES EVERY CURSOR CHANGE ANY APP MAKES. While one exists, DWM - composites magnification-aware and each cursor visibility/shape change costs a re-composite - - a game that toggles its pointer on middle-click hitches even at 1x (Foundation: 25 visibility - flips per 20s; measured 13-24 spike frames per 14 wheel-clicks, 0 with no context). Writing - level 1.0 does NOT leave the mode; only releasing the runtime does. Hence the transform model - creates its context on a session's first write and releases it ~1.2s after the zoom ends. -- COLOUR (WARMTH/BRIGHTNESS, #288) HOLDS THE MAGNIFICATION RUNTIME AT 1X while either is on, so the - gotcha above applies at 1x too (the #148 game-cursor tax). Accepted trade-off: the feature needs it, - and it is off by default. Measure a cursor-toggling game with colour on before blaming anything else. - The DWM effect scales LINEAR light under HDR (encoded in SDR), is one matrix for all monitors (mixed - HDR/SDR: right strength on the primary only), and never reaches the hardware pointer, so Wind swaps - the system pointers for tinted copies at 1x (src/cursor_tint.*). docs/COLOUR-FILTER-FINDINGS.md. -- THE CURSOR GROWS WITH THE ZOOM, IN EVERY ENGINE (owner decision 2026-09-18, issue #253; this - REPLACES the old "cursor size is constant, always" rule - do not restore it). The transform - engine cannot do otherwise (its sprite lives in desktop space, DWM magnifies it), and a render - cursor pinned at desktop size reads as TINY next to it: that was the fresh-install tiny-cursor - bug, reproduced on a wiped box with the public 0.6.2 installer. `cursorScaleWithZoom` is - RETIRED AND IGNORED - the default template wrote it as an explicit 0 into every ini, so a - changed default alone would never have reached an existing install. `cursorConstantSize` - (default 0; 1 = old constant-size look, render only) is the opt-in. DEBUGGING TRAP from the - same hunt: the dev box ini differs from a clean install (model, desktopTransform, this key), - so "works at home" proves nothing about defaults - wipe `%LOCALAPPDATA%\Wind` to test them. -- THE 1X LOOP SLEEPS (#71, docs/architecture/02-tick-loop.md "Idle"). Anything new that must react - at 1x needs a wake: an LL-hook edge must `WakeMain()` (input_router.cpp) after publishing its state, - a window message must be in the wait mask, or `IdleNow` must keep the loop awake while it is - pending. Never add QS_RAWINPUT/QS_INPUT to the mask (every mouse move would wake it). -- Pure-logic files MUST NOT include `` - keeps unit tests desktop-free. - The test build compiles only the pure `.cpp` files and defines `WIND_TESTS`. -- INPUT SWALLOWING: bound keybinds are eaten so they never double-fire into the focused app. Mouse - side-buttons go through the `WH_MOUSE_LL` hook; keyboard zoom/recenter/cursorLock binds go through a - `WH_KEYBOARD_LL` hook (both on the same dedicated hook thread, `input_router.cpp`). A swallowed key - never appears in `GetAsyncKeyState`, so the keyboard hook is the AUTHORITY for bound-key down-state - (`keyPressed()`); `main.cpp` reads it when `kbHookActive()`, else falls back to polling (install - failure / `WIND_NOHOOK`). hide-cursor + hotkey-mode quick-zoom are swallowed by `RegisterHotKey` - instead, not this hook. SAFETY (#285): ONE rule set for every bind, `src/keybind_rules.h` - (`CheckKeyBind`/`CheckWheelBind`/`CheckClickBind`), mirrored in `ui/src/lib/keybindRules.js`; both - are tested against `tests/fixtures/keybind_cases.txt`, so change the rules in BOTH or the tests - fail. `ParseConfig` reads any unsafe bind as unbound, the UI refuses it with a reason, and the hook - still never swallows `IsForbiddenBindVk` keys. AltGr sends Ctrl+Alt, so Ctrl+Alt + a typing key is - refused (the owner types on a Norwegian layout). Button binds: 1/2 side, 3/4/5 left/right/middle - (these need modifiers, never Ctrl or Shift alone; the wheel allows Ctrl alone, #295, since the notch - is swallowed); 6/7 = wheel up/down in the zoom slots (#318: one zoom step per notch, swallowed only with the - slot's mods held, plain scrolling untouched; the old `zoomWheelMods` is migrated into free slots once at - start, `MigrateIniFiles`, and stays honoured only when a direction has no free slot). The extra-key - switches `panKeysOn`/`hideCursorOn`/`cursorLockOn` (default 1, per profile) make ParseConfig read that - key as unbound when 0 (no hook tracking, no swallow, no RegisterHotKey) while the ini keeps the binding; - LIGHT MODE IS REMOVED (0.20.0, #324, Max 2026-10-02): Settings, onboarding and the tray flyout are always dark, there is no Mode row, no `prefers-color-scheme` following and no light token or palette block; `uiTheme` is an ignored legacy key (kept in the global and UI-only key lists so old inis load, never written by the UI). The tray ICON still follows the taskbar theme. `uiPalette` is a global UI-only key (stripped from the core text, `NormalizeUiPalette`; FOUR themes only, Max 2026-10-02: `grey ember ocean hicon`, High contrast last, removed ids read as grey; the Settings picker is a row of mini window cards, the tray flyout uses `src/tray_app/flyout_palettes.h`, both GENERATED from the mockup palettes by `ui/tools/gen-themes.cjs` / `gen-flyout-palettes.cjs`, and `WindTray.exe --render-test out.png --palette ` shows each (an old `--light` flag is ignored)); - the most specific matching slot - wins. SWALLOWING WITH ALT OR WIN HELD INJECTS ONE MASK KEY (VK 0xE8): otherwise Windows sees the - modifier tapped alone (Start opens, the app's menu bar activates; Alt measured both ways, Win fixed-case only). Wind's own - injections carry `kWindInjectTag` in dwExtraInfo and are skipped by the bind matcher; other - injectors count as real input. The quick-zoom modifier only turns binds that LACK it into taps. A KEY bind is swallowed only when a - bind on that key has all its modifiers held (`keyBindMatches`), decided once per press; the old - VK-only test ate a plain F1 system-wide for a Ctrl+F1 bind. KEYBOARD PANNING (#287, unbound by - default since #307): pan slots match only while the tick ARMS them (zoomed, not Inspect, no mouselook - lock), so at 1x the keys reach apps (IntelliJ navigate back/forward); the tick pans only on presses - the hook swallowed (`keySwallowed`), as the `ViewOwner::Keys` detached view (src/keyboard_pan.h). Down/up swallows are balanced - (only swallow an UP whose DOWN we swallowed) and released on teardown so a key is never stranded. - `cursorLockVk` (Inspect mode) is VK-only (no mods), swallowed like `recenterVk`. - Inspect mode is a FREEZE-cursor + free-look reticle toggle (driven entirely in `main.cpp` RunTick, - no mouse-hook involvement): toggling on FREEZES the real OS cursor with a 1px `ClipCursor` at its - current spot (`frozenCursor`) and hides it, so any hover/tooltip stays alive. A crosshair "look point" is then - driven by Raw Input (the frozen cursor can't move, but HID mickeys still arrive): the look point IS - the `CursorMapper` center, so moving the mouse pans the magnified view. SPEED MATCH: the freeze makes - the normal OS-cursor-delta oracle read ~0, so the look point pans from the raw mickeys run through - Windows pointer ballistics per `WM_INPUT` packet (`src/mouse_ballistics`, pure + unit-tested) so it - moves at the SAME speed/DPI as the desktop cursor: exact pointer-speed-slider multiplier + the - SmoothMouse acceleration curve, NORMALIZED so slow movement is 1:1 with the slider baseline and the - curve only adds gain above it (this cancels the undocumented DPI/refresh scaling constants), blended - at a reduced `accelStrength` because `WM_INPUT` can coalesce HID reports and otherwise over-accelerate. - `input_router` cooks each packet (only while `inspectActive`); RunTick drains the cooked delta with a - sub-pixel carry, still scaled by `cursorSensitivity`. The crosshair sprite (`render_engine` draws it - when `RenderFrameParams.cursorLocked`) is a 48x48 anti-aliased full-length thin cross - light-grey - core + black outline, lines run continuously through the center (no gap), built once with 4x4 - supersampled coverage, scaled by zoom - drawn at `cursorScreen`. The overlay stays active while Inspect is on (`active = zoomed || inspect`), - so the reticle PERSISTS and roams the full screen at 1x (the mapper returns `cursorScreen == center` - at level 1.0, which is the roaming look point) - it never vanishes at 1x and never snaps across the - zoom boundary. A click is ROUTED TO THE LOOK POINT (the crosshair), not the frozen cursor: the - `WH_MOUSE_LL` hook swallows the real left/right press (and its matching up) while Inspect is on - it - would otherwise land at the frozen point - and `main.cpp` RunTick fires a clean ABSOLUTE click at the - look point (mapper center) so it registers where you aim, at any zoom. The 1px freeze clip is released - for a couple ticks around the click (`clickReleaseTicks`) so the synthesized click isn't clamped back - to the frozen pixel, then re-asserted; Inspect STAYS on (no auto-exit - that was a prior regression). - Safe by construction: the injected click carries `LLMHF_INJECTED` (the hook skips it) and its absolute - move is dropped by the raw accumulator (`WM_INPUT` ignores `MOUSE_MOVE_ABSOLUTE`), so the look point is - not disturbed; the raw zoom path reads only the side-buttons, never left/right. Toggle off (or - zoom out to idle) releases the clip, warps the cursor to the look point, and resumes normal follow. - The 1px clip is released on every exit (toggle-off-while-zoomed, teardown-to-idle, device-lost - recovery, `shutdown`, the crash filter, atexit `RestoreInputState`) so it is never stranded. - LIMITATION (by design, not fixable in user mode): LL hooks swallow only the legacy/cooked input - path (`WM_*`, `GetAsyncKeyState`) that desktop apps and browsers use. They CANNOT block Raw Input - (`WM_INPUT`), which most GAMES read directly - so a bound key/button still reaches a raw-input game - no matter what the hook returns. There is no user-mode API to suppress raw input to another - process; the only reliable fix is a kernel filter driver (e.g. Interception), which we deliberately - do NOT use (no-driver design + anti-cheat ban risk). Confirmed: swallowing works in normal apps, - not in raw-input games. Pick game keys/buttons you don't otherwise use. - GAME-INSPECT (issue #144) sidesteps this for Inspect mode only: when Inspect is toggled while a - mouselook game holds the mouse (pure decision `ShouldGameInspect`, src/inspect_focus.h: a cursor - hidden at the toggle edge by the APP is the tell, valid whenever WE are not hiding it too - i.e. - at 1x and in transform FOLLOW sessions; a LockDetector lock also engages on its own; render - sessions hide + weld so only the detector is usable there. Do NOT make the zoomed path - detector-only again, issue #158: a raw-input game never clips or recenters the pointer, so the - detector reads FREE right through mouselook), main.cpp - steals foreground to an invisible 1x1 helper window (`WindFocusStealer`, layered alpha 0) - a - backgrounded game stops receiving raw input, so its camera freezes (the Snipping Tool effect) - while our RIDEV_INPUTSINK pan keeps working. The steal is DEFERRED one step past the reveal logic - (ForegroundCoversMonitor must read the game, not the helper), re-asserted if the game re-grabs - foreground (an alt-tab to a third app is respected), and foreground is handed back on every exit - path (toggle-off, teardown-to-idle, device-lost, shutdown). Click-to-look-point is DISCARDED in - game-inspect (a click would re-activate the game mid-inspect). Exclusive-fullscreen games may - minimize on focus loss; games that pause on focus loss show their paused frame - both by design. -- MAGNIFIED IMAGE + CURSOR QUALITY = THE DWM SMOOTHING FLAG (issues #197 + #227). DWM magnifies - with NEAREST NEIGHBOUR unless something calls the undocumented - `MagSetFullscreenUseBitmapSmoothing` (Magnification.dll ORDINAL 1 - what native Magnifier's - "smooth edges of images and text" flips; callable without UIAccess; the raw user32 - `SetMagnificationDesktopSamplingMode` takes a DWORD POINTER and a by-value call - access-violates). `txSamplingMode` ships 0 (nearest); 1 = smooth (no longer experimental - - issue #242 made it the "High resolution cursor" half of the combined high-res/MPO option, and - SAFETY now depends on it: nearest+MPO-on is the 16-bit TDR combo, walled always; smooth is the - float path, full range). The flag is the ENTIRE quality gap to - native Magnifier - image AND cursor (the pointer grows naturally with zoom; WM does NOT swap - cursor bitmaps, probed 1x-16x arrow stays 32x32 - the sharpness is DWM's filter). The - 2026-08-13 dwmcore crash (two first-try repros over browser Mica/acrylic at high zoom) did - NOT reproduce 2026-08-22 (newer driver, MPO off, rewritten transform): full stress suite at - 20x over heavy acrylic + zoom storms + 3 rounds of max-zoom pans over real Edge Mica and - Settings, dwm PID unchanged. If dwm.exe crashes return in the field, this flag is the FIRST - suspect: set that user's txSamplingMode=0 and re-bisect. Modes 2/3/4 the kernel also accepts - are field-tested ALIASES OF NEAREST - no middle filter exists. The flag is DWM-GLOBAL and - survives the process that set it until DWM restarts - which is why smoothing appeared to come - and go between builds and why a stale "smooth" state can frame an innocent build. KNOWN - INTERACTIONS now that rendering is clean: (a) warm-keeping perturbs the LEVEL, never the - position, so it cannot shake the cursor. The 1px translation warm write (`txWarmMode=1`, - shipped; it replaced the retired `txKeepAliveMaxLevel` keep-alive) perturbs the position, so it - can shake under smoothing; `txKeepAliveMaxLevel` is still parsed for old inis but NOTHING READS IT. (b) During LEVEL ramps a slight shimmer remains under smoothing - the filter - re-interpolates every edge per scale step. NOT our geometry, cadence, or frame coherence: - all instrumented 2026-08-22 (telemetry w_level/w_tx channel + optical capture; written - transforms proved sub-0.1px consistent while the shimmer persisted), and WM shows the same - artifact character under its notchy ease (its steps mask it). A nearest-during-ramp / - smooth-at-rest HOTSWAP was field-rejected: nearest renders the source rounded to whole - pixels while smooth honors the fraction, so every swap shifted the image 1-2px - (zoom-dependent) even with the source snapped to integers on the swap tick. Final design: - ONE static filter, user's choice (txSamplingMode 1 smooth DEFAULT / 0 nearest), no dynamic - switching. Pans are clean under smooth; only changing SCALE shimmers. -- Declare Per-Monitor-V2 DPI awareness (`Wind.manifest`) or offset pixel math is wrong - on scaled displays. -- The lens-must-move-when-cursor-locked behavior is THE core feature. It relies on - Raw Input deltas (HID-level, unaffected by ShowCursor/ClipCursor/SetCursorPos), - NOT GetCursorPos, when a lock is detected. Do not "simplify" this away. -- Clicks are routed by syncing `SetCursorPos` under the drawn cursor (NOT `MagSetInputTransform`, - which needed UIAccess and is no longer used anywhere). -- RENDER ENGINE: the overlay MUST set `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` or - Desktop Duplication captures our own presented frame -> we magnify our own output -> - feedback loop (black). This is the #1 render-engine gotcha. -- RENDER ENGINE / HDR: NEVER cache the OS SDR white level (issue #160). On an HDR desktop we capture - FP16 scRGB and divide by Windows' "SDR content brightness" level so SDR white lands on 1.0; DWM - re-applies that SAME level when it composites our SDR overlay, so the round trip is exact only - while our scale tracks the LIVE slider. It was sampled once per device build, so any later slider - move left a permanent brightness step of actual/cached on every zoom-in and zoom-out (darker below - the cached point, brighter above, matching only at it). Re-read per duplication rebuild + on a - 4 Hz throttle while rendering (`refreshSdrWhite`; ~0.007 ms/query), match the path by GDI device - name (multi-monitor displays differ), and keep the last known good value on a failed query - a - default would be its own step. Pure math + throttle in `src/hdr_scale.h`. Render-model-only: - transform/magnify magnify inside DWM, so they never convert color. -- RENDER ENGINE: cross-process click-through needs `WS_EX_LAYERED | WS_EX_TRANSPARENT` - (+ `SetLayeredWindowAttributes(.,255,LWA_ALPHA)`). `WS_EX_TRANSPARENT` + HTTRANSPARENT - alone only forwards to *same-thread* windows, so clicks to other apps get eaten. The layered - window's swapchain is blt-model (`DXGI_SWAP_EFFECT_DISCARD`), composited through the DWM - redirection surface. Latency capped with `IDXGIDevice1::SetMaximumFrameLatency(1)`. -- RENDER ENGINE: present pacing is the blt-model swapchain through DWM. It NEVER tears (DWM always - composites it at vblank). Its one artifact is a phase-mismatch microstutter (NOT our loop - proven - clean at 144fps via `WIND_PACINGTEST`), tamed by the `dwmFlush` knob: `dwmFlush=0` (default) = - plain vsync `Present(1,0)`; `dwmFlush=1` = present immediately (`Present(0,0)`) then `DwmFlush()` - to align 1:1 with composition. Both hot-reloadable. -- RENDER ENGINE: the blt-model present onto the WS_EX_LAYERED overlay can be mis-composited by DWM at - NON-INTEGER DPI (observed: 3840x2160 @ 225% on an RTX 5090). The surface gets a small intermittent - down-left offset, so the bottom/left edge of a full-screen draw (the zoom outline) is clipped off the - panel while top/right stay. This is driver/DWM-level (NOT our draw code - the outline is one - full-screen pass that paints all 4 bands; the present clips 2). It resets on a device rebuild (a GPU - driver update / TDR) and recurs. Do NOT chase it as a render-code bug. Mitigation if ever needed: - inset the outline a few px (a complete frame just in from the edge survives the clip); or integer DPI - avoids it. The per-edge constant-buffer fragility that ALSO dropped an edge (the bottom) is a real - code bug and was fixed (the outline is now a single full-screen pass, not 4 UpdateSubresource'd quads). -- RENDER ENGINE: DO NOT re-attempt a DirectComposition flip-model present path. It was tried and - abandoned TWICE (#11, #69): a flip-model swapchain on the layered HWND (via an `IDCompositionVisual`) - presents, but DWM promotes the fullscreen visual to an independent-flip / MPO plane that scans out - unsynced and TEARS on any frame hitch - badly on a VRR/G-SYNC display (confirmed via diagnostics: - tear correlates 1:1 with loop hitches at a steady physical refresh). Forcing it onto the composited - path with `dwmFlush=1` stops the tear but then chains us to the VRR-floated composite rate (drooped - to ~68Hz on a 23-143Hz panel). A "composition pin" (forever-animating child visual) was also tried - and made tearing worse. Net: dcomp is never a win over blt on this layered click-through overlay. - RTSS overlay is a quick tell - it shows over blt (hookable composited path), vanishes over dcomp. -- RENDER ENGINE: never leave the OS cursor hidden. `shutdown()` restores via - `MagShowSystemCursor(TRUE)` + `MagUninitialize` + `SystemParametersInfo(SPI_SETCURSORS)`, - plus a `SetUnhandledExceptionFilter` net for crashes. The Inspect-mode 1px freeze clip is - likewise released (`ClipCursor(nullptr)`) on every teardown path: zoom-out, toggle-off, - click-commit, recenter, shutdown, the crash filter, and device-lost (TDR) recovery -- so the - cursor is never stranded pinned to one pixel. -- RENDER ENGINE: show/hide the overlay by toggling the layer alpha (`SetLayeredWindowAttributes` - 0/255), NOT `SW_HIDE`/`SW_SHOW`. A layered window that is hidden then re-shown makes DWM cache - and re-display the frame from when it was last visible, flashing the previous zoom session's - window on the next zoom-in (worst right after an alt-tab). The window is created shown at - alpha 0 and stays shown. On zoom-in, present the live frame FIRST, then flip alpha to 255. -- RENDER ENGINE: the overlay is PARKED at 1x1 whenever we are not rendering, and restored to full - monitor bounds before any present (`setParked`, called from initialize/renderFrame/primeReveal/ - setVisible/retarget). A shown fullscreen topmost LAYERED window keeps a fullscreen game off its - independent-flip plane by geometry alone - even at alpha 0 - which is the same lever `primeReveal` - pulls deliberately at alpha 1. Left unparked, a game ran DWM-composited for its WHOLE session just - because Wind sat idle in the tray, and it looked model-independent and "sticky" (switching to - render or restarting Wind never helped, only restarting the game seemed to) because the overlay is - created shown at startup in every model. PresentMon on RDR2, same session, no game restart: - 3% -> 99.8% `Hardware: Independent Flip`, mean frametime 12.28 -> 7.26 ms, p99 18.3 -> 9.5 ms. - DWM re-promotes on its own as soon as nothing covers the game, so there is no latch to work around. - Park by MOVING the window off the virtual desktop. NOT `SW_HIDE` (reintroduces the stale-frame - flash below) and NOT a resize: shrinking to 1x1 makes DWM reallocate the redirection surface, and - the freshly allocated area is undefined until presented into, which showed as a one-frame BLACK - flash per zoom over a game. A move leaves the surface and swapchain untouched. Each park/unpark is - also a `SetWindowPos` over the game, i.e. a synchronous DWM z-order transaction (see the hitch note - in `renderFrame`), so two per zoom session is the floor - do not add more. `WIND_NOPARK=1` disables - parking entirely for A/B. -- RENDER ENGINE: presenting first is NOT enough - the reveal is GATED (issue #140, in `RunTick`): - Present's blt into the layered redirection surface is GPU work, but the alpha flip is a CPU call - DWM honours at its next composite, so under GPU load the flip wins the race and DWM shows the - surface's RETAINED frame (the previous zoom session's last present). Every zoom-in arms a D3D - event query fenced right after the session's first Present (`armRevealFence`/`revealFrameDone`); - the reveal waits for it (desktop path spins 3 ms to keep the same-tick instant feel; ~250 ms tick - cap as fallback). A fullscreen app additionally needs `frameCompositedSincePrime()` - a captured - frame composited AFTER the alpha-1 `primeReveal()` (issue #90: DDA can't see a game on an - independent-flip/MPO plane until the prime forces DWM to composite it). On hide, a black scrub - frame is presented STRICTLY AFTER the alpha-0 flip (scrub-then-hide flashed black on every - zoom-out); it scrubs the retained frame so any residual race can only ever flash black. -- RENDER ENGINE: stay above EVERYTHING - re-assert `HWND_TOPMOST` every frame in `renderFrame` - (transparent + click-through + capture-excluded, so being on top is safe). If we sit below an - always-on-top app overlay (RTSS, Task Manager), that window draws a second unmagnified copy - over our magnified view. `zorderBand=16` (signed UIAccess build) also covers shell + same-band. - BAND CHOICE IS A TRADE-OFF (issue #162) - the shipped default is **0, unbanded**, and restoring - 16 without re-testing BOTH halves is a regression: - - band 16 covers the Start menu / taskbar thumbnails / tray flyouts, but the **Snipping Tool** - capture overlay then composites over US: zooming under Win+Shift+S shows the unmagnified - screen with **NO cursor at all**, in every model (we hide the OS cursor plane and draw a - replacement, so covering the replacement leaves nothing). Rig-measured both ways. - - band 0 makes the snip overlay work; the shell surfaces above are the price. - - band 17 (ZBID_LOCK) would cover both and **is rejected by `CreateWindowInBand` on 26200**. It - fell through silently to unbanded, which is exactly why it looked like the fix at first. - Both bandable windows (render overlay + transform cursor sprite) go through - `wind::CreateBandedWindow` (src/band_window.h), which cascades the requested band -> 16 -> - unbanded and LOGS when the request was refused - never let a refused band be silent again. - TRANSFORM CURSOR SWITCHES BANDS AUTOMATICALLY (issue #269, measured with GetWindowBand): a - UIAccess sprite lands in band 2; taskbar thumbnails, Start and tray flyouts are band 16; the - snip overlay (ScreenClippingHost's CoreWindow) is band 17. `cursorBandAuto=1` (default) keeps a - band-16 twin of the sprite and shows it unless the FOREGROUND window's band is above 16, when - the low window shows instead (pure rule: `src/sprite_layer.h`). The sprite is also exempt from - Aero Peek (#267: it vanished ~0.5 s into a thumbnail hover) and EXCLUDED FROM CAPTURE (owner - decision: otherwise it is frozen into the snip screenshot as a dimmed arrow; recordings show no - cursor). `tools/testenv/dualcursor.ps1` turns the hidden `spriteCapturable=1` knob on because - it measures the sprite from captures. - DIAGNOSTIC TRAP: `ScreenClippingHost.exe` holds foreground with no visible top-level window, so - a z-order walk shows us at index 0 while we are plainly covered. Do not "verify" band problems - that way. `CURSOR_SHOWING` also stays 1 throughout, so it is not the `cursorVisibility` gate. -- RENDER ENGINE: on zoom-in, `invalidateCapture()` + `capture()` drains to the LATEST duplication - frame (not the first): the first AcquireNextFrame after (re)creating the duplication can be a - transitional composite (the window underneath), which otherwise flashed on reveal. -- Verify the render overlay only from INSIDE the app (it is capture-excluded, so external - screenshots can't see it): `WIND_SELFTEST=1 Wind.exe` dumps `wind_selftest.png`. -- MULTI-MONITOR: `multiMonitor=1` magnifies the monitor the cursor is on at each zoom-in; `0` - (the shipped default) = primary only. The overlay is moved/resized and the DXGI output is re-selected - by device name (`render_engine` `retarget`/`selectOutput`); the pipeline works in LOCAL monitor - pixels with a `(originX,originY)` offset applied only at `GetCursorPos`/`SetCursorPos`. Limit: - if the cursor's monitor is on a DIFFERENT GPU than our D3D device, `retarget` returns false and - we keep the current monitor (no cross-adapter chase). While zoomed you stay on one monitor - (the OS cursor is pinned to it); switch by zooming out and back in on the other one. -- CURSOR SENSITIVITY auto-matches the real OS cursor: while zoomed (cursor hidden), each tick reads - the OS cursor's own movement since our last `SetCursorPos` (Windows' pointer acceleration already - applied) and pans by that scaled by `cursorSensitivity` (default 1.0 = exact match), so panning - equals the user's normal cursor without reimplementing ballistics, with an optional speed multiplier - on top. `GetCursorPos` works as this "oracle" only because we read it BEFORE re-setting it each - tick. Raw mickeys are kept to (a) feed `LockDetector` (a game clipping/recentering the cursor - -> `GetClipCursor` confined, or raw-active-but-cursor-frozen with hysteresis), (b) drive panning - while locked (also scaled by `cursorSensitivity`), and (c) feed the Inspect-mode ballistics cooking - (the OS cursor is frozen there, so the oracle is unusable - see the Inspect notes). Both the free and - locked zoom regimes integrate a DELTA into the same - accumulator, so a free/locked switch never snaps position (avoids the old Tracker flicker, issue #3). - The click point, drawn cursor, and view all derive from the SMOOTHED center (`cx_`), so a click lands - under the visible cursor; do not "fix" the click/warp point to the unsmoothed target (it would - misalign clicks) and do not revert to a fixed sensitivity multiplier. - THREE INVARIANTS ON THE ORACLE (issue #169, all violated at once - the window-drag flicker): - 0. A CLIP IS A LOCK SIGNAL ONLY WHEN MEANINGFULLY SMALLER THAN THE MONITOR (`ClipRectConfines`, - lock_detector.h; <90% in either dimension). THIS RIG HAS A PERMANENT MACHINE-WIDE WORK-AREA - CLIP (desktop minus taskbar, ~95%, external utility) - GetClipCursor NEVER returns the full - desktop here. The old any-clip test therefore ran every zoomed desktop session on the locked - path (raw-mickey panning + weld = the flicker), and masked the two defects below. - 1. THE BASELINE IS MEASURED, NEVER ASSUMED. `lastSetVirtual` is a fresh post-present - `GetCursorPos`, not the point the weld was ASKED to park at. The park can be deduped - (unchanged centre pixel), suppressed (drag-follow), or skipped (gatePresent / fps-cap - ticks) - and BOTH engines report whether it really ran (render `parkedLastFrame()`, - transform `weldedLastFrame()`). Assuming it landed makes the next delta measure - hand + (pointer-centre gap); the mapper integrates the gap, the centre overshoots the pointer, - the sign flips, and the loop oscillates at an amplitude proportional to hand speed. - 2. NEVER WELD WHILE A MOUSE BUTTON IS HELD (drag-follow). Mid-drag the pointer IS the interaction - (window drag, text selection); re-parking it each tick fights the hand and the dragged content - flickers between the two positions (probe-measured ~85 px square wave). `ShouldDragFollow` - (src/drag_follow.h, pure + unit-tested) suspends the weld for exactly the button-hold and the - lens follows the pointer 1:1 UNSCALED (like transform FOLLOW - scaling would desync the lens - from the pointer that owns the drag). The press landed under the welded cursor before the - button went down; the release lands where pointer and content both are. Weld resumes on - release (renderFrame invalidates its park dedupe so the first post-release frame re-parks). -- TRACKING (issue #276; docs/architecture/07-cursor.md, field notes docs/TRACKING-FINDINGS.md): - caret/focus/edge-mode views are DETACHED (weld off, `t.viewDetached`); the POINTER comes to the - view on a mouse-move takeover, never the view to the pointer (that wobbled). Only keyboard-driven - caret moves are followed (first caret after a focus change = baseline; 1 s click quiet period, ended - early by a fresh non-modifier key down after the click, #328 - never by key-ups, auto-repeat or Ctrl/Shift). - Edge mode hides edge-pinned motion from the lock detector, or corners fling the pointer. - JAVA CARET = Java Access Bridge (src/java_bridge.*, #281). UIPI DROPS THE JVM'S HANDSHAKE TO A - UIACCESS PROCESS: without the narrow ChangeWindowMessageFilterEx allowance on the bridge's hidden - windows it loads but never connects. Never poll the bridge (each read runs on the Java app's UI - thread); only load Authenticode-signed bridge DLLs (Wind is UIAccess, window classes are spoofable). -- SHELL INPUT PANELS (emoji picker, #283; docs/SHELL-PANEL-CURSOR-FINDINGS.md): composed above - every band, so only the REAL pointer shows over them. One public MagSetFullscreenTransform write - makes DWM draw it magnified; it is frozen (1px clip) and moved by Wind with the view to avoid - wobble. Do NOT use hook-thread writes for this (runtime ownership marshals every write onto the - input thread = hitches), and keep the freeze hidden from the lock detector (flap = flicker). -- THE INSTALLER IS ELEVATED, WHICH MAKES HKCU AND `%LOCALAPPDATA%` THE WRONG USER'S. An - elevated process's HKCU is whichever hive the ELEVATED token owns, which is an admin - account's whenever a standard user elevated with different credentials. So autostart goes in - HKLM `...\CurrentVersion\Run`, never HKCU, and Wind is launched at the end through - `explorer.exe` (the shell owns the user's token) rather than `Exec`. A plain `Exec` hands Wind - an ADMIN token and `ResolveIniPath()` then puts magnifier.ini, the profiles and the logs in the - administrator's profile where the user never finds them. Rig-measured both ways (probe: plain - launch = ELEVATED, via explorer = not elevated). Related: an upgrade must stop a running Wind - by SETTING `Local\Wind_QuitRequest` and waiting for the process to actually go, not by - `taskkill` - only the clean exit restores the OS cursor, releases ClipCursor and restores the - user's native-Magnifier registry backup. The mutex comes free ~3ms in, well BEFORE the process - exits, so the mutex wait alone is not a safe gate to kill after. -- PROGRAM FILES IS READ-ONLY FOR NON-ADMIN: any file the runtime needs to write MUST go to a - per-user-writable location, never next to the exe. The UIAccess build is installed to - `C:\Program Files\Wind\` and WindConfig.exe runs as a normal user, so an in-place write there - silently fails (Apply / live keybind capture / WebView2 init all break this way historically). - - magnifier.ini: ALWAYS resolve the path via `wind::ResolveIniPath()` (src/config_path.h), - used by both Wind.exe core and WindConfig.exe host. It probes whether the exe dir is - writable; dev keeps the ini next to the exe, Program Files transparently falls back to - `%LOCALAPPDATA%\Wind\magnifier.ini` and seeds it from the install template on first launch - so deploy-time defaults carry over. Never hardcode `L"magnifier.ini"` (it would re-break - the Program Files deploy on the next feature that touches the ini). - - WebView2 user-data folder: WindConfig.exe explicitly passes `%LOCALAPPDATA%\Wind\WebView2` - to `CreateCoreWebView2EnvironmentWithOptions`. The default (`\WindConfig.exe.WebView2`) - is read-only in Program Files, which makes the env creation fail and the window paint as - an empty shell. Keep the explicit path when touching the host's env setup. - - Diagnostics: the unified logger writes rolling per-process logs (`wind-core.log` / - `wind-config.log`), the startup system snapshot, and crash dumps (`wind-crash-*.dmp/.txt`) to - `%LOCALAPPDATA%\Wind\logs\` (resolved via `wind::ResolveLogDir`; src/logging.*). The tray and - WindConfig "Export diagnostics" action zips that folder to the Desktop (Compress-Archive). The - opt-in `diagnostics=1` frame-pacing trace still goes to `%TEMP%\wind_diag.log` separately; - `wind_selftest.png` is dev-only (env-gated). +**Cursor (07)** +- The cursor grows with the zoom in every engine (#253). Do not restore constant size; + `cursorConstantSize=1` is the render-only opt-in. Test defaults on a wiped `%LOCALAPPDATA%\Wind`: + the dev ini differs from a clean install. +- The view must keep moving when a game locks the cursor: locked sessions pan from Raw Input deltas, + not `GetCursorPos`. Forced locks go through `t.detector.seedLock()`. +- The oracle baseline is measured, never assumed (`parkedLastFrame`/`weldedLastFrame`), and never a + post-present read. Never weld while a mouse button is held (`ShouldDragFollow`). +- Click point, drawn cursor and view all come from the smoothed centre; do not move clicks to the + unsmoothed target. +- 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. +- Shell input panels: the real pointer, frozen and moved by Wind after each view write. No hook-thread + writes; hide the freeze from the lock detector. -## Toolchain notes (this machine) -- VS 2026 Community is a prerelease channel, so `vswhere` needs `-all -prerelease` - (NOT `-latest`) to find it. `build.bat` accounts for this. -- MSVC toolset 14.51.36231, Windows SDK 10.0.26100.0. +**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`). +- 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. -## Workflow -Feature/fix work: GitHub issue -> branch -> PR. README-only changes commit directly. -Remote: `github.com/Maxaubert/Wind`. Own-renderer work is on `feat/own-renderer` (issue #4). +**Transform engine (05)** +- Apply the level every tick; do not quantize ramps or throttle writes (`txWriteHz`/`txMinOffsetPx` + stay 0); do not gate the sprite on "the view moved". +- Keep the 2 px right/bottom clamp and the 1-texel left/top floor in `ComputeMagTransform` (TDR and + grey-edge classes). +- MPO on + nearest sampling overflows a 16-bit driver field above ~9.3x at the far right: walls + always. Never offer nearest with MPO on. +- `txWarmMode`/`txWarmHz`: every warm write is a full DWM re-render; composition-rate metrics miss + the pan-start hitch. Field-verify in a game. +- Publish the source-rect input transform on every change (needs UIAccess); identity or none gives + hover dead zones. Judge publishes by read-back, not the return value. +- The bitmap smoothing flag is DWM-global and outlives Wind; a stale state can make a build look + smooth. First suspect if dwm.exe crashes return. -## Releases are automatic (STANDING RULE) -Every push to `main` that can change the binary rebuilds the installer and republishes it -(`.github/workflows/release.yml`). `src/version.h` is the only place a version is declared, so -BUMPING IT IS WHAT CUTS A NEW RELEASE; a push that leaves it alone refreshes the existing -release's asset in place. Never hand-upload a release artifact: the workflow owns them, and a -manual upload is how the download drifts from main. -CI signs nothing (no cert). It ships BOTH variants, and setup signs the uiAccess one on each -PC with a per-machine root whose private key it deletes at once (issue #261, -`installer\local-sign.ps1`, see `installer\README.md`); on failure it installs the -`uiAccess=false` build. Do NOT put the self-signed dev cert in CI - trusted by nobody, and -worse than no signature. Installing a release over this box replaces the dev-signed build -with the locally signed one, which is equivalent (both give `token UIAccess=1`). -WHY THIS IS MECHANICAL: v0.1.0 shipped, the #209 fix landed on main, and the release kept -serving the old installer. That stale build was then installed over this dev box and silently -removed a working fix (the branch it lived on was unmerged). Anything deployed here that is not -on `main` is one installer run from being lost. +**Render engine (04)** +- The overlay must use `WDA_EXCLUDEFROMCAPTURE`, or it magnifies its own output (black feedback loop). +- Never cache the SDR white level; re-read it (`refreshSdrWhite`). +- Click-through needs `WS_EX_LAYERED | WS_EX_TRANSPARENT`. +- No DirectComposition flip-model path: it tears on VRR. Blt only. +- Show and hide by layer alpha, never `SW_HIDE`; park by moving, never resizing. Two park moves per + session is the floor. +- The reveal is gated on the present fence (and composite evidence over fullscreen apps). +- Always restore the OS cursor and release `ClipCursor` on every exit path, including the crash filter. +- Verify the overlay only from inside: `WIND_SELFTEST=1 Wind.exe` writes `wind_selftest.png`. +- `zorderBand` ships 0: band 16 covers the shell but loses the cursor under the Snipping Tool. + Re-test both before changing it. Never let a refused band be silent (`CreateBandedWindow`). -## Alpha / nightly channel -`.github/workflows/alpha.yml` builds an installer from ANY branch on demand (Actions -> alpha -> -Run workflow, pick the branch, optional "what is being tested" note). It publishes a GitHub -PRE-RELEASE tagged `v-alpha.` with asset -`Wind-Setup-x64--alpha..exe`. It does NOT bump src/version.h. -WHY IT IS SAFE beside release.yml: GitHub's "Latest release" pointer ignores pre-releases, so the -repo download button - and any future in-app updater asking for the latest release - keep seeing -the stable build however many alphas exist. release.yml stays the only publisher of a real release. -An alpha REPLACES a normal install (same dir, same `%LOCALAPPDATA%\Wind` settings); roll back by -running the stable installer, which always keeps the Latest badge. Newest 5 alphas are kept, older -ones are deleted with their tags. The build steps intentionally MIRROR release.yml rather than -sharing a composite action - if they ever drift in a way that matters, extract one and use it in -BOTH, never fix just one. -NOTE: workflow_dispatch only lists workflows present on the DEFAULT branch, which is why this -lands on main separately rather than riding inside a feature PR - otherwise the channel could not -be used to test the very branch that introduces it. +**DPI and monitors** +- Declare Per-Monitor-V2 DPI awareness (`Wind.manifest`), or offset maths is wrong on scaled displays. +- `multiMonitor=1` retargets per zoom-in and keeps the session on one monitor; no cross-adapter chase. -## Deploy for testing (STANDING RULE) -Whenever you build something new the user should test/verify (a new feature, a behaviour change, a -bug fix with a runtime effect), DEPLOY it to `C:\Program Files\Wind` so Max can test the real signed -UIAccess build, then tell him it's live and what to check. Do NOT wait to be asked. Skip the deploy -only for changes with no runtime surface (docs, tests, comments, build-script tweaks). The magnifier -cannot be driven headlessly, so deploying IS how a change gets verified. -- Deploy (elevated; UAC is silent on this machine, so it runs unattended - allowlisted in - `.claude/settings.json`): +**Installer and paths (11, `installer/README.md`)** +- The installer runs elevated, so HKCU and `%LOCALAPPDATA%` can belong to another user: autostart in + HKLM, launch Wind through `explorer.exe`, stop it by setting `Local\Wind_QuitRequest` and waiting + for the process, not `taskkill`. +- Program Files is read-only for the runtime: resolve the ini with `wind::ResolveIniPath()`, logs with + `ResolveLogDir`, and keep the explicit WebView2 user-data folder. Never write next to the exe. + +## Toolchain and workflow +- Visual Studio is a prerelease channel here; `build.bat` calls vswhere with `-all -prerelease`. +- Issue, branch, PR for every change. Remote: `github.com/Maxaubert/Wind`. + +## Release, alpha and deploy +- Every push to `main` that can change the binary rebuilds and republishes the installer + (`.github/workflows/release.yml`). Bumping `src/version.h` (the only version declaration) cuts a + new release; otherwise the current release's assets are refreshed. Never hand-upload an asset. +- CI signs nothing. Setup signs the UIAccess build per PC (`installer/local-sign.ps1`). Never put the + self-signed dev certificate in CI. +- `alpha.yml` (manual) builds any branch as pre-release `v-alpha.`; it keeps the newest 5 + and does not bump the version. Its build steps mirror `release.yml`: change both together. +- Anything deployed locally that is not on `main` is one installer run from being lost. +- Changes with a runtime surface are verified by deploying the signed UIAccess build. Deploy + (elevated; give it a 2 minute timeout): `$p = Start-Process pwsh -Verb RunAs -PassThru -WorkingDirectory '' -ArgumentList '-ExecutionPolicy','Bypass','-File','\tools\uiaccess_setup.ps1'; $p.WaitForExit(110000)` - (about 40 s; give the call a 2 minute timeout). NOT `-Wait`: in PowerShell 7 it waits for the whole process tree, - and the build leaves mspdbsrv / the esbuild service running, so it hangs long after the deploy finished. - The elevated process starts in System32, so the `-File` path MUST be absolute (a relative - `tools\...` path silently fails to launch). The script builds `uiaccess` + `config`, signs both - exes, and copies to Program Files; it logs to `tools\uiaccess_setup.log` (read it to verify - `status=Valid` + `DONE`). -- To test a WIP branch, build the tree that has the change checked out first (merge feature branches - into a throwaway integration branch if verifying several at once), then run the deploy. -- Launch the SIGNED copy from a NORMAL (non-elevated) shell so UIAccess engages: - `Start-Process "C:\Program Files\Wind\Wind.exe"`. + Use an absolute `-File` path (the elevated process starts in System32) and not `-Wait` (it waits + for leftover build processes). Check `tools\uiaccess_setup.log` for `status=Valid` and `DONE`. + Then launch from a normal shell: `Start-Process "C:\Program Files\Wind\Wind.exe"`. ## Style -- NEVER use em-dashes (the U+2014 character) anywhere: code, comments, docs, commit messages, - and UI copy. Use en-dashes, commas, or rephrase. Avoid the `—` HTML entity in UI strings too. +- Never use em-dashes (U+2014) anywhere: code, comments, docs, commit messages, UI copy (also not + `—`). Use en-dashes, commas or rephrase. diff --git a/README.md b/README.md index 588aee2e..34375264 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,89 @@
- Wind + Wind logo # Wind - Barely there. Everywhere. + Fullscreen magnifier for Windows. - A lightweight magnifier for Windows. + [![Latest release](https://img.shields.io/github/v/release/Maxaubert/Wind?style=flat-square&color=5b5bd6&label=release)](https://github.com/Maxaubert/Wind/releases/latest) + [![Windows 10 | 11](https://img.shields.io/badge/Windows-10%20%7C%2011-5b5bd6?style=flat-square)](https://github.com/Maxaubert/Wind/releases/latest) + [![Licence: proprietary](https://img.shields.io/badge/licence-proprietary-5b5bd6?style=flat-square)](LICENSE) - [![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D4?style=flat-square)](https://github.com/Maxaubert/Wind) - [![Built with](https://img.shields.io/badge/C%2B%2B-Direct3D%2011-00599C?style=flat-square)](https://github.com/Maxaubert/Wind) - - [Download](https://github.com/Maxaubert/Wind/releases/latest/download/Wind-Setup-x64.exe) · [Documentation](docs/architecture/README.md) + [Download](https://github.com/Maxaubert/Wind/releases/latest/download/Wind-Setup-x64.exe) · [Releases](https://github.com/Maxaubert/Wind/releases) · [Developer docs](docs/architecture/README.md)
---- +https://github.com/user-attachments/assets/59939cd8-8bf3-4fbf-b978-8e89a4ebde1f +Wind replaces the built-in Magnifier with smooth, continuous zoom. It keeps tracking the mouse when +a game hides, clips or center-locks the cursor, and clicks pass through to the app under it. +## Features -https://github.com/user-attachments/assets/59939cd8-8bf3-4fbf-b978-8e89a4ebde1f +- Smooth sub-pixel zoom and pan. +- Clicks, hover and dragging keep working while zoomed. +- Tracks the mouse in games through raw input, without injecting into the game. +- Auto picks the best engine for each window and switches when you alt-tab. +- The real cursor shapes, sharp at every zoom level. +- HDR aware: the zoomed view matches the desktop's brightness. +- Follows the text caret as you type, and optionally the keyboard focus. +- Inspect mode: freeze the pointer to keep a tooltip open and look around with a crosshair. +- Named profiles for different setups. +- Warmth and brightness filter for the whole screen. +## Install +[Download the installer](https://github.com/Maxaubert/Wind/releases/latest/download/Wind-Setup-x64.exe) +and run it. It always points at the latest release. +- Needs 64-bit Windows 10 or 11 and administrator rights. +- Installs to `C:\Program Files\Wind`. Windows grants the UIAccess permission Wind uses only to apps + in that kind of protected location. +- Adds the WebView2 runtime if Settings needs it. +- Settings, profiles and logs stay in `%LOCALAPPDATA%\Wind`, also after an uninstall unless you + choose otherwise. +The installer is not signed, so SmartScreen warns on first run: choose **More info**, then **Run +anyway**. Some browsers and work computers block the download. Setup signs Wind locally for your PC +so its zoom keys keep working over elevated windows; if that step fails, it installs a build without +that ability. +## Usage +The first launch walks you through choosing zoom keys: mouse side buttons, keyboard keys, or both. -A replacement for the built-in Magnifier, with smooth continuous zoom that keeps tracking the -mouse even when games hide, clip, or center-lock the cursor. +- **Hold** the zoom-in key to zoom in, the zoom-out key to zoom out. Release to stay at that level. +- **Wheel zoom:** bind a modifier plus the scroll wheel (for example Ctrl+wheel). +- **Keyboard panning:** optional arrow-key binds move the view while zoomed. +- **Quick zoom:** Ctrl plus a zoom key toggles between 1x and your last level. +- **Inspect mode:** an optional key freezes the pointer and lets you look around with a crosshair. +- **Ctrl+Alt+Q** quits Wind from anywhere. -> Contributing or curious how it works? The developer book lives at -> [docs/architecture](docs/architecture/README.md): twelve chapters covering every subsystem, -> end to end. +Bound keys are not passed on to the app you are using. Wind refuses binds that would break normal +typing or Windows shortcuts and tells you why. -Wind renders the magnified view itself - capturing the desktop with DXGI Desktop Duplication -and scaling it on the GPU (Direct3D 11) onto a click-through overlay, or magnifying inside the -compositor (DWM fullscreen transform) when a game is in front. That gives sub-pixel smooth -panning and a crisp cursor that the integer-offset Windows Magnification API can't, and lets -you keep clicking and using the screen while zoomed. +## Settings -## Features -- **Smooth, sub-pixel zoom and pan** with light inertia - no stepping or cursor hop. -- **Interact while zoomed** - clicks pass through to the app under the cursor. -- **Auto engine per situation** - games get the compositor-internal transform path (stays - smooth under heavy GPU load), everything else the high-fidelity render overlay; Wind switches - live when you alt-tab, keeping the zoom level. -- **Named settings profiles** - full snapshots (keybinds included), switchable from the tray - menu or the Settings titlebar. -- **Real cursor** - the actual pointer shapes (text I-beam and link hand included), drawn - crisp at every zoom. -- **HDR-aware** - on an HDR display it tonemaps to match the desktop automatically (tracking - the live SDR-brightness slider); on SDR it's a straight passthrough. No per-machine tuning. -- **Follows the mouse even when a game locks/hides the cursor** (HID-level Raw Input, no - injection - anti-cheat safe). -- **Inspect mode** - freeze the cursor (keeps a hover/tooltip alive) and free-look around with - a crosshair; clicks land where you aim. -- **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, including Java apps such as IntelliJ and PyCharm) 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 -read once at launch, so switching it restarts Wind (Settings does this automatically on Apply). - -- **`hybrid`** (default, shown as **Auto**) - constructs both engines below and picks per - zoom-in: the transform for a borderless-fullscreen foreground on the primary monitor (games, - F11 video), the render overlay for everything else. Re-picks live when the foreground - changes mid-zoom. -- **`render`** - captures the desktop with DXGI Desktop Duplication and redraws it into a - D3D11 overlay. The cursor is drawn into the same frame as the content, so it can never drift - against the view. The only model that can cover the shell (see the `zorderBand` note below). -- **`transform`** - the DWM fullscreen transform only (what `hybrid` uses over games), with no - overlay at all. Compositor-internal, so it stays smooth while a heavy game renders. - -The old **`magnify`** model (driving the native Windows Magnifier) is no longer selectable since -0.18.0: Settings and the tray offer only Auto, Render and Transform, and an ini that still says -`model=magnify` runs `hybrid` (Auto). - -## Controls -Zoom binds ship **unbound** - the first-launch guided setup captures your choice (mouse -side-buttons and/or keyboard keys, with optional alternates). Everything is rebindable in -Settings; bound keys are swallowed so they never double-fire into the focused app. A bind can be -a key, a key combination (Ctrl, Alt, Shift, Win), a mouse side-button, or a left/right/middle click -with modifiers. Binds that would break normal use are refused with the reason: typing keys alone, -Shift or AltGr (Ctrl+Alt) plus a typing key, and combos Windows reserves (Alt+F4, Win+L, ...). - -- Hold your **zoom-in** bind - zoom in (smooth ramp). Hold **zoom-out** - zoom back. -- **Scroll-wheel zoom** (optional): hold the modifiers you chose (for example Ctrl, - Alt or Ctrl+Alt; never Shift alone) and turn the wheel - up zooms in, down zooms out. -- **Keyboard panning** (optional, off until you set keys): while zoomed, your pan keys move the - view (tap to nudge, hold to pan). Windows Magnifier uses Ctrl+Alt+arrows. At 1x the keys go to - your apps as normal. -- Release - zoom stays at the current level. -- **Quick zoom** (default Ctrl + a zoom key, or a dedicated hotkey) - toggle between 1x and - your remembered level. -- **Inspect mode** (optional bind) - freeze the cursor and free-look with the crosshair. -- **Ctrl+Alt+Q** - quit from anywhere (also restores the cursor); or use the tray icon. - -## Releases -[Download the latest installer](https://github.com/Maxaubert/Wind/releases/latest/download/Wind-Setup-x64.exe) -(always the newest release) and run it. Release notes and older versions are on the -[Releases page](https://github.com/Maxaubert/Wind/releases). - -Setup installs **per-machine** to `C:\Program Files\Wind` and asks for administrator rights. -That location is not a preference: Windows only grants UIAccess to a signed binary in a -"secure location", and UIAccess is what lets Wind's shortcuts keep working while an elevated -window has focus, and what enables the desktop zoom path. Setup also offers to start Wind when -you sign in, and installs the WebView2 runtime if Settings has no browser engine to run in. -Your settings, profiles and logs stay in `%LOCALAPPDATA%\Wind`, and uninstalling keeps them -unless you say otherwise. - -**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 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: -- `build.bat` - builds `Wind.exe` and its tray helper `WindTray.exe` (runs from anywhere). -- `build.bat test` - builds and runs the unit tests. -- `build.bat uiaccess` - builds the UIAccess variant (signed-install prerequisite). -- `build.bat config` - builds the Settings app (`WindConfig.exe` + the Svelte UI). -- `build.bat installer` - compiles the setup program (needs NSIS: `winget install NSIS.NSIS`). - -## Install from source (development) -Run **elevated**: -``` -powershell -ExecutionPolicy Bypass -File tools\uiaccess_setup.ps1 -``` -This builds the UIAccess variant and the Settings app, self-signs them, and installs to -`C:\Program Files\Wind`. Launch `C:\Program Files\Wind\Wind.exe` from a normal (non-elevated) -window so UIAccess engages. Settings (and the ini) live per-user under `%LOCALAPPDATA%\Wind`. - -Note on shell coverage: covering the Start menu / taskbar / tray flyouts additionally requires -the opt-in `zorderBand=16` (UIAccess build only). It ships **off** (`zorderBand=0`) because the -high band puts Wind under the Snipping Tool's capture overlay, which costs the cursor entirely -during Win+Shift+S - a deliberate trade-off (issue #162). - -## Config (`magnifier.ini`, hot-reloads unless noted) -The Settings app (tray -> Open Settings) is the comfortable way to edit this file; it keeps -the everyday settings front and center (the rest sit behind "Show advanced settings") and -closes itself if the magnifier exits. Every ini key below keeps working even when it has no -Settings row. -Profiles (tray -> Profiles, or the Settings titlebar) snapshot the whole file per activity. - -- `zoomInButton`/`zoomOutButton` (1/2 mouse side-buttons, 3/4/5 left/right/middle click with - `zoomInButtonMods` etc.) and `zoomInVk`/`zoomOutVk` + `zoomInMods`/`zoomOutMods` (keyboard) - - hold to zoom; all ship unbound until the guided setup. Alternates: `*2` variants. -- `zoomWheelMods` (0 = off) - scroll-wheel zoom. A notch zooms as far as holding the bind does in - 0.1 s, so `zoomInSpeed`/`zoomOutSpeed` set its speed too. -- `panLeftVk`/`panRightVk`/`panUpVk`/`panDownVk` + `*Mods` (unbound by default), `panSpeed` - (default 1.0) - keyboard panning while zoomed. -- `maxLevel`, `zoomInSpeed`/`zoomOutSpeed`, `smoothZoom*` - zoom range and feel. -- `cursorSensitivity`, `cursorSmoothing` - pan speed and inertia. -- `bilinear`, `sharpness`, `cursorConstantSize` (default 0: the cursor grows with the zoom), - `cursorVisibility` - image and cursor rendering. -- `brightness`, `hdrTonemap` - output tuning. -- Pacing/perf: `vsync` (default on), `dwmFlush` (default 0), `gameFpsCap`, `gpuPriority`. -- `model` - `hybrid` (default) / `render` / `transform` (`magnify` is read as `hybrid` since 0.18.0). Restart to switch. -- `multiMonitor` - 0 (default, primary only) or 1 (follow the cursor's monitor per zoom-in). -- `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). - -## Scope -Primary monitor by default (`multiMonitor=1` follows the cursor's monitor). Covers the desktop, -normal apps, and **borderless / windowed-fullscreen** games. Exclusive-fullscreen games are out -of scope (set the game to borderless). +Open them from the tray icon > Settings. Changes apply immediately; Save keeps them in the current +profile. Preferences > Show advanced settings shows the rest. Every setting is also a key in +`%LOCALAPPDATA%\Wind\magnifier.ini`; see the +[key reference](docs/architecture/08-config-profiles.md#key-reference). -## Licence -Wind is **proprietary**. Copyright (c) 2026 Max Aubert, all rights reserved. The source code -may not be used, copied, modified or redistributed without written permission; official -binaries are free to install and use, personally or inside an organisation. See `LICENSE`. +## Limits + +- Magnifies the primary monitor unless `multiMonitor=1` is set. +- Games must run borderless or windowed. Switch exclusive-fullscreen games to borderless. +- The Start menu, taskbar and tray flyouts are not magnified by default. +- Protected video (Netflix and similar) shows normally in Auto, which switches to the transform + engine for it. Forcing the Render engine shows it black. -Releases published on or before 2026-08-31 (up to `v0.6.1`) were issued under the MIT licence, -and that grant still covers those versions. It does not extend to anything after them. +## Development -Third-party components and their licences are listed in `THIRD-PARTY-NOTICES.md`. +`build.bat` builds Wind, `build.bat test` runs the unit tests, and `build.bat config` builds the +Settings app. The developer docs are in [docs/architecture](docs/architecture/README.md). + +## Licence -Commercial licensing enquiries: aubert@post.com +Wind is proprietary: the source code may not be used, copied, modified or redistributed without +written permission, and the official binaries are free to install and use. Releases up to v0.6.1 +were MIT-licensed and stay so. See [LICENSE](LICENSE) and +[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md). Commercial licensing: aubert@post.com diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index f519df1a..0d5bfb15 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -15,18 +15,13 @@ https://developer.microsoft.com/microsoft-edge/webview2/ `installer/MicrosoftEdgeWebview2Setup.exe`, redistributed unmodified as published by Microsoft. Setup runs it only when the WebView2 runtime is absent. Microsoft Software License Terms apply. -### Svelte 4 (MIT) +### Svelte 5 (MIT) Copyright (c) 2016-2026 Svelte contributors. The Svelte runtime is compiled into `ui/dist`, which ships inside the installer. https://github.com/sveltejs/svelte ## Build and test only, not shipped -### doctest (MIT) -Copyright (c) 2016-2023 Viktor Kirilov. `third_party/doctest.h`, compiled only into -`wind_tests.exe`. https://github.com/doctest/doctest - -### Vite, @sveltejs/vite-plugin-svelte, @playwright/test -Build tooling and end-to-end tests. MIT and Apache-2.0. Not linked into any shipped binary. +doctest (MIT, `third_party/doctest.h`, compiled only into `wind_tests.exe`), Vite, @sveltejs/vite-plugin-svelte and @playwright/test (MIT and Apache-2.0) are build and test tooling, not linked into any shipped binary. --- diff --git a/docs/COLOUR-FILTER-FINDINGS.md b/docs/COLOUR-FILTER-FINDINGS.md index d6e01cda..0de73f1f 100644 --- a/docs/COLOUR-FILTER-FINDINGS.md +++ b/docs/COLOUR-FILTER-FINDINGS.md @@ -1,136 +1,87 @@ # Colour filter findings (issue #288) -Spike 2026-09-29, this PC (3840x2160, 225%), Wind stopped, a standalone probe calling -`MagSetFullscreenColorEffect` with an invert matrix at level 1 (no UIAccess needed). +> **Status.** Live. Warmth and brightness ship; design in +> [architecture/04](architecture/04-render-engine.md#colour-filters). + +## Mechanism + +Probe on the test machine (3840x2160, 225%), Wind stopped, `MagSetFullscreenColorEffect` with an +invert matrix at level 1 (no UIAccess needed): | Question | Result | |---|---| -| Does the effect apply at level 1? | Yes (`set=1`, the screen inverted). | -| Does GDI capture (BitBlt) see it? | Yes: centre-block mean luma 19.8 before, 235.2 after. | -| Does Desktop Duplication (the render engine's capture) see it? | **Yes**: 34.2 before, 251.8 after. | -| Does the effect survive the process being killed? | **No**: after `TerminateProcess` with the effect on, the screen read 19.8 / 34.2 again. Windows clears it with the process. | - -Consequences for the design: +| Does the effect apply at level 1? | Yes | +| Does GDI capture (BitBlt) see it? | Yes: centre luma 19.8 before, 235.2 after | +| Does Desktop Duplication see it? | Yes: 34.2 before, 251.8 after | +| Does it survive the process being killed? | No: Windows clears it with the process | -- **Render engine:** its capture already contains the filtered desktop and DWM would filter its - overlay again (invert twice = no filter). So while a render session is live, the DWM effect is set - to identity and the matrix is applied in the render pixel shader instead. The transform engine and - 1x use the DWM effect. -- **Crash safety comes free:** a killed or crashed Wind never leaves the screen filtered. The normal - exits still write identity so the change is immediate. -- Screenshots and recordings of the screen show the filter (both capture paths see it). +So while a render session is visible the DWM effect is identity and the render shader applies the +matrix (otherwise the overlay is filtered twice); the transform engine and 1x use the DWM effect. +A crashed Wind never leaves the screen filtered. Screenshots and recordings show the filter. -## Why there are only warmth and brightness (measured 2026-09-29) +## Why only warmth and brightness -Field report: with the filters on, coloured text in Windows Terminal became hard to read or -vanished. Measured on the Campbell palette (14 text colours on #0C0C0C) and a light web page -(black text, links, red/green/grey/orange on white): WCAG contrast of each text colour against -its filtered background, and the smallest RGB distance between any two filtered text colours. +Field report: with the first filters on, coloured text in Windows Terminal became hard to read. +Measured on the Campbell palette (14 text colours on #0C0C0C) and a light web page: WCAG contrast of +each text colour against its filtered background, and the closest pair of filtered text colours. -| filter | terminal: min contrast | terminal: closest pair | page: min contrast | +| Filter | Terminal: min contrast | Terminal: closest pair | Page: min contrast | |---|---|---|---| | none | 2.38 | 0.146 | 3.49 | | invert | 1.20 | 0.146 | 6.35 | | greyscale | 1.52 | 0.002 | 4.48 | -| yellow on black (as first built) | 1.43 | 0.001 | 5.57 | -| same, balanced luma weights | 1.83 | 0.013 | 5.88 | -| same, 50% of the original colour kept | 1.51 | 0.057 | 5.81 | +| yellow on black | 1.43 | 0.001 | 5.57 | | hue-keeping "smart invert" | 1.56 | 0.038 | 6.35 | -| brightness-keeping yellow tint | 1.56-1.74 | 0.001-0.094 | 3.8-3.9 | - -Two limits, both inherent to one affine matrix applied to every pixel (all the DWM colour effect, -and Windows' own colour filters, can do): - -1. Squashing three channels onto a grey or two-colour ramp makes colours of equal brightness - identical (closest pair 0.001). Greyscale has the same flaw. -2. Anything that inverts swaps light and dark for every window at once: it helps white pages and - wrecks dark apps (bright green in the terminal keeps 35% of its contrast under Invert). - -Only a per-pixel, non-linear mapping could keep each text's contrast, and Wind has that only in -the render engine's shader (zoomed, render engine), not at 1x or on the transform engine. Owner -decision: keep only warmth and brightness, which scale channels and never merge or swap colours. -The numbers come from a small Python harness over the palette (not committed; rerun by computing -the matrix per colour and WCAG contrast in linear light). - -## HDR, Night light and the warmth maths (measured 2026-09-29/30, this PC: LG OLED, Windows HDR on, SDR white 188 nits) - -- **Under HDR the DWM colour effect scales LINEAR scRGB.** Desktop Duplication in R16G16B16A16_FLOAT - with a green gain of 0.339: green 2.198 -> 0.746 (exactly x0.339). A GDI capture (SDR, after - Windows' HDR->SDR step) had suggested sRGB-encoded maths; that was the conversion, not the effect. - In SDR the effect acts on encoded values. So the same number means a far weaker tint under HDR: - the first warmth build's 100% (green 0.339) was really about 2400 K on this screen, "yellow". -- **Night light's scale** (from its stored setting, CloudStore `bluelightreduction.settings`, the - value after `cf 28` is 2 x Kelvin as a varint): 0% = 6500 K, 100% = 1200 K, 50% = 3850 K, i.e. - LINEAR in Kelvin. Wind's Warmth now uses the same scale (`WarmKelvin`). -- **Night light is invisible to software capture under HDR** (no change in the FP16 duplication, gamma - ramp untouched): it runs in the display pipeline. Wind therefore models it: CIE 1931 blackbody - gains in linear light (`kKelvinGains`, Planck + Wyman-Sloan-Shirley CMF fit), encoded for SDR and - the render shader, linear for DWM under HDR (`BuildColorMatrix(..., linearLight)`). Brightness is - decoded to linear under HDR so the slider looks the same in both. Verified on the HDR desktop: - warm 100% green x0.074 / blue 0; warm 50% x0.67 / x0.34; brightness 50% x0.214; 0% black. -- **The pointer at 1x is out of the DWM effect's reach**: Windows draws it on a hardware cursor plane - after composition (Night light, in the display pipeline, does reach it). Since 2026-09-30 Wind - swaps the standard pointers for tinted copies while idle (spec 2026-09-30-cursor-tint-design.md). - Verified on this PC: Desktop Duplication's pointer shape reads 246,76,0 at warmth 100 (246,246,246 - off), size 64x64 and hotspot kept; zoom-in swaps back in 1.1 ms (direct, no scheme reload) and the - engine draws + filters its own cursor; a fullscreen window in front and colour off restore the real - scheme (reload, ~6 ms, idle only); a force-killed Wind leaves the tint until its next start heals it. - In-memory COPIES of the pointers are not identical to the scheme's own (fixed size; the default - text beam came back as a different format), so idle restores always reload the real scheme. -- Wind's diagnostics snapshot logs `hdr=0` regardless (logging.cpp records it conservatively); the - engine's own `GetHdrEnabled` is the truth. - -## Settings crash (RTSS), 2026-09-29 - -WebView2 154's browser process loads and unloads `dxgi.dll` at start-up; RTSS's global hook -(RTSSHooks64 of 2025-09-27) remembers it and, on the next window creation (Chromium's network-cost -watcher's hidden COM window), reads the unloaded DLL in `ValidateRuntimes`: an access violation that -kills the whole WebView2 engine (dump: faulting address inside "unloaded module dxgi.dll"). Chromium -creates that watcher unconditionally (`network_change_notifier_win.cc`), so there is no flag to avoid -it. Settings now recovers: `ProcessFailed` -> recreate the engine (page process: reload), at most 3 a -minute (`src/config_ui/webview_recover.h`), and the page's unapplied edits are mirrored to the host and -handed back. Verified by killing the engine with a staged change: back in ~0.3 s with the edit kept. - -## How other night-light apps do it (research 2026-09-30, sources cited) - -- f.lux, Iris, LightBulb, Night Ember tint with the GDI gamma ramp (`SetDeviceGammaRamp`, a 1D - 256-entry table per channel; LightBulb source: https://github.com/Tyrrrz/LightBulb). -- They have THE SAME pointer problem: f.lux's FAQ says a bright white cursor "happens when your - videocard displays uses a 'hardware cursor'" and ships "Software mouse cursor when needed" - (https://justgetflux.com/faq.html); Iris has "use software mouse cursor" - (https://iristech.co/troubleshooting/). Wind's tinted-pointer swap is the same class of fix. -- They are WEAKER under HDR: Microsoft documents the gamma ramp as "undefined behavior in HDR modes" - (https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-setdevicegammaramp); f.lux - users report it stops working with HDR/Advanced Color - (https://forum.justgetflux.com/topic/6163/windows-10-with-hdr-and-advanced-color-working-with-f-lux). - Ramps are also reset by sleep/display changes (apps re-apply constantly) and deep warm shifts need - the admin `GdiIcmGammaRange` registry change. -- Windows Night light: Microsoft says it "might also use" the post-composition display pipeline - (3x3 linear matrix + 1D LUT), which apps can only reach through an ICC profile with the private - `MHC2` tag (https://learn.microsoft.com/en-us/windows/win32/wcs/display-calibration-mhc). That fits - the measurements above (invisible to capture, gamma ramp identity, hardware cursor tinted). -- Rejected for Wind: dwm_lut (injects into dwm.exe; https://github.com/ledoge/dwm_lut), NVAPI via - novideo_srgb (NVIDIA-only, fails under HDR, its README admits cursor issues; - https://github.com/ledoge/novideo_srgb), `IDXGIOutput::SetGammaControl` (exclusive fullscreen only), - `D3DKMTSetGammaRamp` (no public user-mode contract). -- The one untested lead: an MHC2 profile associated at runtime - (`ColorProfileAddDisplayAssociation`) would sit in Night light's own pipeline (HDR-capable, - likely reaching the pointer). Unknowns: pointer behaviour (https://github.com/dantmnf/MHC2 notes a - "buggy mouse cursor and MPO composition" with MHC active), change latency, stacking with Night - light, and it would displace a user's own calibration profile. - -## Review fixes (2026-09-30) - -- The render engine's drawn pointer, Inspect crosshair and zoom outline are filtered too (cursor - shader + CPU-filtered outline colour); an inverting text beam is drawn untinted (a matrix on an - inverting texture changes the inversion, it does not tint). -- Colour follows the VISIBLE engine (`RenderModel::visible()`), not the selected one: a pending - render reveal keeps the DWM-filtered desktop, the effect returns before the overlay hides at - zoom-out and the moment an outgoing render overlay rests in a hybrid switch. Worst case left: one - double-filtered frame while the capture catches up after the effect is cleared. -- HDR state is the PRIMARY monitor's, re-read once a second while a colour setting is on (toggling - HDR is not guaranteed to raise WM_DISPLAYCHANGE). Mixed HDR/SDR monitors: the single DWM matrix is - right on the primary only. -- Tinted pointer: a separate "pointers are ours" flag, so an idle restore after a render session - (which never reloads the scheme) still reloads the user's real scheme. -- Trade-off kept: colour on holds the Magnification runtime at 1x (CLAUDE.md gotcha). +| brightness-keeping yellow tint | 1.56–1.74 | 0.001–0.094 | 3.8–3.9 | + +One affine matrix on every pixel (all the DWM effect and Windows' own colour filters can do) has two +limits: squashing channels onto a ramp makes colours of equal brightness identical, and inverting +swaps light and dark in every window at once. Only a per-pixel non-linear mapping could keep each +text's contrast, and Wind has that only in the render shader. Owner decision: keep warmth and +brightness, which scale channels and never merge or swap colours. (Computed with a small Python +harness, not committed.) + +## HDR and the warmth maths + +- **Under HDR the DWM effect scales linear scRGB**: a green gain of 0.339 took green 2.198 to 0.746 + in an FP16 duplication. In SDR it acts on encoded values. So the same number is a far weaker tint + under HDR. +- **Night light's scale is linear in Kelvin**: 0% = 6500 K, 50% = 3850 K, 100% = 1200 K (from its + stored setting). Warmth uses the same scale (`WarmKelvin`). +- Night light runs in the display pipeline and is invisible to capture under HDR, so Wind models it: + CIE 1931 blackbody gains in linear light (`kKelvinGains`), encoded for SDR and the render shader, + linear for DWM under HDR (`BuildColorMatrix(..., linearLight)`). Brightness is decoded to linear + under HDR so the slider looks the same in both. +- HDR state is the primary monitor's, re-read once a second while a filter is on. With mixed HDR and + SDR monitors the single DWM matrix is right on the primary only. + +## The pointer at 1x + +Windows draws the pointer on a hardware cursor plane after composition, outside the DWM effect +(Night light does reach it). Wind swaps the standard pointers for tinted copies while idle +(`src/cursor_tint.*`): at warmth 100 the arrow read 246,76,0 in Desktop Duplication, size and +hotspot kept. Zoom-in swaps back in 1.1 ms. Idle restores always reload the real scheme, because +in-memory copies of the pointers are not identical to the scheme's own. A force-killed Wind leaves +the tint until its next start. Night-light apps that use the gamma ramp (f.lux, Iris) have the same +hardware-cursor problem and offer a software cursor. + +## Rejected approaches + +- Invert, greyscale and colour-on-black filters: contrast loss above. +- GDI gamma ramp (`SetDeviceGammaRamp`, f.lux and similar): undefined under HDR per Microsoft, reset + by sleep and display changes, deep warm shifts need an admin registry change. +- dwm_lut: injects into dwm.exe. +- NVAPI (novideo_srgb): NVIDIA only, fails under HDR, cursor issues. +- `IDXGIOutput::SetGammaControl`: exclusive fullscreen only. `D3DKMTSetGammaRamp`: no public + user-mode contract. +- Untested lead: an MHC2 ICC profile associated at runtime would sit in Night light's pipeline, but + would displace the user's calibration profile and has known cursor and MPO issues. + +## Design rules + +- A filter holds the Magnification runtime at 1x, which taxes cursor changes in other apps; it is + off by default. Measure a cursor-toggling game with colour on before blaming anything else. +- Colour follows the visible engine (`RenderModel::visible()`), so a pending reveal or a resting + overlay in a handover keeps the right filter. +- The render engine filters its drawn cursor, crosshair and outline too; an inverting text beam is + drawn untinted. diff --git a/docs/FALSE-POSITIVE-APPEALS.md b/docs/FALSE-POSITIVE-APPEALS.md deleted file mode 100644 index b159de88..00000000 --- a/docs/FALSE-POSITIVE-APPEALS.md +++ /dev/null @@ -1,142 +0,0 @@ -# False-positive appeals: Wind 0.6.1 - -Prepared 2026-09-02. Everything below is ready to paste into the submission forms. The one gap -is marked **[NEED FROM JOB PC]** - the appeal cannot be filed properly without it. - -## The artifact - -| Field | Value | -| --- | --- | -| File | `Wind-Setup-x64-0.6.1.exe` | -| Size | 12,839,056 bytes | -| SHA256 | `041ddc39b16dbbba4b585aca603e58b9afc562a5ac8d9a992661ec42cddf639b` | -| Download URL | https://github.com/Maxaubert/Wind/releases/download/v0.6.1/Wind-Setup-x64-0.6.1.exe | -| Source | proprietary, not public (was public + MIT up to v0.6.1) | -| Publisher | Max Aubert | -| Signature | none (unsigned) | - -Payload binaries inside the installer, in case a form wants the inner file rather than the setup: - -| File | SHA256 | -| --- | --- | -| `Wind.exe` | `6367829396ccc86a56df5f56ba38bcbf04d11385180114a153aee4079b47cfa7` | -| `WindConfig.exe` | `d52408f018586f9b1e89cb106341394e0b8d955b4b727adb3b36c3abecd35c39` | -| `Uninstall.exe` | `25d4193722f8341871443e3a104ae4b1e2b20e0a4844a730d9d04af65052d3bc` | - -Verified on 2026-09-02 against Microsoft Defender with cloud protection enabled -(`MpCmdRun -Scan -ScanType 3`): the installer and all three payload binaries return -**no threats**. So Defender itself is not currently the thing blocking the download. - -## Submission 1 - Microsoft Defender (WDSI) - -**URL:** https://www.microsoft.com/en-us/wdsi/filesubmission - -Sign in with a Microsoft account, then: - -- Submission type: **Software developer** -- Product: **Microsoft Defender Antivirus** -- What do you believe this file is: **Incorrectly detected as malware/malicious** -- Company name: `Max Aubert` -- Detection name: **[NEED FROM JOB PC]** - the exact detection string the work machine showed - (for example `Trojan:Win32/Wacatac.B!ml`). This field is mandatory and the form cannot be - submitted without it. -- File: upload `Wind-Setup-x64-0.6.1.exe` - -Paste into **Additional information**: - -> Wind is a fullscreen screen magnifier for Windows, a lightweight replacement for the built-in -> Magnify.exe. Downloads are published at https://github.com/Maxaubert/Wind-releases. The -> installer is built entirely by GitHub Actions on a clean windows-latest runner from a tagged -> commit, with no manual step and no packer beyond standard NSIS LZMA compression. -> -> The binary uses several APIs that resemble a malicious profile in aggregate, but each is -> required for a magnifier. Source references are given so they can be checked against a -> decompilation: -> -> - WH_KEYBOARD_LL and WH_MOUSE_LL hooks (src/input_router.cpp): used only to read and swallow -> the user's own configured zoom shortcuts so they do not double-fire into the focused -> application. No keystroke is recorded, stored, or transmitted. IsForbiddenBindVk in -> src/config.cpp explicitly refuses to bind mouse clicks, Backspace and the Windows key. -> - Raw Input (WM_INPUT): used to keep tracking mouse movement in games that hide or lock the -> cursor, which is the product's main feature. -> - DXGI Desktop Duplication and Direct3D 11 (src/render_engine.cpp): captures the screen in -> order to magnify it and draw it back to a fullscreen overlay. Nothing is written to disk or -> sent anywhere; there is no network code in the application at all. -> - SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE) on the overlay window: this is not -> anti-analysis. Desktop Duplication would otherwise capture the magnifier's own presented -> frame and feed it back into the capture, producing an infinite feedback loop. This is -> documented in the project's CLAUDE.md as the top rendering pitfall. -> - SendInput: used only to route a click to the point under the drawn cursor while zoomed, and -> for the Ctrl+Alt+wheel shortcut when driving the native Windows Magnifier. -> - HKLM\Software\Microsoft\Windows\CurrentVersion\Run: the optional "start when I sign in" -> checkbox offered during setup. HKLM rather than HKCU because the installer is elevated and -> an elevated process's HKCU is the wrong user's hive. -> -> The application makes no network connections, contains no update or telemetry code, and writes -> only to %LOCALAPPDATA%\Wind (settings, profiles, logs). -> -> The installer is NSIS. It briefly extracts Microsoft's own official WebView2 bootstrapper -> (MicrosoftEdgeWebview2Setup.exe, shipped by Microsoft) to $PLUGINSDIR and runs it with -> /silent /install only when the WebView2 runtime is absent, because the settings window is a -> WebView2 host. This is the documented Microsoft deployment method for WebView2. -> -> The build is unsigned because no code signing certificate has been obtained yet. Please -> re-evaluate; I believe this is a machine-learning false positive driven by the combination of -> input hooks plus screen capture plus an unsigned low-reputation binary. - -## Submission 2 - SmartScreen / browser reputation - -There is no standalone developer form for this. The report has to be made **from the block -itself**, on the machine that saw it: - -- **Microsoft Edge:** on the download warning, choose **More information** (or the `...` next to - the blocked download) then **Report this file as safe**. That opens the SmartScreen feedback - page with the file's identity already attached, which is why filing it from the block is worth - far more than filing it cold. -- **Chrome:** on the blocked download, expand it and use **Report as safe** / **Send feedback** - if the enterprise policy leaves that available. If the entry is fully greyed out there may be - no report path at all, which is itself the answer: the block is policy, not a verdict. - -SmartScreen reputation is not something an appeal really resolves. It resolves when the file is -signed by a certificate with publisher history, or when enough people download it without -incident. - -## Submission 3 - Google Safe Browsing - -**URL:** https://safebrowsing.google.com/safebrowsing/report_error/ - -Enter the download URL: - -``` -https://github.com/Maxaubert/Wind/releases/download/v0.6.1/Wind-Setup-x64-0.6.1.exe -``` - -Be aware of the limit here: the proper owner channel for a Safe Browsing download verdict is -Search Console's Security Issues report, and that requires verified ownership of the hosting -domain. The file is hosted on github.com, which cannot be verified by us. So this generic form is -the only available route and expectations should be low. - -Also worth distinguishing: if Chrome said the file **"isn't commonly downloaded"**, that is not a -Safe Browsing malware verdict and there is nothing to appeal - it is pure reputation and it clears -by itself with signing or download volume. If Chrome said the file **"contains malware"** or -**"is dangerous"**, that is a real Safe Browsing verdict and the form above is the right route. - -## What is still needed - -The exact wording of the block on the work machine, and the detection name if one was shown. -That single string decides which of the three submissions above is the applicable one: - -- A named detection (`Trojan:Win32/...`) means Defender or a third-party AV. Submission 1. -- "could harm your device" / "blocked because it is not commonly downloaded" means SmartScreen. - Submission 2. -- "is dangerous" / "contains malware" in Chrome means Safe Browsing. Submission 3. -- No message, just a greyed-out entry, means enterprise download policy, and none of the three - applies - only a signature would change it. - -## Timing note - -This packet was written while the repository was still public under MIT. The single strongest -argument in a false-positive submission is "the reviewer can rebuild this artifact from public -source in a few minutes", and that argument does not survive the repo going private. If these -are filed after the source is closed, expect a weaker result and lean on the signature instead: -a signed binary from a verified publisher is what actually resolves this class of block. diff --git a/docs/HITCH-FINDINGS.md b/docs/HITCH-FINDINGS.md index d229c6d1..c462b695 100644 --- a/docs/HITCH-FINDINGS.md +++ b/docs/HITCH-FINDINGS.md @@ -1,12 +1,18 @@ # Transform-model hitching: findings and vetted builds (issue #148) +> **Status.** Live design: no magnification context outside sessions, identity park at zoom-out, +> release after `txIdleReleaseMs`, per-tick level writes, the `txWarmHz` warm pulse +> ([05](architecture/05-transform-engine.md)). Open work: zoom-in response time (#310). The cursor +> grows with the zoom in every engine by owner decision (#253), so the transform's magnified +> pointer is intended, not a defect. + Everything below is harness-measured over Foundation (an OpenGL city builder) on the 4K/144Hz RTX 5090 box with MPO disabled. Game frametimes come from RTSS shared memory (`rtssread.exe`); "spike frames" means game frames over 25 ms. Every zoom test verifies the zoom actually engaged -(the model logs `txsession ... maxLevel=` per session) - a dead keybind silently faking a clean +(the model logs `txsession ... maxLevel=` per session) – a dead keybind silently faking a clean result was the single biggest source of false positives in this work. -Harness (scratchpad): `bench.ps1` (middle-click recipes), `hitchrun.ps1` (aggressive zoom +Harness (scratchpad, not in the repo): `bench.ps1` (middle-click recipes), `hitchrun.ps1` (aggressive zoom flicks + camera roaming), `validate.ps1` (correctness gate), `cursorwatch.exe` (what an app does to the cursor), `maglab.exe` (Magnification API lifecycle), `rtssread.exe`, `gl_churn.exe`. @@ -15,7 +21,7 @@ to the cursor), `maglab.exe` (Magnification API lifecycle), `rtssread.exe`, `gl_ While ANY magnification context exists in the process, DWM composites magnification-aware, and then every cursor visibility or shape change any app makes costs a re-composite. Foundation hides and re-shows the pointer on every middle-click (`cursorwatch`: 25 visibility flips in one -20 s test), so wheel-clicks spiked frames while left-clicks were free - with Wind merely +20 s test), so wheel-clicks spiked frames while left-clicks were free – with Wind merely RUNNING, never zoomed. Writing level 1.0 does NOT leave the mode; only `MagUninitialize` does. | recipe (14 middle-click drags) | before | after | @@ -44,7 +50,7 @@ park at exact identity at zoom-out; release the context 1.2 s later. `phaseprobe.ps1` spaces the phases a few seconds apart so each lands in its own sample second. Result across cycles: the spikes sit **exactly at zoom-in** (~35-42 ms, one per zoom), with nothing during the hold, the pan, or the idle after. That is DWM building its magnification -machinery when the level first leaves 1.0 - the unavoidable other half of releasing the context +machinery when the level first leaves 1.0 – the unavoidable other half of releasing the context between sessions. Entering at a sub-pixel level first ("session warm-up") was tried and measured WORSE (4 spikes per 3 cycles instead of 2, and it added zoom-out spikes). @@ -117,11 +123,11 @@ smoothness, which is why the transform model stays the default for games. ## Measured-negative experiments (do not re-try without new evidence) -- **Async transform writes**: impossible - the Magnification API is thread-affine, a writer +- **Async transform writes**: impossible – the Magnification API is thread-affine, a writer thread's calls ALL fail (144/144), so Wind reports a zoom level while DWM applies nothing. Pointless anyway (see write cost above). - **`txGrid`** (snap levels to a geometric ladder so DWM's per-factor surface cache hits): - much worse - 0 spikes/22 ms continuous vs 8 spike-seconds/551 ms at 3 %, 7/583 ms at 6 %. + much worse – 0 spikes/22 ms continuous vs 8 spike-seconds/551 ms at 3 %, 7/583 ms at 6 %. - **`txLevelStep`** (skip sub-threshold level changes): no better than continuous. - **Hover sync** (one absolute cursor move per pan-rest in freeze sessions): TDRs the driver even with MPO off. Absolute cursor placement under an active transform is an independent @@ -134,11 +140,11 @@ All three run the same deployed build; switch with the `model` key (restart Wind | config | ini | measured | |---|---|---| -| **A - default** | `model=hybrid` | transform in games, render on the desktop. Middle-click recipes all 0 spikes; while zoomed 144 fps / 1 hitch; one ~36 ms spike per zoom-in. Cursor magnifies with zoom (violates the cursor-size rule). | -| **B - render everywhere** | `model=render` | Middle-click recipes 0 spikes; constant-size cursor (satisfies the rule); no zoom-in spike. Cost: the magnifier's own loop runs 92 fps with many hitches while panning. | -| **C - per-app opt-out** | `model=hybrid` + the app's exe in `%LOCALAPPDATA%\Wind\churny_apps.txt` | keeps transform for other fullscreen apps (F11 video) while a specific game uses render. | +| **A – default** | `model=hybrid` | transform in games, render on the desktop. Middle-click recipes all 0 spikes; while zoomed 144 fps / 1 hitch; one ~36 ms spike per zoom-in. Cursor magnifies with zoom. | +| **B – render everywhere** | `model=render` | Middle-click recipes 0 spikes; no zoom-in spike. Cost: the magnifier's own loop runs 92 fps with many hitches while panning. | +| **C – per-app opt-out** | `model=hybrid` + the app's exe in `%LOCALAPPDATA%\Wind\churny_apps.txt` | keeps transform for other fullscreen apps (F11 video) while a specific game uses render. | -Correctness gate for A (`validate.ps1`, teardown between every session): PASS - 6/6 sessions +Correctness gate for A (`validate.ps1`, teardown between every session): PASS – 6/6 sessions reach 12x, 6 releases, cursor never stranded. ## Auto-mode lockup (fixed 2026-07-26) @@ -174,19 +180,19 @@ desktop and in games. COST, and the 2026-09-03 refinement (issue #246): every warm write is a real source change, so DWM re-renders the whole magnified screen for it. Measured with `tools/gpu_ab.ps1` on the controlled solid target (dwm.exe 3D-engine %): a zoomed session sitting still cost 16.1% with -per-tick warming, 0.0% with it off, 0.2% for native Magnifier at rest - which was the entire GPU +per-tick warming, 0.0% with it off, 0.2% for native Magnifier at rest – which was the entire GPU gap the field reported, since panning costs both magnifiers the same order (Wind 16%, native 12% in either tracking mode). The warm write is now a PULSE on `txWarmHz` (one displacement plus its return per period; an open pulse always closes before any other gate). Rest cost per cadence: 48Hz 10.1%, 24Hz 8.3%, 12Hz 4.6% (shipped), 6Hz 2.4%. `tools/warm_cadence_sweep.ps1` scores each cadence with the pan-wake probe, the txtrace wake-write dt and rest GPU; on the desktop the wake-write dt stays 7-13ms at every cadence INCLUDING warming off, so the desktop cannot set the -floor - the field verdict in a game does (12Hz: no hitch and no visible twitch reported). +floor – the field verdict in a game does (12Hz: no hitch and no visible twitch reported). ### Why this took so long, and what was measured wrong - **Composition rate is the wrong metric.** Mode 4 (perturb the LEVEL by 2e-5) held composition at - a flat 6.94ms through every rest and scored 0.00 stalls/s in 15/15 automated rounds - and the + a flat 6.94ms through every rest and scored 0.00 stalls/s in 15/15 automated rounds – and the user still felt the spike. A sub-pixel level nudge is not a real source change, so DWM skips the work and the first genuine pan write still pays. Anything that measures only WHEN DWM composited, and not whether it re-rendered the magnified region, will pass a build that is still broken. @@ -207,7 +213,7 @@ floor - the field verdict in a game does (12Hz: no hitch and no visible twitch r The panel is variable-refresh 23-143Hz, and the transform model paces on DwmFlush by design, so Wind's tick interval follows whatever the display is doing (measured: DOOM gameplay presents 13.68ms / 73fps, display change 13.29ms). The lens easing used to keep a fixed fraction of the gap -PER TICK, so an uneven interval changed the felt inertia every tick - a steady hand produced an +PER TICK, so an uneven interval changed the felt inertia every tick – a steady hand produced an unsteady lens. Now re-derived from the MEASURED interval (`CursorMapper::setTickDeltaMs`), so the inertia is constant in real time whatever the refresh does. Issue #223 had already fixed this for different FIXED rates; VRR is the case it did not cover. @@ -246,7 +252,7 @@ FIXES, in order of preference: REBOOT). No planes exist, so the game is always composited and the behaviour is deterministic. This is also what lifts the pan walls (issue #148), so it fixes two things at once. 2. **The MPO buster ghost** (`mpoBuster=1`) is meant to force the demotion when MPO is on, and it - DOES work when it wins - but it does not reliably win: several takes sat at ~53 % plane with the + DOES work when it wins – but it does not reliably win: several takes sat at ~53 % plane with the ghost enabled. Making the demotion deterministic (verify the plane state and re-assert until it takes, rather than a blind 500 ms cadence) is an open Wind bug. @@ -260,7 +266,7 @@ does and does not respond to. - **Re-sending the same transform** (`txWarmMode=2`): wake 23.8/s. DWM ignores an identical write, exactly as the old "DWM parks on static values anyway" comment claimed. -- **Republishing the input transform** (`txWarmMode=3`): wake 26.5/s - WORSE than baseline, and it +- **Republishing the input transform** (`txWarmMode=3`): wake 26.5/s – WORSE than baseline, and it degraded sustained motion too (3.7/s vs 0.07/s). - **An unrelated per-frame damage source** (probe `-DamagePin`): wake 28.2/s, and it did not move the composition rate at all. It is not about generic damage. @@ -269,12 +275,12 @@ does and does not respond to. ### Why nothing shipped 1. **Neither working mode is visually free.** Mode 1 shifts the whole image a rigid 1 screen px at - tick rate (the #204 shimmer). Mode 4 was believed to displace 0.077px - that is in SOURCE pixels, + tick rate (the #204 shimmer). Mode 4 was believed to displace 0.077px – that is in SOURCE pixels, so on screen it is `0.077 * level`: ~0.6px at 7x, ~1.6px at 21x, worse than mode 1 at high zoom. Its applied stream also shows the derived source origin flipping a whole source pixel (offX 2411 <-> 2412 at 7.37x). 2. **The premise was wrong.** Sampling native's applied stream shows it writes NOTHING across a - 330ms rest - a single level value for an entire run - and still holds 6.94ms composition. Native + 330ms rest – a single level value for an entire run – and still holds 6.94ms composition. Native is not staying smooth by keeping warm. Do not rebuild the "keep writing" theory on this evidence. 3. **The metric is bimodal on one binary.** The same build scored 0.00/s and 18-29/s wake stalls on consecutive takes with nothing changed. Leading suspect is VRR refresh hunting: the panel runs @@ -319,7 +325,16 @@ phone slow-motion video), with `spriteCapturable=1` so Wind's cursor is visible ## Open items -- Zoom-ramp spikes (~1 per cycle, 45 ms) - DWM re-scale cost during the ramp. -- CURSOR SIZE: the transform model's pointer is magnified by DWM, which violates the standing - product rule (constant on-screen size at every zoom level). Unsolved for this model; the - render model already satisfies it. +- Zoom-ramp spikes (~1 per cycle, 45 ms) – DWM re-scale cost during the ramp. +- Zoom-in response time (#310): the cursor bridge (two `DwmFlush` calls) is ~19 of the ~27 ms to + the first composite. + +## Hook writes: fast and wrong (issue #206) + +Tick-paced writes measured 4.36 ms median cursor-to-write latency (spread across one 144 Hz tick) +against the built-in Magnifier's 0.58 ms, which writes from inside its mouse hook. Wind's hook-write +path (`txHookWrite=1`: runtime ownership on the hook thread, `MouseProc` writes the transform from +each event's coordinates, single-writer contract with the tick) reached 0.37 ms median. The field +rejected it: writing 434-685 times a second against a 144 Hz compositor rewrote the view 4-5 times +per displayed frame, so the content and the DWM-sampled cursor came from different instants and the +cursor swam. Time-to-write is not the metric; frame coherence is. It ships off. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md deleted file mode 100644 index 5d7fdc6a..00000000 --- a/docs/KNOWN-ISSUES.md +++ /dev/null @@ -1,542 +0,0 @@ -# Wind - Known Issues (behavior / interaction bugs) - -**Date opened:** 2026-05-25 -**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 **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 - Task Manager - confirms UIAccess engaged and input routing is active). -- Issue 2 changed character: now **position-dependent** - which buttons are clickable - depends on where the window sits, and moving it changes which work; only when zoomed. - This is the signature of a scale mismatch (error grows from the origin). -- Environment confirmed: **single 4K monitor at 225% scale** (AppliedDPI 216; physical - 3840x2160, logical 1707x960). So the input rects must be in logical, not physical px. - -Fix branch: `fix/interaction-bugs`. Plan: -[`superpowers/plans/2026-05-25-interaction-fixes.md`](superpowers/plans/2026-05-25-interaction-fixes.md). - -Performance / in-game FPS hitching is tracked separately in -[`PERFORMANCE-FINDINGS.md`](PERFORMANCE-FINDINGS.md) (concluded: hard ceiling of the -public Magnification API). The "hitching" reported in this round is a **different** -problem (view flicker, Issue 3 below), not that FPS ceiling. - ---- - -## Summary - -| # | Issue | Where it shows up | Root cause | Status | -|---|---|---|---|---| -| 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 | **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 -symptom (mis-targeted clicks). Issue 1 is a separate, privilege/integrity problem. - ---- - -## Issue 1 - Zoom side-buttons dead over elevated / protected windows - -### Reported behavior -- With Task Manager focused / under the cursor, pressing the zoom side-buttons does - nothing. The magnified view itself keeps showing. -- This was the original "Issue #1" from the first round of testing as well. - -### Affected windows -- Task Manager (confirmed). -- Other elevated or protected windows are likely affected; not yet enumerated. - -### Leading hypothesis (not yet confirmed) -**User Interface Privilege Isolation (UIPI).** Wind installs a `WH_MOUSE_LL` -low-level mouse hook (`input_router.cpp:35`). Low-level hooks are global, but Windows -will **not invoke a lower-integrity process's hook for input destined to a -higher-integrity foreground window.** If Wind runs at medium integrity and Task -Manager is elevated (high integrity), the hook callback that sets `inHeld`/`outHeld` -never fires while Task Manager is foreground, so the side-buttons appear dead. - -### Relation to the UIAccess work already done -- We built a UIAccess path precisely for this: `Wind.manifest` requests - `uiAccess="true"`, and `tools/uiaccess_setup.ps1` signs the binary and deploys it to - `C:\Program Files\Wind\` (UIAccess requires a signed binary in a secure location). -- The user reported that running the UIAccess build felt "about the same." -- **Open:** we have not confirmed the UIAccess build (`C:\Program Files\Wind\Wind.exe`, - `TokenUIAccess=1`) was the binary actually running during that test, vs. the dev - build in the repo (`Wind.exe` next to the source). UIAccess only takes effect for the - signed+secure-location copy. This needs a clean re-test before we conclude UIAccess - does not help input over elevated windows. - -### Evidence to gather (before any fix) -- Confirm Task Manager's integrity level on this machine (is it actually elevated?). -- Log inside `MouseProc`: does it fire at all while Task Manager is foreground? (write - to a file from the hook, then bring Task Manager forward and press the buttons). -- Re-run with the confirmed UIAccess build (verify `TokenUIAccess=1` for the running - process) and repeat the hook-fire test. - -### Candidate fixes to evaluate (do NOT implement yet) -- Make UIAccess actually active and re-test (correct binary, correct location). -- If UIAccess still does not deliver hook events over elevated windows, consider - whether the zoom trigger should also be observed via the Raw Input path (which we - already register, `main.cpp:62-65`) rather than only the low-level hook, since Raw - Input button state may arrive even when the hook is bypassed. Needs testing. - -### Resolution attempt 1 (FAILED) + re-opened -Implemented a Raw Input button path (`WndProc` decodes `RI_MOUSE_BUTTON_4/5` and sets -`inHeld`/`outHeld`, `src/main.cpp`). **Live test: still dead over Task Manager**, and the -user reports Task Manager is non-elevated - so the UIPI/elevation hypothesis is wrong and -the Raw Input fix did not help. The button decode is harmless and stays in (it is the -right architecture for elevated windows if UIPI ever is the cause), but it is not the -root cause here. - -Root cause is **re-opened**. Next step is instrumentation, not another guess: log every -`WH_MOUSE_LL` fire and every Raw Input button event together with the foreground window -title/process, then press the zoom buttons over Task Manager and read the log. That will -say definitively whether the input reaches Wind at all. Plausible that committing to -UIAccess (needed for Issue 2 anyway) also resolves this, since a UIAccess process can -receive input destined for protected windows - to be confirmed by the instrumentation. - -### Resolution (FIXED via UIAccess) -Confirmed: after signing + deploying the UIAccess build, the zoom side-buttons work over -Task Manager. UIAccess is what was missing (the Raw Input button decode was harmless but -not the fix). No instrumentation needed. The earlier UIPI/elevation framing was the wrong -detail, but the broad class (input not reaching a non-UIAccess process over certain -windows) was right, and UIAccess is the correct remedy. - ---- - -## Issue 2 - Partial interaction while zoomed (focus / click / cursor shape) - -### Reported behavior -- While zoomed in, "can click some things usually but other stuff is unclickable." -- Text input fields cannot be focused. -- The cursor "doesn't even show it's an input field before clicking", i.e. the I-beam - (text) cursor does not appear when hovering an input field that should show it. -- Happens on File Explorer, the Cyberpunk launcher (REDengine / RED launcher), and - "some other apps, but not all." - -### Affected windows -- File Explorer, Cyberpunk launcher, plus unspecified "some apps." Not universal. - -### Leading hypothesis (not yet confirmed) -**The magnified view center has drifted away from the real OS cursor position.** Wind -is intentionally *visual-only*: it does **not** call `MagSetInputTransform` (that needs -UIAccess; see project CLAUDE.md). With correct centering this is fine, because the -cursor is rendered at its real position and the same transform maps both the cursor -visual and the hit-test, so "click what you see" holds. - -But if the view center (`Tracker::cx_/cy_`) diverges from the true cursor (the exact -failure in Issue 3), then: -- The real OS cursor (where hover feedback and clicks are computed) is no longer where - the user visually sees the magnified cursor. -- Hovering an input field *in the magnified view* does not put the real cursor over - that field, so no I-beam (`WM_SETCURSOR` fires for whatever the real cursor is - actually over), and clicks land off-target or miss the control entirely. -- This produces exactly "partial interaction": controls happen to work when the drift - is small, and fail when the drift is large. - -This is why Issues 2 and 3 are probably one bug seen from two angles. - -### Evidence to gather (before any fix) -- While reproducing, log per tick: `GetCursorPos` (real), the tracker center - (`cx_,cy_`), and whether the tracker took the free or locked branch. Confirm the - center diverges from the real cursor during the failure. -- Confirm whether the failure correlates with the flicker in Issue 3 (same windows, - same moments). - -### Candidate fixes to evaluate (do NOT implement yet) -- Fix the tracker oscillation (Issue 3); if Issue 2 disappears with it, root cause - confirmed shared. -- Separately decide whether desktop interaction should ever use locked-mode drift at - all (locked-mode is meant for games that hide/clip/lock the cursor, not for normal - windowed apps where the cursor is free). - -### Resolution (root cause CONFIRMED; tracker fix did NOT address it) -The "same root cause as Issue 3" hypothesis was **wrong** (falsified by the live test: -the problem persists with the mouse held perfectly still, so it is not tracker drift). - -Confirmed root cause, per MS docs -([MagSetInputTransform](https://learn.microsoft.com/en-us/windows/win32/api/magnification/nf-magnification-magsetinputtransform)): -since Windows 10 1703, an app **must** call `MagSetInputTransform` for mouse input to -route to the magnified element. Without it, "input is passed to the element located at -the unmagnified screen coordinates, not to the item that appears in the magnified screen -content." Wind uses `MagSetFullscreenTransform` (visual) but never sets the input -transform, so while zoomed the cursor sits over the magnified target visually but the -click/hit-test lands at the unmagnified coordinate. Small targets (input fields) miss; -large targets happen to still land. Matches every observation. - -**Fix:** call `MagSetInputTransform(TRUE, &rcSource, &rcDest)` whenever the fullscreen -transform changes, with `rcSource = [xOffset, yOffset, +W/level, +H/level]` and -`rcDest = full screen` (disable it at 1x and on shutdown). **This API requires -UIAccess** (fails with `ERROR_ACCESS_DENIED` otherwise), i.e. a signed binary run from a -secure location (`C:\Program Files\Wind`). Note: this is a *different* capability than -the perf test - UIAccess did nothing for performance, but it is mandatory for input -routing, and we never actually called `MagSetInputTransform` before, which is why simply -enabling UIAccess "looked the same." - -### Refinement after first deploy (DPI coordinate-space mismatch) -With UIAccess + `MagSetInputTransform` active, the symptom changed from "small targets -miss" to **position-dependent** clickability (which targets work depends on window -position). That is a scale mismatch: input was being mapped through rectangles given in -**physical** pixels, but the OS routes input in **logical** (DPI-scaled) coordinates. On -this machine (225% scale, physical 3840x2160 vs logical 1707x960) the rects were 2.25x -too large, so the click offset grew with distance from the screen origin. - -`MagSetFullscreenTransform` offsets are explicitly DPI-independent (physical), so the -visual stayed correct; only the input transform needed conversion. **Fix:** in -`MagnifierEngine::setTransform`, divide the input-transform rect coordinates by the DPI -scale (`GetDpiForSystem()/96`) so they are in logical pixels, while leaving the visual -transform in physical. Implemented; pending the user's click-test at 225%. (Assumes the -primary monitor / single DPI; revisit for mixed-DPI multi-monitor.) - ---- - -## Issue 3 - Magnified view flickers / jumps while moving the cursor - -### Reported behavior (verbatim sense) -- Split layout: browser on the left, terminal on the right. Moving the cursor over the - terminal (which uses a GPU renderer), "the magnified window jumps when moving cursor, - it rapidly flickers as I move, as if it's getting off-centered and recentering - repeatedly very rapidly, causing flickering and jaggy movement." -- The user noted this is what they meant by "hitching" this round, and that it is - distinct from the in-game FPS drop documented in PERFORMANCE-FINDINGS.md. - -### Affected windows -- GPU-rendered windows: Windows Terminal, browser, the game launcher. The common thread - the user keeps pointing at is GPU / hardware-accelerated rendering. - -### Leading hypothesis (strong, grounded in code) -**The free/locked mode heuristic in `Tracker::update` flip-flops on consecutive -ticks.** The logic (`src/tracker.cpp:14-27`): - -```cpp -bool cursorMoved = !haveCursor_ || cursorX != lastCursorX_ || cursorY != lastCursorY_; -if (cursorMoved) { - cx_ = cursorX; // free mode: snap center to OS cursor - cy_ = cursorY; -} else if (rawDx != 0 || rawDy != 0) { - cx_ += rawDx * sensitivity_; // locked mode: integrate raw deltas onto center - cy_ += rawDy * sensitivity_; -} -``` - -Each tick decides mode purely from "did `GetCursorPos` change since last tick?" During -ordinary mouse movement both signals are active at once: `GetCursorPos` is changing -*and* raw deltas are arriving. Our tick samples them asynchronously. On a tick where -the `GetCursorPos` sample happens to read the **same** value as the previous tick -(sampling alias) while raw deltas are nonzero, the code takes the **locked** branch and -pushes the center off by the raw delta, instead of snapping to the cursor. The next -tick usually sees `GetCursorPos` change again and snaps the center **back** to the -cursor. Result: the center oscillates between "cursor" and "cursor plus accumulated raw -delta" from tick to tick, which reads as rapid flicker / jumpiness. - -**Why worse over GPU-rendered windows (hypothesis):** those windows -(DirectComposition / independent flip / high-refresh cursor handling) likely change the -cadence at which `GetCursorPos` updates relative to our fixed tick, making the -"GetCursorPos unchanged this tick but raw deltas present" alias condition fire more -often. To be confirmed. - -Secondary contributor: integer-offset quantization in `ComputeOffset` -(`src/transform.cpp:9-12`, `lround`) makes the view move in L-pixel steps at zoom L. -That adds judder but is monotonic stepping, not the off-and-back oscillation the user -describes, so it is a minor factor here, not the main cause. - -### Evidence to gather (before any fix) -- Add per-tick logging of which branch (`free` vs `locked`) was taken plus `cursorX/Y`, - `rawDx/Dy`, and the resulting `cx_/cy_`. Reproduce over the terminal and confirm rapid - free<->locked toggling during continuous movement. - -### Candidate fixes to evaluate (do NOT implement yet) -- Do not enter locked mode opportunistically. Only integrate raw deltas when a real - cursor lock is detected (the cursor is genuinely pinned/clipped, e.g. confirmed over - several ticks or via an explicit lock signal), and fall back to `GetCursorPos` - otherwise. This must preserve the core game feature (lens-moves-when-cursor-locked). -- Or smooth/blend the center so a single anomalous tick cannot snap it. -- Whatever we choose, it must keep the locked-mode game behavior intact (project - CLAUDE.md flags this as THE core feature; do not simplify it away). - -### Resolution (fixed, unit-tested) -Root cause confirmed by a failing unit test that reproduced the oscillation: a lone -frozen-cursor tick with raw deltas jumped the lens off-centre (`960 -> 1010`) and the -next moved tick snapped it back. `Tracker::update` now uses a hysteresis lock detector -(`src/tracker.cpp`): a lock engages only after `kLockEngageTicks` (6) consecutive -frozen-cursor-with-raw ticks and disengages the instant the OS cursor moves; while -unconfirmed the lens holds (never jumps). The flicker-regression and game-lock tests -both pass. Game cursor-lock panning is preserved (engages after ~40-100 ms once, then -follows). Commit on `fix/interaction-bugs`. - ---- - -## Issue 4 - In-game FPS hitching (cross-reference) - -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 - -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. - ---- - -## Open questions (to confirm with the user) - -1. **Issue 1 binary:** when you tested "UIAccess about the same," were you launching - `C:\Program Files\Wind\Wind.exe` (the signed UIAccess copy) or the dev `Wind.exe` in - the project folder? They behave differently. -2. **Issue 2/3 zoom level:** do the partial-interaction and the flicker happen at all - zoom levels, or only at higher zoom? (Helps confirm the drift/quantization theory.) -3. **Issue 3 without a game:** the flicker repro you described was pure desktop (browser - + terminal, no game running) - correct? That confirms it is unrelated to the in-game - FPS ceiling. -4. **Affected-app list:** beyond Task Manager, File Explorer, Windows Terminal, browser, - and the Cyberpunk launcher, are there other apps where you have noticed any of these? - Are there apps where it is notably fine (good contrast cases)? -5. **Cursor stillness:** when you stop moving the mouse over a "bad" window, does the - flicker stop and the view settle? (The tracker theory predicts yes.) - ---- - -## Live-test checklist (for the user) - -Rebuild with `build.bat`, run `Wind.exe`, then: - -1. **Flicker (Issue 3) - main fix.** Split a browser and Windows Terminal side by side. - Zoom in, then move the cursor across the terminal and back. Expected: smooth, no - rapid off-centre/recenter flicker. Try the browser and the Cyberpunk launcher too. -2. **Mis-clicks / I-beam (Issue 2).** Zoomed in over File Explorer and the launcher, - hover a text field. Expected: the I-beam shows and clicking focuses it; the cursor - sits where you see it. -3. **Game lock still works (regression guard).** In a game that locks the cursor - (mouse-look), confirm the lens still pans with the mouse as before. There may be a - one-time ~40-100 ms delay before it engages when you first start moving; after that it - should track normally. -4. **Zoom over Task Manager (Issue 1).** Open Task Manager, put the cursor over it, press - the zoom side-buttons. Expected: zoom now responds. (If it still does nothing, tell me - and confirm whether Task Manager was running elevated.) - -Optional deeper check: set `diagnostics=1` in `magnifier.ini`. The `wind_diag.log` line -now includes `lockedTicks=/`. During desktop use over "bad" windows it should -read ~0 (no false locks); during game mouse-look it should climb toward the iteration -count (lock engaged). - -## Process notes - -- Investigate per `systematic-debugging`: gather the per-tick evidence above to confirm - the free/locked oscillation before writing any fix, then fix root cause with a test. -- When we move to fixing, mirror each issue as a GitHub issue -> branch -> PR per the - project workflow (repo is currently local-only; create the issues once a remote - exists, or track here in the meantime). - ---- - -## Issue 5 - Bound keybinds not swallowed in games (raw-input limitation) - -**Date:** 2026-06-16. **Status:** Working as designed for normal apps; **games are an OS -limitation, accepted + documented** (issue #99 / PR #100). - -### Reported behavior -With a keyboard (or mouse side-button) zoom/recenter bind, the bind still fires inside -**games** even while Wind zooms - "both things happen." In normal apps (browsers, Notepad, -etc.) swallowing works correctly (user-confirmed). - -### Root cause (confirmed) -Wind swallows input with low-level hooks (`WH_MOUSE_LL` + `WH_KEYBOARD_LL`, -`src/input_router.cpp`). Returning 1 from a LL hook blocks only the **legacy/cooked** input -path - `WM_KEYDOWN`/`WM_XBUTTONDOWN`, `GetAsyncKeyState`, the thread input queue - which -desktop apps use. Games read input through the **Raw Input API** (`WM_INPUT`), which the OS -delivers from the Raw Input Thread independently of the hook chain. **A low-level hook cannot -block Raw Input**, and there is no user-mode API to suppress raw input destined for another -process. (Wind itself relies on this: it reads raw mouse deltas a game can't hide.) - -### Resolution -- Keyboard swallow added for the legacy path (issue #99 / PR #100): zoom in/out primary + - alternate and recenter are now swallowed in normal apps, matching the existing mouse - side-button behavior. Plus a safety blocklist (`IsForbiddenBindVk`) so left/right click, - Backspace, and the Windows keys can never be bound. -- **Games: accepted as a limitation.** The only reliable fix is a kernel filter driver - (e.g. Interception), which we deliberately avoid: it breaks Wind's no-driver/no-injection - design and risks anti-cheat (EAC/BattlEye/Vanguard) bans. Guidance: pick game keys/buttons - you don't otherwise use. - ---- - -# Own renderer (issue #4, branch feat/own-renderer) - -A second engine that captures the desktop (DXGI Desktop Duplication) and renders the -magnified view itself with Direct3D 11, for true sub-pixel pan and a smooth centered -cursor. Selected with `engine=render` (default) vs `engine=mag` (the Magnification-API -engine). Spec: `docs/superpowers/specs/2026-05-25-own-renderer-design.md`. - -## Cursor-hide spike (Task 0, 2026-05-25) -- GUI / Magnification-API programs run in this session (D3D + DDA viable). -- `MagShowSystemCursor(FALSE)` returns success and does NOT change the cursor shape - (`hCursor` unchanged), so DDA still reports the real sprite to draw. -- `GetCursorInfo`'s `CURSOR_SHOWING` is unchanged by it (the hide, if any, is a - magnification-runtime effect GetCursorInfo doesn't expose) - so it cannot confirm the - visual hide. Verified instead via DDA `PointerPosition.Visible` in the capture task. -- Decision: cursor-hide is a swappable strategy. Primary = `MagShowSystemCursor(FALSE)` - (shape-preserving). Fallback (if a double cursor appears) = `SetSystemCursor` blank + - draw a generic arrow. Safe-restore net: `MagShowSystemCursor(TRUE)` + `MagUninitialize` - on every exit path, plus `SystemParametersInfo(SPI_SETCURSORS,...)`. - -## Build/verification results (2026-05-25, autonomous) -Verified by building + running on the 4K display and reading render-then-dump PNGs (the -overlay is WDA_EXCLUDEFROMCAPTURE, so it can only be captured from inside the app): -- D3D11 device + click-through overlay + flip swapchain present (Task 4). -- DXGI Desktop Duplication capture, cursor excluded (Task 5). -- Sub-pixel float-source-rect magnify shader, bilinear (Task 6) - after fixing a - capture-feedback loop via WDA_EXCLUDEFROMCAPTURE, a static-desktop first-frame retry, - and a render-then-dump-before-Present fix (FLIP_DISCARD back-buffer read is undefined). -- Real cursor decoded (GetCursorInfo) + drawn centered, alpha-blended, scaled by zoom (Task 7). -- Cursor hide + SetCursorPos click-sync + clean shutdown restore (Task 8). -- End-to-end via the real app: `WIND_SELFTEST=1 Wind.exe` -> correct 4x magnified frame - with the cursor centered (Task 9/10). -- 41 unit tests pass; full app builds with no warnings; launches without crashing. - -## Interaction fixes (2026-05-25, live testing on the render engine) -- **Clicks were eaten while zoomed** (cursor "disabled"): the overlay was `WS_EX_TRANSPARENT` - + `WM_NCHITTEST -> HTTRANSPARENT`, but HTTRANSPARENT only forwards to *same-thread* windows, - so cross-process clicks went nowhere. Fixed by `WS_EX_LAYERED | WS_EX_TRANSPARENT` + - `LWA_ALPHA 255` (the documented cross-process click-through). That rules out a flip swapchain, - so the overlay now uses a BLT-model swapchain - standalone-tested to display (center screen - pixel read back red) on a layered window. Latency kept low via `SetMaximumFrameLatency(1)`. - (`WindowFromPoint` is a misleading probe here - it ignores transparent windows differently - than live click routing, so it returned the app below even when clicks were actually eaten.) -- **Shell surfaces showed an unmagnified copy** (Start menu, taskbar thumbnail previews, tray - flyouts): those live in Windows 11 immersive z-order *bands* above normal app windows, so a - plain `HWND_TOPMOST` overlay can't cover them (the re-assert didn't help). The fix - how - accessibility magnifiers (ZoomText, etc.) do it - is **UIAccess + a higher z-band**: build - `build.bat uiaccess` (uiAccess=true manifest), deploy signed to `C:\Program Files\Wind` via - `tools\uiaccess_setup.ps1`, and create the overlay via `CreateWindowInBand` with - `zorderBand=16` (ZBID_SYSTEM_TOOLS, above the shell bands). Falls back to a normal topmost - window when UIAccess/band is unavailable (casual `build.bat` build, zorderBand=0). The exact - band is config-tunable (`zorderBand`) since `CreateWindowInBand` is undocumented. **Superseded - by issue #162: the band is now OPT-IN and the shipped default is 0, because a banded overlay is - covered by the Snipping Tool capture overlay. See the #162 entry below for the trade-off.** - (Games - cover the shell via *exclusive fullscreen*, a different mechanism that doesn't apply to a - desktop overlay.) - -- **No cursor and an unmagnified view under the Snipping Tool overlay** (issue #162, fixed - 2026-08-02): with the Win+Shift+S capture overlay up, zooming showed the *unmagnified* screen - and **no cursor at all**, in every model. `ScreenClippingHost.exe` composites above - `ZBID_SYSTEM_TOOLS` (16), so both the render overlay and the transform model's cursor sprite - were covered - and since Wind hides the OS cursor plane and draws its own replacement, covering - that replacement leaves nothing at all. - - **Fixed by shipping `zorderBand=0` (unbanded).** This is a TRADE-OFF, not a strict improvement, - and it partially reverses the shell-surface fix above: - - | | Start / taskbar / tray | Snipping Tool overlay | - |---|---|---| - | band 16 (old default) | covered correctly | **covers us; no cursor at all** | - | band 0 (new default) | may show an unmagnified copy | works | - | band 17 (ZBID_LOCK) | would cover both | **rejected by `CreateWindowInBand` on 26200** | - - A missing cursor is an accessibility failure; an unmagnified Start menu is a cosmetic one, so - band 0 wins by default and band 16 stays available via the `zorderBand` knob. - - Band 17 is the trap in this bug. `CreateWindowInBand` refuses it and the old code's only - fallback was an unbanded window, so asking for 17 *silently produced band 0* - which is why - setting 17 appeared to fix it and a "proper" 17 -> 16 cascade then reintroduced the bug. - `wind::CreateBandedWindow` (`src/band_window.h`) now logs when a requested band is refused. - - **Diagnostic traps, all hit while chasing this:** - - `ScreenClippingHost` holds the foreground but has **no visible top-level window**. A z-order - walk reports Wind's sprite at index 0 (topmost) while it is plainly covered, so z-order - enumeration cannot confirm or refute a band problem here. - - `CURSOR_SHOWING` stays 1 for the whole snip, so this is not the `cursorVisibility=auto` gate. - - It is not a cursor-decode failure either: `transform_model.cpp:419` falls back to the real - system pointer for any shape it cannot render, so a decode failure shows a cursor, not none. - - The snip overlay is **not** `WS_EX_LAYERED`, so `IsOverlayFg`'s cheap first test misses it and - only the built-in name list catches it. - -- **Dragging a window while zoomed flickered between two positions** (issue #169, fixed - 2026-08-02): the dragged window ping-ponged ~85 px while Wind's own drawn cursor stayed smooth. - Probe data showed the WINDOW's position tracking the pointer 1:1 at full rate - nothing was slow; - the OS pointer itself oscillated between two coherent tracks (the hand's position and the lens - centre), amplitude scaling with hand speed. Two defects, one structural, one behavioural: - 1. The pan oracle's baseline was ASSUMED (`lastSetVirtual = clickDesktop`, "the weld landed") - rather than measured. Wherever no park actually landed - transform FOLLOW never places the - cursor, render's park is deduped on an unchanged centre pixel, gatePresent/fps-cap ticks skip - the frame - the next delta measured hand + (pointer-centre gap), the mapper integrated the - gap, and the loop became an unstable servo. Fixed: the baseline is a fresh post-present - `GetCursorPos`, correct in every park-landed/skipped/absent case. - 2. The weld itself fights the hand mid-drag: while a button is held the pointer IS the - interaction, and re-parking it every tick made everything following it flicker. Fixed: - drag-follow (`src/drag_follow.h`) suspends the weld for exactly the button-hold; the lens - follows the pointer 1:1 unscaled, and the weld resumes on release. - 3. THE GATING DEFECT (found when 1+2 alone changed nothing in the field): the LockDetector - treated ANY ClipCursor rect smaller than the virtual desktop as a game lock - and this - machine has a permanent machine-wide WORK-AREA clip (desktop minus taskbar, ~95% of the - monitor; an external taskbar utility). Every zoomed desktop session therefore ran the LOCKED - path from the first tick: panning from unaccelerated raw mickeys while the pointer moved with - ballistics, the weld re-parking the pointer to the slower lens centre - the measured fight - - and drag-follow could never engage because the free branch never ran. Fixed: - `ClipRectConfines` (lock_detector.h, pure) - a clip is a lock signal only when meaningfully - smaller than the monitor (<90% in either dimension). A game clipping to a FULL monitor never - needed the clip signal; the raw-active-but-cursor-frozen detection catches mouselook there. - Diagnostic traps from the hunt, so nobody re-treads them: it was NOT the z-band (A/B'd), NOT - capture (transform showed it too), NOT pointer quantization (didn't scale with zoom), NOT a live - magnification context (bare MagInitialize held 20 s: smooth), NOT the LL mouse hook - (WIND_NOHOOK: no change), and NOT cursorSmoothing - smoothing OFF made it 3x WORSE because the - inertia was DAMPING the servo oscillation, not causing it. When testing cursor regimes on this - rig, remember the permanent work-area clip: GetClipCursor never returns the full desktop here. - A magnify-model sighting remains unexplained (Wind touches nothing cursor-related there); - re-verify before treating it as real. - -- **I-beam / invert cursors went invisible** over text fields: the I-beam is a color cursor - with NO alpha channel (`anyAlpha=0`) - an invert-style cursor that shows by inverting the - pixels beneath it. The decoder was making it transparent/white -> invisible on a white field. - Fixed: no-alpha cursors are now drawn with an **invert blend** (`result = src*(1-dest) + - dest*(1-src)`; white glyph -> `1-dest`), so they show on any background. Arrow/hand (which - have alpha) keep the normal alpha blend. (Confirmed via `tools/cursor_decode_test.cpp`: - ARROW/HAND `anyAlpha=1`, IBEAM `anyAlpha=0`.) - -## HDR brightness/color (fixed: in-shader tonemap) -On HDR the magnified view was brighter / colors off, because the SDR overlay showed HDR -content at the wrong white level. Fixed by capturing the true HDR signal and tonemapping it -to SDR ourselves (`hdrTonemap=1`, on by default): -- Capture FP16 scRGB via `DuplicateOutput1` (clean linear HDR); the copy texture adapts to - the *acquired* format so `CopyResource` can't mismatch (a format mismatch black-screened - the first attempt - DXGI format 10 is R16G16B16A16_FLOAT, not R10G10B10A2). -- Shader: scRGB linear -> scale by the auto-queried SDR white level (DisplayConfig) -> sRGB. - scRGB is already Rec.709 so no gamut matrix. -- **Gated on Windows' `advancedColorEnabled`, NOT the DXGI color space** - some monitors stay - in HDR10 color space even when Windows HDR is off, which would wrongly tonemap (and dim) - SDR. DisplayConfig is queried live, so toggling HDR at runtime (which fires ACCESS_LOST -> - recreate) switches the path without a relaunch. On SDR it's a no-op (BGRA8 passthrough). -- Diagnosis log at `%TEMP%\wind_render.log`. `brightness` config remains an optional fine-tune. - -## Human-only checks (cannot be verified autonomously - please confirm) -With `engine=render`, zoom in (hold the forward side button) and confirm: -1. Exactly ONE cursor is visible (not two). If two, MagShowSystemCursor isn't hiding the - OS cursor here; swap to the SetSystemCursor-blank fallback (and draw a generic arrow). -2. The cursor stays centered and SMOOTH while panning (no L-pixel hop) - the whole goal. -3. Content pans smoothly at 8x (no judder). -4. Clicks land where the centered cursor points. -5. DRM video (Netflix) shows black in the magnified layer (known DDA limit). -6. On exit, the cursor + screen are back to normal everywhere. diff --git a/docs/NATIVE-MAGNIFIER-STOMP.md b/docs/NATIVE-MAGNIFIER-STOMP.md new file mode 100644 index 00000000..4fa2cf2f --- /dev/null +++ b/docs/NATIVE-MAGNIFIER-STOMP.md @@ -0,0 +1,52 @@ +# The built-in Magnifier stomps the transform engine (issue #217) + +> **Status.** Closed. The stomp guard shipped (PR #218). Picking Render while a foreign magnifier +> runs is parked. Design: [architecture/05](architecture/05-transform-engine.md#the-input-transform). + +## Symptom + +In a transform session the cursor stopped sitting at the view centre: it trailed the view while +panning and caught up at rest ("wobble with inertia"). Measured with `tools/mag_wobble_probe.ps1` +at a constant 900 px/s pan at ~8x: the sprite sat a constant ~276 screen px behind the content +(~36 ms, about 5 ticks), collapsing to zero at rest. Wind's own `cursor divergence` log read +0–9 px throughout, because it samples at the weld instant. + +## Root cause + +The built-in Magnifier (Magnify.exe) was running, even unzoomed. Reproduced with +`tools/mag_wobble_repro.ps1 -WmOpen` and `tools/mag_wobble_monitor.ps1`: + +- Magnify.exe at 1x continuously publishes an enabled identity input transform + (`enabled=1 src=(0,0,3840,2160) dst=(0,0,3840,2160)`). +- Wind zoomed with Magnify.exe open: the slot stays identity. A magnified desktop with an identity + input transform gives hover dead zones ([POINTER-HITTEST-FINDINGS.md](POINTER-HITTEST-FINDINGS.md)). +- Magnify.exe closed cleanly (Win+Esc): the slot clears at once. +- Magnify.exe killed while zoomed: its last rect stays enabled system-wide and survives Wind and + DWM restarts, until a later publish overwrites it. +- The visible wobble itself is sprite move latency: Magnify.exe's second magnification context + makes DWM apply the sprite's per-tick move late relative to the transform write. + +## Shipped: the stomp guard + +Every zoomed tick reads the input transform back (`MagGetInputTransform`, ~0.1 ms) and republishes +when it is not Wind's last publish; the same tick re-asserts `MagShowSystemCursor(FALSE)`. Success +is judged by read-back, because `MagSetInputTransform` can return FALSE while the publish lands. + +## Verdicts + +- Against a running Magnify.exe the guard loses every race (144 stomps a second). It heals a rect + stranded by a killed Magnify.exe (one republish wins) and reliably detects a foreign writer. +- The wobble reproduced on the fixed build, so the identity input transform is a symptom of + Magnify.exe running, not the wobble mechanism. +- `spriteBand16=1`: negative. Band-16 windows are magnified like everything else on build 26200; + the screen-space sprite was misplaced. +- `cursorSprite=0` (raw welded cursor): negative for Transform. The cursor plane composites + outside the magnification and points at the wrong content. +- `model=render` with Magnify.exe open: clean. Render draws its cursor in its own frame and is + immune to these shared-state stomps. + +## Parked + +Auto picks Render while a foreign magnifier is detected (the guard's detection latch plus a +Magnify.exe check at zoom-in), through the existing mid-zoom switch; a pinned `model=transform` +gets a warning. Not built. diff --git a/docs/PERF-ACRYLIC-PARITY-2026-08-21.md b/docs/PERF-ACRYLIC-PARITY-2026-08-21.md index 62d0456c..22b57aee 100644 --- a/docs/PERF-ACRYLIC-PARITY-2026-08-21.md +++ b/docs/PERF-ACRYLIC-PARITY-2026-08-21.md @@ -1,69 +1,39 @@ -# Wind vs native Magnifier over acrylic (issue #219) - iteration log, 2026-08-21 +# Wind versus the built-in Magnifier over acrylic (issue #219) -Max's report: Wind performs far worse than wm zoomed ~14-15x over the (maximized, acrylic) -Prism window; the hitch is MOSTLY at zoom-in, a bit while panning, and swapping focus to -another maximized window (Tabby) and back helps reproduce it. +> **Status.** Closed. Shipped `txMaxStepPct=25`, capping up-steps only +> ([architecture/05](architecture/05-transform-engine.md#write-cadence)). -Instrumentation (tools/mag_perf_run.ps1): identical injected zoom+pan cycles for both drivers; -DwmFlush inter-return intervals for compositor pacing (DwmGetCompositionTimingInfo is -unavailable on this VRR panel - 0x88980090 at every struct size); level plateau/jump evenness -and offset-change gaps via MagGetFullscreenTransform read-back on the Mag-affine thread; GPU -3D-engine counters per process in a child sampler; CPU/WS per process group. Cycle mode = the -focus-swap repro: activate Tabby, activate Prism, zoom to 15x, pan 2.5s, zoom out; 20 cycles. +## Problem -## Findings - -1. Steady-state pans, fast pans, and ramp cycling all measure IDENTICAL Wind vs wm (compositor - 143.6fps, no stutters, GPU ~5%, both drivers). The gap is not steady-state. -2. The 20-cycle soak found the real artefact, matching Max's description exactly: - - UNCAPPED Wind: 3/20 zoom-ins freeze 35-43ms mid-ramp then SNAP 1.2-1.9 levels at once, - with a matching compositor gap; 2/20 pans freeze 43-46ms. Sporadic, DWM-internal - (txwrite stayed <5ms - not the write call; input-transform machinery off changed nothing: - 2/20 + 2/20 with magInputTransform=0). - - NATIVE 20-cycle tail is WORSE: over-25ms ramp stalls in 7/20 cycles, routine 23-32ms - gaps every ramp (7-14 coarse steps, jumps up to 3.9 levels), pan spikes 36-63ms in 3/20. - Native's uniformly coarse ease masks its stalls; Wind's 143Hz fine cadence makes its rarer - freeze-then-snap maximally visible. That perceptual asymmetry IS the reported "way worse". -3. THE FIX: txMaxStepPct=25 (cap applied level change at 2.5%/tick; knob existed, shipped 0). - 20-cycle soak with the cap: EVERY ramp even - plateau <=13ms, uniform 0.36-level steps, - zero over-25ms compositor gaps, ramp ~35ms longer (890 -> 924ms). Pan clean (worst 18.5ms - flush gap) once the catch-up tail is kept out of the pan window; the 8/20 pan stalls in the - no-settle run were top-of-zoom catch-up level writes landing during the pan - in real use - that is ~2 capped ticks right after release. - Normal ramp ticks are 0.8-2.2% relative, so the cap only ever bites the post-stall snap. -4. Cursor guardrail with the cap: devMed 0px (welded), p95 270 (reversal transients, same as - baseline), 143.6fps, zero stutters. No cursor regression. +Field report: at ~14–15x over a maximized acrylic window, Wind hitched far worse than the built-in +Magnifier, mostly at zoom-in. Switching focus to another maximized window and back helped +reproduce it. -## Outcome +## Method -txMaxStepPct default 0 -> 25 (config.h; hot-reloadable). Wind now measures BETTER than native -on every phase of Max's own repro: ramps 20/20 clean vs native 13/20; pan worst 18.5ms vs -native routine ~30ms with 63ms spikes. +`tools/mag_perf_run.ps1` drives identical injected zoom and pan cycles for both magnifiers and +records compositor pacing (`DwmFlush` return intervals; `DwmGetCompositionTimingInfo` fails on the +VRR panel), level steps and offset gaps via `MagGetFullscreenTransform`, per-process GPU and CPU. +Cycle mode: focus another window, focus the acrylic one, zoom to 15x, pan 2.5 s, zoom out; 20 +cycles. -Numbers that define "parity or better" for future regressions (15x, focus-swap cycle, acrylic): -ramp plateau <=13ms / jump <=0.4 levels / no over-25ms flush gaps; pan flush gaps <=20ms. - -Prior config.h comments say txLevelStep and txGrid measured NO BETTER / WORSE - those skip or -quantize writes. The cap is different: it never skips, it limits the SIZE of a change, which is -what kills the snap without touching cadence. +## Findings -## Round 2: session-start bounce (Max field report after the cap shipped) +- Steady pans, fast pans and ramp cycling measured the same for both (143.6 fps, ~5% GPU). +- The 20-cycle soak found the artifact: uncapped, 3 of 20 Wind zoom-ins froze 35–43 ms inside DWM + and then snapped 1.2–1.9 levels at once. The write call stayed under 5 ms. +- The built-in Magnifier's tail was worse (over-25 ms ramp stalls in 7 of 20 cycles), but its + coarse steps mask the stalls; Wind's fine cadence makes a rare freeze-then-snap obvious. +- A down-step cap made a quick re-zoom start backwards (5–7 backward steps, 4 of 4 runs). -Quick re-zoom (15x -> full out -> immediately in) made the level BOUNCE at ramp start: rig- -reproduced 4/4 with the harness rezoom mode (5-7 backward level steps, 0.13-0.19 levels of -backward travel). Cause: the cap's DOWN clamp anchored on a stale lastLevel_ - zoom-out trailed -under the cap and the identity park wrote 1.0 without updating the cache. Fix (ae1f126): cap -UP-steps only (zoom-out measured clean uncapped, outGaps <=8ms in every soak) + the park syncs -lastLevel_. After: back0 on every re-zoom, ramps unchanged. Residual: a sporadic DWM stall -(~1/10 ramps, system-internal) still occurs but now recovers as a capped glide, not a snap. +## Fix -## Round 3: Max's zig-zag protocol (bottom -> top climb at 15x, both pan axes, 8 cycles each) +`txMaxStepPct=25` caps the applied level change at 2.5% per tick, up-steps only; the identity park +also resets the cached level. With the cap every ramp was even (uniform 0.36-level steps, no +over-25 ms gaps, ~35 ms longer ramp). On the zig-zag climb at 15x Wind ran 8 of 8 cycles without a +stall; the built-in Magnifier stalled on every pass. -- WIND: 8/8 clean. Ramps even (back0), zig offset gaps max 21-29ms, compositor flush gaps - max 18.9ms, ZERO over-25ms stutters in any phase. Cursor welded: dev median 0.0 both axes. - Resources: dwm CPU ~0, GPU ~0.5%, dwm WS 157MB, Wind WS 19MB. -- NATIVE: stutters in EVERY zig pass - 1-6 over-25ms compositor stalls per climb (28 across - 8 cycles), ramp gaps 24-28.6ms routinely, plus Magnify.exe 124MB WS / 1.3% CPU. +## Regression thresholds -Wind is as good or better than wm on every protocol measured: steady pan, fast pan, ramp -cycling, focus-swap cycles, quick re-zooms, and the zig-zag climb. +At 15x on the focus-swap cycle over acrylic: ramp plateau ≤ 13 ms, level jump ≤ 0.4, no compositor +gap over 25 ms; pan compositor gaps ≤ 20 ms. diff --git a/docs/PERF-SETTINGS-STARTUP-2026-10-01.md b/docs/PERF-SETTINGS-STARTUP-2026-10-01.md deleted file mode 100644 index c1a3bdb5..00000000 --- a/docs/PERF-SETTINGS-STARTUP-2026-10-01.md +++ /dev/null @@ -1,49 +0,0 @@ -# Settings window start-up measurement (#303, Task 11) - -Measured 2026-10-01 on the dev box (9950X3D, RTX 5090, warm WebView2 runtime), release `WindConfig.exe` -from `build.bat config`, launched from a normal shell, killed after 4 s, 11-12 launches per build, -alternating old/new. Numbers come from the host's own log lines in `logs\wind-config.log`: - - startup: navigation completed N ms after launch - startup: first paint N ms after launch (page posts "ready" two animation frames after App mount) - -Old build = this branch with the `ready` post and host logging, but with the static Onboarding import and -the eager banner image (the pre-Task-11 behaviour). New build = HEAD (lazy Onboarding chunk, banner image -set two frames after mount). - -| Build | navigation completed (median) | first paint (median) | first paint (range) | -|-------|-------------------------------|----------------------|---------------------| -| Old | 344 ms | 407 ms | 390-719 ms | -| New | 313 ms | 407 ms | 390-469 ms | - -Raw first paint, old: 407 390 406 422 469 391 390 406 719 390 422. New: 390 438 407 469 406 391 407 407 406 391 406. -Raw navigation, old: 344 328 344 360 391 329 328 328 719 328 329. New: 312 344 329 344 312 297 328 313 313 313 313. - -## Reading - -- Settings launches (the normal case) never use Onboarding, so lazy-loading it removes parse work from the - main bundle: navigation completes about 30 ms sooner. First paint is unchanged (median 407 ms for both), - so first paint is dominated by WebView2 start-up, not by our bundle. -- The banner image is requested two frames after mount, about when `ready` is posted. That keeps it off the - first render's critical path, but it is not a measurable gain; do not claim one. -- Honest summary: a modest (about 30 ms) navigation gain, no first-paint change. The onboarding chunk is 9.65 kB. -- `ready` is posted when App mounts, not when the session has loaded, so "first paint" means the shell's first - paint, not fully populated settings. - -## Caveats - -- The commit subject of 9aa1c94 ("measured faster first paint") overstates the result: first paint did not - improve. Only navigation completion did (about 30 ms). -- The numbers were captured with the code of commit 63b4075 (same lazy Onboarding and banner code as 9aa1c94; - 63b4075 only adds a comment to Banner.svelte). The "old" build is a hand-built variant of this branch with the - static Onboarding import and eager banner, not a checkout of the real pre-Task-11 commit. - -## Gate state (Task 11 fix-up) - -- `build.bat test`: 407 doctest cases pass. -- `build.bat config`: builds, exit 0 (stop any running WindConfig.exe from the worktree first, it locks the link). -- `cd ui && npx playwright test`: 47 passed, 72 skipped, 0 failed, 6.5 s. `a11y.spec.js` and `settings.spec.js` - target the pre-redesign UI and are skipped with `test.skip(true, ...)` until Task 12 rewrites them. They failed - before Task 11 as well (66 failed, 6 passed), so this is not caused by the lazy-loading change. The earlier - "52 vs 53 passed" difference came from those old-UI specs timing out and was not reproduced; with them skipped - the count is stable. diff --git a/docs/PERFORMANCE-AUDIT-2026-05-26.md b/docs/PERFORMANCE-AUDIT-2026-05-26.md deleted file mode 100644 index 353f4ce6..00000000 --- a/docs/PERFORMANCE-AUDIT-2026-05-26.md +++ /dev/null @@ -1,59 +0,0 @@ -# Wind - Render-Engine Performance Audit (2026-05-26) - -Audit of the **own DXGI capture + D3D11 renderer** (the current engine), looking for things that -add per-frame weight, cause stutters, or lower fps. Triggered by a reported symptom: frametimes -are very consistent but with a **consistent spike roughly every 1 second, even when the overlay is -zoomed but idle**. - -(The older `PERFORMANCE-FINDINGS.md` analyzes the superseded Magnification-API engine and is kept -only for historical context. This document is about the render engine.) - -## Method + key evidence - -The build has a `WIND_PACINGTEST` harness: it runs the **real** forced-zoom render loop (capture -the real desktop, draw, present with vsync) for ~4s and logs loop-interval stats, but it runs a -**lean** loop - it does NOT do the per-tick `RunTick` wrapper work (config poll, input polling, -oracle). Comparing it against the symptom isolates where the spike lives. - -Measured (2026-05-26, this machine, 144 Hz, default `dwmFlush=0`/`vsync=1`): - -``` -PACINGTEST vsync=1 frames=577 ~fps=144.0 targetDt=6.94ms avgDt=6.94ms maxDt=7.69ms hitches>1.5x=0 big>2.5x=0 -``` - -**The render/capture/present path is flawless: 144 fps, avg == target, max 7.69 ms (< 1.5x), zero -hitches over 4 s.** So the periodic spike is NOT in the engine; it is in the per-tick `RunTick` -wrapper that `PACINGTEST` skips. Within `RunTick`, exactly one action runs at ~1 Hz: the config -hot-reload mtime poll (Finding #1). - -## Findings - -| # | Finding | Runs | Effect | Spike confidence | Why it costs | Status / fix | -|---|---------|------|--------|------------------|--------------|--------------| -| 1 | **Config-poll file stat** - `RunTick` calls `ConfigMTime("magnifier.ini")` = `GetFileAttributesExW` (sync filesystem stat on the render thread) every ~1.0 s, even when unchanged | every **1 s** | single-frame stutter ~1x/s | **HIGH (prime suspect)** | a sync file stat blocks the frame if intercepted by Defender/AV, a filesystem filter, or a busy disk. Exactly 1 Hz; absent from the clean PACINGTEST | **FIXING NOW** - issue #40. Replace the poll with a `FindFirstChangeNotificationW` dir watch checked via non-blocking `WaitForSingleObject(h,0)`; stat+reload only on an actual dir change | -| 2 | **Topmost re-assert** - `renderFrame` does `SetWindowPos(HWND_TOPMOST,...)` every 250 ms | 4x/s | minor micro-hitch up to 4x/s | LOW-MED (4/s, not 1/s) | `SetWindowPos` synchronizes with the window manager/DWM (per-frame was dropped to 250 ms for this reason) | **candidate** - raise interval to ~500 ms-1 s, or re-assert only when actually displaced. Tradeoff vs staying on top of always-on-top overlays | -| 3 | **Per-tick Win32 polling** - `GetAsyncKeyState` x2-3, `GetCursorPos`, `GetClipCursor`, `GetSystemMetrics` x4 (virtual-screen bounds, added by the auto-sensitivity oracle), `GetCursorInfo` | every frame | tiny baseline cost, no spike | NONE (uniform) | per-frame syscalls; the 4x `GetSystemMetrics` are redundant (virtual-screen bounds rarely change) | **candidate** - cache the 4 virtual-screen metrics in `TickState`, refresh only on monitor-retarget/config-reload | -| 4 | **DDA `CopyResource` on desktop change** - when the captured desktop changes, `capture()` copies the full-screen texture; idle/static = `AcquireNextFrame(0ms)` times out cheaply | on desktop change | ~1 s GPU spike **only if** a 1 s-updating element is on screen | MED (environmental) | a taskbar clock-with-seconds, an app counter, or an FPS/overlay tool updating ~1x/s makes DDA deliver a frame each second -> a full-screen GPU copy. Heavier at 4K | **investigate** - rule out by watching frametimes with a fully static desktop vs a 1 s-updating element. Not a bug (capturing changes is the job); could skip re-copy if the changed region is outside the magnified source rect | -| 5 | `LoadConfig` full reparse + `ZoomController`/`CursorMapper` rebuild | only on ini change | one-off hitch on save | NONE (not idle) | listed for completeness | acceptable | -| 6 | blt-model present through DWM (layered swapchain composites via DWM) | every frame | baseline compositing cost | NONE for the spike | inherent to a fullscreen layered capture-excluded overlay; `dwmFlush=0`/vsync chosen as fewest stutters (issue #9) | architectural; already tuned | - -## Bottom line - -The engine itself is clean (PACINGTEST proved it). The original hypothesis - that **#1** (the -once-a-second config file-stat) caused the ~1 s idle spike - was **wrong**: fixing it (PR #41) did -not remove the spike in live testing. The remaining suspect is **#4** (a ~1 s-updating on-screen -element, e.g. a seconds-clock or an FPS/frametime overlay, triggering a DDA capture and, before -this work, a full-screen copy). **#2** added a smaller 4/s jitter. - -All four findings are now addressed (see backlog below). #4 in particular makes a tiny periodic -on-screen update nearly free (dirty-rect copy), which should remove the spike if it is #4. -**To confirm:** watch the frametime graph with a fully static desktop (no seconds-clock, close -RTSS/Afterburner overlays) vs. with a 1 s-updating element, before and after #4. - -## Optimization backlog (work through as desired) - -- [x] **#1** config-poll file stat -> dir-change watch (issue #40, PR #41). NOTE: this did NOT - fix the ~1s spike (live-tested), so #1 was not the cause - kept as a clean micro-opt only. -- [x] **#2** topmost re-assert -> displaced-only check (`GW_HWNDPREV`) + 1s backstop (issue #42, PR #43) -- [x] **#3** cache the 4 virtual-screen `GetSystemMetrics`, refresh on zoom-in (issue #42, PR #43) -- [x] **#4** copy only DDA dirty rects + skip pointer-only frames; full-copy fallback (issue #44) diff --git a/docs/PERFORMANCE-AUDIT-THREADING-2026-05-26.md b/docs/PERFORMANCE-AUDIT-THREADING-2026-05-26.md deleted file mode 100644 index 32f9b16c..00000000 --- a/docs/PERFORMANCE-AUDIT-THREADING-2026-05-26.md +++ /dev/null @@ -1,50 +0,0 @@ -# Wind - Threading / Parallelism Audit (2026-05-26) - -Follow-up to `PERFORMANCE-AUDIT-2026-05-26.md`. Goal: find work that should move to a separate -thread, be parallelized, or otherwise change for performance. Triggered after moving the -`WH_MOUSE_LL` mouse hook to a dedicated thread (issue #46) eliminated an in-game per-frame input -microstutter (the hook on the blocking main thread was batching all system mouse input by a frame). - -## Threading model (after #46) - -- **Main thread:** message pump (raw input `WM_INPUT`, tray, hotkey, `WM_TIMER`) + the pacing block - (`Present(1,0)` / `DwmFlush` / high-resolution waitable-timer wait) + the entire `RunTick` - (input poll, control logic, capture, render, present). -- **Hook thread (#46):** `WH_MOUSE_LL` only; does nothing but pump messages, so the hook is serviced - instantly and never batches system input behind the render/pacing block. -- **GPU / DWM:** composition. - -D3D11: a single device (the default thread-safe device, no `SINGLETHREADED` flag), one immediate -context, `SetMaximumFrameLatency(1)`, no `timeBeginPeriod`, no deferred contexts. - -## Findings - -| # | Inline work (main thread) | Threading / parallelism opportunity | Verdict | -|---|---------------------------|--------------------------------------|---------| -| A | DXGI **capture** (`AcquireNextFrame` poll + dirty-rect GPU copy) at the top of `render()`, then we block on present/flush | Dedicated **capture thread** producing into a keyed-mutex shared texture; render samples the latest with no wait, capture overlaps the present block, and an always-ready frame makes zoom-in instant | **DEFERRED** (see below) | -| B | Zoom-in **"fresh" drain** could block up to `40 x 25ms` (~1s) waiting for a first frame | Cap the fresh-grab wall time | **DONE (#47):** ~100ms deadline; drain-to-latest unchanged | -| C | Cursor decode + texture upload (only on shape change) | Offload decode + double-buffer the texture | **SKIP** - rare, small | -| D | Raw input `WM_INPUT`, batched per frame on main | Own thread | **SKIP** - passive `INPUTSINK`, consumed per-tick as a summed delta, so batching is correct (unlike the hook, which gated system input) | -| E | Present / `DwmFlush` idle-blocks ~1 frame | The only useful overlap is next-frame capture | = **A** | -| F | Config reparse on save; sequential GPU magnify+cursor passes | - | **SKIP** - rare / the GPU already pipelines; not CPU-bound | -| - | Split render onto its own thread from a high-rate input/control loop | Decouple input sampling from frame rate | **SKIP** - the pan is frame-locked by design; no perceptible gain, high effort | - -## Decision on A (dedicated capture thread) - -**Deferred.** After B capped the zoom-in stall, A's remaining benefit is marginal: overlapping a -sub-millisecond capture with the present block, plus frame-freshness headroom for which there is no -measured problem (the synthetic `WIND_PACINGTEST` loop is clean at 144fps with an active pan, and the -in-game stutter was the hook - now fixed). A is also the **highest-risk** change available: it is the -capture path, home of the feedback-loop exclusion (`WDA_EXCLUDEFROMCAPTURE`), runtime HDR format -changes, multi-monitor `retarget`, and it would add cross-thread GPU texture sharing (a keyed mutex). - -**Re-open A only if a concrete need appears** - e.g. measured render-thread stalls during heavy 4K/HDR -scene changes, or much-higher-refresh displays. If built, it MUST sit behind a config flag defaulted -OFF (current inline capture stays the default) and be validated to measurably help before the default -is flipped. - -## Bottom line - -The mouse-hook thread (#46) was the one true threading win, and it is done. With B (#47) capping the -zoom-in stall, the remaining items are marginal or correctly frame-locked. The program is in a strong -state; further threading would add risk and complexity without a measured payoff. diff --git a/docs/PERFORMANCE-FINDINGS.md b/docs/PERFORMANCE-FINDINGS.md deleted file mode 100644 index 41606879..00000000 --- a/docs/PERFORMANCE-FINDINGS.md +++ /dev/null @@ -1,203 +0,0 @@ -# Wind - Performance Investigation & Limitations - -> **SUPERSEDED (issue #4):** this document analyzes the **Magnification-API** engine and -> concludes a "build our own renderer" approach "measures the same or worse." That conclusion -> was wrong for the desktop case: Wind subsequently shipped exactly that own DXGI capture + -> D3D11 renderer as the default engine, which fixed the sub-pixel/pan smoothness this doc said -> was unreachable. Kept for historical context (the in-game compositor ceiling analysis and -> PresentMon methodology are still valid). The `mag` engine is now an unadvertised fallback. - -**Date:** 2026-05-24/25 -**Status:** Investigation complete. Conclusion: the in-game / high-zoom performance gap -is a hard ceiling of the public Windows Magnification API and is **not fixable** from a -third-party, no-injection app. Details and evidence below. - ---- - -## TL;DR - -- Wind magnifies via the public **Magnification API** (`MagSetFullscreenTransform`), - which performs the scaling inside the **desktop compositor (DWM)**. -- For a borderless game, a *changing* magnified frame forces the game off its GPU - fast path (`Hardware Composed: Independent Flip`) onto composited presentation - (`Composed: Flip`) -> large FPS / frame-pacing hit while panning or zooming. -- On the desktop, the API's offset is **integer pixels only**, so at zoom L the view - moves in L-pixel steps -> judder that worsens with zoom. -- **Windows Magnifier (`Magnify.exe`) avoids both** because it is a privileged system - component wired directly into DWM / the GPU scanout path - a route Microsoft does - **not** expose to third-party apps. -- We tested every lever a normal app has - **update cadence, update strategy, and full - UIAccess privilege** - and none close the gap. The limitation is architectural (where - the pixels get scaled), not a bug in Wind's code. -- The only architecture that actually fixes it is **injecting into the game's render - pipeline** (ReShade / Special-K style `Present()` hook), which trades away - anti-cheat safety and is per-game. - ---- - -## What "good" looks like (user-reported ground truth) - -Measured by feel in Kingdom Come: Deliverance II (KCD2), borderless, 144 Hz display: - -| | Windows Magnifier | Wind | -|---|---|---| -| Pan at a fixed zoom level | buttery smooth | **hitchy** | -| Change zoom level (zoom in/out) | hitches | hitches | -| Perf loss while zoomed + moving | ~10% | ~80-95% (feels like 30 fps / worse) | -| More zoom | heavier drops | **much** heavier drops | - -So: zoom-change hitches happen on *both* (likely inherent to the present-mode -transition). The Wind-specific gap is **panning at a fixed zoom** and the **magnitude** -of the loss. - -## What works well (not in question) - -1. Smooth, gradual (non-stepped) zoom. -2. Responsive. -3. Works in games even when the cursor is hidden/clipped/locked (Raw Input tracking) - - **this is Wind's unique value; the Windows Magnifier cannot do it.** - ---- - -## Root cause - -Magnification = "take screen pixels, scale them, present them." That scaling can only -happen in one of **three** places: - -1. **In DWM (the compositor).** What the public API does. Compositing a magnified frame - is exactly what drops a borderless game from direct-scanout onto composited - presentation. The penalty *is* "DWM did the scaling." -2. **In the GPU scanout / overlay hardware (MPO).** The display controller stretches the - game's plane directly - no composition, full framerate. This is the path Magnify - rides. **User-mode apps cannot program scanout/overlay planes; only the OS + GPU - driver can.** Physically locked to third parties. -3. **Inside the game's own render pipeline.** = DLL injection + `Present()` hook. - -A "build our own renderer" approach (Desktop Duplication / Windows.Graphics.Capture + -Direct3D) lands the magnified image in **our window on top of the game**, which forces -the game into composited presentation anyway (same penalty) **plus** capture/re-render -overhead. It measures the same or worse - not better. So there is no fourth option: -without injection or the OS's private hardware path, scaling always adds composition. - ---- - -## Evidence (PresentMon) - -Tool: **PresentMon 1.10.0** (`tools/PresentMon.exe`), run elevated, capturing -`KingdomCome.exe`. The `PresentMode` column tells us the presentation path; `Composed: -Flip` = slow composited path, `Hardware Composed: Independent Flip` = fast MPO path. - -### Clean, user-driven captures (the ones to trust) - -| Capture | PresentMode split | avg | notes | -|---|---|---|---| -| `pm_wind3` (non-UIAccess, pan + zoom changes) | 254 Independent-Flip / 1126 Composed (18% fast) | 14.5 ms (~69 fps) | 16 frames > 40 ms (spikes to 79 ms) | -| `pm_uiaccess` (UIAccess, pan + zoom changes) | 240 Independent-Flip / 928 Composed (21% fast) | 17.1 ms (~59 fps) | 25 frames > 35 ms, max 69 ms | - -The game oscillates between the fast and slow paths and spends the **majority** of time -on the slow composited path. **UIAccess did not change this** (21% vs 18% fast - within -noise). The spikes line up with present-mode transitions, which happen on zoom-level -changes. - -### Wind's own loop is NOT the bottleneck - -From the in-app diagnostics build (`diagnostics=1` -> `wind_diag.log`), during heavy -panning: -- Loop frame gap (`maxDt`) stayed ~7.7-8.5 ms (steady ~139 Hz) - **our loop never - stalls.** -- `MagSetFullscreenTransform` (`maxSt`) returned in ~0.01-0.6 ms - **instant** - except a - one-time ~182 ms cost when magnification first activates. -- Foreground window logged as `"Kingdom Come: Deliverance II"` throughout -> **not a - focus / "treated as background" issue.** The game is foreground; the cost is DWM-side - and asynchronous to our process. - -### Caveats on the data - -- Several **early captures were invalid** because the capture fired before the user was - set up / the user alt-tabbed mid-capture (e.g. `pm_wind`, `pm_wind2`, `pm_magnify` - showed a flat ~35 fps `Composed: Flip` that does not match lived experience). Only - `pm_wind3` and `pm_uiaccess` were properly driven. -- PresentMon measures **present rate**; in-game FPS overlays often show **render rate**, - which can be much higher than the displayed rate under composition. This likely - explains part of the gap between "the counter says 127" and "it feels like 30." -- A clean, properly-driven **Windows Magnifier** capture was not taken (user was - confident in its behavior and we didn't want more capture churn). The qualitative - conclusion does not depend on it. - ---- - -## What we tested and ruled out - -| Lever | Change | Result | -|---|---|---| -| **Pacing cadence** | `DwmFlush()` (vsync-synced) -> high-res waitable timer at refresh rate | No change. Original `DwmFlush` build juddered; timer build "about the same." | -| **Update strategy** | `updateMode` 0 = emit on integer-offset change; 1 = emit on float-center change; 2 = emit every frame while zoomed; plus `maxUpdateHz` throttle | No mode made a perceptible difference. Even continuous per-frame emission still oscillated off the fast path. | -| **Skip optimization** | Skipping redundant `setTransform` when the rounded offset is unchanged | Suspected of causing fast<->composed flip-flop; removing it (updateMode 1/2) did not help. | -| **Privilege (UIAccess)** | Signed binary + `uiAccess="true"` manifest + run from `C:\Program Files\Wind` (confirmed `TokenUIAccess = 1`) | **No change to perf.** Same oscillation (21% fast vs 18%). UIAccess is the same privilege class as Magnify, and it did **not** unlock the hardware-scale path. | - -UIAccess was the strongest/last lever (it's what makes Magnify a privileged system -magnifier). Its failure to help is the key result: the fast path Magnify holds is **not -gated by UIAccess** - it's reserved to the system magnifier via DWM internals not exposed -to apps. - -### Also confirmed inherent (not Wind-specific) - -- The present-mode transition on **zoom-level changes** appears to hit Windows Magnifier - too (user observation). This is the cost of switching presentation paths and is not - uniquely a Wind problem. - -### Integer-offset quantization (desktop judder) - -- `MagSetFullscreenTransform` takes `int xOffset, int yOffset`. At zoom L the source - region moves in whole source-pixels = **L screen-pixels per step**. Confirmed by feel: - smooth at 2x, juddery at 8x (judder scales with zoom). A timing bug would judder - equally at all zooms; it doesn't -> it's quantization, a hard API limit. Magnify almost - certainly pans sub-pixel via its private path. - ---- - -## Conclusion - -- The performance/smoothness gap is a **ceiling of the public Magnification API**, not a - bug we can fix by tuning. Cadence, update strategy, and UIAccess privilege were all - tested and ruled out. -- Wind already extracts everything the public API offers and adds the one thing Magnify - can't: cursor tracking that survives games hiding/clipping/locking the cursor. -- **The only way to truly match/beat Magnify in games** is to scale inside the game's - render pipeline via an injected `Present()` hook (ReShade / Special-K architecture): - full framerate + sub-pixel smoothness, but injection = anti-cheat risk, per-game work, - and a substantially different/bigger project than v1. - -## Open questions (not resolved) - -- The exact private mechanism Magnify uses to keep the game on the hardware-scaled MPO - path while panning. It is reserved to the system magnifier and not exposed; we did not - reverse-engineer it. -- A clean, apples-to-apples Windows Magnifier PresentMon capture (to quantify its present - mode and frame pacing) was not taken. - ---- - -## Reproduction & tooling - -- Capture: `tools/PresentMon.exe -process_name KingdomCome.exe -output_file out.csv -timed 20 -terminate_after_timed -no_top -stop_existing_session` (run elevated). -- Summarize present modes: count the `PresentMode` column (index 12); frame pacing is - `msBetweenPresents` (index 10). -- In-app diagnostics: set `diagnostics=1` in `magnifier.ini` (must be in a writable - location; the Program Files copy can't write its log) -> `wind_diag.log`, one line per - 2 s window with `avgDt/maxDt` (loop) and `avgSt/maxSt` (`setTransform`) and the - foreground window title. - -## Experimental scaffolding to clean up - -The following were added for this investigation and should be removed/trimmed before a -final v1 (they are on branch `fix/perf-pacing`, not merged to `main`): -- `updateMode` and `maxUpdateHz` config knobs (experiment only - no mode helped). -- `diagnostics` frame-timing logging (could keep as opt-in, or remove). -- The `DwmFlush` -> waitable-timer change (neutral; pick one deliberately). -- UIAccess artifacts on this machine: self-signed "Wind Dev Test Cert" in LocalMachine - Root/TrustedPublisher and `C:\Program Files\Wind\`. Removal: - ```powershell - Get-ChildItem Cert:\LocalMachine\Root,Cert:\LocalMachine\TrustedPublisher,Cert:\LocalMachine\My | ? { $_.Subject -eq "CN=Wind Dev Test Cert" } | Remove-Item -Force - Remove-Item "C:\Program Files\Wind" -Recurse -Force - ``` diff --git a/docs/POINTER-HITTEST-FINDINGS.md b/docs/POINTER-HITTEST-FINDINGS.md index 107b85b7..b1651f02 100644 --- a/docs/POINTER-HITTEST-FINDINGS.md +++ b/docs/POINTER-HITTEST-FINDINGS.md @@ -1,34 +1,38 @@ -# Pointer-framework hit-testing under the DWM fullscreen transform (2026-08-12) +# Pointer-framework hit-testing under the DWM fullscreen transform (issue #185) + +> **Status.** Closed. The source-rect input transform publish ships (`magInputTransform=1`). +> Design: [architecture/05](architecture/05-transform-engine.md#the-input-transform). The definitive record of the transform-desktop hover dead zones: symptom, the measurement -chain, the WRONG intermediate verdict, and the proven fix. Field rounds by Max on the rig -(3840x2160@225%), instrumented probes in-tree (`probeClicks`) and standalone (scratchpad rigs). +chain, the WRONG intermediate verdict, and the proven fix. Field rounds on the test machine +(3840x2160@225%), instrumented probes in-tree (`probeClicks`) and standalone (scratchpad rigs, +not committed). ## The symptom With `model=transform` on the desktop and the welded cursor, hover/hit-testing had hard DEAD -ZONES: regions where the cursor was "not registered to be there or anywhere" - no hover, no +ZONES: regions where the cursor was "not registered to be there or anywhere" – no hover, no titlebar grab. Zoom-level dependent. At 4x: File Explorer's lower file rows, parts of its titlebar, the strip above the taskbar. Legacy surfaces (Electron/Win32) were immune. ## THE FIX (field-verified 4x-20x, round 4) **Publish `MagSetInputTransform(TRUE, srcRect, monitorRect)` per transform change while the -fullscreen transform is live** - exactly what native Magnifier does continuously (measured via +fullscreen transform is live** – exactly what native Magnifier does continuously (measured via `MagGetInputTransform`: enabled=1, src tracking its pan per frame). With the source-rect input transform published, the welded cursor gets correct hover/hit-testing in pointer-framework apps at every position and level, and legacy apps stay correct. `magInputTransform=1` is the knob; requires UIAccess. The MSDN "pen and touch input" scoping on MagSetInputTransform is WRONG or incomplete: -pointer-input frameworks (XAML/DirectUI - Explorer, Settings, shell) consume it for MOUSE +pointer-input frameworks (XAML/DirectUI – Explorer, Settings, shell) consume it for MOUSE pointer hit-testing under a fullscreen magnification transform. Without it published they hit-test through the visual transform with no inverse, producing the positional dead zones. ## Why we briefly concluded the opposite (the test-matrix hole) -The A/B knob was first field-tested in mode 2 (ENABLED IDENTITY) - no effect - and the -standalone rig measured the DEFAULT (nothing published) - coordinates unmapped. Both true, +The A/B knob was first field-tested in mode 2 (ENABLED IDENTITY) – no effect – and the +standalone rig measured the DEFAULT (nothing published) – coordinates unmapped. Both true, both irrelevant: the only configuration that works is the SOURCE RECT (native parity), which sat untested while the wrong verdict ("inert for mouse") was written. The matrix that matters: @@ -53,16 +57,17 @@ sat untested while the wrong verdict ("inert for mouse") was written. The matrix identical raw pixel+himetric coordinates with the transform on or off WHEN NO INPUT TRANSFORM IS PUBLISHED. 5. **Weld-vs-physical trace** (`probeClicks=2`, ~36Hz, 1329 samples at 4x): the physical - cursor tracks the weld within <=4px always, <=2px inside the dead band - pointer apps DID + cursor tracks the weld within <=4px always, <=2px inside the dead band – pointer apps DID receive correctly-positioned frames in dead zones; the miss was in their hit-testing. ## Design consequences - One default engine for desktop AND games becomes viable: welded transform + per-tick - source-rect input transform. See the one-model spec/plan (2026-08-12). + source-rect input transform. - HARD DEPENDENCY: MagSetInputTransform needs UIAccess. Non-UIAccess runs (dev builds, portable use) CANNOT publish it -> transform desktop sessions there keep the dead zones -> the render engine must remain the desktop engine wherever UIAccess is absent, and the session must verify the publish SUCCEEDS before trusting the transform on the desktop. -- Diagnostics kept: `probeClicks=1/2`, `magInputTransform` modes (0/1/2), scratchpad rigs - itprobe (input-transform sampler) and ptrprobe (pointer-pipeline coordinate rig). +- Diagnostics kept: `probeClicks=1/2`, `magInputTransform` modes (0/1/2). The scratchpad rigs + itprobe (input-transform sampler) and ptrprobe (pointer-pipeline coordinate rig) are not + committed. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 943787c9..00000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,89 +0,0 @@ -# Wind roadmap - -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; 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. 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 -- **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; 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, 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. - **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) - -1. **Drag does not work in transform game sessions (top priority, field-blocking).** The freeze - design pins the cursor and re-fires clicks at the aim point, so press-move-release never - happens: no dragging a scrollbar, a slider, or anything else. Proposed design: on the - swallowed press, inject the absolute move + BUTTON DOWN at the aim point (as today), then - RELEASE the 1px clip so the user's own hand drags the real cursor from there (no injected - motion - injected absolute placement is a proven driver-reset trigger), let the real button-up - pass through, and re-freeze at the cursor's resting position on release. Needs the hook's - swallow bookkeeping to let the UP through while a drag is live. - DO NOT "fix" this by switching game sessions to follow mode - tried 2026-07-26 and it is - worse: with the cursor free, the marker has to sit at the lens point while the user's hand is - elsewhere, so the pointer reads as not tracking the view at all. Freeze and follow each break - one half of the problem; only moving the real cursor with the lens would satisfy both, and - that is the proven driver-reset trigger. Hence the press-then-unclip design above. -2. **Intermittent huge spike near max zoom - still unexplained.** Every plausible Wind-side cause - has been individually ruled out in the field (see docs/HITCH-FINDINGS.md "negative results"), - and the passive flight recorder (scratchpad/spikewatch.ps1) is the tool for catching one in - the act: it logs the spike size, GPU memory/utilisation and Wind's activity in that second. - If the record shows no Wind activity at the spike, the honest conclusion is DWM magnification - cost colliding with the game's GPU load - the lever is headroom or a lower ceiling, not code. -3. **Cursor size rule** (constant on-screen size at every zoom) still unmet by the transform - model - see the entry below; needs the session lifecycle handled inside the model and one - visual check from Max. - -## Transform model polish -- Hover-follows-aim in game sessions: TRIED AND REVERTED (2026-07-26) - injecting one absolute - cursor move per pan-rest TDRs the NVIDIA driver even with MPO off (absolute-placement - injection is an independent trigger; clicks survive only by being rare). Hover updates on - CLICK only, by design. Viable future routes: the MPO-buster/composited path might also - neutralize this trigger (test when built), or WM_MOUSEMOVE posted directly to the game - window (no cursor state touched - hit-test only; many engines honor it). -- Small-cursor option for game sessions: the aim-point sprite is DWM-magnified with the scene - (grows with zoom). A constant-size cursor needs a compensating sprite scale or a different - compositing band; parked as cosmetic. -- Pan feel in game sessions comes from raw mickeys x cursorSensitivity (no OS acceleration). - If field feedback wants accel-matched panning, reuse the Inspect ballistics cooking - (mouse_ballistics) for the freeze regime. diff --git a/docs/SHELL-PANEL-CURSOR-FINDINGS.md b/docs/SHELL-PANEL-CURSOR-FINDINGS.md index 82ec471e..d9487b00 100644 --- a/docs/SHELL-PANEL-CURSOR-FINDINGS.md +++ b/docs/SHELL-PANEL-CURSOR-FINDINGS.md @@ -1,8 +1,19 @@ -# Cursor over shell input panels (issue #283, 2026-09-29) +# Cursor over shell input panels (issue #283) Field report: with the emoji picker (Win+.) open, Wind's cursor went UNDER the picker (still drawn, just covered). Same for clipboard history (Win+V) and the touch keyboard, which share the host. +> **Status.** Closed; the freeze shipped (`panelPointer=1`). Design: +> [architecture/07](architecture/07-cursor.md#shell-input-panels). + +## Known limitation + +While a shell panel is open and Wind is zoomed in, the real pointer is frozen and moved only from +mouse (relative) raw input. Pens, touch screens and remote-desktop pointers set ABSOLUTE positions, +which the 1px clip blocks, so they cannot steer the pointer over the panel meanwhile (accepted by +the owner). Zooming out, or closing the panel, restores +normal behaviour. A possible later fix: skip the freeze when the last pointer input was absolute. + ## What the picker is - Hosted by `TextInputHost.exe` and composed by the shell ABOVE every window band. A 2-minute z-order @@ -55,32 +66,12 @@ transform: it drifts off the view centre by speed x tick x level. - **Invisible pointer when zooming with the picker open.** `setActive(true)` pre-blanks the cursor set without `cursorHidden_`; the panel branch now restores the blanker however it was hidden, and nudges the pointer so the plane repaints. -- **Shell dead zones during testing.** One session showed every `MagSetInputTransform` publish stomped - (`ixwrite ... stomps=144`): taskbar previews and jump lists unclickable. It did not reproduce after a - restart; stray probe processes driving the magnifier are the suspect. - **The saved clip matters.** This PC keeps a permanent work-area clip; the freeze snapshots it and gives it back on close. ## Why not use the real pointer everywhere -Considered and declined for now (owner, 2026-09-29): the freeze replaces direct pointer motion with +Considered and declined (owner decision): the freeze replaces direct pointer motion with reconstructed ballistics (feel differs slightly), blocks absolute devices (pens, touch, remote tools) and apps that move the pointer, moves a clip every frame, and interferes with mouselook detection. The sprite stays the everyday cursor; the real pointer is used only while a shell panel is open. - -## Known limitation - -While a shell panel is open and Wind is zoomed in, the real pointer is frozen and moved only from -mouse (relative) raw input. Pens, touch screens and remote-desktop pointers set ABSOLUTE positions, -which the 1px clip blocks, so they cannot steer the pointer over the panel meanwhile (review of #284, -2026-09-29; accepted by the owner, mouse-only setup). Zooming out, or closing the panel, restores -normal behaviour. A possible later fix: skip the freeze when the last pointer input was absolute. - -## Review of PR #284 (2026-09-29) - -Three sonnet reviewers (state/edge cases, input, performance) plus one adversarial verifier each: -13 confirmed, 9 distinct, all fixed before merge: a quick-zoom snap-out left the pointer pinned -(teardown now runs on every session end and only releases our own pin), the public prime flag -survived a runtime rebuild (tiny pointer on re-zoom), the mouselook gain learner learned from -Wind-driven motion, the repaint nudge was a same-position no-op, a panel open at startup was -missed, and a leftover publish retry from the rejected hook design was removed. diff --git a/docs/TRACKING-FINDINGS.md b/docs/TRACKING-FINDINGS.md index 1a8e2ae7..c73a8c13 100644 --- a/docs/TRACKING-FINDINGS.md +++ b/docs/TRACKING-FINDINGS.md @@ -1,8 +1,11 @@ # Tracking findings (issue #276) -Field notes from building caret tracking, keyboard-focus tracking and mouse edge mode, 2026-09-28 -to 2026-09-29, on this PC (3840x2160, 225%, signed UIAccess build, `trackLog=1`). Design: -`docs/superpowers/specs/2026-09-28-tracking-modes-design.md`. +> **Status.** Live. Caret, focus and mouse edge tracking ship; design in +> [architecture/07](architecture/07-cursor.md#tracking-caret-focus-and-mouse-edge-mode) and +> [specs/2026-09-28-tracking-modes-design.md](specs/2026-09-28-tracking-modes-design.md). + +Field notes from building caret tracking, keyboard-focus tracking and mouse edge mode on the test +machine (3840x2160, 225%, signed UIAccess build, `trackLog=1`). ## Which source resolves where @@ -11,112 +14,80 @@ to 2026-09-29, on this PC (3840x2160, 225%, signed UIAccess build, `trackLog=1`) | App | Caret source | Focus | |---|---|---| | Notepad, classic Win32 dialogs | `win32` (GetGUIThreadInfo) | `uia-focus` | -| Chrome / Edge, VS Code, Electron | `uia-caret` (TextPattern2::GetCaretRange) | `uia-focus` | +| Chrome / Edge, VS Code, Electron | `uia-caret` (TextPattern2::GetCaretRange), corrected (below) | `uia-focus` | +| Firefox and forks (Gecko) | `uia-caret` / `win32`, skipped when outside its element (below) | `uia-focus` | | Windows Terminal / Prism | `uia-selection` (TextPattern::GetSelection) | `uia-focus` | | IntelliJ, PyCharm, other Java (Swing/AWT) apps | `java` (Java Access Bridge, issue #281) | (none) | -Known limit: terminal TUI option pickers (e.g. Claude's AskUserQuestion list) do not expose the -highlighted option through UIA, so Wind follows only the terminal caret there. +Known limit: terminal TUI option pickers do not expose the highlighted option through UIA, so Wind +follows only the terminal caret there. ## Field decisions and why -- **The pointer comes to the view, not the other way round.** The first build glided the view back - to the pointer when the mouse moved. A moving pointer was never reached: the view wobbled and felt - stuck until a zoom out/in. Now the first real mouse move (3 px within 100 ms) places the pointer in - the view (centre, or just inside the edges in edge mode) and the view stays. A button press hands - back without moving the pointer (a warp under a held button would drag). -- **Follow only what the keyboard moves.** Landing in a filled field (by Tab or a click) reports a - caret at the END of the text, and following it jumped the view away. The first caret after any - focus change is a baseline; only later caret moves in the same focus are followed. `focusGen` is - bumped when the focus event ARRIVES so the 60 Hz poll cannot publish the new field's caret early. +- **The pointer comes to the view, not the other way round.** Gliding the view back to a moving + pointer never caught it: the view wobbled and felt stuck. Now the first real mouse move (3 px + within 100 ms) places the pointer in the view and the view stays. A button press hands back + without moving the pointer (a warp under a held button would drag). +- **Follow only what the keyboard moves.** Landing in a filled field reports a caret at the end of + the text, and following it jumped the view. The first caret after any focus change is a baseline. + `focusGen` is bumped when the focus event arrives, so the 60 Hz poll cannot publish the new + field's caret early. - **Click quiet period, 1 s.** A click that opens a page moves focus somewhere the user never asked - to look. Caret/focus changes within 1 s of a mouse button are consumed, never followed. - EXCEPTION (#328, field 2026-10-02): a key typed after the click ends the quiet period early. - Clicking into Notepad and typing at once lost the first ~8 characters while the caret ran off - screen. Only a FRESH down of a non-modifier key counts (`src/typing_key.h`): the first version - used the tracking key clock, which also counts key-ups and auto-repeat, so releasing Ctrl or Shift - after a Ctrl/Shift+click ended the quiet period and the click's own caret move took the view - (review #349). -- **Keyboard focus "not working" in the browser** was the setting being off (default off, and the - Settings page needs Apply), not a bug. -- **Glide: critically damped spring, 200 ms** (A/B of 0, 25, 150, 200 ms and old ease vs spring). - The old exponential ease restarted on every keystroke (a nudge per key); the spring carries its - velocity, so typing becomes one continuous glide that still keeps up. Caret and focus share it. - `trackGlideMode=0` restores the old ease. + to look, so caret and focus changes within 1 s of a mouse button are not followed. A fresh down of + a non-modifier key ends it early (#328, `src/typing_key.h`); key-ups and auto-repeat must not, or + releasing Ctrl after a Ctrl+click handed the view to the click's own caret. +- **Glide: critically damped spring, 200 ms** (A/B of 0, 25, 150, 200 ms and old ease versus + spring). The old exponential ease restarted on every keystroke; the spring carries its velocity, + so typing becomes one continuous glide. `trackGlideMode=0` restores the old ease. ## Mouse edge mode -- **Corner repulsion.** Pushing the free pointer into a screen edge or corner sends raw mickeys while - the pointer cannot move: exactly the lock detector's mouselook tell. The log showed LOCKED/free - flapping every ~20 ms, and each false lock took the centred path and welded the pointer away from - the corner. In edge mode that motion is hidden from the tell (`PointerPinnedAtEdge`). -- **Uneven edges.** The band was measured to the hotspot, which is the arrow's tip: the body reached - the right edge while the left kept a gap the width of the arrow. The band is now measured to the - cursor's visible body (opaque bounds around the hotspot, re-measured on cursor change). -- **Margin.** Edge mode has its own `mouseMarginPct` (default 0: the cursor reaches the view edge - before the view moves; Settings slider 0-30%). `trackMarginPct` (15%) stays the caret/focus one. - -## Firefox in a zoomed iframe (issue #278, 2026-09-29) - -- A Claude artifact (an iframe) at high Ctrl+ page zoom in Zen: Firefox reports the caret wrongly - from BOTH sources. The Win32 caret sits below and right of the input (input 2226,997 798x74, - caret 3097,1226 1x118); the UIA caret range and the element bounds point above the real text. - Not reproducible on a plain page at the same zoom. There is no correct source to fall back to. -- Fix: in Gecko windows (`MozillaWindowClass`, Firefox and all forks) a caret whose centre is outside - its own element is skipped, so the view stays put. Limited to Gecko so no app that tracked - correctly before can lose tracking. A UIA fallback was tried and field-rejected (it lands above). -- Leaving a text box moves focus to the whole page (3400x1912); centring on it dropped the view. - Focus rects covering half the monitor or more are containers and are skipped (all apps). -- Diagnostic tool from this hunt: a recorder logging the foreground process, Win32 caret, UIA caret - and focus bounds every 100 ms; the Win32/UIA disagreement is what located the bad source. - -## Java apps: the Java Access Bridge (issue #281, 2026-09-29) - -- Java apps report the caret only through the Java Access Bridge, not the Win32 caret or UIA (Windows - Magnifier does not follow them either). Probe on IntelliJ 2026.2: `getAccessibleContextWithFocus` + - `getAccessibleTextInfo` + `getAccessibleTextRect` return a correct, moving caret rect, 1-12 ms per - read with an outlier of 134 ms, and ONLY while the Java window is active (inactive: context 0, and - the text calls then return TRUE with garbage, so a zero context is treated as "no caret"). -- **UIPI was the blocker.** The bridge loaded but every read failed with zero events: Wind is a - UIAccess process, and Windows drops messages an ordinary process (the JVM) sends to it, so the - bridge handshake never completed. The same calls worked from a non-UIAccess probe. Fix: allow - exactly the bridge protocol's messages on the bridge's own hidden windows (`WM_COPYDATA`, the two - `AccessBridge-From*-Hello` registered messages, `WM_USER+0x1000..0x1003` from OpenJDK - `AccessBridgeMessages.h`). -- **Event-driven, never polled.** Each read is a round trip into the Java app's UI thread, so reads - happen only after a bridge caret/focus callback or a Java window switch (plus one retry per 250 ms - after a failed read). IntelliJ delivered ~100 caret events in a few seconds of typing. -- **Security (review of #281).** Any process can register a window with a Java class name, and Wind - is UIAccess, so the client DLL (loaded from the Java app's folder) must carry a valid Authenticode - signature, as must a `vcruntime140.dll` shipped beside it; both are held open against replacement - while verified and loaded, and dependencies resolve only from that folder and System32. Every - bridge DLL on the dev box verified (JetBrains, Oracle, Microsoft, Amazon). -- **No manual steps.** Wind writes `assistive_technologies=com.sun.java.accessibility.AccessBridge` - into `%USERPROFILE%\.accessibility.properties` (what `jabswitch -enable` does) the first time it sees - a Java window; a Java app picks it up at its next start. The file is only rewritten after a clean - read or when it does not exist, so a locked file is never wiped. IntelliJ's own "Support screen - readers" setting was already on here; on a PC where it is off, IntelliJ may need it (IntelliJ offers - it itself when it detects the bridge). -- A window Windows reports as not responding (`IsHungAppWindow`) is skipped, so a hung Java app cannot - stall tracking for other apps. - -## Chromium web editors: tall caret rects (issue #337, 2026-10-03) - -Outlook on the web (Chromium) reports its UIA selection caret as one line right after Enter -(`1927,1159 h44`), but from the first typed character as a rect that also covers the blank lines -above it (`1947,1136 h111`). The BOTTOM stays on the real caret line; only the top climbs. Centring -on that rect left the caret below centre by half the extra height times the zoom, more with every -blank line ("the text moves further and further down; Enter re-centres it"). `src/caret_rect.h` -learns the one-line height per focus and trims a rect taller than 1.4 lines to one line at its -bottom, but only when that bottom sits on the known line grid (same line, or whole lines below after -a wrap); any other tall rect (a bigger font) is learned as the new line. Chromium's Win32 caret does -the same (1927,1669 h44 then 1945,1558 h155), so UIA and Win32 carets are both trimmed; Java carets -(bridge) are not. `trackLog=1` logs each trim. - -Enter on the last visible line reports the new line before the page scrolls it up a few pixels (field: line 1989-2033, then the tall typing rect ends at 2009), so a tall rect whose bottom scrolled UP by less than a line is also trimmed. -What separates Chromium from a genuinely taller line (review #349): in every field rect the TOP climbed 67-164 px (1.5-3.7 lines) above the previous line's top, because the extra height is whole blank lines above. A heading, a font-size change on the line or Down into a heading grows downward or both ways, so its top stays at or near the previous line's top. Only a rect whose top climbed more than half a line above is ever trimmed; a bottom that moved down off the grid is always learned as the new line height. -That Enter at the bottom also reports the NEW line part-way through the scroll (old line 1965-2009, new line 1989-2033: half a line lower), and the page settles it where the old line was without another caret report until the next key, so the view dipped and slid back on every Enter. A caret that moves back left by a fraction of a line (0.2-0.8) is held on the current line (HoldMidScrollCaret; logged once as caret held). The hold lasts while the SAME raw report repeats: the 60 Hz poll re-reads it every ~16 ms, and the first version held only the first read, then published the stale half-line rect (review #349). - -## VS Code / Electron: the caret reported as the whole line (issue #341, 2026-10-03) - -VS Code's UIA selection caret is the whole editor line (`263,1752 3330x44`), and sometimes a 3330x3 strip at the top of the window: no caret x, so typing never moved the view and a paste or select-all centred it on the middle of a line. A first attempt pinned x to the pointer; that published the pointer as the caret on every mouse move (worse, field-rejected). Now a UIA caret wider than 6x its height is replaced by the character at the caret (`ExpandToEnclosingUnit(Character)`) or, failing that, by the MSAA system caret object (`OBJID_CARET`, which Chromium keeps for screen magnifiers; logged as `via msaa-caret`); with neither, the caret is ignored (logged as `caret ignored`), never guessed. Review #349: the fallback applies only to a collapsed range (a long keyboard selection is simply wide and keeps its own rect), and its answer is cached per focus: re-asked on a real caret/focus event, a changed line rect, or at most every 250 ms from the poll, and `caret ignored` is logged only when the outcome changes. +- **Corner repulsion.** Pushing the free pointer into a screen edge sends raw mickeys while the + pointer cannot move: the lock detector's mouselook tell. Each false lock welded the pointer away + from the corner. In edge mode that motion is hidden from the tell (`PointerPinnedAtEdge`). +- **Uneven edges.** The band was measured to the hotspot (the arrow's tip), so the left edge kept an + arrow-wide gap. It is now measured to the cursor's visible body, re-measured on cursor change. +- **Margin.** `mouseMarginPct` (default 0, Settings 0–30%) for edge mode; `trackMarginPct` (15%) for + caret and focus. + +## Firefox in a zoomed iframe (issue #278) + +- At high page zoom inside an iframe, Firefox reports the caret wrongly from both sources (the + Win32 caret below and right of the input, the UIA range above the text). There is no correct + source to fall back to. +- In Gecko windows (`MozillaWindowClass`) a caret whose centre is outside its own element is + skipped, so the view stays put. Limited to Gecko so no other app can lose tracking. +- Focus rects covering half the monitor or more are containers and are skipped (all apps). + +## Java apps: the Java Access Bridge (issue #281) + +- 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. +- 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. + +## Chromium web editors: tall caret rects (issue #337) + +- Outlook on the web reports the caret as one line after Enter, then as a rect that also covers the + blank lines above; the bottom stays on the caret line. Centring on it pushed the text lower with + every blank line. +- `src/caret_rect.h` learns the one-line height per focus and trims a rect taller than 1.4 lines to + one line at its bottom, but only when its top climbed more than half a line above the previous + line's top (headings and font changes grow downward and are learned instead). UIA and Win32 carets + are trimmed; Java carets are not. +- Enter on the last visible line reports the new line part-way through the page scroll. A caret + that moves back left by 0.2–0.8 of a line is held on the current line while the same raw report + repeats (`HoldMidScrollCaret`). + +## VS Code and Electron: the caret as the whole line (issue #341) + +- VS Code's UIA caret is the whole editor line, so typing never moved the view. Pinning x to the + pointer was field-rejected. +- A collapsed UIA caret wider than 6x its height is replaced by the character at the caret + (`ExpandToEnclosingUnit(Character)`) or the MSAA system caret (`OBJID_CARET`); with neither, it is + ignored, never guessed. The answer is cached per focus and re-asked on a real event, a changed + line rect, or at most every 250 ms. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 9c17ec6e..79c60e89 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -1,112 +1,53 @@ -# Wind manual verification - -Build with `build.bat`, then run `Wind.exe` (it copies/creates `magnifier.ini` next -to it on first run). Wind sits in the system tray. Then verify: - -**Safety:** press **Ctrl+Alt+Q** anytime to quit cleanly (restores the cursor + unzooms), -even while the render overlay covers the screen. The tray right-click -> Quit also works. - -## Desktop -- [ ] Hold forward (XButton2): screen zooms in smoothly (no steps), follows the cursor. -- [ ] Hold back (XButton1): zooms out smoothly; stops at 1.0x (screen back to normal). -- [ ] Release mid-zoom: level stays put. -- [ ] Move the mouse while zoomed: the lens follows the cursor. -- [ ] Quit from the tray (right-click -> Quit): screen returns to 1x (never left zoomed). -- [ ] Edit magnifier.ini (set maxLevel=4.0), save: new max applies within ~1s. -- [ ] Tray right-click -> "Edit config" opens magnifier.ini in Notepad. - -## In a borderless-fullscreen game (cursor hidden / center-locked) -- [ ] Hold forward: the game view zooms in. -- [ ] Move the mouse: the lens PANS even though the game hides/locks the cursor - (this is the core feature - Raw Input driving the lens). -- [ ] The forward/back side buttons do not trigger anything unexpected in-game. - -## Performance -- [ ] Task Manager: Wind CPU stays near 0% idle-zoomed; low while panning. -- [ ] No noticeable stutter added to the game. - -## Own GPU renderer (model=render) - -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. -- Desktop Duplication capture (cursor excluded; overlay excluded from capture via - WDA_EXCLUDEFROMCAPTURE so we don't magnify our own output). -- Sub-pixel float source-rect magnify shader (bilinear). -- Real cursor decoded (GetCursorInfo) and drawn centered, alpha-blended, scaled by zoom. -- Cursor hide + SetCursorPos click-sync + clean shutdown (cursor restored). -- 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 (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 model=magnify and vs Windows Magnifier for smoothness/feel. - -## Installer (issue #213) - -Spec `docs/superpowers/specs/2026-08-20-installer-design.md`, sources in `installer/`. - -**Auto-verified** by `build.bat installer`, which compiles the script and then runs -`tools\installer_check.ps1`: -- every `File` source the script packs exists, `/nonfatal` ones included, -- every rectangle the screens read is present in the generated `over.nsh` (the failure a - rename in `over.html` causes, which a compile does not catch), -- a silent install lands the payload, the ARP key and the Run value, and a silent uninstall - removes all three and KEEPS `%LOCALAPPDATA%\Wind`. - The install half needs an elevated shell and skips itself without one. - -Also rig-verified once, by probe rather than by suite: -- the `Local\Wind_QuitRequest` handshake stops a running Wind on its own, mutex released - after 3 ms, no `taskkill` needed, -- an install over a running Wind writes the clean-shutdown line - (`MagUninitialize -> refs=0`) to `wind-core.log`, so the polite path really ran, -- launching through `explorer.exe` yields a NOT-elevated Wind where a plain launch from the - same elevated context yields an ELEVATED one. - -**The limit worth stating:** `/S` exercises the section, not the drawn UI, and the drawn UI -is most of the code. Three approaches to capturing the live window failed (`PrintWindow` -returns blank on the DIB-into-static drawing), so the screens below are human-only. - -**Human-only checks:** -- [ ] Fresh install on a machine with no Wind: files in `C:\Program Files\Wind`, entry in - Settings > Apps, Run value in Task Manager > Startup, tray icon after Finish. -- [ ] The window is frameless with rounded corners, centred, and the loop plays smoothly and - 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. -- [ ] The done screen's two boxes toggle, and Finish opens Wind only when "Open Wind now" is - ticked. The Wind it opens is NOT elevated (Task Manager > Details > Elevated column). -- [ ] Upgrade while Wind is running AND zoomed: no "file in use" error, and the OS cursor is - visible afterwards. A stranded hidden cursor means the polite quit was skipped. -- [ ] Upgrade with the Settings window open: WindConfig closes, no orphan process. -- [ ] Uninstall, answer NO to removing settings: `%LOCALAPPDATA%\Wind\magnifier.ini` survives. -- [ ] Uninstall, answer YES: it does not. -- [ ] At 100% DPI and at 225%: window centred, type sharp, hit targets land where they look. - 225% loads the 1440 overlays, 100% loads the 960 set. -- [ ] Tray > Open Settings works after install (proves WebView2 is present or was installed). - -## Notes / known v1 behavior -- 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). +# Release smoke checklist + +Install the build under test, launch Wind from a normal (non-elevated) shell, and work through the +list. Ctrl+Alt+Q quits cleanly at any time. Settings live in `%LOCALAPPDATA%\Wind\magnifier.ini`. +`build.bat installer` already checks the installer's files, generated layout and a silent +install/uninstall round trip (`tools\installer_check.ps1`). + +## Install and uninstall +- [ ] Fresh install: files in `C:\Program Files\Wind`, entry in Settings > Apps, Run value in Task Manager > Startup, tray icon after Finish. +- [ ] The setup window is frameless, centred, the loop plays and wraps without a jump; buttons light up on hover and hit where they look. +- [ ] Licence screen: Install stays disabled until the box is ticked; "Read the full licence" opens LICENSE.txt. +- [ ] The progress bar sits on the drawn trough in Wind's accent colour (#5b5bd6), not Windows green. +- [ ] Finish opens Wind only when "Open Wind now" is ticked, and that Wind is not elevated (Task Manager > Details > Elevated). +- [ ] Upgrade while Wind runs zoomed: no "file in use" error, the OS cursor is visible afterwards. Upgrade with Settings open: WindConfig closes. +- [ ] Uninstall keeping settings: `magnifier.ini` survives. Uninstall removing settings: it does not. +- [ ] At 100% and 225% DPI the setup window is sharp and its hit targets line up. + +## Desktop zoom +- [ ] First launch runs the guided setup; the chosen binds work. +- [ ] Hold zoom-in: smooth ramp, no steps. Hold zoom-out: back to 1x. Release mid-zoom: the level stays. +- [ ] Wheel zoom with the chosen modifiers; plain scrolling is unaffected. +- [ ] Keyboard panning while zoomed; at 1x the keys reach the app. +- [ ] Clicks, hover, drag and text selection land under the cursor while zoomed. +- [ ] Ctrl+Alt+Q and tray Quit both leave the screen at 1x with a visible cursor. + +## Game (borderless) +- [ ] Zoom in a game that hides or centre-locks the cursor: the view pans with the mouse. +- [ ] Clicks and drags in the game work while zoomed; no visible frame-rate drop while panning. + +## Tracking +- [ ] Typing in Notepad, a browser and VS Code moves the view with the caret; a mouse move takes it back. +- [ ] Caret tracking in IntelliJ or PyCharm (Java Access Bridge). + +## Colour +- [ ] Warmth and Brightness change the screen at 1x and zoomed in both engines; the pointer is tinted at 1x. +- [ ] Quitting Wind clears the filter. + +## Settings +- [ ] Tray > Open Settings opens; a slider applies at once; Save and Discard behave; the engine row restarts Wind. +- [ ] Killing the WebView2 browser process: Settings recovers with unsaved edits kept. + +## Tray flyout +- [ ] Opens from the icon, closes on Esc and outside click; sliders, toggles and the engine dropdown apply; Quit prompts when there are unsaved changes. + +## Profiles +- [ ] Create, switch and delete a profile from Settings; switch from the tray; a profile with another engine restarts Wind. + +## HDR +- [ ] With Windows HDR on, zooming in and out shows no brightness step, also after moving the SDR content brightness slider. + +## DRM video +- [ ] Netflix or another protected stream in Auto: the magnified view shows the video, not black. +- [ ] With `model=render` the same video shows black (expected). diff --git a/docs/WOBBLE-CAPTURE-2026-08-21.md b/docs/WOBBLE-CAPTURE-2026-08-21.md deleted file mode 100644 index 48681661..00000000 --- a/docs/WOBBLE-CAPTURE-2026-08-21.md +++ /dev/null @@ -1,139 +0,0 @@ -# Wobble capture, 2026-08-21 (~11:45 local) - -Live capture of the degenerate cursor state Max reported: cursor not locked to screen centre, -wobbles with inertia, trails the zoom window and catches up. `tools/mag_wobble_probe.ps1 --Driver wind` run twice against the LIVE degenerate session (Wind was not restarted; the state -survived the measurement). - -## Session context at capture - -- Wind instance started 2026-08-20 19:28:26 local (17:28:26Z), one minute after a clean - uiaccess_setup deploy (log: sign Valid, DONE 19:27:06). Binary: uiAccess=true manifest, - signature Valid (Wind Dev Test Cert). -- Transform session, hybrid model, level 7.6-8.4 during capture. -- ini highlights: `txHookWrite=0` (issue #206 hook-write path OFF, all transform writes are - tick-paced), `cursorSprite=1`, `spriteBand16=0`, `fastPan=1`, `ixDecimate=4`, - `cursorSmoothing=0.4`, `cursorScaleWithZoom=1`, `desktopTransform=1`. -- Startup line confirms: `magthread runtime stays on the tick thread (txHookWrite off)`. - -## Probe results (900 px/s constant pan, 6 s, level ~8) - -Run 1 (level 8.1): view |dev| median 32.7 px, p95 49, max 1057, p2p 1602; writes 860 -(143/s = tick rate); backwards writes 32 (3.7%), backwards travel 3411 px/s. -Run 2 (level 8.4): view |dev| median 34.2 px, p95 51, max 158.6, p2p 259.8; writes 860; -backwards writes 4 (0.5%), backwards travel 1366 px/s. - -Run 1's extreme outliers are POLLUTED: the log shows `launch quiesce: fresh cover pwsh.exe - -transform writes held ~1.5s` at 09:45:20Z, i.e. the quiesce fired on the probe's own target -window mid-measurement and froze the view while the pan continued. Run 2 (target already -running) is the honest steady-state number. - -**Sprite desync (both runs, stable):** - -- Run 1: sprite |dev| median = p95 = 260.2 px; cursor-vs-content median 276.6 px, p95 309.1. -- Run 2: sprite |dev| median = p95 = 267.5 px; cursor-vs-content median 276.6 px, p95 318.5. - -median == p95 means the offset is essentially CONSTANT during constant-speed pan (|dev| is the -same magnitude in both pan directions). 276 screen px at 8.4x = ~33 desktop px = ~36 ms of lag -at 900 px/s, i.e. ~5 ticks at 144 Hz. A constant-velocity lag that collapses to zero when the -pan stops is exactly the "inertia / trails then catches up" feel reported. Wind's own -`cursor divergence` log read 0-9 px throughout - it samples at the weld instant and is blind -to this artefact (which is why the probe grew the sprite metric in the first place). - -The view-vs-centre median (~33 px = ~4.5 ms staleness) matches tick-paced writes exactly -(#206 measured 4.36 ms median tick latency), consistent with txHookWrite=0. - -## Environment anomaly captured alongside - -This instance has NEVER successfully published the input transform: every `ixwrite` line since -instance start shows fails == publishes (e.g. `publishes=36 ... fails=36`), plus one-shot -`WARN MagSetInputTransform failed (no UIAccess?) - desktop pick disabled` per session. The -PREVIOUS instance the same evening had `fails=0` (18:35:25Z, 18:46:25Z UTC). The installed -binary is correctly signed with uiAccess=true, so UIAccess is not ENGAGING for this instance - -most plausibly it was relaunched from the wrong context after the 19:27 deploy (elevated -shell, or launched by the elevated setup process directly). Consequences while it persists: -desktop hover dead zones (pointer-framework apps) and `desktopTransform` pick disabled. - -## ROOT CAUSE FOUND (same day, ~13:00): native Magnifier stomps the shared input transform - -Reproduced deterministically with `tools/mag_wobble_repro.ps1 -WmOpen` + `tools/mag_wobble_monitor.ps1` -(now logs `MagGetInputTransform` per second). Max's minimal recipe - start wm, leave it UNZOOMED, -zoom Wind - is confirmed, and the instrument shows exactly what flips: - -- wm running at 1x publishes an ENABLED IDENTITY input transform, continuously: - `enabled=1 src=(0,0,3840,2160) dst=(0,0,3840,2160)` - measured the whole time wm is open. -- Wind zoomed to 8.87x with wm open: the input transform STAYS identity. Wind's per-change - publish loses the two-writer war (wm republishes continuously). Magnified desktop + enabled - identity input transform = the documented #185 poison ("identity = dead zones, measured"), - now ALSO shown to unmoor the visible cursor: wobble with inertia, trailing the view. -- wm closed CLEANLY (Win+Esc): input transform clears instantly (`enabled=0`), and the next pan - shows Wind's own source rect tracking the session. Healthy. This is "closing wm fixed it". -- wm KILLED while zoomed (dirty exit): its last rect stays enabled system-wide - - `enabled=1 src=(1280,360,3840,1800)` measured after the kill. The state survives Wind - restarts AND a DWM restart (it lives in the input stack, not the compositor). This is - "closing wm did NOT fix it". It clears only when a later clean publish overwrites it. -- Free-floating capture from Max's live repro: with wm open at 1x and Wind zoomed 4.81x, the - system input transform read identity while Wind's internal `cursor divergence` read 0-2px - - Wind cannot see this bug from inside, which is why every internal metric stayed clean. - -Note on `ixwrite fails=100%`: Wind's `MagSetInputTransform` calls return FALSE in this instance -(lost UIAccess after the 2026-08-20 19:28 relaunch) yet the publishes ARE observed to take -effect (the read-back tracks Wind's session rect whenever wm is closed). The failure accounting -is misleading; verify via `MagGetInputTransform` read-back, not the return value. - -A second shared-global stomp is plausible on top: wm manages `MagShowSystemCursor`, so it can -re-show the raw cursor Wind hid (the CLAUDE.md two-cursors gotcha); the DWM-magnified raw -pointer plane lags the transform during pans, which also LOOKS like a rubber-banding cursor. -Discriminator: whether the broken state shows ONE cursor or TWO (sprite + raw arrow). - -Fix directions for Wind (issue to file): -1. Input-transform keep-alive: while a transform session is live, read back - `MagGetInputTransform` each tick (~0.1ms) and republish ours whenever the read-back is not - our rect. Wind at 144Hz beats wm's republish cadence. -2. Publish an explicit DISABLE on session end, and publish ours on session START - the start - publish also heals any stale rect left by a dead wm. -3. Re-assert `MagShowSystemCursor(FALSE)` on the same mismatch tick if the two-cursor variant - is confirmed. -4. Fix the ixwrite fail accounting (see note above) so the log stops crying wolf. - -## FINAL VERDICTS (same day, ~13:30) - parked per Max, tracked in issue #217 - -The stomp-guard fix (PR #218) shipped and armed correctly, and its ground-truth logging settled -the remaining questions the hard way: - -- Wind's MagSetInputTransform publishes SUCCEED (ixdiag: ret=1, read-back matches). But wm - rewrites the slot faster than once per tick: at 144 republishes/s Wind lost EVERY race - (stomps=144/s, not-stuck=144/s). The publish war is unwinnable while wm runs. The guard still - earns its keep by healing the stale rect a dirty-killed wm strands (one republish wins against - a corpse) and by providing reliable foreign-writer DETECTION. -- Max reproduced the wobble "to a tee" on the fixed build, so the identity input transform is a - correlated symptom of wm running, not the wobble mechanism itself. -- Eyeball discrimination (Max): ONE cursor, content pans smoothly, the ARROW detaches and - trails. The only visible cursor in a transform session is the sprite, so the mechanism is - SPRITE WINDOW MOVE LATENCY: wm's second magnification context makes DWM apply the sprite's - per-tick SetWindowPos late relative to transform writes; the sprite (desktop coords, magnified) - then appears speed x lag px behind centre and catches up at rest. -- spriteBand16=1 field verdict (the P2 experiment's one missing datum): NEGATIVE. Band-16 - windows are magnified/remapped like everything else on 26200 - the screen-space sprite was - visibly misplaced. Experiment closed. -- cursorSprite=0 (raw welded cursor) field verdict: NEGATIVE for the transform model. The - cursor PLANE composites OUTSIDE the magnification (constant size, raw desktop position), so - it sits off-centre and points at the wrong content under the transform. Matches the old - transform.h measurement; unusable. -- model=render with wm open: CLEAN (Max-verified). The render engine draws the cursor inside - its own frame, so it is immune to all of wm's shared-state stomps by construction. - -Recommended fix when picked up again: hybrid auto-picks the RENDER engine while a foreign -magnifier is detected (the stomp guard's detection latch, plus a Magnify.exe process check at -zoom-in), re-picking via the existing mid-zoom instant-switch machinery; a pinned -model=transform gets a tray/log warning that native Magnifier degrades the cursor. Not built - -parked at Max's request. - -- No `-Driver native` baseline: that path kills and restarts Wind, which would destroy the - degenerate state. Run it (plus a wind rerun) after Wind is next restarted cleanly. -- No healthy-Wind baseline for the sprite metric exists yet - the sprite measurement was added - to the probe in this round. Re-run the probe when the cursor feels correct to learn whether - ~276 px desync is the degeneration or the (previously unmeasured) steady state. -- No fix attempted. Prime suspects to investigate, in order: how the sprite window position is - driven (what accumulates ~5 ticks of lag), the `cursorSmoothing=0.4` pan inertia interaction, - and whether missing UIAccess changes any of it (restart Wind from a normal shell and re-probe). diff --git a/docs/architecture/01-overview.md b/docs/architecture/01-overview.md index f43dc303..ac41926a 100644 --- a/docs/architecture/01-overview.md +++ b/docs/architecture/01-overview.md @@ -1,282 +1,96 @@ # 01. Overview -Wind is a lightweight standalone fullscreen magnifier for Windows: a replacement for the built-in -Magnify.exe that keeps zoom smooth and sub-pixel, keeps the screen fully interactive while zoomed, -and keeps tracking the mouse even when a game hides, clips, or center-locks the cursor. It ships as -three cooperating binaries: `Wind.exe` (the always-running magnifier), `WindTray.exe` (its tray -icon and menu, a separate process since issue #291) and `WindConfig.exe` (an on-demand settings -app). Settings travel only through the `magnifier.ini` file. This chapter -covers what Wind promises the user, why the code is split the way it is, and what every file in -`src/` does. - -## What Wind is - -The elevator pitch lives in `README.md`: Wind magnifies the whole screen with smooth continuous -zoom, either by capturing the desktop itself (DXGI Desktop Duplication scaled on the GPU with -Direct3D 11 onto a click-through overlay) or by magnifying inside the compositor (the DWM -fullscreen transform) when a game is in front. A hybrid model picks between those two per zoom-in -and re-picks live when the foreground changes; a third model drives the native Windows Magnifier -for DRM-protected video that blanks under screen capture. The engines and the pick are the subject -of [Engines and the hybrid pick](03-engines.md); this chapter only needs the fact that they exist -and that they all sit behind one interface, `IMagnifierModel` (`src/magnifier_model.h`), driven by -one paced tick loop (`RunTick` in `src/main.cpp`, covered in [The tick loop](02-tick-loop.md)). - -Wind targets the primary monitor by default (`multiMonitor=1` follows the cursor's monitor per -zoom-in), covers the desktop, normal apps, and borderless/windowed-fullscreen games. -Exclusive-fullscreen games are explicitly out of scope. +Wind is a fullscreen magnifier for Windows. It zooms smoothly at sub-pixel precision, keeps the +screen interactive while zoomed, and keeps tracking the mouse when a game hides, clips or +centre-locks the cursor. It ships as three binaries that share settings only through +`magnifier.ini`. ## Product rules -These are commitments, not implementation details. Code that violates one is a bug even if it -"works". They are stated here once so later chapters can refer back instead of re-arguing them. - -**The cursor grows with the zoom, in every engine.** (Owner decision 2026-09-18, issue #253; this -replaces the earlier "constant on-screen size" commitment.) The transform engine's sprite lives in -desktop space, so DWM magnifies it with everything else; the render engine draws the decoded real -cursor (`src/cursor_decode.cpp`) scaled by the zoom level so both engines look the same. A render -cursor held at desktop size read as tiny on every fresh install. `cursorConstantSize=1` -(`src/config.h`) is the opt-in for a constant desktop-size pointer in the render engine; the old -`cursorScaleWithZoom` key is retired and ignored, because every ini carried it as an explicit 0. - -**The screen stays interactive while zoomed.** Wind is not a screenshot viewer. Clicks pass through -to the app under the drawn cursor: the render overlay is click-through -(`WS_EX_LAYERED | WS_EX_TRANSPARENT`, `src/render_engine.cpp`) and clicks are routed by syncing -`SetCursorPos` under the drawn cursor each tick, so a click lands exactly on what the user sees. -Hover, tooltips, drags, and text selection all keep working (the drag-follow rule in -`src/drag_follow.h` exists precisely to protect drags from the cursor weld). - -**Hold-to-zoom.** The zoom binds are hold gestures: hold zoom-in to ramp in, hold zoom-out to ramp -back, release to stay at the current level. Both held is ambiguous, so the level freezes -(`ZoomController` in `src/zoom_controller.h`, pure and unit-tested). Quick zoom toggles between 1x -and a remembered level (`QuickZoomToggle`, same file). Binds ship unbound; first launch runs a -guided setup that captures the user's choice. - -**One shared `maxLevel`.** The zoom ceiling is a single setting across all models. There is no -per-model cap: the historical 12x transform cap turned out to be guarding the NVIDIA MPO 16-bit -overflow bug, which is now handled at its root (the pan wall in `src/cursor_mapper.cpp`, keyed to -MPO boot state via `src/mpo_boot.h`; see [The transform engine](05-transform-engine.md)). - -**No driver, no injection.** Wind reads mouse motion at the HID level via Raw Input and swallows -bound keys with ordinary `WH_MOUSE_LL`/`WH_KEYBOARD_LL` hooks (`src/input_router.cpp`). It never -installs a kernel filter driver and never injects into other processes, which keeps it anti-cheat -safe. The accepted cost is documented in `src/inspect_focus.h` and CLAUDE.md: LL hooks cannot -suppress Raw Input, so a bound key still reaches a raw-input game. Game-inspect (issue #144) works -around that for Inspect mode by stealing foreground to an invisible helper window rather than by -blocking input. See [The input pipeline](06-input.md). - -**Follow the mouse even when a game owns it.** The lens must keep panning when a game clips, -recenters, or hides the cursor. `LockDetector` (`src/lock_detector.h`) decides free vs game-locked -per tick; locked sessions pan from raw mickeys instead of the OS cursor. Two recent extensions -refine the decision: `lockApps` (issue #221, `src/config.h`) is an exe list whose sessions run the -locked regime outright, no heuristics, and `warpLock` adds warp-anchor/box/seed tells for -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 -ini file is the entire IPC surface.** +These are commitments. Code that breaks one is a bug even if it works. + +- **The cursor grows with the zoom, in every engine.** `cursorConstantSize=1` is the render-only + opt-in for a desktop-size cursor. See [07](07-cursor.md). +- **The screen stays interactive while zoomed.** Clicks, hover, drags and text selection reach the + app under the drawn cursor. See [04](04-render-engine.md) and [07](07-cursor.md). +- **Zoom is a hold gesture.** Hold zoom-in to ramp in, hold zoom-out to ramp back, release to stay. + Both held freezes the level. Quick zoom toggles between 1x and a remembered level + (`src/zoom_controller.h`). Binds ship unbound; first launch runs a guided setup. +- **One shared `maxLevel`**, the same for every engine. +- **No driver, no injection into other processes.** Mouse motion comes from Raw Input, binds are + swallowed with ordinary LL hooks. The cost: a bound key still reaches a raw-input game + ([06](06-input.md)). +- **Follow the mouse when a game owns it.** The view keeps panning when a game clips, recentres or + hides the cursor ([07](07-cursor.md)). +- **Tracking never moves the pointer.** Caret, focus and edge-mode views detach from the pointer; + the next real mouse move places the pointer in the view ([07](07-cursor.md)). +- **Scope.** Primary monitor by default (`multiMonitor=1` follows the cursor's monitor per + zoom-in); desktop, apps and borderless or windowed games. Exclusive fullscreen is out of scope. + +## Three binaries ```mermaid flowchart LR - W[Wind.exe\ntray magnifier, tick loop] -->|dir-watch + hot reload| INI[(magnifier.ini\nresolved via ResolveIniPath)] - C[WindConfig.exe\nWebView2 host] -->|writes on Apply| INI - UI[Svelte app\nui/dist] --> C - W --> P[(profiles\\Name.ini)] - C --> P + W[Wind.exe\ncore, UIAccess] -->|watch + hot reload| INI[(magnifier.ini)] + C[WindConfig.exe\nWebView2 settings] -->|writes| INI + T[WindTray.exe\ntray icon + flyout] -->|writes| INI + W <-->|Local\\Wind_TrayState_v1\nLocal\\Wind_QuitRequest| T ``` -`Wind.exe` is the perf-critical core: the tick loop, the input hooks, the engines. -It runs from login to logout and must never hitch. `WindConfig.exe` is a thin C++ WebView2 host -(`src/config_ui/main.cpp`) that loads a built Svelte app from `ui/dist/` and talks to the core only -by writing `magnifier.ini`. The core dir-watches the ini and hot-reloads it; there is no pipe, no -shared memory, no window messages between the two. That buys three things: the settings app can -crash, restart, or be rewritten without touching the magnifier loop; the config process runs -non-admin and non-elevated by design; and the ini stays a plain, hand-editable text file that is -also the profile snapshot format (`src/profiles.h`, [Config and profiles](08-config-profiles.md)). - -The tray is the one exception to "no shared memory", and it carries no settings. Wind.exe is a -signed UIAccess process, and Windows stacks a UIAccess process's popup menu above almost -everything, including the magnified cursor and the Snipping Tool overlay (measured 2026-09-29). -So the tray icon and menu live in `WindTray.exe`, which runs without UIAccess (`src/tray_app/`). -Wind starts it with `ShellExecuteExW` and its PID (`src/tray_host.cpp`), restarts it if it dies -(at most three launches a minute), and publishes its live status into a small named block -(`src/tray_ipc.h`). The tray writes one flag back, `menuOpen`, which suspends the cursor re-park -while the user aims at menu items; Quit sets `Local\Wind_QuitRequest`, the same clean-exit event -the installer uses. The tray exits when its Wind exits, taking the icon with it. - -Since 0.17.0 (issue #313) the tray's menu is a custom flyout window, not an HMENU -(`src/tray_app/flyout_window.cpp`, painted with Direct2D by `flyout_draw.cpp`; menus cannot host -sliders). It opens above the icon, closes on deactivation, Esc or a second icon click, and is always dark (the theme comes from -`uiPalette`; `uiTheme` is ignored). Top to bottom: an optional Performance header (zoom, fps, frame sparkline from the shared -block), up to four quick sliders, one segmented group of icon toggles with the engine dropdown below it, and a bottom row (profile, Settings, Quit). -What it shows is user-chosen in Settings > Tray menu and stored as global (non-profile) ini keys -`trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`, parsed by the pure -`src/tray_items.*` (shared by WindTray and the config host). Eligible sliders: Warmth, Brightness, -Max zoom, Zoom-in speed, Zoom-out speed, Pan speed, Pan smoothing, Release glide. Eligible toggles: -Follow the text cursor, Follow keyboard focus, and Keep within the edges (one segment that writes -`mouseAlign` and `trackAlign` together). Toggle and slider changes write the live ini with the same -atomic helper as the settings host and are session changes, like every Settings change. Spec: -`docs/superpowers/specs/2026-10-01-tray-flyout-design.md`. Since 0.18.0 (issue #315) the tray can also hold an engine dropdown (full width under the toggle group: engine glyph, the current choice as text, a chevron; layout v02, 2026-10-02). It picks the main engine (`model`: Auto, Render, Transform, System, the same options as the Settings row). `model` is read once at launch, so a pick writes the live ini and restarts the Wind core at once with no prompt, exactly like Settings' Restart Wind: only the live ini changes (a session change, the Save capsule shows it) and `session.keep` makes the restarted Wind keep it. The toggle segments stretch to the full content width (3, 2 or 1). Spec: `docs/superpowers/specs/2026-10-02-tray-tools-design.md`. Known gap: no UI Automation names yet. - -Two refinements keep this simple channel honest. First, the ini path is never hardcoded: -`wind::ResolveIniPath()` (`src/config_path.h`) probes whether the exe directory is writable, so a -dev build keeps the ini next to the exe while a Program Files install transparently falls back to -`%LOCALAPPDATA%\Wind\magnifier.ini`. Second, the core does not treat every ini write as a -core-relevant change: `StripUiOnlyKeys` (`src/config.cpp`) strips UI-only keys before the reload -fingerprint is compared in `RunTick`, so a Settings-side theme toggle or advanced-row flip does not -disturb a live zoom session (a Max field report of a collapsed zoom is what motivated the guard). - -The bridge message set between the Svelte app and the host (`getConfig`, `setConfig`, the profile -messages, and friends) is owned by `HandleWebMessage` in `src/config_ui/main.cpp` and covered in -[The settings UI](09-settings-ui.md). +| Binary | Role | Chapter | +|---|---|---| +| `Wind.exe` | The always-running core: tick loop, hooks, engines. Must never hitch | [02](02-tick-loop.md) | +| `WindConfig.exe` | On-demand settings: a C++ WebView2 host around the Svelte app in `ui/` | [09](09-settings-ui.md) | +| `WindTray.exe` | Tray icon and flyout, a separate non-UIAccess process | below | + +**Settings travel only through `magnifier.ini`.** Engine-shaped keys (`model`) need a restart; the +rest hot-reload. See [08](08-config-profiles.md). + +**The tray is a separate process because Wind.exe is UIAccess.** Windows stacks a UIAccess +process's popups above almost everything, including the magnified cursor and the Snipping Tool +overlay; an ordinary process's popups do not. + +- Wind starts `WindTray.exe` with `ShellExecuteExW` (never `CreateProcess`, which would pass on the + UIAccess token) and its PID, restarts it if it dies (at most 3 launches a minute, + `src/tray_host.cpp`), and the tray exits with its Wind. +- The shared block `Local\Wind_TrayState_v1` (`src/tray_ipc.h`) carries status and a frame-pacing + ring one way and `menuOpen` back (it pauses the cursor re-park while the user aims at the + flyout). Quit sets `Local\Wind_QuitRequest`. +- The flyout is a Direct2D window, not a menu (`src/tray_app/flyout_*`). Its content is chosen in + Settings > Tray menu and stored in the global `tray*` keys (`src/tray_items.*`). Details in + [09](09-settings-ui.md). +- The flyout's engine dropdown writes `model` to the live ini, drops `session.keep` and restarts + Wind without a prompt (`src/tray_app/engine_dropdown.cpp`). +- `WindTray.exe --render-test out.png [--palette ]` renders the flyout headless for visual + checks. ## The pure/Win32 split -**Everything that can be expressed without `` is, and only those files compile into the -test binary.** - -```mermaid -flowchart TD - PURE[Pure logic files\nno windows.h] --> APP[Wind.exe build\nsrc/*.cpp] - WIN[Win32 I/O files\nrender_engine, input_router, main, tray] --> APP - PURE --> TESTS[wind_tests.exe\nbuild.bat test, /DWIND_TESTS] - T[tests/*.cpp\ndoctest] --> TESTS -``` - -The magnifier cannot be driven headlessly: verifying a zoom needs a desktop, a GPU, and usually a -game. The project's answer is to force every decision that can rot into a pure function with no -`` include, and to unit-test those. `build.bat test` compiles `tests/*.cpp` against -exactly the pure sources (`src/transform.cpp`, `src/zoom_controller.cpp`, `src/config.cpp`, -`src/profiles.cpp`, `src/cursor_mapper.cpp`, `src/lock_detector.cpp`, `src/cursor_lock.cpp`, -`src/mouse_ballistics.cpp`, `src/crosshair.cpp`, `src/config_ui/ini_edit.cpp`, `src/logging.cpp`) -with `/DWIND_TESTS`, links no Win32 libraries, and runs the doctest binary; exit 0 is the pass -signal. Header-only pure logic (`engine_pick.h`, `drag_follow.h`, `hook_geometry.h`, -`tx_cadence.h`, `inspect_focus.h`, `installer_state.h`, and the rest) rides along via includes. - -The reason this split is enforced so hard is stated in `src/engine_pick.h`'s own header comment: -the most regression-prone predicates in the app used to live inline in two `RunTick` sites that had -to stay identical by hand. Extracting them into pure files means the hybrid pick, the drag-follow -truth table, the lock heuristics, the transform clamps (the TDR class), and the installer's upgrade -rules are all pinned by tests that run in seconds on any machine, while the Win32 shell around them -stays thin. The convention is absolute: a pure file that grows a `` include breaks the -desktop-free test build and is a review reject. The whole build story is in -[Build, test, release](11-build-test-release.md). - -## Repo map - -Top level: `src/` (the core and the config host), `ui/` (the Svelte settings app: `ui/src/` with -`App.svelte`, `Settings.svelte`, `Onboarding.svelte`, `settings-schema.js`, plus Playwright tests -in `ui/tests/`), `tests/` (doctest suites for the pure files), `tools/` (deploy, release, and a -large fleet of PowerShell measurement probes, see -[Instrumentation and field method](12-instrumentation.md)), `installer/` (the NSIS setup), -`third_party/` (doctest, the WebView2 SDK), and `docs/` (specs, findings, this book). +**Everything that can be written without `` is, and only those files compile into the +test binary.** The magnifier cannot be driven headlessly, so every decision that can regress (the +engine pick, drag-follow, lock heuristics, transform clamps, installer upgrade rules) is a pure +function with doctest coverage. A pure file that grows a `` include breaks the test +build. See [11](11-build-test-release.md). -### Every file in `src/` +## Source map -| File | Role | +| Area | Files | |---|---| -| `band_window.h` | `CreateBandedWindow`: requested z-band cascades 16 then unbanded, logging every refusal (issue #162) | -| `caret_rect.h` | Pure caret-rect corrections (issues #337/#341): trims Chromium's tall caret rects to the caret line, holds a mid-scroll Enter report on its line, recognises a whole-line caret | -| `com_util.h` | `SafeRelease` COM helper shared by renderer and PNG dump | -| `comp_pin.cpp/.h` | Composition pin and the MPO-buster ghost window that demotes a game off its hardware overlay plane (issue #191) | -| `config.cpp/.h` | `Config` struct, `ParseConfig`, `StripUiOnlyKeys`, `IsForbiddenBindVk`; the parse half is pure and tested | -| `config_path.h` | `ResolveIniPath`: writable-dir probe with `%LOCALAPPDATA%\Wind` fallback | -| `crosshair.cpp/.h` | Pure Inspect crosshair sprite pixels (one source for both engines) | -| `cursor_blanker.cpp/.h` | Swaps system cursor shapes for blanks so the raw pointer vanishes under the transform sprite | -| `cursor_decode.cpp/.h` | `HCURSOR` to 32bpp BGRA decode, including invert-style (I-beam) cursors | -| `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) | -| `hook_transform.cpp/.h` | Inline transform writes from the mouse hook; single-writer contract (issue #206) | -| `input_router.cpp/.h` | The dedicated hook thread: LL mouse/keyboard hooks, Raw Input state, key swallowing | -| `inspect_focus.h` | `ShouldGameInspect`: pure decision to engage game-inspect foreground stealing (issue #144) | -| `installer_state.h` | Pure version/upgrade rules shared between the NSIS script and the tests | -| `launch_quiesce.h` | Pure predicate: may a fresh fullscreen cover arm the launch quiesce (issues #199, #209) | -| `lock_detector.cpp/.h` | `LockDetector` + `ClipRectConfines`: free vs game-locked, with hysteresis and the `warpLock` tells | -| `logging.cpp/.h` | Pure log formatting/rotation rules plus the Win32 rolling-log, crash-dump, and diagnostics-zip backend | -| `mag_host.cpp/.h` | `MagApiAcquire`/`MagApiRelease`: the refcounted, process-scoped Magnification runtime | -| `mag_thread.cpp/.h` | Magnification owner-thread marshalling; thread affinity measured, opt-in hook ownership (issue #206) | -| `magnifier_model.h` | `IMagnifierModel` interface + `PresentExtras`, the per-tick contract between `RunTick` and every engine | -| `magnify_model.cpp/.h` | The native-Magnifier driver model (DRM-safe path, issue #146) | -| `main.cpp` | `wWinMain`, `RunTick`, session state, Inspect mode, hot reload, teardown/restore paths | -| `mouse_ballistics.cpp/.h` | Pure model of Windows pointer ballistics for Inspect-mode speed matching | -| `mpo_boot.h` | Records the MPO state in force at OS boot so restart prompts compare against the truth (issue #164) | -| `png_dump.cpp/.h` | GPU texture to PNG via WIC; selftest-only | -| `profiles.cpp/.h` | Pure profile logic: global-key rules, name validation, switch/mirror text transforms (issue #178) | -| `profiles_io.h` | Win32 profile I/O shared by both exes | -| `render_engine.cpp/.h` | The own renderer: DXGI Desktop Duplication capture + D3D11 scale to a click-through overlay | -| `render_model.cpp/.h` | Adapts `RenderEngine` to `IMagnifierModel` | -| `render_shaders.h` | HLSL sources: magnify/sharpen/tonemap PS, cursor quad, single-pass edge outline | -| `resource.h` / `wind.rc` | App/tray icon resources | -| `sched_priority.h` | Raises the tick thread's priority and opts Wind out of power throttling, falling back to execution speed only where the timer bit is refused (issue #334) | -| `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_app/` | `WindTray.exe` (issue #291): `main.cpp` lifecycle (serves one Wind PID, single instance, TaskbarCreated), `tray_icon.cpp` icon and balloons, `flyout_window.cpp` the quick-controls flyout window (placement, dismissal; issue #313), `flyout_draw.cpp` its Direct2D painter, `flyout_model.h` its pure model (placement, layout, hit-testing, view; unit-tested), `tray_menu.cpp` what its buttons do (Quit guard, profile switch) | -| `tray_host.cpp/.h` | Wind.exe side of the tray split: creates the shared block and supervises `WindTray.exe` | -| `tray_ipc.h` | Pure layout of the block shared with `WindTray.exe` (`Local\Wind_TrayState_v1`): status, frame-pacing ring, `menuOpen` | -| `tray_items.h/.cpp` | Pure quick-control list model for the flyout (which sliders/toggles, order, 4-slider cap; ini keys `trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`) | -| `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 | -| `typing_key.h` | Pure rule for which keystrokes count as typing (fresh non-modifier downs) to end the click quiet period early (issue #328) | -| `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) | -| `config_ui/mpo.h` | Reads MPO state; writes it via an elevated `reg.exe` (the host itself stays non-elevated) | -| `config_ui/wind_watchdog.h` | Pure decision: close the settings window when the magnifier is genuinely gone | - -Reading the table column of "pure" files against the `build.bat test` list above shows the pattern: -`.h`-only files with a "Pure" role are header-only logic pulled in by the test suites; `.cpp` pure -files are compiled in explicitly; everything else touches Win32 and exists only in the app build. - -## Where the truth lives - -`CLAUDE.md` at the repo root is the compressed working-notes version of this same knowledge and can -lag the code; this book is the readable version and can too. The code wins over both. Historical -design specs live under `docs/superpowers/specs/` (the original magnifier design is -`../superpowers/specs/2026-05-24-magnifier-design.md`) and field investigations live under `docs/` -(for example `../POINTER-HITTEST-FINDINGS.md`, `../HITCH-FINDINGS.md`, -`../WOBBLE-CAPTURE-2026-08-21.md`). Each later chapter links the evidence it stands on. - -## Pointers - -Key sources for this chapter: - -- `README.md`, `CLAUDE.md` (repo root): the product pitch and the compressed notes -- `src/main.cpp` (`RunTick`), `src/magnifier_model.h`: the loop and the engine contract -- `src/zoom_controller.h`, `src/lock_detector.h`, `src/drag_follow.h`, `src/engine_pick.h`: the - pure decisions behind the product rules -- `src/config_path.h`, `src/config.cpp` (`StripUiOnlyKeys`), `src/config_ui/main.cpp`: the - two-binary ini channel -- `build.bat` (`:test`): the authoritative pure-file list - -Related chapters: [The tick loop](02-tick-loop.md), [Engines and the hybrid pick](03-engines.md), -[Config and profiles](08-config-profiles.md), [The settings UI](09-settings-ui.md), -[Build, test, release](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.*`, `mag_thread.*`, `tx_cadence.h`, `tx_warm.h`, `comp_pin.*`, `mpo_boot.h`, `hook_transform.*`, `hook_geometry.h` | +| Colour | `color_filter.*`, `color_matrix.h`, `cursor_tint.*` | +| Input | `input_router.*`, `keybind_rules.h`, `pointer_binds.h`, `mouse_ballistics.*`, `keyboard_pan.h`, `typing_key.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*` | +| 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`, `wobble_cage.*` | +| Installer support | `installer_state.h`, `webview2_probe.h`, `version.h` | + +Other top-level folders: `ui/` (Svelte settings app and Playwright tests), `tests/` (doctest), +`tools/` (deploy, release and measurement scripts, [12](12-instrumentation.md)), `installer/` +(NSIS), `third_party/` (doctest, WebView2 SDK). diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index 8e4e8d54..1c75d091 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -1,37 +1,28 @@ # 02. The tick loop -Everything Wind does at runtime happens inside one function: `RunTick` in `src/main.cpp`. There is -no per-engine thread, no render thread separate from an input-processing thread, no timers firing -independent work: one paced loop samples input, advances the zoom, resolves a pan, and asks the -current engine to present, every display refresh. This chapter walks the phases of a tick in the -order the code runs them, then covers how the loop is paced and which work deliberately lives on -the one other thread Wind owns (the input-hook thread). +Everything Wind does at runtime happens in one function, `RunTick` in `src/main.cpp`. Each tick +samples input, advances the zoom, resolves a pan and asks the current engine to present. This +chapter lists the phases in source order, then covers pacing, idle sleep and the threads that sit +beside the loop. ## Why one loop -A magnifier's job is to keep a view glued to a hand. Splitting that across threads means the view -and the cursor sample different instants, and every place Wind ever did that produced a visible -beat (the wobble history in [../WOBBLE-CAPTURE-2026-08-21.md](../WOBBLE-CAPTURE-2026-08-21.md) and -issue #205 is exactly this class). So the rule is: all state that feeds the view is read and -written on the tick thread, in one pass, and the tick itself is pure of pacing. `RunTick` never -sleeps or waits; the caller paces it (see [Pacing](#pacing)). That is also why it is safe to call -from a `WM_TIMER` handler during a modal loop (the tray menu keeps ticking via the `WM_TIMER` -branch in `WndProc`, `src/main.cpp`), and why the one sanctioned exception, the hook-write fast -path, needed a whole ownership layer to exist (see [Threads](#threads-hooks-vs-the-magnification-runtime)). +All state that feeds the view is read and written on the tick thread, in one pass. Splitting it +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)). -## The phases of a tick +`RunTick` never sleeps or waits; the caller paces it (see [Pacing](#pacing)). That is also why it +is safe to call from the `WM_TIMER` branch in `WndProc` during a modal loop. -**A tick from timestamp to present, in source order (`RunTick`, src/main.cpp).** +## The phases of a tick ```mermaid flowchart TD DT[dt from QueryPerformanceCounter] --> CFG[config hot-reload check] CFG --> HELD[resolve held state: hooks or polling] - HELD --> ZOOM[ZoomController.tick - clamped dt] + HELD --> ZOOM[ZoomController.tick, clamped dt] ZOOM --> INS[Inspect toggle edges] - INS --> SELF{magnify model? selfDrivenZoom} - SELF -- yes --> NZT[nativeZoomTick, return] - SELF -- no --> ACT{zoomed or inspect?} + INS --> ACT{zoomed or inspect?} ACT -- no --> IDLE[teardown or idleTick] ACT -- yes --> ENTER[activation: retarget, engine pick, seeds] ENTER --> PAN[pan delta: free / locked / inspect] @@ -43,360 +34,156 @@ flowchart TD REVEAL --> BASE[measure cursor baseline for next tick] ``` -### Timing - -The tick opens with a `QueryPerformanceCounter` read and computes `dt`, the real elapsed time -since the previous tick. Raw `dt` feeds the diagnostics (which must see true hitches) and the -config-poll fallback; the copy fed to the zoom is clamped to 50 ms (`kMaxZoomDt` in `RunTick`) so -a single long tick, a cold first capture, an alt-tab, cannot jump the zoom level mid-ramp. The -ramp always eases at a steady rate regardless of frame-time spikes. - -### Config hot-reload - -Wind has no IPC with the settings app (the tray helper's status block, `src/tray_ipc.h`, carries no settings). `WindConfig.exe` writes `magnifier.ini` and the core -notices. The noticing is deliberately cheap: - -- At startup, `wWinMain` arms a `FindFirstChangeNotificationW` on the ini's parent directory - (`LAST_WRITE` + `FILE_NAME`, so both in-place saves and write-temp-then-rename saves fire it). -- `RunTick` polls that watch handle with a zero-timeout `WaitForSingleObject`, and only about four - times a second (`t.sinceCheck >= 0.25`): `WaitForSingleObject` is a kernel transition, and at - 144 Hz zoomed that would be ~144 pointless syscalls a second for a file that changes when a - human clicks Apply. ~250 ms of reload latency is imperceptible (issue #70). If the watch handle - is unavailable or dies, the loop falls back to a ~1 s timed poll. -- When the directory changed, `ConfigMTime` (src/main.cpp) stats the ini; only a changed mtime - proceeds to a reload. The old design stat'ed the file at 1 Hz unconditionally, and under - AV/disk contention that single stat caused a ~1 s frametime spike on the render thread. - -Then comes the guard that is easy to miss and important to keep: **the UI-only-change -fingerprint**. A reload is not free; the tail of the reload path constructs a fresh -`ZoomController(1.0, maxLevel)`, which collapses any active zoom to 1x. The settings app also -writes keys the core never reads, `uiTheme`, `showAdvanced`, `onboarded`, and a theme toggle -mid-zoom used to collapse the user's zoom for no reason. So `RunTick` runs the ini text through -`wind::StripUiOnlyKeys` (src/config.cpp), which drops exactly those three keys line by line, and -compares the result against the fingerprint of the last applied config (`t.lastCoreIni`). If the -stripped text is identical, the write was UI-only and the whole reload is skipped. The -fingerprint is seeded from the current ini at startup (`wWinMain`, before the loop starts), -because an empty fingerprint means "unknown" and would force the first settings write of a -session to reload, which is exactly how the first theme flip of a session still collapsed the -zoom in the field before the seed was added. - -When a real reload happens, `RunTick` does more than swap the `Config` struct: it re-binds the -mouse hook's button mapping and the keyboard hook's swallowed-key set (`g_input.setButtons` / -`setKeys`, otherwise the hook keeps eating the OLD key and ignores the new one), re-registers the -hide-cursor and quick-zoom hotkeys, pushes the hot `txIdleReleaseMs` into whichever transform -model exists, invalidates the foreground predicate cache, and rebuilds the `ZoomController` and -`CursorMapper` while preserving the mapper's center so the view does not jump. Note the asymmetry: -most knobs are hot, but engine-shaped settings (`model=`, `txHookWrite`) need a restart, because -they decide things that were fixed at initialization (which models exist, which thread owns the -Magnification runtime). - -### Input resolution - -The effective held state for zoom is `mouse side-button held OR keyboard bind held`. The mouse -half comes from the hook thread via atomics (`g_input.state().inHeld`). The keyboard half is -subtler: a bound key is *swallowed* by the `WH_KEYBOARD_LL` hook so it never double-fires into -the focused app, and a swallowed key never appears in `GetAsyncKeyState`. So while the hook is -active it is the **authority** for bound-key down-state (`g_input.keyPressed`), and only when it -is not (install failure, `WIND_NOHOOK`, or a `noSwallowApps` suspension) does `RunTick` fall back -to polling. Two related mechanisms live right here in the tick: - -- **Hook suspension** (`noSwallowApps`): an LL keyboard hook taxes the whole system's input - pipeline; holding an auto-repeating key stalls the mouse stream into a foreground game on every - repeat, purely because the hook exists. Since an LL hook cannot block Raw Input anyway (games - read raw), swallowing buys nothing there, so users can name apps where the hook is dropped. - Foreground is probed at ~10 Hz, not per tick, because foreground changes are human-speed events. -- **The hook watchdog** (issue #156): Windows silently evicts an LL hook whose callback misses - `LowLevelHooksTimeout`, with no error and a still-valid handle. The tell costs nothing: a live - hook swallows every bound key, so `GetAsyncKeyState` seeing a bound key held while the hook - reports it up means the hook is dead. A 250 ms dwell filters the ordinary press-before-callback - race, then `g_input.requestKbHookReinstall()` heals it. - -With the held state resolved, the tick pushes the live zoom profile into the controller -(`t.zoom.setProfile`, free hot-reload, does not reset the level), sets the direction, and calls -`t.zoom.tick(dt)` with the clamped dt. One exception: while the launch quiesce holds transform -writes for a still-loading game (issue #199, `QuiesceHoldActive`), the controller is frozen too, -otherwise the level accrues invisibly and lands as one discrete jump when writes resume, exactly -the 30-50 ms game-frame class the quiesce exists to avoid. Quick zoom (hotkey or -modifier+zoom-key edge) then snaps the level via the pure `ApplyQuickZoom`, and the recenter key -is edge-detected. - -### Inspect handling - -The Inspect toggle (`cursorLockVk`) is edge-detected here, and the toggle edge is where the -game-inspect tells are snapshotted: whether the cursor was showing at that instant and whether -*we* were the ones hiding it, read together so the pair describes the same moment (they feed -`wind::ShouldGameInspect`, src/inspect_focus.h). The actual entry work, freezing the real cursor -with a 1 px `ClipCursor`, baselining the ballistics-cooked accumulator, deferring the foreground -steal, happens later in the active block on the `inspectEnter` edge. Inspect is a large enough -subsystem that it gets its own treatment in [The cursor system](07-cursor.md); what matters for -the loop shape is that Inspect keeps the overlay active at 1x (`active = zoomed || inspect`) and -swaps the pan-delta source (next section). - -### The magnify-model bypass - -If the current model reports `selfDrivenZoom()` (only the magnify model does, -src/magnify_model.cpp), the tick drains the raw accumulator, calls -`t.model->nativeZoomTick(direction, cfg)`, and returns. The entire level pipeline, controller, -mapper, quick zoom, Inspect, overlay, is bypassed: Windows Magnifier owns the zoom and Wind only -injects wheel notches. See [The magnify model](10-magnify-model.md). - -### Pan delta resolution: three regimes - -While active, the tick resolves one pan delta `(dx, dy)` for the mapper, from one of three -sources depending on who currently owns the truth about the pointer: +1. **Timing.** `dt` is the real time since the previous tick. Diagnostics see the raw value; the + zoom gets a copy clamped to 50 ms (`kMaxZoomDt`), so one long tick cannot jump the level. +2. **Config hot-reload.** See [below](#config-hot-reload). +3. **Input resolution.** Zoom is held when a mouse bind OR a keyboard bind is held. While the + keyboard hook is active it is the authority for bound-key state (`g_input.keyPressed`), because + a swallowed key never shows in `GetAsyncKeyState`. Without the hook (install failure, + `WIND_NOHOOK`, a `noSwallowApps` suspension) the tick polls. The hook watchdog and the + suspension rules are in [06](06-input.md). +4. **Zoom.** The tick pushes the zoom profile into `ZoomController` (no level reset), sets the + direction and calls `tick(dt)`. While the launch quiesce holds transform writes for a loading + game (`QuiesceHoldActive`), the controller is frozen too, or the level would land as one jump + when writes resume. Quick zoom then snaps the level via the pure `ApplyQuickZoom`. +5. **Inspect edges.** The toggle edge snapshots whether the cursor was showing and whether Wind was + hiding it; both feed `ShouldGameInspect` (`src/inspect_focus.h`). Inspect keeps the overlay + 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 + (`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 + 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 + fades, cursor mode, `suppressCursorSync` (drag-follow, free cursor or a detached tracking view), + transform write pauses (Inspect clicks, launch quiesce) and the game pacing flags, then calls + `model->present`. The opt-in game pacing modes (`gameFpsCap`, `lowGpuPriority`) can skip + presents; skipped ticks still sample input and advance the mapper. +10. **Reveal gating.** The render overlay is shown only after the session's first Present has run + on the GPU (`revealFrameDone`), and for a fullscreen app also after a frame composited behind + the alpha-1 prime (`frameCompositedSincePrime`). A 3 ms spin keeps the idle-GPU case in the + same tick; ~250 ms is the fallback cap. During a hybrid handover the outgoing engine rests a + few ticks after the incoming one is live (`restAfterReveal`), so no bare frame is composited. + Details in [04](04-render-engine.md). +11. **Baseline.** `t.lastSetVirtual`, the point the next free delta is measured from, is measured, + never assumed: the park point when the engine reports the park or weld really ran + (`parkedLastFrame`/`weldedLastFrame`), otherwise this tick's start-of-tick cursor read. Never + a post-present read: `Present` blocks about a frame, and a later read swallows the hand motion + made during the block. Assuming the park landed made the loop oscillate with hand speed + (the window-drag flicker, issue #169). + +**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 +timeline and, under `diagnostics=1`, the 2 s frame-pacing window. + +## Config hot-reload + +There is no settings IPC. `WindConfig.exe` writes `magnifier.ini` and the core notices: + +- `wWinMain` arms `FindFirstChangeNotificationW` on the ini's directory (`LAST_WRITE` + + `FILE_NAME`, so rename-based saves fire it too). +- `RunTick` polls the handle with a zero timeout about four times a second. Per-tick polling is a + 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. + +**UI-only writes never reload.** A reload rebuilds `ZoomController`, which collapses an active zoom +to 1x. `StripUiOnlyKeys` (`src/config.cpp`) drops `uiTheme`, `uiPalette`, `showAdvanced` and +`onboarded`, 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.setButtons`/`setKeys`), +re-registers the hotkeys, pushes `txIdleReleaseMs` into the transform model, invalidates the +foreground cache, and rebuilds `ZoomController` and `CursorMapper` with the mapper's centre kept. +Engine-shaped keys (`model`, `txHookWrite`) need a restart: they decide which models exist and +which thread owns the Magnification runtime. + +## Pan delta: three regimes | Regime | Source of truth | Delta | |---|---|---| -| Free (desktop) | The OS cursor itself | `GetCursorPos - lastSetVirtual`, scaled by `cursorSensitivity` | +| Free (desktop) | The OS cursor | `GetCursorPos - lastSetVirtual`, times `cursorSensitivity` | | Locked (game holds the mouse) | Raw Input mickeys | `rawDx/rawDy * cursorSensitivity` | | Inspect (cursor frozen) | Ballistics-cooked mickeys | `drainCooked` with a sub-pixel carry | -The free regime is the "oracle": Windows already applied pointer acceleration to the real cursor, -so reading its own movement since we last placed it auto-matches the user's normal cursor feel -without reimplementing ballistics. The locked regime exists because a game clipping or recentering -the pointer destroys that oracle, so panning integrates raw mickeys instead; the switch is decided -by `LockDetector` (src/lock_detector.cpp) fed with whether the current clip rect *meaningfully* -confines (`wind::ClipRectConfines`, under 90% of the monitor in either dimension; a machine-wide -work-area clip is desktop-like, issue #169). Two newer levers force the locked path without -heuristics: `lockApps` (issue #221) locks outright whenever a listed exe is foreground, and -`warpLock=1` adds the motion-based tells globally (warp-anchor detection inside the detector, plus -the zoom-in seed on the activation edge: covering foreground with an app-hidden cursor is -mouselook with near-certainty, so the session starts locked and pans from the first tick). Every -lock edge logs which tell engaged (`"lock"` category, wind-core.log) so field reports are -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 -mapper is simply **reset to the real cursor position each tick** (the #205 native-Magnifier -model: the view is a pure function of the pointer, no integration, no feedback loop to -oscillate), with the mapper fed zero delta. - -One defensive clamp bounds any single tick's pan to the monitor span, so a stray cursor jump can -never teleport the lens. - -### Foreground facts and the pan wall - -`GetForegroundWindow`, `ForegroundCoversMonitor`, and the borderless-style check are read **once -per tick** into locals (`fgTick`, `fsCover`, `fgBorderless`); split reads can disagree mid-tick -and the queries add up at 144 Hz. Those facts feed the MPO pan wall (`t.mapper.setMaxSourceLeft` -/ `setMaxSourceTop`, bounding `|src*level|` to the 16-bit-safe range whenever a transform session -runs on an MPO-exposed machine, issues #148/#191, see [The transform engine](05-transform-engine.md)), -the device-lost churny backstop's timestamp, the launch-quiesce cover tracking, and the perf -levers below. - -### Engine machinery: activation pick and the instant switch - -On the idle-to-active edge (`enterActive`), the tick first retargets to the cursor's monitor if -`multiMonitor` is on (before the pick, so the pick evaluates the session's monitor, and -re-reading `DetectRefreshHz` for the new panel so pacing tracks it, issue #74), then, in hybrid, -runs the pure engine predicate `ShouldPickTransform` (src/engine_pick.h) to choose transform or -render for the session. The same predicate runs again every zoomed tick as the **instant switch**: -if the foreground changes mid-zoom, the engine is re-picked and handed over with the controller -and mapper untouched, so level and lens position carry across. The switch is sticky (the -candidate must be stable for 350 ms) because engine flapping rebuilds DWM's magnification context -each flip, a stall every time; and it is frozen entirely while a transient overlay holds -foreground (`IsOverlayFg`) or the game-inspect focus stealer does. The full pick logic and the -handover choreography live in [Engines and the hybrid pick](03-engines.md). - -### Present and its per-tick overrides - -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, 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) -presents every Nth vblank and blocks skip ticks on `WaitForVBlank` so the loop stays -vblank-locked, and the timer-paced mode (engaged by `lowGpuPriority` or `gameFpsCap` without -vsync) decouples presents from ticks entirely. Both are opt-in because they trade present cadence -for headroom, and pan smoothness outranks game fps by product rule; skipped ticks still sample -input and advance the mapper. - -### Reveal gating - -For the render model, activation does not show the overlay; it arms evidence. The alpha flip is -gated on the session's first Present having actually **executed on the GPU** (`revealFrameDone`, -a fenced event query; issue #140: under GPU load the CPU-side alpha flip wins the race and DWM -composites the surface's retained previous-session frame), and a fullscreen app additionally -needs a captured frame composited after the alpha-1 prime (`frameCompositedSincePrime`, issue -#90: Desktop Duplication cannot see a game on an independent-flip plane until the prime forces a -composite). The desktop path spins a 3 ms budget so the common idle-GPU case still reveals in the -same tick; `revealPending` (~250 ms of ticks) is only the fallback cap so nothing can wedge. A -non-render model reveals immediately. During a hybrid handover, the outgoing engine rests a few -ticks **after** the incoming one is live (`restAfterReveal` / `restOverlapTicks`), so the -crossover never composites a bare unmagnified frame. - -### Baseline bookkeeping - -The last thing the active path does is set `t.lastSetVirtual`, the baseline the next tick's free -delta is measured against, and the rule here is the issue #169 invariant: **the baseline is -measured, never assumed**. If the engine reports the weld/park really ran this frame -(`parkedLastFrame` / `weldedLastFrame`), the baseline is the park point; otherwise it is this -tick's start-of-tick cursor read. Assuming the park landed when it was deduped or suppressed made -the next delta include the pointer-to-center gap, the mapper integrated the gap, and the loop -oscillated proportionally to hand speed, the window-drag flicker. And it is never a fresh -post-present read: `Present` blocks about a frame, and a read taken after it swallows the hand -motion that happened during the block, which shipped once as a "slowed cursor" bug. - -### 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 canceled. While -fully idle, the tick still calls `idleTick()` on the model (and on hybrid's transform half), -which is how the transform releases its magnification context ~1.2 s after a zoom ends; a live -context makes DWM composite magnification-aware and taxes every cursor change any app makes (the -1x hitching in [../HITCH-FINDINGS.md](../HITCH-FINDINGS.md)). - -The tick closes with the stuck-input diagnostics (edge-logged held-state timeline, issue #167) -and, under `diagnostics=1`, the 2 s frame-pacing stats window. +- **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()`. +- **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 weld while a mouse button is held and + follows the pointer 1:1. +- With `txFreeCursor` in a transform session, the mapper is reset to the real cursor each tick and + fed zero delta: the view is a pure function of the pointer. +- One clamp bounds any single tick's pan to the monitor span. ## Pacing -`RunTick` never paces itself; the main loop in `wWinMain` (src/main.cpp) does, and the pace -depends on the state: - -- **Idle / 1x**: a high-resolution waitable timer (`CREATE_WAITABLE_TIMER_HIGH_RESOLUTION`) at - the detected refresh rate. `DetectRefreshHz` (src/main.cpp) reads `EnumDisplaySettingsW` for - the current monitor's real rate, never assumes the dev box's 144 Hz, and is re-queried on - retarget so a mixed-refresh setup paces the panel it is actually on (#74). The timer interval - is recomputed only when the paced Hz actually changes. - -A tick is one display refresh, so anything expressed in TICKS is refresh-rate dependent. Every -tick-tuned quantity therefore derives from the detected rate at init and on retarget (issue -#223): `LockDetector::setTickRate` re-derives its streak/window constants from millisecond -baselines (the 144 Hz derivation reproduces the original field-tuned values exactly, and the -per-tick mickey floor scales with tick duration), `CursorMapper::setTickRate` re-derives the -smoothing ease so the felt inertia keeps the same real-time constant, and the small windows in -`RunTick` (`clickReleaseTicks`, `clickPauseTicks`, `restOverlapTicks`) scale through -`TicksAtHz`. New tick-count constants must go through the same mechanism - a bare tick count -tuned on this rig silently changes behavior on 60 Hz and 240 Hz panels. -- **Zoomed, render model, vsync (default)**: `Present(1,0)` blocks to the refresh and paces the - loop by itself; the timer is skipped to avoid double-pacing. -- **Zoomed, render model, `dwmFlush=1`**: present immediately, then `DwmFlush()` after the tick - aligns 1:1 with DWM's composite (targets the blt-model phase-mismatch microstutter, see - [The render engine](04-render-engine.md)). -- **Zoomed, transform model**: always `DwmFlush` paced. The transform has no blocking present to - pace it (it submits via `MagSetFullscreenTransform`), and an unpaced loop floods DWM's - desktop-transform queue until the view lags seconds behind input; `DwmFlush` also lands the - sprite update and the transform write in the same composite, which is what keeps the cursor - from beating against the panning view. -- **Game pacing modes**: pace themselves inside `RunTick` (vblank waits or the present - accumulator) and are excluded from the timer wait. - -Device-lost recovery also lives in the main loop, not the tick: when the render engine reports a -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. - -## Idle: the loop sleeps at 1x (issue #71) - -At 1x with nothing in flight the loop does not tick at the refresh rate. `IdleNow` (main.cpp, -the rule itself pure in `src/idle_policy.h`) is read live right before the wait; when it allows, -the loop sleeps in `MsgWaitForMultipleObjectsEx` on the input router's auto-reset wake event and the -quit event, with a 100 ms timeout and the mask `QS_POSTMESSAGE | QS_SENDMESSAGE | QS_HOTKEY`. -Raw Input is deliberately NOT in the mask (it arrives for every mouse move system-wide); it is -drained after the wake. The LL hooks `SetEvent` only on edges: a bound key's first down and its up, -a button/click bind's held state changing (`PublishButtonHeld`), a wheel step. After a wake the loop -drains messages at once so a hotkey is seen by that same tick. The wake tick keeps the raw dt for -wall-clock gates (config watch) but clamps the motion dt to one frame, skips the tray pacing ring -and diagnostics, and zeroes the raw motion accumulated while asleep. It stays awake while a zoom or -key is held, wheel steps or a quick zoom are pending, an engine rest/reveal/glide/pan is running, -for 500 ms after a zoom session, and whenever an input could only be seen by polling (mouse hook -missing, keyboard hook suspended or failed, or a bound key the OS reports held while the hook did -not see it - a silently evicted hook). Measured: Wind CPU at 1x 37.5 -> 3.1 ms per second; a zoom -starts 3-6 ms after a wheel notch that wakes it. The focus tracker installs its LOCATIONCHANGE -hook and 16 ms caret poll only while active, and is switched off at every zoom-out. - -## Priority under background load (issue #334) +The main loop in `wWinMain` paces the tick: + +| State | Pacing | +|---|---| +| Idle at 1x | Sleeps, see [Idle](#idle-the-loop-sleeps-at-1x) | +| 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 | +| 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 +are refresh-rate dependent**, so every tick-tuned constant derives from the detected rate: +`LockDetector::setTickRate`, `CursorMapper::setTickRate`, and `TicksAtHz` for the small windows in +`RunTick` (`clickReleaseTicks`, `clickPauseTicks`, `restOverlapTicks`). A bare tick count tuned at +144 Hz behaves differently at 60 or 240 Hz. + +Device-lost recovery lives in the main loop: restore the cursor, clean Inspect state, mark the +churny backstop if a transform game session was live in the last 30 s, rebuild on a 500 ms backoff. + +## Idle: the loop sleeps at 1x + +At 1x with nothing in flight the loop sleeps in `MsgWaitForMultipleObjectsEx` on the input +router's wake event and the quit event (100 ms timeout, mask +`QS_POSTMESSAGE | QS_SENDMESSAGE | QS_HOTKEY`). The rule is `IdleNow` (pure, `src/idle_policy.h`). +Measured: Wind's CPU at 1x went from 37.5 to 3.1 ms per second, and a zoom starts 3–6 ms after the +waking wheel notch. + +- **Anything that must react at 1x needs a wake.** The LL hooks `SetEvent` on edges only (a bound + key's first down and up, a button bind changing, a wheel step); a window message must be in the + mask; or `IdleNow` must keep the loop awake. +- **Never add `QS_RAWINPUT`/`QS_INPUT` to the mask**: every mouse move system-wide would wake it. + Raw Input is drained after the wake, and motion accumulated while asleep is zeroed. +- The loop stays awake while a zoom or key is held, wheel steps or a quick zoom are pending, an + engine rest, reveal, glide or pan runs, for 500 ms after a session, and whenever input could only + be seen by polling (hook missing, suspended or evicted). +- The wake tick clamps the motion dt to one frame and skips the pacing ring and diagnostics. + +## Priority under background load The tick thread runs at `THREAD_PRIORITY_HIGHEST` and the process opts out of power throttling -(`src/sched_priority.h`, both set once before the loop). At normal priority a saturated PC (renders, -builds) queued the tick behind every other normal thread: traces showed 139-737 ms stalls mid-zoom, -only while the machine was busy. One step above normal is enough to win against background work and -stays below DWM, so the tick never delays composition; the loop sleeps or timer-waits, so it costs -nothing at rest. It does not help when the GPU is the bottleneck (DWM does that work). - -## Threads: hooks, the focus tracker, and the Magnification runtime - -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* -input pipeline while running. They cannot share the tick thread: a tick blocked in `Present(1,0)` -would hold every keystroke and mouse move on the machine hostage for a frame. The hook thread -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 -that owning thread is the tick thread, which is why every Magnification call in the codebase -either runs on the tick thread or goes through `wind::MagThreadInvoke`. - -The exception is the opt-in hook-write fast path (`txHookWrite`, issue #206). Waiting for the -next tick to notice a cursor move costs a uniform 0.42-7.13 ms (one tick); native Magnifier -writes from inside its mouse hook at 0.58 ms median. To match that, `src/mag_thread.*` lets the -hook thread claim runtime ownership at startup (`SetMagThreadClaimEnabled` in `wWinMain`, then -`MagThreadClaim` when the hook thread's message loop starts, which logs -`"runtime owner = thread N (hook thread)"` under the `magthread` category, your tell in -wind-core.log for which mode a session ran in). Then `src/hook_transform.*` publishes a small -armed-state struct from the tick thread each frame, and `MouseProc` writes the transform inline -from the event's own coordinates. The contract is **single writer**: while armed, the hook owns -position writes completely and the tick thread's present suppresses its own transform write -(`ex.suppressTransformWrite`), triggering ramps and hold-still refreshes through the same -function on the owner thread (`RequestHookTransformWrite`), one formula, one code path. Two -writers sampling the cursor at different instants is the tick-rate wobble #205 eliminated. This -is only safe because the free-cursor model made the view a pure function of the pointer; there -is no mapper state for the hook to race. - -Ownership is off by default and decided once at startup: with `txHookWrite=0` nothing writes -from the hook, and routing ~288 calls a second through a marshalled round trip on the thread -that carries system-wide mouse input would be pure cost (measured 0.02 ms inline vs 0.2-0.5 ms -marshalled). Thread affinity means ownership can never move after `MagInitialize`, so the knob -needs a restart. With no owner claimed (unit tests, failed hook install), `MagThreadInvoke` runs -inline on the caller, degrading exactly to the old single-threaded behavior. - -## Pointers - -- `src/main.cpp`: `RunTick`, `wWinMain` (pacing loop, device-lost recovery), `DetectRefreshHz`, - `ConfigMTime`, `TickState` -- `src/config.cpp`: `wind::StripUiOnlyKeys`, `LoadConfig` -- `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), - [../HITCH-FINDINGS.md](../HITCH-FINDINGS.md), - [../POINTER-HITTEST-FINDINGS.md](../POINTER-HITTEST-FINDINGS.md) -- Related chapters: [Engines and the hybrid pick](03-engines.md), - [The render engine](04-render-engine.md), [The transform engine](05-transform-engine.md), - [The input pipeline](06-input.md), [The cursor system](07-cursor.md), - [Config and profiles](08-config-profiles.md) +(`src/sched_priority.h`). At normal priority a busy machine queued the tick behind other threads +(139–737 ms stalls mid-zoom). One step above normal stays below DWM, so the tick never delays +composition. It does not help when the GPU is the bottleneck. + +## Threads + +- **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 + [06](06-input.md). +- **Focus-tracker thread** (`FocusTracker`, `src/focus_track.cpp`). WinEvents, `GetGUIThreadInfo` + and UI Automation can block and want their own message loop and COM apartment. The tick only + calls `setActive()` and reads `snapshot()`. See [07](07-cursor.md). +- **Magnification runtime owner.** Magnification calls are thread-affine: only the thread that + called `MagInitialize` can drive the transform. By default that is the tick thread; the opt-in + `txHookWrite` moves ownership to the hook thread. See [05](05-transform-engine.md). diff --git a/docs/architecture/03-engines.md b/docs/architecture/03-engines.md index 169febf8..f5ba8343 100644 --- a/docs/architecture/03-engines.md +++ b/docs/architecture/03-engines.md @@ -1,266 +1,149 @@ # 03. Engines and the hybrid pick -Wind can put a magnified view on screen in three fundamentally different ways: its own -capture-and-scale overlay (render), a magnification transform executed inside the DWM compositor -(transform), or the native Windows Magnifier driven by injected input (magnify). The shipped -default, `model=hybrid` ("Auto" in the settings UI), holds the first two alive at once and picks -between them per zoom session with a pure, unit-tested predicate. This chapter explains the common -model interface, what each engine is for, exactly how the pick decides, and the handover choreography -that lets hybrid swap engines mid-zoom without ever compositing a bare unmagnified frame. +Wind has three engines: Auto (`hybrid`, the default), Render and Transform. Auto holds the other +two alive and picks one per zoom session with a pure, unit-tested predicate. This chapter covers +the model interface, what each engine is for, how the pick decides and how a mid-zoom handover +avoids a bare frame. -## The model interface - -Every engine implements `wind::IMagnifierModel` (`src/magnifier_model.h`). The tick loop -(`RunTick` in `src/main.cpp`, chapter [02](02-tick-loop.md)) never talks to a concrete engine for -its steady-state work; it drives whatever `TickState::model` currently points at: - -- `initialize` / `shutdown`: bring up or tear down the engine's resources for a monitor - (`MonitorTarget`). In hybrid, both engines are initialized at startup and stay initialized. -- `setActive(bool)`: reveal or hide the magnified view. For render this flips the overlay's layer - alpha; for transform it enables or disables the DWM transform. -- `onActivate()`: called on the idle-to-active edge so the engine grabs a live frame rather than a - stale cached one (render: `invalidateCapture` + reveal priming). -- `present(...)`: the per-tick draw. It takes the mapper's `MapResult`, the level, the config, the - monitor, and `PresentExtras`, a struct of per-tick overrides RunTick computes (outline fade, - Inspect crosshair, drag-follow weld suppression, game pacing flags). The transform model ignores - almost all of it by design; the comments in `magnifier_model.h` say which engine reads which field. -- `idleTick()`: called every tick while idle. This exists for one load-bearing reason: the - transform model releases its Magnification context here, because a live context keeps DWM in - magnification-aware compositing where every cursor change any app makes costs a re-composite - (issue #148, see [05](05-transform-engine.md)). In hybrid, RunTick ticks both the active model - and the idle transform half (`t.mTransform->idleTick()` in the idle branch of RunTick). -- `retarget(MonitorTarget)`: render-only, for `multiMonitor` follow. Others return false. -- `selfDrivenZoom()` / `nativeZoomTick(dir, cfg)`: the magnify model returns true and RunTick then - bypasses the entire level pipeline (ZoomController, mapper, overlay) and just reports the held - zoom direction each tick. See chapter [10](10-magnify-model.md). -- `supportsInspect()`: magnify returns false (the native Magnifier owns the view and cursor, so - Wind's freeze-and-reticle Inspect mode cannot run there). - -`model=` in `magnifier.ini` selects the engine at launch (restart to switch, not hot-swapped). -Missing or unknown values fall back to `hybrid` (`Config::model` in `src/config.h`). +## Where pixels can be scaled -## The four models +Magnification means taking screen pixels, scaling them and presenting them. There are three places +that can happen: -| Model | What it is | What it is for | -|---|---|---| -| `hybrid` (default, "Auto") | Not a class: `TickState` holds both a `RenderModel` (`mRender`) and a `TransformModel` (`mTransform`) and points `model` at one of them per session | The product default; picks the right engine per situation | -| `render` | Own DXGI Desktop Duplication capture + D3D11 scale onto a click-through, capture-excluded fullscreen overlay (`src/render_engine.*`) | The desktop engine: centered cursor, sub-pixel pan, unlimited levels. Chapter [04](04-render-engine.md) | -| `transform` | The DWM fullscreen magnification transform (`MagSetFullscreenTransform` and the private channel), zero presents of our own (`src/transform_model.cpp`) | The game engine: the only path that stays compositor-smooth over a heavy game's present load (revived for issue #148). Chapter [05](05-transform-engine.md) | -| `magnify` | Launches and drives the native Windows Magnifier via injected Ctrl+Alt+wheel notches | The DRM-safe fallback: protected video (Netflix) blanks under Desktop Duplication. Dormant: not selectable since 0.18.0 (parsed as `hybrid`). Chapter [10](10-magnify-model.md) | +1. **In DWM.** The DWM fullscreen transform. Wind's Transform engine and the built-in Magnifier + both use it. It scales protected video and stays smooth over a heavy game, because no extra + window covers the game. +2. **In the app's own window.** Capture the desktop (DXGI Desktop Duplication) and draw it scaled + into an overlay. Wind's Render engine. Sub-pixel pan and a cursor drawn in the same frame, but + protected video captures black and the overlay forces a game onto composited presentation. +3. **Inside the game's render pipeline.** A `Present()` hook in the game process. Wind does not do + this: it is injection, per-game, and an anti-cheat risk. -Hybrid's construction lives in `wWinMain` (`src/main.cpp`): when `cfg.model == "hybrid"` it builds -a `RenderModel` plus a second `TransformModel`, initializes both, and stores them in -`TickState::mRender` / `TickState::mTransform`. If the transform half fails to initialize, Wind -logs a warning and runs render-only; every pick site guards on `t.mTransform` being non-null, so a -pure `model=render` run simply never enters the pick code (`model=magnify` is not selectable since -0.18.0: config parse maps it to `hybrid`). +## The model interface -## The pure pick: ShouldPickTransform +Every engine implements `wind::IMagnifierModel` (`src/magnifier_model.h`). `RunTick` drives +whatever `TickState::model` points at: -The decision itself is a header-only pure function, `wind::ShouldPickTransform` in -`src/engine_pick.h`, taking an `EnginePickInputs` struct. It was extracted precisely because it is -the most regression-prone predicate in the app (issues #148 and #172 both bit here) and because it -runs at two call sites in `RunTick` that previously had to be kept identical by hand. Being pure -(no ``) it compiles into the doctest binary and is unit-tested. +| Method | Purpose | +|---|---| +| `initialize` / `shutdown` | Bring up or tear down the engine for a `MonitorTarget`. In Auto both engines stay initialized. | +| `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`. | -The inputs, and who computes them in `main.cpp`: +`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`. -| Field | Meaning | Source in RunTick | +| Model | What it is | For | |---|---|---| -| `coversMonitor` | The foreground window covers the session's target monitor | `ForegroundCoversMonitor(t.mon)` (per tick, cheap user32 reads; also matches maximized windows) | -| `borderless` | The foreground has no `WS_CAPTION` | `GetWindowLongPtrW(fgw, GWL_STYLE)` | -| `primaryMonitor` | The target monitor is the primary | `t.mon.x == 0 && t.mon.y == 0` | -| `shellDesktop` | The foreground is the shell desktop window class (Win+D reads as a borderless cover, issue #172) | `RefreshFgCache` -> `IsShellDesktopFg` | -| `excluded` | The exe is on `transformExclude` | `RefreshFgCache` -> `IsTransformExcluded` | -| `churny` | The exe was learned into `churny_apps.txt` | `RefreshFgCache` -> `IsChurnyFg` | -| `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 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: 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 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):** +| `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) | + +If the transform half fails to initialize, Auto logs a warning and runs render only; every pick +site checks `t.mTransform`. + +## The pick + +`wind::ShouldPickTransform` (`src/engine_pick.h`) is header-only, has no `` and is +doctested. It runs at the zoom-in edge and every zoomed tick, with the same `EnginePickInputs`. +In order: + +1. **Protected content forces Transform.** `captureProtected` (the foreground or a child window + has display-affinity capture protection: Netflix, Apple TV, PlayReady) or an exe on + `renderExclude`. On Render such a window is a black rectangle, so this beats every other rule. +2. **The per-category preference applies next.** `ClassifyWindow` sorts the foreground into Game, + Acrylic, Desktop or Other, and `engineGame`, `engineAcrylic`, `engineDesktop` and `engineOther` + (each Auto, Transform or Render) choose for it. Render is honoured outright; Transform still + needs the primary monitor and an exe not on `transformExclude`. +3. **Auto rule.** Transform when the session is on the primary monitor, the exe is not on + `transformExclude` or in `churny_apps.txt` (unless `tdrTest` is on), and either: + - **game path**: the foreground covers the monitor, is borderless and is not the shell desktop + (Win+D reads as a borderless cover, issue #172); or + - **desktop path**: `desktopTransform=1` (the default) and the source-rect input transform was + verified at init (`inputTransformAvailable`, needs UIAccess). Without it pointer frameworks + get hover dead zones ([../POINTER-HITTEST-FINDINGS.md](../POINTER-HITTEST-FINDINGS.md)). + + Everything else gets Render. A maximized desktop app covers the monitor but keeps its caption, + so it stays on Render. ```mermaid flowchart TD - A[Zoom-in edge or foreground change while zoomed] --> B{covers monitor AND borderless AND not shell desktop?} - B -- yes --> G[game path candidate] - B -- no --> C{desktopTransform on AND input transform verified?} - C -- yes --> G2[desktop path candidate] - C -- no --> R[RENDER] - G --> P{primary monitor?} - G2 --> P + A[Zoom-in edge or foreground change while zoomed] --> D{capture-protected or renderExclude?} + D -- yes --> T[TRANSFORM] + D -- no --> PR{category preference} + PR -- Render --> R[RENDER] + PR -- Transform --> PX{primary and not transformExclude?} + PX -- yes --> T + PX -- no --> R + PR -- Auto --> B{game path or desktop path?} + B -- no --> R + B -- yes --> P{primary, not excluded, not churny?} + P -- yes --> T P -- no --> R - P -- yes --> X{exe on transformExclude?} - X -- yes --> R - X -- no --> CH{exe in churny_apps.txt?} - CH -- "yes, and tdrTest off" --> R - CH -- "no, or tdrTest harness on" --> T[TRANSFORM] ``` ## Where the pick runs -The predicate is evaluated at exactly two places in `RunTick` (`src/main.cpp`), both feeding the -same `EnginePickInputs`: - -**1. The zoom-in edge.** On the idle-to-active transition (`enterActive`), after the optional -multi-monitor retarget. The ordering is deliberate and commented at the site: retarget runs -*before* the pick so the predicate evaluates the session's actual monitor; the old order evaluated -the previous session's monitor and could hand a secondary-monitor session the transform engine. -The winning engine becomes `t.model` for the session, so every later teardown call routes to the -engine that activated. A transform session also records the foreground exe into `t.transformExe` -for the device-lost backstop (below). This same edge is where a few session-scoped tells are -seeded, for example the `warpLock` lock seeding for mouselook games (chapter [07](07-cursor.md)). - -**2. The mid-zoom instant switch.** While zoomed, hybrid re-evaluates the predicate every tick so -a foreground change (alt-tab from the desktop into a game at 8x) swaps engines mid-session. The -ZoomController and CursorMapper are untouched, so the level and lens position carry across the -swap. Three dampers keep this from thrashing: - -- **Per-HWND cache** (`RefreshFgCache`, `TickState::fgCache*`): the exe-derived inputs (shell - class, exclusion, churny) are re-resolved only when the foreground *window* changes. Opening the - foreground process and building exe-name strings 144 times a second answered a question that only - changes on a window change; `coversMonitor` and `borderless` stay per-tick because they are cheap - and genuinely dynamic. -- **350 ms stickiness** (`t.wantModel` / `t.wantSinceMs`): the candidate engine must hold for - 350 ms before the swap fires. Field evidence: the engine flapped render-transform-render inside - one session on one-frame foreground wobbles, and every flip releases and rebuilds DWM's - magnification context, a visible stall each time. A real alt-tab still switches. -- **Overlay freeze** (`IsOverlayFg`): while a transient system surface (Snipping Tool, - ScreenSketch, TextInputHost; the hard-coded `kOverlayExes` list) holds foreground, the whole - pick block is skipped, not just the swap. That leaves `wantModel` untouched, so the overlay never - becomes the settled candidate and handing foreground back is a no-op instead of a second - handover (the taskbar-flyout ping-pong, issue #180). The game-inspect focus stealer is likewise - ignored (`fgIsStealer`). Inspect sessions are never switched under at all. - -Note a subtlety the code comments call out: CLAUDE.md summarizes the mid-zoom switch as -"instant", and it is from the user's point of view, but the code applies the 350 ms settle first. -The code is the truth here. - -## The handover rule: restAfterReveal - -Swapping engines mid-zoom means one magnification source must stop and another must start, and the -two travel *different DWM channels* (a transform rest is a compositor write; the overlay reveal is -a layer-alpha flip). If the outgoing engine rests on the same tick the incoming one goes live, DWM -can composite one frame in the gap, and the user sees the bare unmagnified desktop flash at, say, a -9x crossover (the field report that produced this design). The fix is an overlap: -`TickState::restAfterReveal` keeps the *outgoing* engine alive until the incoming one is verifiably -on screen, plus `restOverlapTicks = 3` extra ticks, and only then calls `setActive(false)` on it. -The overlap's worst case is a roughly 14 ms over-zoom pulse of still-magnified content, never a -bare desktop. - -The two directions differ in when the overlap countdown arms: - -- **transform to render**: the render overlay's reveal is evidence-gated (issue #140: the first - Present must have executed on the GPU; over a fullscreen app, additionally a post-prime composite - must appear in the capture, issue #90; chapter [04](04-render-engine.md)). So the transform keeps - magnifying while `revealPending` counts down, and `restOverlapTicks` is armed only at the moment - the reveal actually fires (`rm->setActive(true)` in the reveal block). -- **render to transform**: the transform is activated the same tick (`t.model->setActive(true)`), - the overlay stays up for the same short overlap, then drops. - -A rapid double-switch settles the previous handover first (the pending `restAfterReveal` is rested -immediately before the new one is queued), and every teardown path (zoom-out to idle, device-lost -recovery, shutdown) clears a pending `restAfterReveal` so an engine is never left running. - -**Mid-zoom instant switch, transform to render (alt-tab from a game to the desktop at 8x):** - -```mermaid -sequenceDiagram - participant FG as Foreground - participant RT as RunTick (main.cpp) - participant TX as TransformModel (outgoing) - participant RE as RenderModel (incoming) - FG->>RT: foreground changes (game -> desktop app) - RT->>RT: ShouldPickTransform = false, candidate = render - Note over RT: wantModel debounce: candidate must hold 350 ms - RT->>RE: onActivate() + primeReveal() if a cover needs compositing - RT->>RT: restAfterReveal = transform, revealPending armed - Note over TX: transform keeps magnifying (level preserved) - RE-->>RT: revealFrameDone (first Present executed on GPU) - RT->>RE: setActive(true), overlay alpha 255 - RT->>RT: restOverlapTicks = 3 - Note over TX,RE: both live for 3 ticks: no bare frame can composite - RT->>TX: setActive(false) when the countdown hits 0 -``` - -## The vetoes: transformExclude and the churny backstop - -**transformExclude** (`Config::transformExclude`, hybrid-only) lists exe names that must never get -the transform engine even when fullscreen and borderless. The default is the browser set -(`zen.exe, firefox.exe, chrome.exe, msedge.exe, brave.exe, opera.exe, opera_gx.exe, vivaldi.exe`): -fullscreen browser video is indistinguishable from a game by the foreground test, but it wants the -render engine's constant-size cursor and desktop-style behavior; the transform path exists for -games. Matching goes through the shared `FgExeInList` helper in `main.cpp` (bare file name, -case-insensitive, exact) so it behaves identically to `noSwallowApps` and the overlay list. A -config hot-reload clears the per-HWND cache (`t.fgCacheHwnd = nullptr`) so an edited exclusion -list takes effect without a foreground change. - -**churny_apps.txt** (`%LOCALAPPDATA%\Wind\churny_apps.txt`) is the learned veto. Rig-proven for -issue #148: a fullscreen app that churns its cursor *shape* (ordinary `SetCursor` hover logic, -which real games do whenever the mouse moves) makes per-tick fullscreen-transform writes reset the -GPU driver within seconds, at any write rate. Wind cannot stop another process's SetCursor traffic, -so hybrid learns instead. The persistence and matching live in `main.cpp` (`LoadChurnyApps`, -`MarkChurnyApp`, `IsChurnyFg`). The primary writer today is the **device-lost backstop**: every -transform game session records its exe (`t.transformExe`) and timestamps the session -(`t.lastTransformGameMs`); if the render device later reports device-lost (`RenderModel`'s -`deviceLost()` in the main loop's recovery block) within 30 seconds of a transform game session, -that reset is attributed to the session's app, `MarkChurnyApp` records it, and every future zoom-in -over that exe picks render. One crash per exotic app, ever, then never again. The `tdrTest` ini -knob (`pin.tdrHarness`) exists solely to force transform past this list for field experiments; the -bisection evidence behind the whole TDR class is in [../HITCH-FINDINGS.md](../HITCH-FINDINGS.md). - -## Pointers - -Key sources: - -- `src/engine_pick.h`: `EnginePickInputs`, `ShouldPickTransform` (pure, doctested) -- `src/magnifier_model.h`: `IMagnifierModel`, `PresentExtras` -- `src/main.cpp`: the zoom-in pick (the `enterActive` block in `RunTick`), the mid-zoom instant - switch, `RefreshFgCache`, the churny registry, the reveal gate and `restAfterReveal` handling, - hybrid construction in `wWinMain` -- `src/config.h`: `model`, `transformExclude`, `desktopTransform`, `tdrTest` - -Related chapters: [02. The tick loop](02-tick-loop.md), [04. The render engine](04-render-engine.md), -[05. The transform engine](05-transform-engine.md), [07. The cursor system](07-cursor.md), -[10. The magnify model](10-magnify-model.md). History: -[../superpowers/specs/2026-05-25-own-renderer-design.md](../superpowers/specs/2026-05-25-own-renderer-design.md), -[../POINTER-HITTEST-FINDINGS.md](../POINTER-HITTEST-FINDINGS.md), -[../HITCH-FINDINGS.md](../HITCH-FINDINGS.md). +**Zoom-in edge.** On `enterActive`, after the multi-monitor retarget, so the pick evaluates the +session's own monitor. The winner becomes `t.model` for the session; a transform session records +its exe in `t.transformExe` for the churny backstop. + +**Mid-zoom switch.** While zoomed the pick re-runs every tick, so alt-tabbing from the desktop into +a game at 8x swaps engines with level and lens kept. Three dampers: + +- **Per-HWND cache** (`RefreshFgCache`): exe-derived inputs (shell class, exclusion, churny, + capture protection) are re-resolved only when the foreground window changes. `coversMonitor` + and `borderless` stay per tick. +- **350 ms settle** (`t.wantModel`/`t.wantSinceMs`): the candidate must hold for 350 ms. Each flip + rebuilds DWM's magnification context, a visible stall. +- **Overlay freeze** (`IsOverlayFg`): while Snipping Tool, ScreenSketch or TextInputHost + (`kOverlayExes`) or the game-inspect focus stealer holds foreground, the pick is skipped, so + handing foreground back is not a second handover. Inspect sessions are never switched. + +## Handover: restAfterReveal + +The outgoing and incoming engines use different DWM channels (a transform write versus an overlay +alpha flip). Resting the old engine on the same tick the new one goes live can composite one bare +unmagnified frame. So `TickState::restAfterReveal` keeps the outgoing engine alive until the +incoming one is verifiably on screen, plus `restOverlapTicks` (3), then rests it. The worst case is +a ~14 ms still-magnified overlap, never a bare desktop. + +- **Transform to Render**: the overlay reveal is evidence-gated ([04](04-render-engine.md)), so the + overlap countdown arms when the reveal fires. +- **Render to Transform**: the transform activates the same tick; the overlay drops after the + overlap. + +A rapid second switch rests the pending engine first, and every teardown path clears +`restAfterReveal`. + +## Vetoes + +- **`transformExclude`** lists exes that never get Transform. Default: the common browsers + (`zen.exe`, `firefox.exe`, `chrome.exe`, `msedge.exe`, `brave.exe`, `opera.exe`, + `opera_gx.exe`, `vivaldi.exe`), because fullscreen browser video passes the game test. Matching + is `FgExeInList`: bare file name, case-insensitive, exact, the same as `noSwallowApps`. +- **`churny_apps.txt`** (`%LOCALAPPDATA%\Wind`) is learned. A render device-lost within 30 s of a + transform game session marks that session's exe (`MarkChurnyApp`), and later zoom-ins over it + pick Render: one crash per app, not two. `tdrTest` bypasses the list for field tests. Evidence: + [../HITCH-FINDINGS.md](../HITCH-FINDINGS.md). + +## Why Wind does not drive the built-in Magnifier + +Wind once had a third engine that launched `Magnify.exe` and drove it with injected input, as a +fallback for protected video. The `captureProtected` rule above made it unnecessary and it was +removed. The approaches measured on the way, so nobody repeats them: + +| Approach | Result | +|---|---| +| Injected Win+Plus/Minus bursts | About half the chords dropped; each survivor animated, so zoom lagged and kept going after release | +| Injected Win+wheel | Ignored entirely | +| Injected Ctrl+Alt+wheel notches every 60 ms | Works 1:1 (the shipped design), but Wind holds no zoom state, so no quick zoom, Inspect, tracking or game cursor handling | +| Streaming the `Magnification` registry value per tick | Read at ~280 ms animation boundaries; faster writes snap ~40% at each boundary | +| `MagSetFullscreenTransform` during ramps while Magnify.exe runs | Magnifier overwrites the transform within ~7 ms of a wake, and its registry handler animates from a stale level | + +Registry facts from the same probes: `Magnification` values above 1600 are ignored, not clamped, +and a same-value write fires no change notification. A running Magnify.exe also fights Wind's +transform; see [../NATIVE-MAGNIFIER-STOMP.md](../NATIVE-MAGNIFIER-STOMP.md). diff --git a/docs/architecture/04-render-engine.md b/docs/architecture/04-render-engine.md index c8f66966..c4105cf4 100644 --- a/docs/architecture/04-render-engine.md +++ b/docs/architecture/04-render-engine.md @@ -1,317 +1,236 @@ # 04. The render engine -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. 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. - -## The shape of the thing - -`RenderEngine` (src/render_engine.h) is a PIMPL class; all D3D/DXGI headers stay inside -`src/render_engine.cpp`. The tick loop never talks to it directly: `RenderModel` -(src/render_model.cpp) adapts it to the `IMagnifierModel` interface from -[the engines chapter](03-engines.md), and `RunTick` in `src/main.cpp` owns the activation and -reveal choreography, because parts of it need information the engine does not have (whether the -foreground window covers the monitor, which tick is the idle-to-active edge). - -Per frame the flow is: `renderFrame(RenderFrameParams)` captures the desktop if it changed, -draws three passes into the back buffer (magnify, edge outline, cursor sprite), and presents. The -magnified view is a float source rect (`srcLeft`/`srcTop` plus `level`), so panning is sub-pixel -smooth; the pure math that produces the rect lives in `src/cursor_mapper` and `src/transform`, -covered in [the tick loop](02-tick-loop.md). - -The original design spec is -[2026-05-25-own-renderer-design.md](../superpowers/specs/2026-05-25-own-renderer-design.md) -(issue #4). Where this chapter and the spec disagree, the code has moved on and this chapter -follows the code. +The render engine captures the desktop with DXGI Desktop Duplication, scales a sub-pixel source +rectangle on the GPU with Direct3D 11 and presents it onto a fullscreen, click-through, +capture-excluded overlay. In Auto it is the engine for everything the transform is refused: other +monitors, `transformExclude` apps, learned churny apps, and desktop sessions without a verified +input transform (no UIAccess). Protected content never uses it: Desktop Duplication returns black +for it ([03](03-engines.md)). + +## Structure + +`RenderEngine` (`src/render_engine.h`) is a PIMPL class; D3D and DXGI headers stay in +`src/render_engine.cpp`. `RenderModel` (`src/render_model.cpp`) adapts it to `IMagnifierModel`. +`RunTick` owns activation and reveal, because they need facts the engine does not have (does the +foreground cover the monitor, is this the idle-to-active edge). + +Per frame, `renderFrame(RenderFrameParams)` captures if the desktop changed, draws three passes +(magnify, outline, cursor) and presents. The view is a float source rect (`srcLeft`/`srcTop` plus +`level`), so panning is sub-pixel. ## The overlay window -`RenderEngine::initialize` creates one borderless popup (`WindRenderOverlay` class) covering the -target monitor. Every extended style on it is load-bearing: +`RenderEngine::initialize` creates one borderless popup (`WindRenderOverlay`) over the target +monitor. Every style is required: | Style / attribute | Why | |---|---| -| `WS_EX_LAYERED` + `SetLayeredWindowAttributes(.., LWA_ALPHA)` | True cross-process click-through, and the alpha channel is the show/hide mechanism (below). `WS_EX_TRANSPARENT` + `HTTRANSPARENT` alone only forwards clicks to same-thread windows; other apps' clicks were eaten. | -| `WS_EX_TRANSPARENT` + `HTTRANSPARENT` in `OverlayProc` | Belt and braces for the hit-test path. | -| `WS_EX_TOPMOST`, `WS_EX_NOACTIVATE`, `WS_EX_TOOLWINDOW` | Stay above app overlays, never steal focus, never appear in alt-tab. | -| `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` | THE number one gotcha. Without it, Desktop Duplication captures our own presented frame, we magnify our own output, and the image degenerates into a black feedback loop. The window stays visible on screen but invisible to DDA, so we always capture the real desktop beneath. | -| `DwmSetWindowAttribute(DWMWA_EXCLUDED_FROM_PEEK)` | Aero Peek is a compositor effect, not a window, so no z-band beats it; excluded, the overlay keeps magnifying during a taskbar-thumbnail peek (issue #141). | - -Because the overlay is capture-excluded, external screenshots cannot verify it. Verification is -done from inside the process: `WIND_SELFTEST=1 Wind.exe` dumps `wind_selftest.png` via -`RenderEngine::dumpFrame`, which renders without presenting so the PNG matches the drawn frame. - -## The present path: blt, and only blt - -The swapchain (`RenderEngine::State::buildPresent`) is deliberately old-fashioned: -`DXGI_SWAP_EFFECT_DISCARD`, one buffer, windowed, on the layered HWND, with -`IDXGIDevice1::SetMaximumFrameLatency(1)` capping latency. A blt-model present composites through -the window's DWM redirection surface, which means it can never tear: DWM always composites it at -vblank. - -A DirectComposition flip-model path was built and abandoned twice (issues #11 and #69), and the -conclusion is a standing rule: DWM promotes a fullscreen dcomp visual to an independent-flip / MPO -plane that scans out unsynced, and on a VRR/G-SYNC display it tears exactly on loop hitches. -Forcing it back onto the composited path with `DwmFlush` stopped the tear but chained the present -rate to the VRR-floated composite rate (~68 Hz on a 23-143 Hz panel). Net: dcomp is never a win on -this layered click-through overlay. Do not re-attempt it. - -The blt path's one artifact is a phase-mismatch microstutter against DWM's composition clock, -tamed by the `dwmFlush` ini knob (Config in src/config.h, applied in the pacing section of -`RunTick`): `dwmFlush=0` (default) presents with `Present(1,0)` and lets vsync pace the loop; -`dwmFlush=1` presents immediately and then calls `DwmFlush()` after the tick to align 1:1 with -composition. Both are hot-reloadable. `RenderFrameParams.syncOverride` can also force -`Present(N,0)`; the game half-rate mode uses N=2 so every frame gets two vblanks of slack, which -turns an irregular hitch into a steady cadence (steadiness is what reads as smooth, issue #148). - -## Parking: the overlay's geometry alone taxes games - -The overlay window is created shown and stays shown for the process lifetime, but while Wind is -idle it is **parked**: moved just past the right edge of the virtual desktop -(`RenderEngine::setParked`). The reason is one of the most expensive lessons in the codebase: a -fullscreen topmost layered window stacked over a fullscreen game keeps DWM from granting the game -its independent-flip plane **by geometry alone, even at alpha 0**. A game ran DWM-composited for -its entire session just because Wind sat idle in the tray, and it looked model-independent and -sticky because every model creates the overlay at startup. PresentMon on RDR2, same session, no -game restart, before/after parking: 3% to 99.8% "Hardware: Independent Flip", mean frametime -12.28 ms to 7.26 ms, p99 18.3 ms to 9.5 ms. - -Parking is a **move**, never `SW_HIDE` (that reintroduces the stale-frame flash below) and never a -resize to 1x1 (shrinking makes DWM reallocate the redirection surface, and the fresh allocation is -undefined until presented into, which showed as a one-frame black flash per zoom over a game). A -move leaves the surface and the swapchain untouched. Each park/unpark is a `SetWindowPos` over the -game, i.e. a synchronous DWM z-order transaction that hitches it, so two per zoom session is the -floor; do not add more. The park lands past the virtual desktop's right edge specifically so it -cannot sit on another monitor and demote a fullscreen app there. `WIND_NOPARK=1` disables parking -for A/B measurement. - -## Show and hide by alpha, never SW_HIDE - -`RenderEngine::setVisible` flips `SetLayeredWindowAttributes` between alpha 0 and 255. A layered -window that is hidden with `SW_HIDE` and later re-shown makes DWM cache and re-display the frame -from when it was last visible, flashing the previous zoom session's content on the next zoom-in -(worst right after an alt-tab). So the window is created shown at alpha 0 and its visibility only -ever changes through the alpha byte. - -On hide, `setVisible(false)` also presents one black **scrub frame**, strictly *after* the alpha-0 -flip: the redirection surface otherwise retains the session's last magnified frame forever, and any -residual reveal race in a future zoom-in could only ever flash black instead of stale content. -Scrub-then-hide (the other order) flashed black on every zoom-out, because DWM composited the black -frame while the overlay was still visible. The scrub is skipped when the previous present is still -in flight on a starved GPU (checked via the present fence), because a blocking `Present` on the -teardown path could wedge the cursor restore; the reveal gate protects the next zoom-in anyway. +| `WS_EX_LAYERED` + `SetLayeredWindowAttributes(.., LWA_ALPHA)` | Cross-process click-through, and the alpha byte is the show/hide switch. `WS_EX_TRANSPARENT` + `HTTRANSPARENT` alone forward clicks only to same-thread windows. | +| `WS_EX_TRANSPARENT` + `HTTRANSPARENT` in `OverlayProc` | The hit-test path. | +| `WS_EX_TOPMOST`, `WS_EX_NOACTIVATE`, `WS_EX_TOOLWINDOW` | Above app overlays, never takes focus, not in alt-tab. | +| `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` | **Required.** Without it Desktop Duplication captures Wind's own output and the view degenerates into a black feedback loop. | +| `DwmSetWindowAttribute(DWMWA_EXCLUDED_FROM_PEEK)` | Keeps the view during a taskbar-thumbnail Aero Peek. | + +Because the overlay is capture-excluded, external screenshots cannot see it. Verify from inside the +process: `WIND_SELFTEST=1 Wind.exe` writes `wind_selftest.png` (`RenderEngine::dumpFrame`). + +## Present: blt model only + +The swapchain is `DXGI_SWAP_EFFECT_DISCARD`, one buffer, windowed, on the layered HWND, with +`SetMaximumFrameLatency(1)`. A blt present composites through DWM's redirection surface and never +tears. + +**Do not use a DirectComposition flip-model path.** It was built twice (issues #11, #69). DWM +promotes the fullscreen visual to an independent-flip plane that tears on loop hitches on a VRR +display; forcing it back onto composition with `DwmFlush` tied the present rate to the floating +VRR composite rate (~68 Hz on a 23–143 Hz panel). RTSS is a quick tell: its overlay shows over blt +and vanishes over dcomp. + +The blt path's one artifact is a phase-mismatch microstutter, tuned by `dwmFlush`: `0` (default) +presents with `Present(1,0)`; `1` presents immediately and calls `DwmFlush()` after the tick. Both +are hot. The game half-rate mode uses `Present(2,0)` (`syncOverride`) to turn irregular hitches +into a steady cadence. + +## Parking + +**The overlay is parked whenever Wind is not rendering**: moved past the right edge of the virtual +desktop (`setParked`). A shown fullscreen topmost layered window keeps a fullscreen game off its +independent-flip plane by geometry alone, even at alpha 0. Measured on RDR2 with PresentMon: +parking raised Independent Flip from 3% to 99.8% and cut mean frame time from 12.28 to 7.26 ms. + +- Park by **moving**. `SW_HIDE` brings back the stale-frame flash (next section). Resizing to 1x1 + makes DWM reallocate the redirection surface, which shows one black frame per zoom. +- Each park or unpark is a synchronous DWM z-order transaction over the game, so two per session + is the floor. Do not add more. +- The park position is past the virtual desktop's right edge so it cannot cover another monitor. +- `WIND_NOPARK=1` disables parking for A/B tests. + +## Show and hide by alpha + +`setVisible` flips the layer alpha between 0 and 255. A layered window hidden with `SW_HIDE` and +shown again makes DWM show the frame it had when last visible, which flashed the previous session. +The window is created shown at alpha 0 and only the alpha changes. + +On hide, one black **scrub frame** is presented strictly after the alpha-0 flip, so the surface +never keeps the last session's content. The other order flashed black on every zoom-out. The scrub +is skipped while the previous present is still in flight on a starved GPU, because a blocking +`Present` on the teardown path could delay the cursor restore. ## The reveal gate -Presenting the live frame before flipping the alpha is necessary but not sufficient (issue #140). -The `Present` blt into the redirection surface is **GPU work**; the alpha flip is a **CPU call** -DWM honors at its next composite. Under GPU load the flip wins the race and DWM shows the -surface's retained frame, i.e. the previous session's last present. Two independent mechanisms -close the race, both owned by `RunTick` in `src/main.cpp` with the primitives in `RenderEngine`: - -1. **The present fence.** `RenderModel::onActivate` calls `armRevealFence()`; the session's first - `Present` then issues a D3D event query (`revealFence` in `renderFrame`). - `revealFrameDone()` reports true only once that Present has executed on the GPU, so the surface - provably holds this session's content. On an ordinary desktop zoom-in `RunTick` spins a 3 ms - budget on it so the common idle-GPU case still reveals within the same tick (the instant feel is - kept); a loaded GPU defers to per-tick checks. -2. **The composite evidence gate**, for fullscreen apps only. A game on an independent-flip/MPO - plane is invisible to Desktop Duplication (issue #90), so `RunTick` calls `primeReveal()`: - alpha 1, visually imperceptible, but enough to make DWM de-promote the game and composite it. - `frameCompositedSincePrime()` then reports true once `capture()` has copied a desktop frame - whose `LastPresentTime` is newer than the prime's QPC timestamp, which is hard evidence the game - is actually in the capture. A fixed tick deferral was tried first and flashed the pre-alt-tab - window under GPU load. - -Both gates are non-blocking; the smooth-zoom ramp runs undisturbed while they pend. A fallback cap -(`revealPending`, about a quarter second of ticks) guarantees the reveal can never wedge. - -**The zoom-in reveal sequence, from idle to visible overlay:** +Presenting before flipping the alpha is not enough (issue #140). The blt is GPU work; the alpha +flip is a CPU call DWM honours at its next composite. Under GPU load the flip wins and DWM shows +the surface's retained frame. Two gates close the race: + +1. **Present fence.** `onActivate` arms it; the session's first `Present` issues a D3D event query. + `revealFrameDone()` is true once that Present executed on the GPU. On the desktop `RunTick` + spins up to 3 ms so an idle GPU still reveals in the same tick. +2. **Composite evidence** (fullscreen apps only). A game on an independent-flip plane is invisible + to Desktop Duplication (issue #90). `primeReveal()` sets alpha 1, which makes DWM composite the + game, and `frameCompositedSincePrime()` turns true once a captured frame is newer than the + prime. A fixed tick delay flashed the pre-alt-tab window under GPU load. + +Both are non-blocking; the zoom ramp runs while they pend. `revealPending` (~250 ms of ticks) is +the fallback cap. ```mermaid flowchart TD - A[Zoom-in edge in RunTick] --> B[RenderModel::onActivate\ninvalidateCapture + armRevealFence] - B --> C{Foreground covers\nthe monitor?} - C -- no, desktop --> D[renderFrame: unpark, capture\ndrains to latest frame, Present\nissues the reveal fence] - C -- yes, fullscreen app --> P[primeReveal: unpark,\nalpha 1, timestamp QPC] - P --> D2[keep rendering normal ticks\nnon-blocking] - D --> E{revealFrameDone?\nspin up to 3 ms} - D2 --> F{revealFrameDone AND\nframeCompositedSincePrime?} - E -- yes --> G[setVisible true:\nalpha 255 over the live frame] - E -- not yet --> H[re-check each tick] + A[Zoom-in edge] --> B[onActivate: invalidateCapture + armRevealFence] + B --> C{Foreground covers the monitor?} + C -- no --> D[renderFrame: unpark, capture, Present issues the fence] + C -- yes --> P[primeReveal: unpark, alpha 1, timestamp] + P --> D2[keep rendering] + D --> E{revealFrameDone? spin up to 3 ms} + D2 --> F{revealFrameDone AND frameCompositedSincePrime?} + E -- yes --> G[alpha 255 over the live frame] F -- yes --> G - F -- not yet --> H - H --> I{revealPending\nticks exhausted?} - I -- yes, ~250 ms cap --> G - I -- no --> H + E -- no --> H{cap reached?} + F -- no --> H + H -- yes --> G + H -- no --> D2 ``` -## The capture path - -`RenderEngine::State::capture` has two regimes, and the split matters: - -- **Steady state** polls `AcquireNextFrame` with a 0 ms timeout, once. A static screen returns - `WAIT_TIMEOUT` immediately and the engine re-pans its cached copy (`desktopCopy`), so panning is - never gated on a desktop change; an earlier 8 ms wait here stalled every pan frame into - microstutter. A frame whose `LastPresentTime` is zero means only the pointer moved, and since the - cursor is drawn from `GetCursorInfo` rather than the captured image, nothing is copied at all. -- **Fresh grabs** (zoom-in, via `invalidateCapture()`, which drops the duplication so the next - `AcquireNextFrame` returns the whole desktop) block briefly to land the first frame and then - **drain to the latest one**: the first frame after (re)creating a duplication can be a - transitional composite, the window *underneath* the current one, and taking it flashed that - window on reveal. The drain is bounded (about 3 ms per extra attempt, 100 ms wall-clock budget), - and giving up frameless just retries next tick. - -Steady-state copies are minimized by `copyChangedRegions`: only the dirty rects DDA reports are -patched into `desktopCopy`, falling back to a full `CopyResource` whenever a partial update is not -provably safe (no previous frame, move/scroll rects present, missing metadata, out-of-range rect). -On a near-full repaint (dirty area over half the screen, i.e. a game) the copy can additionally be -**cropped to the magnified view**: `cropCapture=0` by default because on the desktop a -window-switch repaint would leave stale pixels outside the view, but `gameCrop=1` (default) forces -it while the foreground covers the monitor, where every pixel is dirty again next frame so -staleness cannot survive. At 4K FP16 that crop cuts the per-frame copy roughly by zoom squared. - -Rotated (portrait) outputs are not supported by the copy/UV math; `recreateDupl` detects and logs -them loudly rather than magnifying garbage. - -## Staying on top, and the band trade-off - -If an always-on-top app overlay (RTSS, Task Manager) sits above us, it draws a second, unmagnified -copy over the view. `renderFrame` therefore re-asserts `HWND_TOPMOST`, but **only when actually -displaced**: `overlayDisplaced` walks the windows above the overlay (one cheap `GetWindow` syscall -in the common already-on-top case, ignoring cloaked and non-overlapping windows), because a -per-frame `SetWindowPos` synchronizes with DWM and caused constant microstutter. A 1 s -unconditional backstop self-heals missed cases, and is itself skipped while a fullscreen game is -foreground (`RenderFrameParams.fsGame`), since that transaction hitches the game once a second and -nothing the displaced check misses can displace us over a fullscreen app. - -Above ordinary topmost sits the z-order **band** question (issue #162). Both bandable windows go -through `wind::CreateBandedWindow` (src/band_window.h), which cascades the requested band to 16 to -unbanded and logs any refusal, because a silently refused band (band 17 is rejected outright by -`CreateWindowInBand` on Windows 26200) once masqueraded as a fix. The shipped default is -`zorderBand=0`, unbanded, and it is a deliberate trade: band 16 covers the Start menu and taskbar -flyouts, but the Snipping Tool's capture overlay then composites over *us*, showing the unmagnified -screen with no cursor at all (we hide the OS pointer and draw a replacement, so covering the -replacement leaves nothing). Band 0 makes snipping work; the shell surfaces are the price. Do not -restore 16 without re-testing both halves. Diagnostic trap: `ScreenClippingHost.exe` holds -foreground with no visible top-level window, so a z-order walk "proves" we are at index 0 while we -are plainly covered; never verify band problems that way. - -## HDR: scRGB in, SDR out, never cache the slider - -On an HDR desktop the duplication is created with `DuplicateOutput1` requesting FP16 scRGB -(`recreateDupl`, gated on the `hdrTonemap` config and on `GetHdrEnabled` for the *target* device; -the DXGI color space is not trusted because some monitors report HDR10 with Windows HDR off). The -magnify shader then tonemaps: scRGB encodes 80 nits as 1.0, and Windows' "SDR content brightness" -slider sets the white level SDR content composites at, so the shader divides by -`ScrRgbScale = 80 / sdrWhiteNits` to land SDR white back on 1.0 before the sRGB encode. DWM applies -the same white level again when compositing our BGRA8 overlay, making the round trip exact, **but -only while our scale tracks the live slider** (issue #160). The white level was once sampled per -device build; any later slider move left a permanent brightness step of actual/cached on every -zoom-in and zoom-out. Now it is re-read on every duplication rebuild (i.e. every zoom-in) and on a -4 Hz throttle while rendering (`refreshSdrWhite`; the DisplayConfig query measures ~0.007 ms), and -a failed query keeps the last known good value, because snapping to a default would itself be a -visible step. The pure math and the throttle predicate live in `src/hdr_scale.h` -(`ScRgbScale`, `AcceptSdrWhiteNits`, `ShouldRefreshSdrWhite`), unit-tested without ``. -`ensureDesktopCopy` recreates `desktopCopy` to match whatever format the capture actually delivers, -so a runtime HDR toggle can never mismatch the copy (which used to black-screen the magnify pass). -This is render-model-only: the transform and magnify engines magnify inside DWM and never convert -color. - -## Drawing: three passes, and the cursor - -`State::render` draws the magnified desktop as one full-screen opaque triangle (skipping the clear -whenever a desktop copy exists, saving a 4K clear per frame), then the edge outline, then the -cursor. The outline is deliberately **one** full-screen quad whose pixel shader colors only the -border band and discards the interior; an earlier four-quads-in-a-loop version dropped individual -edges on some GPUs. The frame is inset 6 px from the screen edge because at non-integer DPI -(observed at 4K 225% on an RTX 5090) DWM can mis-composite the layered blt present with a small -down-left offset that clips a flush left/bottom band off the panel; that is a driver/DWM artifact, -not draw code, so do not chase it as a render bug. - -The cursor sprite comes from `GetCursorInfo` + `DecodeCursorBGRA` (it works while the OS cursor is -hidden), cached per `HCURSOR` with a 5 s staleness bound because the OS recycles handles, and drawn -with an invert blend for I-beam-style cursors. `cursorMode` 0 (auto) draws only when the focused -app shows its own cursor, so a game that hid its pointer never gets one painted back. In Inspect -mode the 48x48 crosshair from `BuildCrosshairBGRA` (src/crosshair.cpp, shared with the transform -engine) replaces it. The engine also keeps the hidden OS pointer parked under the drawn cursor via -`SetCursorPos` so clicks land where the user sees the pointer, reports `parkedLastFrame()` so the -pan oracle can measure rather than assume its baseline, and suspends the park entirely while a -mouse button is held (`suppressCursorSync`, drag-follow). The full story of the weld, the oracle -invariants, and issue #169 belongs to [the cursor system](07-cursor.md). - -## Surviving games: priority, gating, and the fps cap - -Issue #148 produced a small toolkit for coexisting with a GPU-saturating game, all wired through -`RunTick` (the "game" tell is `ForegroundCoversMonitor`, which also matches maximized windows, -which is exactly why none of these engage by default on the desktop path): - -- **`gpuPriority`** (ini, restart): -1 / 0 / +1 via `IDXGIDevice::SetGPUThreadPriority(+/-7)` plus - a best-effort `D3DKMTSetProcessSchedulingPriorityClass` (`ApplyProcessGpuPriority`; the raise can - be denied without privileges, logged, non-fatal). +1 makes Wind's small per-frame job jump a - saturated game's queue so the zoomed view hits every vblank; -1 yields to the game and accepts - that the view can freeze in heavy scenes. The legacy `lowGpuPriority=1` still means - `gpuPriority=-1` via `EffectiveGpuPriority` (src/config.h). -- **The present-fence gate** (`gatePresent`): with low priority a saturated game starved Wind's - GPU work for minutes, and a blocking vsync `Present` then wedged the whole main thread, input, - teardown, and the cursor restore included. When the gate is engaged, `renderFrame` skips the - entire frame while the previous present has not executed on the GPU (an event query after every - Present); cursor sync and the topmost check still run, so clicks stay live. -- **`gameFpsCap`** (ini, hot): the reduced-push mode. Measured under a game, DWM services the - redirected window's presents at only ~78/s while compositing at 144/s; pushing 144 presents/s - builds a standing queue and every Present waits a queue's worth with jitter. The cap presents - every Nth vblank (N = ceil(hz/cap)), below the service rate, so the queue stays empty; skipped - ticks block on `RenderEngine::waitVBlank` (`IDXGIOutput::WaitForVBlank`) to stay vblank-locked, - and input sampling plus panning still run every tick. Activation and reveal-pending ticks always - present, because the reveal gate needs frames reaching the redirection surface. - -`debugPerf` splits CPU time building the frame from time blocked inside `Present` (where GPU -contention shows) and counts gate skips; [instrumentation](12-instrumentation.md) covers how those -counters are read in the field. - -## Multi-monitor retarget and device loss - -`retarget` re-points the engine at the monitor the cursor is on at zoom-in (`multiMonitor=1`). It -validates first, mutates second: the target output must be on our D3D device's adapter -(`selectOutput` by GDI device name; a cross-GPU monitor returns false and the caller keeps the -current one), and the swapchain `ResizeBuffers`, the only fallible step, runs before the window is -moved, with a best-effort RTV restore on failure. On commit it adopts the new geometry, moves the -overlay (respecting the parked state), and forces a fresh capture on the new output. The pipeline -works in local monitor pixels; the `(originX, originY)` offset is applied only at the -`GetCursorPos`/`SetCursorPos` boundary. - -A TDR or driver update surfaces as `DXGI_ERROR_DEVICE_REMOVED/RESET` from `Present` or -`AcquireNextFrame`; the engine latches `deviceLost()` and becomes a no-op until the caller paces -`recoverDeviceLost()`, which releases every device-dependent resource and rebuilds the whole set -through the same `buildDeviceResources` that `initialize` uses, so the two paths cannot drift. The -HWND, geometry, and zoom state survive. Teardown (`shutdown`, plus the -`SetUnhandledExceptionFilter` crash net `CursorRestoreFilter`) always restores the OS cursor and -releases any `ClipCursor`, because leaving a user cursorless is the one failure mode Wind never -accepts. Cursor hiding itself goes through `wind::MagApiAcquire`/`MagApiRelease` (src/mag_host.h), -the shared Magnification-runtime refcount both engines must use; see -[the engines chapter](03-engines.md) for why independent init/uninit pairs break each other. - -## Pointers - -- `src/render_engine.h` / `src/render_engine.cpp`: the engine itself, every rule above. -- `src/render_model.h` / `src/render_model.cpp`: the `IMagnifierModel` adapter; `onActivate` arms - the reveal machinery. -- `src/main.cpp` (`RunTick`): reveal choreography, pacing modes, the game-survival lever wiring. -- `src/hdr_scale.h`, `src/hdr_info.cpp`: the pure tonemap math and the OS white-level query. -- `src/band_window.h`: `CreateBandedWindow` and the band cascade. -- `src/crosshair.cpp`, `src/cursor_decode.*`: the Inspect crosshair and cursor decoding. -- Spec: [own renderer design](../superpowers/specs/2026-05-25-own-renderer-design.md) (issue #4). -- Evidence files: [HITCH-FINDINGS](../HITCH-FINDINGS.md), - [PERFORMANCE-FINDINGS](../PERFORMANCE-FINDINGS.md), - [WOBBLE-CAPTURE-2026-08-21](../WOBBLE-CAPTURE-2026-08-21.md). -- Related chapters: [Engines and the hybrid pick](03-engines.md), - [The transform engine](05-transform-engine.md), [The cursor system](07-cursor.md), - [Instrumentation](12-instrumentation.md). +## Capture + +- **Steady state** polls `AcquireNextFrame` once with a 0 ms timeout. A static screen returns + `WAIT_TIMEOUT` and the engine re-pans its cached copy (`desktopCopy`); an 8 ms wait here once + stalled every pan frame. A frame with `LastPresentTime == 0` is pointer-only and copies nothing, + because the cursor comes from `GetCursorInfo`. +- **Fresh grabs** (zoom-in, `invalidateCapture()`) drain to the latest frame, not the first: the + first frame after recreating a duplication can be a transitional composite, which flashed the + window underneath. Bounded at ~3 ms per attempt and 100 ms in total. +- **Dirty rects.** `copyChangedRegions` patches only the rects DDA reports and falls back to a full + `CopyResource` whenever a partial update is not provably safe (no previous frame, move rects, + missing metadata, out-of-range rect). +- **Crop to the view.** On a near-full repaint the copy can be cropped to the magnified view. + `cropCapture=0` by default (a desktop window switch would leave stale pixels outside the view); + `gameCrop=1` (default) crops while the foreground covers the monitor, where every pixel is dirty + again next frame. +- Rotated outputs are not supported; `recreateDupl` logs them. +- A dedicated capture thread was considered and deferred: high risk (feedback exclusion, HDR + format changes, retarget, cross-thread texture sharing), no measured stall. + +## Staying on top, and the z-band + +An always-on-top window above the overlay (RTSS, Task Manager) draws an unmagnified copy over the +view. `renderFrame` re-asserts `HWND_TOPMOST` only when displaced (`overlayDisplaced`, one +`GetWindow` call in the common case), because a per-frame `SetWindowPos` synchronizes with DWM and +stutters. A 1 s backstop catches misses and is skipped while a fullscreen game is foreground. + +**The band is a trade-off; the default is `zorderBand=0` (unbanded).** Both bandable windows go +through `wind::CreateBandedWindow` (`src/band_window.h`), which cascades the requested band to 16 +to unbanded and logs every refusal. + +| Band | Covers Start, taskbar, tray flyouts | Snipping Tool (Win+Shift+S) | +|---|---|---| +| 0 (default) | No | Works | +| 16 (UIAccess build) | Yes | The snip overlay composites over Wind: unmagnified screen and no cursor at all | +| 17 | Refused by `CreateWindowInBand` (build 26200) | – | + +Do not restore 16 without re-testing both columns. Diagnostic trap: `ScreenClippingHost.exe` holds +foreground with no visible top-level window, so a z-order walk shows Wind at index 0 while it is +covered. The transform cursor sprite switches bands on its own, see [07](07-cursor.md). + +## HDR + +On an HDR desktop the duplication requests FP16 scRGB (`DuplicateOutput1`) and the magnify shader +tonemaps to SDR (`hdrTonemap=1`, default). + +- **Gate on Windows' advanced-colour state for the target display (`GetHdrEnabled`), not the DXGI + colour space**: some monitors report HDR10 with Windows HDR off, which would dim SDR content. +- The shader divides by `ScrRgbScale = 80 / sdrWhiteNits`, so SDR white lands on 1.0. DWM applies + the same white level when compositing the overlay, so the round trip is exact. +- **Never cache the SDR white level** (issue #160). A cached value left a brightness step on every + zoom-in and zoom-out after the user moved the slider. It is re-read on every duplication rebuild + and at 4 Hz while rendering (`refreshSdrWhite`, ~0.007 ms per query), matched by GDI device name, + and a failed query keeps the last good value. +- `ensureDesktopCopy` recreates the copy in whatever format the capture delivers, so a runtime HDR + toggle cannot mismatch it. +- Pure maths and the throttle: `src/hdr_scale.h`. The transform engine magnifies inside DWM and + never converts colour. + +## Colour filters + +Warmth and brightness (`colorWarmPct`, `colorDimPct`) are one colour matrix (`src/color_matrix.h`), +applied by `ColorFilterController` (`src/color_filter.*`) through `MagSetFullscreenColorEffect`. + +- **Desktop Duplication sees the DWM effect.** While a render session is visible the DWM effect is + set to identity and the render shader applies the matrix instead; otherwise the overlay would be + filtered twice. The drawn cursor, crosshair and outline are filtered too; an inverting text beam + is drawn untinted. +- Colour follows the visible engine (`RenderModel::visible()`), so a pending reveal or a resting + overlay in a handover keeps the right filter. +- **A filter holds the Magnification runtime at 1x**, which taxes cursor changes in other apps + ([05](05-transform-engine.md)). Off by default. +- At 1x the hardware pointer is outside the DWM effect, so Wind swaps the system pointers for + tinted copies (`src/cursor_tint.*`). +- Windows clears the effect when the process dies, so a crash never leaves the screen filtered. + +Measurements and rejected filters: [../COLOUR-FILTER-FINDINGS.md](../COLOUR-FILTER-FINDINGS.md). + +## Drawing and the cursor + +The magnified desktop is one full-screen triangle (no clear when a copy exists), then the outline, +then the cursor. + +- The outline is **one** full-screen pass that colours the border band and discards the interior; + four separate quads dropped edges on some GPUs. It is inset 6 px because at non-integer DPI DWM + can offset the layered present down-left and clip a flush edge. That is a DWM artifact, not draw + code. +- The cursor comes from `GetCursorInfo` + `DecodeCursorBGRA`, cached per `HCURSOR` with a 5 s + staleness bound (handles are recycled), with an invert blend for I-beam cursors. `cursorMode` 0 + draws only when the app shows its own cursor. +- Inspect replaces it with the crosshair from `BuildCrosshairBGRA` (`src/crosshair.cpp`). +- The hidden OS pointer is parked under the drawn cursor with `SetCursorPos` so clicks land where + the user sees the pointer; `parkedLastFrame()` reports whether the park ran, and the park pauses + while a mouse button is held. See [07](07-cursor.md). + +## Surviving games + +The game tell is `ForegroundCoversMonitor`, which also matches maximized windows, so none of these +engage by default on the desktop. + +| Lever | Effect | +|---|---| +| `gpuPriority` (restart) | -1/0/+1 via `SetGPUThreadPriority` and the process scheduling class. +1 lets Wind's small frame jump a saturated game's queue; -1 yields and can freeze the view. `lowGpuPriority=1` means -1. | +| Present-fence gate (`gatePresent`) | With low priority, skip the frame while the previous present has not executed, so a blocking `Present` never wedges input and teardown. Cursor sync still runs. | +| `gameFpsCap` (hot) | Present every Nth vblank, below the rate DWM services a redirected window under a game (~78/s while compositing at 144). Skipped ticks wait on `WaitForVBlank`; input still runs every tick. | + +`debugPerf` splits frame-build CPU time from time blocked in `Present`; see +[12](12-instrumentation.md). + +## Multi-monitor and device loss + +- `retarget` moves the engine to the cursor's monitor at zoom-in (`multiMonitor=1`). 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 + `GetCursorPos`/`SetCursorPos`. +- `DXGI_ERROR_DEVICE_REMOVED/RESET` latches `deviceLost()`. `recoverDeviceLost()` rebuilds through + the same `buildDeviceResources` as `initialize`, so the paths cannot drift. +- **Always restore the OS cursor.** `shutdown` and the crash filter (`CursorRestoreFilter`) restore + the pointer and release any `ClipCursor`. Cursor hiding goes through + `MagApiAcquire`/`MagApiRelease`; see [05](05-transform-engine.md) for why the pairing must be + shared. + +Design history: [../specs/2026-05-25-own-renderer-design.md](../specs/2026-05-25-own-renderer-design.md). diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 5f49fce0..5e24020c 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -1,363 +1,231 @@ # 05. The transform engine -The transform engine (`src/transform_model.*`) zooms by telling DWM itself to magnify the -desktop: one fullscreen scale-and-translate applied inside the compositor, the same mechanism -native Windows Magnifier uses. There is no capture, no swapchain, and no window of ours in the -composition path, which is why it is the only engine that stays smooth over a heavy game: the -game keeps its independent-flip presentation and Wind's entire per-frame cost is a sub-millisecond -API write. The price is that everything is shared, global, and half-documented, and most of this -chapter is about the guardrails that make the shared machinery safe. - -## Why magnify inside DWM - -The render engine ([chapter 04](04-render-engine.md)) captures the desktop and re-presents it, -which means a fullscreen layered window sits over the game and every frame flows through an extra -capture-scale-present pipeline. Over a demanding game that pipeline competes with the game for the -GPU and the compositor. The transform engine sidesteps all of it: `MagSetFullscreenTransform` (or -its private sibling, below) mutates a value DWM consults when it composites, so the magnification -is applied during work DWM was doing anyway. Field measurements against native Magnifier over -games repeatedly put the two in the same class, and after the #219 cadence work Wind measures -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, 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 - -The Magnification runtime is process scoped and both models use it: the transform for its -fullscreen writes, the render engine for `MagShowSystemCursor`. Two independent -`MagInitialize`/`MagUninitialize` pairs silently break each other, and both failure modes were -hit in the field (comment in `src/mag_host.h`): - -- **Two cursors**: the transform's idle release ran `MagUninitialize` while the render engine - still needed the runtime for cursor hiding, so the real pointer reappeared beside the drawn one. -- **Writes return FALSE**: the render engine's teardown killed the transform's context, so every - subsequent transform write failed while the tick loop stayed healthy. The symptom is a - magnifier that "stops zooming" while the cursor still moves. - -The law: never call `MagInitialize`/`MagUninitialize` directly. Everything goes through -`wind::MagApiAcquire()`/`MagApiRelease()` (`src/mag_host.cpp`), a process-wide refcount that keeps -the runtime alive while any holder needs it and releases it exactly when the last one lets go. - -Two further properties of the runtime shape the whole engine: - -- **Thread affinity.** The thread that calls `MagInitialize` is the only thread whose - Magnification calls do anything; a foreign-thread write returns FALSE and changes nothing - (measured, `src/mag_thread.h`). All entry points marshal to the owning thread via - `MagThreadInvoke`, which runs inline when the caller already owns the runtime or when no owner - was claimed. Ownership normally stays on the tick thread; it moves to the input hook thread only - when `txHookWrite=1` asks for hook writes (see the last section). -- **The live-context tax.** While a magnification context exists, DWM composites - magnification-aware and every cursor visibility or shape change any app makes costs a - re-composite. A game that toggles its pointer on middle-click hitches even at 1x (Foundation: - measured 17 spike frames per 14 clicks with a live context, 0 without). Writing level 1.0 does - not leave the mode; only releasing the runtime does. This is why the context lives only around - real zoom sessions and why there is no warm-up write at launch. Note: the CLAUDE.md transform - block still mentions a "launch warm-up 1.001"; the code removed it - (`TransformModel::initialize` comment, harness-measured: 24 spike frames with the warm-up, 0 - without), and the code wins. +The transform engine (`src/transform_model.*`) asks DWM to magnify the desktop: one fullscreen +scale-and-translate applied inside the compositor, the mechanism the built-in Magnifier uses. +There is no capture, no swapchain and no Wind window in the composition path, so a game keeps its +independent-flip presentation and Wind's per-frame cost is a sub-millisecond API write. Measured +against the built-in Magnifier over acrylic windows, Wind is at least as smooth on every protocol +tried ([../PERF-ACRYLIC-PARITY-2026-08-21.md](../PERF-ACRYLIC-PARITY-2026-08-21.md)). + +Auto picks it for games and protected video, and on the desktop when `desktopTransform=1` (the +default) and the input transform is available ([03](03-engines.md)). The cost is that the +machinery is shared, global and partly undocumented; most of this chapter is the guardrails. + +## The Magnification runtime + +**Never call `MagInitialize`/`MagUninitialize` directly.** The runtime is process-scoped and both +engines use it (transform: the fullscreen transform; render: `MagShowSystemCursor`). Use +`wind::MagApiAcquire()`/`MagApiRelease()` (`src/mag_host.cpp`), a process-wide refcount. Two +independent pairs break each other: + +- The transform's idle release killed render's cursor hiding: two cursors. +- Render's teardown killed the transform context: every write returns FALSE and the view stops + zooming while the cursor still moves. + +Holds must be symmetric: take one when you need it, drop it the moment you stop. + +**A live context taxes every cursor change any app makes.** While a magnification context exists, +DWM composites magnification-aware, and each cursor visibility or shape change costs a +re-composite. Measured in a game that toggles its pointer on middle-click: 17 spike frames per 14 +clicks with a live context, 0 without. Writing level 1.0 does not leave this mode; only releasing +the runtime does. So the context lives only around real sessions, and there is no warm-up write at +launch (24 spike frames with one, 0 without). Colour filters hold the runtime at 1x and pay this +tax ([04](04-render-engine.md)). + +**Calls are thread-affine.** Only the thread that called `MagInitialize` can drive the transform; +a write from another thread returns FALSE and changes nothing (`src/mag_thread.h`). Every entry +point goes through `MagThreadInvoke`, which runs inline when the caller owns the runtime or when no +owner was claimed. Ownership is decided once at startup and stays on the tick thread unless +`txHookWrite=1` (see [Hook writes](#hook-writes-not-shipped)). ## Write channels -`MagHost::setTransformOwned` (`src/mag_host.cpp`) knows two channels for the same DWM state: +`MagHost::setTransformOwned` knows two channels for the same DWM state: -- **Private**: `SetMagnificationDesktopMagnification` (user32, resolved by name at init). It takes - a screen-space translation `(zoom, tx, ty)` where `tx = -srcLeft * level`, so it pans level - times more finely than the public API. At high zoom this is the difference between sub-pixel - drift moving the view ~1px per frame and stalling entirely. `fastPan=1` (the default) uses it. -- **Public**: `MagSetFullscreenTransform(zoom, offX, offY)` with whole source pixels, the - documented fallback. +| Channel | Call | Notes | +|---|---|---| +| Private (`fastPan=1`, default) | `SetMagnificationDesktopMagnification(zoom, tx, ty)`, resolved by name | Screen-space translation, `level` times finer than the public offset. At high zoom it is the difference between smooth drift and a stalled view. | +| Public (fallback) | `MagSetFullscreenTransform(zoom, offX, offY)` | Whole source pixels. | -If a private write ever fails, `privateBroken_` latches and the session falls back to the public -channel permanently; the flag is re-probed on every re-init. A 16-bit-translation theory that the -public channel might be safer for large `|tx|` was tested and disproven: both channels crash -identically over the MPO bug below, so there is no channel guard. - -Both forms are computed together by the pure `ComputeMagTransform` (`src/transform.h/.cpp`) so -they always describe the same rect. +A failed private write latches `privateBroken_` and the session falls back to public until the next +init. Both forms come from the pure `ComputeMagTransform` (`src/transform.h`), so they always +describe the same rect. ## Write cadence -The level is applied straight, per tick, continuously. Big discrete level jumps are the expensive -pattern for DWM (each level change re-scales its cached surfaces, and the cost grows with the -level), while small continuous deltas are cheap; the old quantization and ramp-divisor machinery -created exactly the costly jumps and was removed after A/B (comments in -`TransformModel::present`). Two measured-negative experiments survive as diagnostic knobs and -must stay 0: `txGrid` (geometric level ladder, much worse) and `txLevelStep` (minimum relative -change, no better). - -The one cadence lever that shipped is **`txMaxStepPct`** (default 25, i.e. 2.5% per tick, -`src/config.h`): a cap on the per-tick relative level change actually applied. The #219 -investigation (`../PERF-ACRYLIC-PARITY-2026-08-21.md`) found that ~15% of uncapped 15x zoom-ins -over acrylic stalled 35-43ms inside DWM and then snapped 1.2-1.9 levels at once; capped, 20/20 -ramps ran even with uniform 0.36-level steps and zero over-25ms compositor gaps, for ~35ms of -extra ramp time. Normal ramp ticks are 0.8-2.2% relative, so the cap only ever bites the -post-stall catch-up snap. Two hard-won details: - -- **Up-steps only.** Zoom-out measured clean uncapped, and a DOWN clamp anchored on `lastLevel_` - caused the round-2 session-start bounce: the zoom-out trailed the controller under the cap, the - identity park wrote 1.0 without updating the cache, and the next quick re-zoom's first writes - were dragged backward toward the stale anchor (rig-reproduced 4/4). -- **The park syncs the cache.** `setActive(false)` writes identity outside `writeTransform`, so it - explicitly sets `lastLevel_ = 1.0`; forgetting that is the same bounce from the other side. - -When the applied level trails the requested one, the source rect is recomputed for what is -actually applied (`ComputeOffsetF` on `applyLevel`), so geometry and level never disagree. - -Around the level logic sits `ShouldWriteTransform` (`src/tx_cadence.h`, pure and unit-tested in -`tests/test_tx_cadence.cpp`), built from a trace of native Magnifier's write pattern (issue #204: -native writes ~50-60/s in ~2.24px steps; Wind wrote ~92-120/s in 1.41px steps because it wrote -every tick on a 144Hz panel). Its gates, an optional write-rate cap (`txWriteHz`) and a minimum -destination-space pan step (`txMinOffsetPx`), coalesce writes without ever dropping a destination -state: a 100ms settle escape flushes any held residual, and a stopped ramp's final level always -lands exactly. Both knobs ship 0 though, because the field verdict on throttling was -unambiguous: Wind welds the cursor per tick, so throttling the view while the pointer moves at -full rate desynchronizes the two ("super jumpy, not centering"). Per-tick writing is load-bearing -for the welded design; native can afford ~50Hz precisely because it does not weld. The pure gate -stays because the measurement was sound even though the throttle conclusion was not. - -Two smaller cadence mechanisms: - -- **Warm-keeping** (`txWarmMode`, pure gate `WarmAction` in `src/tx_warm.h`). DWM's magnification - re-render path goes cold when the sampled source region sits still, and the first real move - after a pause pays ~25ms (the pan-start hitch, `../HITCH-FINDINGS.md`). Mode 1 (shipped) - displaces the private-channel translation by 1px and returns it, which is a real source change - and is what keeps the path warm; a sub-pixel level nudge (mode 4) passes every composition-rate - metric and still hitches. **Every warm write is a full re-render**, so its cadence is the GPU - cost of a zoomed view at rest: per-tick warming measured 16.1% dwm.exe GPU with the mouse still - against 0.0% with warming off and 0.2% for native Magnifier (`tools/gpu_ab.ps1`, controlled - solid target, issue #246), which was the whole reported GPU gap - panning costs both magnifiers - the same order (Wind 16%, native 12%). `txWarmHz` therefore makes the warm write a **pulse**: - one displacement plus its return per period, nothing between; 0 = every tick, 12 ships - (rest cost 4.6% against 9-16% per tick; the hitch never reproduces on the desktop, so the - floor is a game verdict). An open pulse - always closes before any other gate (period, `txWarmMaxLevel`, `txWarmWindowMs`) can apply, so - the view is never stranded 1px off. `txWarmWindowMs` bounds how long after the last real change - warming continues at all (0 = as long as the session rests). -- **Same-value hygiene.** Once the keep-alive window lapses, a zoomed-idle tick writes nothing at - all; DWM parks on static values anyway, and 144 identical writes per second bought nothing. - -Finally, `ex.pauseWrites` skips the whole write block for ~3 ticks around an Inspect-mode -injected click: a transform write racing an injected cursor-position update is a proven TDR -trigger (issue #148). State is untouched, so the next unpaused tick lands the same values. +**Apply the level every tick, continuously.** Large discrete level jumps are what cost DWM (each +re-scales its cached surfaces); small continuous deltas are cheap. Do not re-quantize ramps. +`txGrid` and `txLevelStep` remain as diagnostic knobs and must stay 0. + +**`txMaxStepPct`** (default 25, i.e. 2.5% per tick) caps the per-tick relative level change. +Uncapped, ~15% of 15x zoom-ins over acrylic stalled 35–43 ms in DWM and then snapped 1.2–1.9 +levels; capped, 20 of 20 ran even. Normal ramp ticks are 0.8–2.2%, so the cap only bites the +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. +- When the applied level trails the requested one, the source rect is recomputed for the applied + level, so geometry and level never disagree. + +**Do not throttle the view.** `ShouldWriteTransform` (`src/tx_cadence.h`, unit-tested) can cap the +write rate (`txWriteHz`) and the minimum pan step (`txMinOffsetPx`) the way the built-in Magnifier +paces itself (~50–60 writes/s). Both ship 0: Wind welds the cursor every tick, so a throttled view +desynchronizes from the pointer (2 px steps wobble at low zoom; 60 Hz looks like low fps at high +zoom). Gating the cursor sprite on "the view moved" froze the cursor in the edge zones. + +**Warm-keeping** (`txWarmMode=1`, pure gate in `src/tx_warm.h`). DWM's magnification re-render goes +cold when the source rect sits still, and the first real pan after a pause pays ~25 ms (the +pan-start hitch). A 1 px translation displacement and return is a real source change and keeps it +warm. A sub-pixel level nudge passes every composition-rate metric and still hitches, so do not +trust composition-rate metrics here. + +- Every warm write is a full DWM re-render. Per-tick warming cost 16% dwm.exe GPU with the mouse + still (the built-in Magnifier: 0.2%). So `txWarmHz` (default 12) makes it a pulse: one + displacement plus return per period, 4.6% at rest. 0 means every tick. +- An open pulse always closes before any other gate applies, so the view is never left 1 px off. +- The hitch reproduces only in games; `tools/warm_cadence_sweep.ps1` and `tools/gpu_ab.ps1` measure + the cadence trade-off. Details: [../HITCH-FINDINGS.md](../HITCH-FINDINGS.md). + +Once warming lapses, a zoomed idle tick writes nothing. `ex.pauseWrites` skips writes for ~3 ticks +around an Inspect injected click; a write racing an injected cursor move is a TDR trigger. ## Session lifecycle -The context is created lazily at zoom-in and torn down in two phases after zoom-out, balancing -the ~36ms context build against the live-context tax. - -**Transform session lifecycle: context creation, identity park, and delayed release.** - ```mermaid stateDiagram-v2 [*] --> Idle - Idle: Idle, no context\nDWM composites normally - Active: Active session\ncontext live, per-tick writes - Parked: Identity parked\ncontext still live, countdown running - Idle --> Active: zoom-in\nsetActive(true) + ensureMag() - Active --> Parked: zoom-out\nsetActive(false) writes identity,\ndisables input transform - Parked --> Active: re-zoom before timeout\n(no context rebuild) - Parked --> Idle: txIdleReleaseMs elapsed\nidleTick() runs teardownMag() - Active --> Idle: shutdown / model swap\nteardownMag() + Idle: Idle, no context + Active: Active, context live, per-tick writes + Parked: Identity parked, context live, countdown + Idle --> Active: zoom-in + Active --> Parked: zoom-out writes identity, disables input transform + Parked --> Active: re-zoom before timeout + Parked --> Idle: txIdleReleaseMs elapsed, teardownMag + Active --> Idle: shutdown or model swap ``` -The details that matter, all in `src/transform_model.cpp`: - -- `setActive(true)` pre-blanks the system cursor set BEFORE creating the context (the blanker - swaps 14 cursors, and under a live context each swap pays the re-composite tax; running the - burst inside the fresh context stacked ~14 taxed swaps onto the ~36ms build). It also stands - the sprite up on the pointer with two `DwmFlush` passes so the blank-to-sprite handoff overlaps - instead of blinking (issue #221). -- `setActive(false)` parks DWM at exact identity immediately, at the end of the zoom-out. - Returning to identity costs a ~150ms compositor stall whenever it happens (measured), so it is - paid while the user is still in zoom motion and expects movement, not 1.2s later mid-game. The - input transform is disabled in the same breath, and the stomp-guard expectation is kept valid - across the idle (see below). -- `idleTick()` releases the context (`teardownMag`, 1-2ms) once `txIdleReleaseMs` (default - 1200ms, hot-reloadable) has passed with no new session. The window is long enough that - zoom-out/zoom-in flicks skip the rebuild, short enough that going back to playing is clean - almost at once. -- `teardownMag` undoes cursor state FIRST: `MagShowSystemCursor(TRUE)` needs a live context, so - doing it after `MagUninitialize` would silently strand the pointer hidden. - `resetTransformState()` then forgets every cached write value; comparing against values DWM no - longer holds would make the next session skip the writes that re-apply them. - -## Clamping: the TDR class - -`ComputeMagTransform` clamps both channel forms strictly inside the desktop with a 2px -right/bottom margin. This is not tidiness: the mapper clamps the FLOAT source to -`maxX = w - w/level`, fractional at any mid-ramp level, and naive rounding can push the integer -offset (or the private translation) past it so the magnified source rect samples outside the -desktop texture. Field-confirmed GPU driver reset (TDR), always at the right or bottom edge. The -margin specifically covers the exact-level-cap case where a bare floor still lets the rect end -exactly at the texture edge and the driver's filter neighborhood walks off it. Do not simplify -the clamp or the margin away; the comment block in `src/transform.h` is the contract. - -## Clamping: the edge sampling margin - -The clamp above is one-sided because only the right/bottom can OVERSHOOT: the left/top clamp to -exact 0 and cannot. But 0 is not a safe source origin either. DWM's NEAREST magnification path -resolves each destination column to a source texel around a half-texel offset, so at source left -EXACTLY 0 the leftmost destination columns resolve below texel 0, outside the desktop texture, -and DWM fills them with an undefined light-grey border: a vertical line roughly `level/2` px wide -down the screen's left edge, and its horizontal twin along the top. It appears only once the view -is parked against that boundary, which is why it reads as "a line that shows up when you stop -zooming". Native Magnifier never shows it because it samples SMOOTH by default and that filter -clamps to edge; our shipped `txSamplingMode=0` is nearest, which does not. - -`SrcEdgeFloor` holds the source rect `txEdgeMargin` texels inside the texture on the LOW side too -(default 1 source px; 0 restores the old behaviour for an A/B). One formula, three consumers, so -they can never describe different rects: `ComputeMagTransform` applies it to both channel forms, -`TransformModel::present` applies it to `srcL/srcT` before the input-transform publish (a visual -rect one texel inside a published rect that was not would put the pointer framework's hover -hit-test one source pixel - `level` screen px - off along that edge), and the hook writer takes it -through `HookTransformState::edgeMargin`. It resolves to 0 wherever there is no headroom for it, -so the identity transform at rest stays exactly identity and the floor can never cross the -right/bottom wall above. - -## The MPO 16-bit overflow, pan walls, and the ghost - -The final root cause of issue #148's crashes over real games: when a game surface rides an NVIDIA -hardware overlay plane (MPO), the driver packs DWM's magnification translation into a 16-bit -field. `|srcX * level| > 32767` (the far-right strip above ~9.3x on a 3840 panel) wraps and TDRs, -through both API channels, only over real games. With MPO disabled -(`HKLM\SOFTWARE\Microsoft\Windows\Dwm\OverlayTestMode=5`, reboot) the identical writes are clean -at full range. - -Wind layers three defenses (boot-state read and wall arming in `src/main.cpp`, write-site clamp -in `TransformModel::present`): - -1. **Pan wall.** With MPO enabled, the mapper's `setMaxSourceLeft` bounds transform GAME sessions - to `srcX * level <= 32000`. Keyed to the session type (transform plus borderless cover), never - to cursor state. The registry is read once at startup and the BOOT state governs until reboot, - because DWM itself reads the key only at boot. -2. **MpoGhost buster** (`mpoBuster=1`, issue #191). A fullscreen alpha-1 click-through ghost - window shown during MPO-exposed transform sessions demotes the covered surface off its - hardware overlay plane; off the plane there is no 16-bit field to overflow, so the walls LIFT - and full zoom range works without a registry edit. Fail-closed: the walls lift only while - `MpoGhost::settled()` verifiably holds. -3. **Write-site backstop.** When the session is MPO-exposed and the ghost is not settled, the - write path clamps `|tx| <= 32000` structurally and recomputes the offsets to match, because - the mapper walls divide by the controller level while the write uses the step-capped - `applyLevel`, and that drift would otherwise spend the headroom on faith. +- `setActive(true)` blanks the system cursors **before** creating the context: each of the 14 + swaps pays the live-context tax otherwise. It stands the sprite up with two `DwmFlush` passes so + blank-to-sprite does not blink. +- `setActive(false)` parks DWM at identity at once. Returning to identity costs a ~150 ms + compositor stall, so it is paid during the zoom-out motion, not seconds later in a game. +- `idleTick()` releases the context once `txIdleReleaseMs` (default 1200, hot) passes, long enough + that quick zoom flicks skip the ~36 ms rebuild. +- `teardownMag` restores cursor state **first** (`MagShowSystemCursor(TRUE)` needs a live context), + then `resetTransformState()` forgets every cached value, so the next session does not skip writes + DWM no longer holds. + +## Clamping + +**Right and bottom: a 2 px margin, or TDR.** The mapper clamps the float source to +`w - w/level`, fractional mid-ramp, and rounding can push the integer offset or private translation +past it, so the rect samples outside the desktop texture: a GPU driver reset, always at the right +or bottom edge. `ComputeMagTransform` clamps both forms with a 2 px margin. The comment block in +`src/transform.h` is the contract. + +**Left and top: one texel inside.** DWM's nearest path samples around a half-texel offset, so at +source 0 the first columns read outside the texture and show a light-grey line about `level/2` px +wide along the left (and top) edge once the view rests there. `SrcEdgeFloor` keeps the rect +`txEdgeMargin` texels inside (default 1; 0 for A/B). One formula feeds `ComputeMagTransform`, the +input-transform publish and the hook writer, so they never describe different rects. It resolves +to 0 where there is no headroom, so identity stays identity. + +## The MPO 16-bit overflow + +With MPO enabled, the NVIDIA driver packs DWM's magnification translation into a 16-bit field on +the **nearest-sampling** path. `|srcX * level| > 32767` (the far-right strip above ~9.3x at 3840 +wide; the bottom strip above ~16.2x at 2160 high) wraps and resets the driver, through both +channels and on the plain desktop too. With MPO disabled +(`HKLM\SOFTWARE\Microsoft\Windows\Dwm\OverlayTestMode=5`, reboot) the same writes are clean. +Smooth sampling takes a float path and survives the same corner, as does the built-in Magnifier, +which samples smooth. + +Defences (wall arming in `RunTick`, write clamp in `TransformModel::present`): + +| MPO at boot | Sampling | Pan walls (`setMaxSourceLeft/Top`, `|src*level| <= 32000`) | +|---|---|---| +| On | Nearest | Always | +| On | Smooth | Lifted while the MPO ghost (`mpoBuster=1`) is shown and settled; fail-closed | +| Off | Either | None | + +- The registry is read once at startup; the boot state governs until reboot, because DWM reads it + only at boot (`src/mpo_boot.h`). +- The ghost is a fullscreen alpha-1 click-through window that demotes surfaces off the overlay + plane. +- A write-site clamp backs the walls up when the session is exposed and the ghost is not settled, + because the walls divide by the controller level while the write uses the step-capped level. +- Settings couples the two: the **High resolution cursor** option sets smooth sampling and stages + MPO re-enable; turning it off sets nearest and stages MPO-disable, both applied at the restart. + Nearest with MPO on is never offered (`EffectiveSamplingMode` keeps the boot state's mode until + the reboot lands). +- `tdrTest` is the field harness: 2 probes the clamp, 4 lifts the wall. ## Bitmap smoothing -DWM magnifies with nearest neighbor unless something calls -`MagSetFullscreenUseBitmapSmoothing`, Magnification.dll ordinal 1, undocumented, resolved by -ordinal in `MagHost::initialize`. Turning it on looks dramatically better and crashes dwm.exe in -dwmcore.dll over complex Mica/acrylic geometry at high zoom (two first-try reproductions, -issue #197). The extra kernel-accepted modes 2-4 were field-tested and render identically to -nearest, so there is no cheaper middle filter; and the raw user32 -`SetMagnificationDesktopSamplingMode` takes a DWORD POINTER, a by-value call access-violates -(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 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). +DWM magnifies with nearest neighbour unless something calls +`MagSetFullscreenUseBitmapSmoothing` (Magnification.dll ordinal 1, undocumented, resolved by +ordinal). `txSamplingMode`: 0 nearest (default), 1 smooth. + +- The flag is the whole quality gap to the built-in Magnifier, image and cursor alike. +- The raw user32 `SetMagnificationDesktopSamplingMode` takes a DWORD **pointer**; a by-value call + access-violates. +- Modes 2–4, which the kernel accepts, render as nearest. There is no middle filter. +- The flag is DWM-global and outlives the process that set it until DWM restarts, so a stale + smooth state can make a build look smooth that is not. The model re-applies its mode per context + with up to 3 retries; the setter's return value is unreliable. +- Under smooth, level ramps shimmer slightly (the filter re-interpolates each scale step); pans are + clean. Swapping to nearest during ramps shifted the image 1–2 px per swap and was rejected. +- Smoothing once crashed dwm.exe over Mica and acrylic at high zoom; it did not reproduce on a + newer driver. If dwm.exe crashes return, set `txSamplingMode=0` first. ## The input transform -Pointer-input frameworks (XAML/DirectUI: Explorer, Settings, the shell, Chromium) hit-test mouse -input through the system input transform under a fullscreen magnification. Without a correct -publish, the welded cursor has hard hover dead zones; MSDN's "pen/touch only" scoping is wrong -(`../POINTER-HITTEST-FINDINGS.md`). So every transform change publishes -`MagSetInputTransform(TRUE, srcRect, monitorRect)`, both rects in virtual-screen coordinates -(`ComputeInputTransformRects`, `src/transform.h`), exactly what native Magnifier does -continuously. Identity or no publish equals dead zones, measured; `magInputTransform=1` is the -shipped default and modes 0/2 are diagnostics that reproduce the bug. - -The publish needs UIAccess. Availability is probed at `initialize` by reading the process -token's `TokenUIAccess` bit directly, zero Magnification calls; the CLAUDE.md description of a -"teardown-shaped acquire/release" probe is outdated, and the code comment explicitly bans that -shape (a startup acquire/release runs an identity WRITE, violating the no-warm-up law and -resetting a running native Magnifier). A verified-failed enabled publish self-heals by clearing -`inputTransformAvailable_`, which stops the hybrid desktop pick from choosing the transform. - -Cost control: `ixDecimate` (default 4) publishes every Nth changed tick during motion, with a -guaranteed publish the moment motion rests, so a stationary aim is always exact. Hover -hit-testing does not need the 144Hz motion rate; clicks ride the welded cursor and never consult -the transform. - -**The stomp guard (issue #217, `../WOBBLE-CAPTURE-2026-08-21.md`).** The input transform is ONE -system-wide slot, and native Magnifier republishes an enabled IDENTITY into it continuously -while it runs, even sitting unzoomed at 100%; a dirty Magnifier exit strands its last rect there, -surviving Wind restarts and even a DWM restart. Wind's response, in `TransformModel::present`: - -- Every zoomed tick reads the slot back (`MagGetInputTransform`, ~0.1ms) and compares it against - what Wind last verifiably published (`InputTransformStomped`, pure, exact integer compare). A - mismatch forces a republish past the decimation and re-asserts `MagShowSystemCursor(FALSE)`, - because the same foreign writer owns that shared global too (the two-cursors gotcha). -- The expectation is kept valid across sessions on purpose: a fresh session's first tick catches - a rect stranded by a dead Magnifier and overwrites it immediately. One republish wins against a - corpse. -- Publish success is judged by read-back, never by the return value: on this rig - `MagSetInputTransform` can return FALSE while the publish demonstrably lands. The first few - publishes per run log full ground truth (`ixdiag`: set return, GetLastError, immediate - read-back). - -The war conclusions, so nobody re-fights them: against a LIVE native Magnifier the publish war -is unwinnable (wm rewrites the slot faster than once per tick; the guard measured 144 lost races -per second), and the wobble Max reported turned out to be sprite move latency under wm's second -magnification context anyway, not the input transform itself. The guard earns its keep as -stale-corpse healing and reliable foreign-writer DETECTION; the recommended (parked) follow-up is -auto-picking the render engine while a foreign magnifier is detected, since render is immune to -all shared-state stomps by construction. +**Every transform change publishes `MagSetInputTransform(TRUE, srcRect, monitorRect)`**, both in +virtual-screen coordinates (`ComputeInputTransformRects`). Pointer frameworks (XAML/DirectUI: +Explorer, Settings, the shell, Chromium) hit-test mouse input through it; without the publish the +welded cursor has hover dead zones. Identity or no publish both produce dead zones. +`magInputTransform=1` is the default; 0 and 2 are diagnostics. Evidence: +[../POINTER-HITTEST-FINDINGS.md](../POINTER-HITTEST-FINDINGS.md). + +- **It needs UIAccess.** Availability is read from the process token's `TokenUIAccess` bit at + `initialize`, with zero Magnification calls; an acquire/release probe would run an identity + write. A verified-failed publish clears `inputTransformAvailable_`, which stops Auto picking + Transform for the desktop. +- `ixDecimate` (default 4) publishes every Nth changed tick during motion, and always when motion + rests. Clicks ride the welded cursor and never consult the transform. + +**Stomp guard.** The input transform is one system-wide slot. A running Magnify.exe republishes an +enabled identity into it continuously, and a dirty Magnifier exit leaves its last rect there, +surviving Wind restarts and DWM restarts. + +- Every zoomed tick reads the slot back (`MagGetInputTransform`, ~0.1 ms) and compares it with + what Wind last published (`InputTransformStomped`, exact). A mismatch forces a republish and + re-asserts `MagShowSystemCursor(FALSE)`, which the same foreign writer resets. +- The expectation survives across sessions, so a fresh session overwrites a stranded rect at once. +- Success is judged by read-back, not the return value, which can be FALSE when the publish landed. +- Against a running Magnify.exe the guard loses every race; it detects and heals, it cannot win. + Picking Render while a foreign magnifier runs is parked work. See + [../NATIVE-MAGNIFIER-STOMP.md](../NATIVE-MAGNIFIER-STOMP.md). ## Launch quiesce -A freshly launched game building its presentation surfaces while DWM services magnification -mutations is a dwmcore APPCRASH (RDR2 at 20x, issue #199). When a young process -(younger than 60s) newly covers the monitor borderless, Wind holds transform writes, the weld, -and the zoom ramp for ~1.5s (`TrackLaunchCover`/`QuiesceHoldActive` in `src/main.cpp`; the ramp -freeze matters because a silently-ramping level would snap the view when the hold lifts). - -The arm decision is the pure `ShouldArmLaunchQuiesce` (`src/launch_quiesce.h`), and its veto is -the interesting part: a window that is layered, click-through, tool, non-activating, or opted out -of a redirection bitmap is a composition-only overlay, not something that presents its own -frames, so it never has the churn the hold sits out. Without the veto, the Snipping Tool capture -overlay (`ScreenClippingHost.exe`: fullscreen, borderless, `WS_EX_NOREDIRECTIONBITMAP`) armed -the full hold on every snip while zoomed, freezing the view and deadening the zoom keys for 1.5s -(log-proven). `WS_EX_TOPMOST` is deliberately not in the veto set: fullscreen games set it. - -## Hook writes: the single-writer design that shipped off - -Issue #206 attacked cursor-to-view latency: tick-paced writes measured 4.36ms median (a uniform -spread across exactly one 144Hz tick) against native's 0.58ms, because native writes from inside -its `WH_MOUSE_LL` callback. Stage 1 made runtime ownership movable to the hook thread -(`src/mag_thread.h`); stage 2 (`src/hook_transform.*`) lets `MouseProc` write the transform -inline from the event's own coordinates, under a strict SINGLE WRITER contract: while armed, the -hook owns position writes completely and the tick thread routes its writes (level ramps, idle -motion) through the same function, one formula, one code path. Two writers sampling the cursor at -different instants is exactly the wobble class issue #205 eliminated, and the hook path is only -safe at all because #205 made the view a pure function of the cursor (`txFreeCursor=1`), with no -mapper state to race. - -It hit its metric (0.37ms median, better than native) and still shipped OFF (`txHookWrite=0`). -The field verdict and its explanation are preserved in `src/config.h`: writing per mouse event, -434-685/s against a 144Hz compositor, rewrote the view 4-5 times per displayed frame, so content -and the DWM-sampled cursor came from different instants and the cursor swam. The lesson worth -keeping: time-to-write is not the metric, frame coherence is. Do not re-enable without bounding -writes to one per composited frame with content and cursor sampled at the same instant. With the -knob off, ownership stays on the tick thread and every call runs inline, exactly the pre-#206 -behavior; the choice is made at startup and cannot move afterward (thread affinity). - -## Pointers - -- `src/transform_model.h/.cpp`: the engine; session lifecycle, write path, sprite, stomp guard. -- `src/transform.h/.cpp`: pure math; `ComputeMagTransform` clamping, input-transform rects, - `InputTransformStomped`. -- `src/tx_cadence.h` + `tests/test_tx_cadence.cpp`: the pure write-cadence gate. -- `src/mag_host.h/.cpp`, `src/mag_thread.h`: the shared runtime refcount, channels, marshalling. -- `src/hook_transform.h/.cpp`: the parked hook-write path. -- `src/launch_quiesce.h`: the quiesce arm predicate; use sites in `src/main.cpp`. -- Evidence: [PERF-ACRYLIC-PARITY-2026-08-21](../PERF-ACRYLIC-PARITY-2026-08-21.md), - [WOBBLE-CAPTURE-2026-08-21](../WOBBLE-CAPTURE-2026-08-21.md), - [POINTER-HITTEST-FINDINGS](../POINTER-HITTEST-FINDINGS.md), - [HITCH-FINDINGS](../HITCH-FINDINGS.md). -- Related chapters: [Engines and the hybrid pick](03-engines.md), - [The render engine](04-render-engine.md), [The cursor system](07-cursor.md), - [Instrumentation and field method](12-instrumentation.md). +A newly launched game building its surfaces while DWM services magnification writes crashes +dwmcore (RDR2 at 20x, issue #199). When a process younger than 60 s newly covers the monitor +borderless, Wind holds transform writes, the weld and the zoom ramp for ~1.5 s +(`TrackLaunchCover`/`QuiesceHoldActive`). + +The pure `ShouldArmLaunchQuiesce` (`src/launch_quiesce.h`) vetoes composition-only overlays: +layered, click-through, tool, non-activating or no-redirection-bitmap windows. Without the veto the +Snipping Tool overlay froze the view for 1.5 s on every snip. `WS_EX_TOPMOST` is not in the veto +set, because games set it. `launchQuiesce=0` disables the hold; it is a test knob only. + +## Hook writes (not shipped) + +`txHookWrite=1` (restart) moves runtime ownership to the hook thread and lets `MouseProc` write the +transform from each mouse event (`src/hook_transform.*`), under a single-writer contract: while +armed the hook owns position writes and the tick routes ramps through the same function. It +reached 0.37 ms cursor-to-write latency but ships off: writing 434–685 times a second against a +144 Hz compositor rewrote the view 4–5 times per frame and the cursor swam. Frame coherence, not +time-to-write, is the metric. Do not enable it without one write per composited frame. diff --git a/docs/architecture/06-input.md b/docs/architecture/06-input.md index c6a9d516..fdd9f226 100644 --- a/docs/architecture/06-input.md +++ b/docs/architecture/06-input.md @@ -1,285 +1,142 @@ # 06. The input pipeline -Wind never has keyboard focus and never owns a visible interactive window, yet it must see every -zoom button press, every bound key, and every mouse movement, system-wide, at microsecond latency, -without breaking input for any other program. This chapter walks through the three channels input -arrives on (low-level hooks, Raw Input, and `RegisterHotKey`), the dedicated thread that services -the hooks, the swallowing rules that keep bound keys out of other apps without ever stranding a -key, and the layered safety nets that recover from Windows silently killing a hook. The code is -`src/input_router.h/.cpp`, the `WM_INPUT` handling in `src/main.cpp`, and the pure ballistics in -`src/mouse_ballistics.h/.cpp`. +Wind never has keyboard focus, yet it must see every bind press and every mouse movement +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.*`. -## Three channels, three reasons +## Channels -Windows offers several ways to observe global input, and Wind uses three of them because no single -one covers all the requirements: - -| Channel | What Wind uses it for | Why this channel | +| Channel | Used for | Why this channel | |---|---|---| -| `WH_MOUSE_LL` hook (`MouseProc`, src/input_router.cpp) | Zoom side-buttons (XBUTTON1/2), Inspect-mode click interception, and the issue #206 inline transform write | The only user-mode way to *swallow* a mouse button so other apps never see it, and the earliest point a mouse event is observable (native Magnifier writes its transform from the same place) | -| `WH_KEYBOARD_LL` hook (`KbProc`, src/input_router.cpp) | Keyboard zoom/recenter/cursorLock binds: tracking their physical down-state and swallowing them | Same swallowing property for keys; also the *authority* for bound-key state, because a swallowed key never appears in `GetAsyncKeyState` | -| Raw Input (`RIDEV_INPUTSINK` registration in `wWinMain`, `WM_INPUT` decode in `WndProc`, both src/main.cpp) | HID mouse deltas ("mickeys") for panning under a game lock and in Inspect mode, plus UP-event safety nets for both hooks | Delivered at HID level, so it is unaffected by `ClipCursor`, `SetCursorPos`, or `ShowCursor`, and it is not subject to the hook timeout that Windows uses to evict slow hooks | -| `RegisterHotKey` (RegisterHideCursorHotkey / RegisterQuickZoomHotkey, src/main.cpp) | The hide-cursor toggle, hotkey-mode quick zoom, and the global Ctrl+Alt+Q quit | The OS suppresses a registered hotkey from other apps for free, no hook logic needed; these are edge-triggered toggles, not held keys, so hotkey semantics fit exactly | - -The Raw Input channel deserves emphasis because a core product behavior hangs on it: **the lens -must keep moving when a game locks the cursor**. A mouselook game confines the pointer with -`ClipCursor` or recenters it with `SetCursorPos` every frame, so `GetCursorPos` deltas read as -zero. HID mickeys keep arriving regardless, so `LockDetector` (src/lock_detector.h) uses the -raw stream both to *detect* the lock (raw activity while the cursor is frozen, or a confining -clip per `ClipRectConfines`) and to *pan* through it. Do not simplify this away; it is the -lens-must-move-when-locked dependency called out in CLAUDE.md. The recent additions ride the same -detector: `lockApps` (issue #221, `Config::lockApps` in src/config.h) forces the locked regime -outright for listed exes with no heuristics, and `warpLock` (`Config::warpLock`, -`LockDetector::warpLocked`) adds the warp-anchor tells for pointer-warping engines. - -## The dedicated hook thread - -Both LL hooks are installed by `HookThreadProc` (src/input_router.cpp), a thread that does nothing -except pump messages. This is not an optimization, it is a correctness requirement twice over: - -1. **Windows services a low-level hook on the thread that installed it**, holding each input - event until that thread's message loop responds. On the main thread the hook was starved - behind the per-frame render/pacing block, batching all *system* mouse input by a frame and - producing in-game microstutter for every process. The dedicated thread's `GetMessage` loop - services every event instantly. -2. **A callback that misses `LowLevelHooksTimeout` gets the hook silently evicted** (see the - watchdog section below). The callbacks are atomics-only, but they cannot run at all while the - thread is descheduled, so `HookThreadProc` raises itself to `THREAD_PRIORITY_TIME_CRITICAL`. - That is safe precisely because the thread does no other work: it can never starve anything, - and a late hook thread delays input for the whole machine, not just Wind. - -Since issue #206 this thread also owns the Magnification runtime (`MagThreadClaim` in -`HookThreadProc`; the API is thread-affine, writes from any other thread return FALSE). That -ownership is what enables the inline transform write: `MouseProc` checks -`wind::HookTransformArmed()` first thing on every `WM_MOUSEMOVE` and, for a free-cursor transform -session, calls `wind::WriteHookTransformFromEvent` with the position the event itself carries -(src/hook_transform.h). Measured motivation: cursor-to-view latency was 4.36 ms median waiting for -the next tick versus native Magnifier's 0.58 ms, and the private write channel costs 0.09-0.24 ms, -cheap enough for a hook callback. The public channel at 3-9 ms must never be routed there. The -hook is the *single writer* while armed; the tick thread triggers writes through the same function -(two writers sampling the cursor at different instants was exactly the wobble issue #205 removed, -captured in [WOBBLE-CAPTURE-2026-08-21](../WOBBLE-CAPTURE-2026-08-21.md)). This claim is gated on -`txHookWrite` (`SetMagThreadClaimEnabled` in `wWinMain`): with nothing writing from the hook, -owning the runtime there would only marshal ~288 tick-thread calls per second across threads for -no benefit. - -Everything the hook callbacks touch is atomics on `InputState` / `InputRouter` (src/input_router.h), -never I/O and never allocation. State flows one way: hooks and `WM_INPUT` write atomics, the tick -thread drains them (`drainRaw`, `drainCooked`, `keyPressed`, the held flags). - -## One mouse move, one keypress - -**One mouse movement through the system (transform session, free cursor).** - -```mermaid -flowchart TD - HW[Mouse HID packet] --> OS[Windows raw input thread] - OS --> HOOK["Hook thread: MouseProc (WH_MOUSE_LL)"] - OS --> RAW["Main thread: WM_INPUT (RIDEV_INPUTSINK)"] - HOOK --> ARM{"HookTransformArmed()?"} - ARM -- yes --> TX["WriteHookTransformFromEvent: inline DWM transform write, sub-ms"] - ARM -- no --> PASS[CallNextHookEx, event continues to the OS] - TX --> PASS - RAW --> ABS{"MOUSE_MOVE_ABSOLUTE?"} - ABS -- "no (relative mickeys)" --> ACC["AccumulateRaw: rawDx/rawDy atomics + cookPacket if Inspect"] - ABS -- "yes (injected click move)" --> DROP[ignored, protects the Inspect look point] - ACC --> TICK["Tick thread: drainRaw / drainCooked -> LockDetector, locked-regime pan, Inspect pan"] -``` - -Note that a single physical movement fans out to *both* paths. The hook path is latency-critical -and stateless; the Raw Input path feeds the accumulators the tick drains. In the normal free-cursor -render case neither path pans the view directly, the tick's `GetCursorPos` oracle does (see the -cursor chapter); the raw stream matters when the oracle is unusable (game lock, Inspect freeze). - -**One bound keypress through the system.** - -```mermaid -flowchart TD - KEY[Key down] --> OS[Windows raw input thread] - OS --> KB["Hook thread: KbProc (WH_KEYBOARD_LL)"] - OS --> RAWK["Main thread: WM_INPUT keyboard (UP events only used)"] - KB --> INJ{"LLKHF_INJECTED and ignoreInjectedKeys?"} - INJ -- yes --> NEXT[pass through untouched] - KB --> BOUND{"isBoundKey(vk)? (forbidden VKs excluded)"} - BOUND -- no --> NEXT - BOUND -- yes --> TRACK["g_kbPressed[vk]=true, noteHookKeyDown, g_kbSwallowedDown[vk]=true"] - TRACK --> EAT["return 1: focused app never sees it"] - RAWK --> NET["rawKeyUp on RI_KEY_BREAK: clears held state if the hook is dead/stalled"] - TICK["Tick thread: keyPressed(vk) when kbHookActive(), else GetAsyncKeyState"] --> ZOOM[zoom/recenter/Inspect logic] -``` - -The tick reads `keyPressed()` as the authority whenever `kbHookActive()`, because a swallowed key -by definition never shows up in `GetAsyncKeyState`. When the hook is absent (install failure, -`WIND_NOHOOK`, suspension, eviction) nothing swallows, so polling is correct again; the fallback -is automatic. - -## Swallowing: rules and guarantees - -Bound inputs are eaten so they never double-fire into the focused app (a zoom side-button that -also navigates the browser back is a bug). The rules, all in src/input_router.cpp: - -- **Side buttons** (`MouseProc`): a DOWN of a configured zoom button is swallowed and recorded in - `g_swallowedDown[id]`; an UP is swallowed *iff* that record is set (`exchange(false)`). -- **Keyboard binds** (`KbProc`): identical shape via `g_kbSwallowedDown[vk]`, covering zoom - in/out (primary and alternate), recenter, and the Inspect `cursorLockVk`. -- **Hide-cursor and hotkey-mode quick zoom**: not hook-swallowed at all; `RegisterHotKey` - suppresses them (see `RegisterHideCursorHotkey` / `RegisterQuickZoomHotkey` in src/main.cpp). - -The *balanced down/up* invariant is the load-bearing part: only an UP whose DOWN we swallowed may -be swallowed. Swallowing an UP the system saw the DOWN for leaves the key or button believed held -system-wide, which is exactly the historical stuck-side-button bug (issue #113, the diagnostic -counters in `InputState` date from it). The records are cleared on every remap (`setButtons`, -`setKeys`), because keybind capture rebinds mid-press, and teardown runs -`ReleaseSwallowedButtons` / `ReleaseSwallowedKeys`, which *synthesize* the missing UP via -`SendInput` for anything still recorded, so no exit path can strand an input. - -**Forbidden binds.** `IsForbiddenBindVk` (src/config.cpp) blocklists keys that would be -catastrophic to swallow system-wide: left/right click (VK 0x01/0x02), Backspace (0x08), and both -Windows keys (0x5B/0x5C). It is enforced at three independent sites, deliberately redundant so no -single path can leak one through: - -1. `ParseConfig` sanitizes forbidden VKs out of the ini (src/config.cpp, the `sanitizeVk` lambda), -2. `InputRouter::isBoundKey` refuses to track or swallow them even if one is stored, -3. the config UI's keybind capture refuses to record them in the first place. +| `WH_MOUSE_LL` (`MouseProc`) | Mouse button and click binds, wheel binds, Inspect click interception | The only user-mode way to swallow a mouse event | +| `WH_KEYBOARD_LL` (`KbProc`) | Keyboard binds: down-state and swallowing | Same for keys; also the authority for bound-key state, since a swallowed key never reaches `GetAsyncKeyState` | +| Raw Input (`RIDEV_INPUTSINK`, `WM_INPUT` in `WndProc`) | HID mouse deltas for locked and Inspect panning; UP-event safety nets | HID level: unaffected by `ClipCursor`, `SetCursorPos` and `ShowCursor`, and not subject to the hook timeout | +| `RegisterHotKey` | Hide pointer, hotkey-mode quick zoom, Ctrl+Alt+Q quit | The OS suppresses a registered hotkey from other apps; these are toggles, not held keys | + +**The view must keep moving when a game locks the cursor.** A mouselook game clips or recentres +the pointer every frame, so `GetCursorPos` deltas read zero. HID mickeys keep arriving, so +`LockDetector` uses the raw stream both to detect the lock and to pan through it. See +[07](07-cursor.md). + +## The hook thread + +Both LL hooks are installed by `HookThreadProc`, a thread that only pumps messages. + +- **Windows services an LL hook on the thread that installed it**, holding each input event until + that thread responds. On the main thread the hook waited behind the present, delaying all system + mouse input by a frame. +- **A callback that misses `LowLevelHooksTimeout` gets the hook evicted**, so the thread runs at + `THREAD_PRIORITY_TIME_CRITICAL`. That is safe because it does no other work. +- Callbacks touch only atomics on `InputState`/`InputRouter`: no I/O, no allocation. Hooks and + `WM_INPUT` write; the tick drains (`drainRaw`, `drainCooked`, `keyPressed`, the held flags). + +One mouse movement reaches both paths: the hook (latency-critical, stateless) and `WM_INPUT` +(accumulators for the tick). In a free desktop session neither pans the view; the tick's +`GetCursorPos` oracle does. The raw stream matters when the oracle is unusable (game lock, Inspect). + +## Swallowing + +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 (`setButtons`, `setKeys`), and teardown + runs `ReleaseSwallowedButtons`/`ReleaseSwallowedKeys`, which synthesize the missing UP. +- **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. +- Hide pointer and hotkey-mode quick zoom are suppressed by `RegisterHotKey`, not the hook. + +## Bind rules + +**One rule set for every bind:** `src/keybind_rules.h` (`CheckKeyBind`, `CheckWheelBind`, +`CheckClickBind`), mirrored in `ui/src/lib/keybindRules.js`. Both are tested against +`tests/fixtures/keybind_cases.txt`, so change both or the tests fail. + +- `ParseConfig` reads an unsafe bind as unbound, the UI refuses it with the reason, and the hook + never swallows an `IsForbiddenBindVk` key (left/right click, Backspace, the Windows keys). +- Refused: typing keys alone; Shift or AltGr plus a typing key (AltGr sends Ctrl+Alt, so + Ctrl+Alt + a typing key is refused); combos Windows reserves (Alt+F4, Win+L and similar). +- Button codes: 1/2 side buttons, 3/4/5 left/right/middle click. Clicks need modifiers, never Ctrl + or Shift alone. +- The most specific matching slot wins. +- `panKeysOn`, `hideCursorOn` and `cursorLockOn` (default 1, per profile) make `ParseConfig` read + that bind as unbound when 0, while the ini keeps the binding. + +## Wheel and pan binds + +**Wheel zoom.** Button codes 6 and 7 are wheel up and down in the zoom slots: one zoom step per +notch, swallowed only while the slot's modifiers are held, so plain scrolling is untouched. The +wheel may use Ctrl alone, because the notch is swallowed. A notch zooms as far as holding the bind +for 0.1 s. The old `zoomWheelMods` key is migrated into free slots once at start +(`MigrateIniFiles`) and stays honoured only when a direction has no free slot. + +**Keyboard panning** (`panLeftVk` .. `panDownVk` + mods, unbound by default). Pan slots match only +while the tick arms them (`setPanArmed`: zoomed, not Inspect, no mouselook lock), so at 1x the keys +reach apps. The swallow is decided once per press, and the tick pans only on presses the hook +swallowed (`keySwallowed`), as the `ViewOwner::Keys` detached view (`src/keyboard_pan.h`). ## Raw Input as the safety net -Both hooks have a Raw Input backstop for lost UP events, and both live in the `WM_INPUT` handler -in src/main.cpp: - -- **Keyboard** (issue #167): a `RI_KEY_BREAK` calls `InputRouter::rawKeyUp`. Raw Input is not - subject to `LowLevelHooksTimeout`, so it still delivers the UP that an evicted hook missed, the - case that otherwise strands a keyboard zoom bind as held forever (and, worse, hides the dead - hook from the watchdog, since the stale held bit masks the divergence tell). -- **Mouse** (issue #113): `RI_MOUSE_BUTTON_4/5_UP` calls `rawButtonUp`. UP only, so the net can - only ever *clear* held state, never set it, which makes it idempotent with the hook's own clear - and incapable of falsely holding a button. - -Both nets carry a reordering guard: `WM_INPUT` is drained up to a tick late, so a raw UP from a -fast release-press can arrive *after* the live hook already recorded the next press's DOWN. -`rawKeyUp`/`rawButtonUp` therefore skip the clear when the hook stamped a DOWN for that key or -button within the last ~30 ms (`noteHookKeyDown` / `noteHookButtonDown` recency stamps). Auto-repeat -keeps a real keyboard hold's stamp fresh; an evicted hook stops stamping, so the net still fires -when it is actually needed. - -DOWN edges stay hook-authoritative while the hook is active: `WM_INPUT` writes button-down state -only in the `!hookActive()` fallback, because both writing would race and double-count (the hook -swallows the legacy message but Raw Input still sees the transition). - -## Eviction, the watchdog, and noSwallowApps - -Windows **silently evicts** a low-level hook whose callback misses `LowLevelHooksTimeout` (300 ms -on the reporting machine). No error, no notification; the handle stays non-null and the callback -simply never fires again. A game's launch load spike deschedules even a time-critical hook thread -long enough to trigger this, which produced the issue #156 field signature: launch a heavily -modded game with Wind running and every keyboard bind goes dead while the mouse binds survive, -and rebinding looks like a broken ini hot-reload (the reload applied; the dead hook just never -reported the key). - -The watchdog lives in `RunTick` (src/main.cpp) and needs no extra bookkeeping because the live -hook's own swallowing *is* the tell: while the hook is alive, a bound key can never appear in -`GetAsyncKeyState`. So `GetAsyncKeyState` seeing a bound key held while `keyPressed()` says up, -sustained for a 250 ms dwell (`kKbHookDeadMs`, filtering the ordinary press-before-callback race), -means the hook is gone. Recovery is `InputRouter::requestKbHookReinstall`: it drops the authority -claim immediately (so the very next tick polls and the binds work again at once), releases any -swallowed-key records the dead hook can never release itself, and posts `kMsgSetKbHook` to the -hook thread, because a hook must be installed by the thread that pumps it. The magnify model is -excluded from the divergence test outright, since its deliberately unswallowed injected chords -would false-positive it. - -**noSwallowApps** (`Config::noSwallowApps`, applied in `RunTick`) exists because the keyboard -hook's *existence* taxes the system input pipeline: the raw input thread dispatches every -keystroke to the hooking thread and waits before delivering any further input, including mouse -movement to the foreground game. Holding a key in a game (auto-repeat, ~30/s) punches a stall -into the mouse stream on every repeat, the "panning is smooth until I hold a key" stutter. It is -not our callback (atomics-only) and not swallowing; an unbound key stalls identically, the stutter -vanished whenever Windows had evicted the hook, and native Magnifier exhibits the same stutter. -Since an LL hook cannot block the raw input a game reads anyway (next section), the hook is pure -cost there. When the user lists an exe in `noSwallowApps`, a ~10 Hz foreground probe calls -`InputRouter::setKeyboardHookWanted(false)` while that app is in front, which uninstalls the -keyboard hook on the hook thread and releases swallowed keys; binds keep working through the -polling fallback. Off by default: unconfigured, the hook stays installed everywhere and the check -is one string test per tick. Note the CLAUDE.md summary compresses this as "suspension over -fullscreen games"; the code honors the listed app whenever it is foreground, windowed or not -(the comment above `IsNoSwallowApp` in main.cpp is explicit). - -## What swallowing cannot do: raw-input games - -LL hooks intercept only the legacy/cooked input path (`WM_*` messages, `GetAsyncKeyState`) that -desktop apps and browsers consume. They **cannot block Raw Input**, and Raw Input is what most -games read, so a bound key or side-button still reaches a raw-input game no matter what the hook -returns. There is no user-mode API to suppress raw input to another process; the only reliable -fix is a kernel filter driver (Interception-class), which Wind deliberately does not use, both -for the no-driver design stance and the anti-cheat ban risk. This is confirmed behavior, not a -theory: swallowing works in normal apps and does not in raw-input games. The practical guidance -is to bind game keys/buttons you do not otherwise use. Game-Inspect (issue #144, -`ShouldGameInspect` in src/inspect_focus.h) sidesteps the limitation for Inspect mode only, by -stealing foreground so the game stops receiving raw input at all; that machinery is covered in -the Inspect discussion of the cursor chapter. - -## Injected input: keeping our own output out of our input - -Wind injects input in several places (magnify-model zoom chords, Inspect click commits, the -teardown UP synthesis), and every injection path is marked so it cannot feed back: - -- **Magnify model**: `setIgnoreInjectedKeys(true)` makes `KbProc` skip `LLKHF_INJECTED` events - entirely. The model drives Windows Magnifier by injecting Ctrl+Alt+wheel and chord keys, and - NumPad +/- are bindable zoom keys, so without the skip Wind would swallow its own injection - (starving Magnifier) *and* register it as a phantom zoom press, a feedback loop. It is off by - default so tools that inject keys, like AutoHotkey remaps, keep working under the other models. -- **Inspect clicks**: the tick's synthesized absolute click carries `LLMHF_INJECTED`, so - `MouseProc` passes it through, and its absolute move is dropped by the raw accumulator - (`WM_INPUT` ignores `MOUSE_MOVE_ABSOLUTE`), so the look point is not disturbed. - -## Inspect mode: click routing and ballistics cooking - -While Inspect is on, the real OS cursor is frozen elsewhere (a 1 px `ClipCursor`), so two special -input behaviors engage. - -**Click-to-look-point.** A real left/right press would land at the frozen pixel, not where the -crosshair aims. `MouseProc` swallows the press when `InputState::inspectActive` is set, records -the per-button DOWN in `g_commitDown` (per-button so a left+right chord cannot strand a stray UP), -and increments `commitLeft`/`commitRight`, counts rather than flags, so a fast double-click before -the tick drains is not lost. The tick fires a clean absolute click at the look point per pending -press. In game-inspect the counts are drained but discarded, since a click would re-activate the -backgrounded game. - -**Ballistics cooking** (src/mouse_ballistics.h/.cpp). The frozen cursor makes the normal -pan oracle (OS cursor movement, Windows acceleration already applied) read ~0, so the look point -must pan from raw mickeys, which are pre-acceleration and pre-pointer-speed and would feel wrong. -`CookMickeyPacket` converts each packet into the cooked pixel delta Windows' own pipeline would -produce: the exact pointer-speed slider multiplier (`PointerSpeedMultiplier`, the standard 1..20 -table) plus, when "Enhance pointer precision" is on, the piecewise-linear SmoothMouse curve, -**normalized** so the low-speed gain equals the slider multiplier exactly. That normalization is -the clever part: it cancels the undocumented absolute DPI/refresh scaling constants, so the match -depends only on the curve's shape, and slow precise movement is guaranteed 1:1 with the desktop. -The curve is blended at a reduced `accelStrength` (default 0.3) because `WM_INPUT` can coalesce -HID reports, inflating per-packet magnitude, and Windows accelerates per packet on magnitude, so -the full curve over-accelerates fast moves. `InputRouter::cookPacket` runs per `WM_INPUT` packet -(matching Windows' per-packet keying), only while `inspectActive`, accumulating sub-pixel results -that the tick drains via `drainCooked`. It is pure logic, no ``, and unit-tested. - -## Keyboard panning keys (issue #287) - -The four pan binds (unbound by default since #307; Ctrl+Alt+arrows is Windows Magnifier's) are tracked like every bound key, but -`keyBindMatches` counts a pan slot only while `setPanArmed(true)`. RunTick arms them each tick -while the view is zoomed, Inspect is off and no mouselook lock holds the mouse. The swallow -decision is still made once per press, so a Ctrl+Alt+Left pressed at 1x goes to the app for its -whole press even if a zoom starts mid-way, and RunTick pans only on presses the hook swallowed -(`keySwallowed(vk)`), never on one the app already saw. In the magnify model Wind's level stays at -1x, so the keys are never armed and Windows Magnifier pans with them natively. - -## Pointers - -- src/input_router.h / src/input_router.cpp: hooks, hook thread, swallowing, watchdog plumbing, - suspension, safety-net entry points. -- src/main.cpp: Raw Input registration and `WM_INPUT` decode, the watchdog and noSwallowApps - logic in `RunTick`, `RegisterHotKey` sites, teardown restore (`RestoreInputState`). -- src/mouse_ballistics.h / src/mouse_ballistics.cpp: pure Inspect-mode speed matching. -- src/hook_transform.h: the issue #206 inline transform write the mouse hook performs. -- src/config.cpp: `IsForbiddenBindVk`, `IsExeInList`, keybind sanitizing; - src/lock_detector.h: the raw-stream lock detection this pipeline feeds. -- Related chapters: [Engines](03-engines.md) for what the drained input drives. -- History: [WOBBLE-CAPTURE-2026-08-21](../WOBBLE-CAPTURE-2026-08-21.md) (single-writer transform - path), [HITCH-FINDINGS](../HITCH-FINDINGS.md) (the perf context that shaped the hook thread), - [POINTER-HITTEST-FINDINGS](../POINTER-HITTEST-FINDINGS.md) (input transform on the desktop). +Both hooks have a Raw Input backstop for lost UP events, in the `WM_INPUT` handler: + +- **Keyboard:** `RI_KEY_BREAK` calls `rawKeyUp`. Raw Input still delivers the UP an evicted hook + missed, which would otherwise leave a zoom bind held forever. +- **Mouse:** button UPs call `rawButtonUp`. UP only, so the net can clear held state but never set + it. +- **Reordering guard.** `WM_INPUT` drains up to a tick late, so a raw UP can arrive after the hook + recorded the next press. The net skips the clear when the hook stamped a DOWN for that input in + the last ~30 ms. +- DOWN edges stay hook-authoritative while the hook is active; `WM_INPUT` writes down-state only in + the no-hook fallback. + +## Eviction and the watchdog + +Windows evicts an LL hook whose callback misses `LowLevelHooksTimeout` without any error; the +handle stays valid and the callback never fires again. A game's launch load spike can trigger it: +keyboard binds die while mouse binds survive (issue #156). + +**The tell costs nothing:** a live hook swallows every bound key, so `GetAsyncKeyState` seeing a +bound key held while `keyPressed()` says up means the hook is gone. After a 250 ms dwell +(`kKbHookDeadMs`), `requestKbHookReinstall` drops the authority claim (the next tick polls), +releases swallowed-key records and posts `kMsgSetKbHook` to the hook thread, which must install +the hook itself. + +**`noSwallowApps`.** An LL keyboard hook taxes system input just by existing: the raw input thread +waits on the hooking thread for every keystroke, so a held auto-repeating key puts a stall into the +mouse stream of the foreground game. Listing an exe uninstalls the keyboard hook while that app is +foreground (a ~10 Hz probe, `setKeyboardHookWanted(false)`); binds keep working through polling. +Off by default. + +## Bound keys still reach games + +**LL hooks cannot block Raw Input**, and most games read Raw Input, so a bound key or button still +reaches a raw-input game whatever the hook returns. There is no user-mode API to suppress raw input +to another process. The only fix is a kernel filter driver, which Wind does not use (no-driver +design, anti-cheat ban risk). Swallowing works in desktop apps and browsers. Guidance for users: +bind keys the game does not use. Game-Inspect sidesteps this for Inspect mode only, see +[07](07-cursor.md). + +## Inspect: click routing and ballistics + +While Inspect is on, the real cursor is frozen by a 1 px `ClipCursor`. + +**Click-to-look-point.** `MouseProc` swallows real left/right presses while `inspectActive` is set, +records the DOWN per button (`g_commitDown`) and counts presses (`commitLeft`/`commitRight`), so a +fast double-click is not lost. The tick fires an absolute click at the look point per press. The +injected click carries `LLMHF_INJECTED` (the hook skips it) and its absolute move is ignored by the +raw accumulator, so the look point does not move. In game-inspect the presses are discarded. + +**Ballistics** (`src/mouse_ballistics.*`, pure). The frozen cursor makes the normal pan oracle read +zero, so the look point pans from raw mickeys run through Windows' pointer ballistics per +`WM_INPUT` packet: the pointer-speed multiplier plus, with "Enhance pointer precision", the +SmoothMouse curve, normalized so slow movement is 1:1 with the slider. The curve is blended at +`accelStrength` (default 0.3) because `WM_INPUT` can coalesce HID reports and over-accelerate. +`cookPacket` runs only while `inspectActive`; the tick drains with a sub-pixel carry. diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index b88b1a6c..eec0d75d 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -1,383 +1,245 @@ # 07. The cursor system -The cursor is Wind's deepest subsystem because a fullscreen magnifier has two positions that must -never disagree: where the pointer *is* (the thing Windows hit-tests, hovers, drags, and clicks -with) and where the pointer *appears* (a point inside a magnified view). Every design in this -chapter exists to keep those two welded together, or to make one of them a pure function of the -other so there is nothing left to disagree. This chapter covers the mapper, the free-cursor view -model, the weld and its measured-baseline law, drag-follow, the sprite/blanker pair, lock -detection, and Inspect mode. - -## The mapper: one lens center for everything - -`src/cursor_mapper.h` / `.cpp` is pure logic (no ``, unit-tested). `CursorMapper` -integrates per-tick pixel deltas into a float lens center `(cx_, cy_)` in local monitor pixels, -optionally eased by `cursorSmoothing`, and each tick `CursorMapper::update` returns one -`MapResult` that the whole frame derives from: - -| Field | Meaning | Consumer | +A fullscreen magnifier has two cursor positions that must never disagree: where the pointer is +(what Windows hit-tests and clicks with) and where it appears inside the magnified view. Every +design here either welds the two together or makes one a pure function of the other. This chapter +covers the mapper, the free-cursor model, the weld, drag-follow, the sprite, lock detection, +Inspect, tracking, keyboard panning and shell panels. + +## The mapper + +`CursorMapper` (`src/cursor_mapper.*`, pure, tested) integrates per-tick deltas into a float lens +centre `(cx_, cy_)` in monitor-local pixels, eased by `cursorSmoothing`. Each tick `update` returns +one `MapResult`: + +| Field | Meaning | Used by | |---|---|---| -| `srcLeft/srcTop` | float top-left of the source rect (`ComputeOffsetF`, src/transform.cpp) | both engines' view position | -| `cursorScreenX/Y` | where the lens center *displays* on screen | sprite/crosshair draw point | -| `clickDesktopX/Y` | the center rounded to a pixel | `SetCursorPos` weld target | -| `centerX/Y` | the un-rounded center | transform model's fixed-point anchor | - -The click point *is* the lens center, so a click through the transparent overlay lands exactly on -the content under the drawn cursor. Do not "fix" the click point to the unsmoothed target: the -drawn cursor and the view come from the smoothed center, so that would misalign clicks -(CLAUDE.md's standing warning, borne out by the code). - -The mapper also enforces the MPO pan walls (`setMaxSourceLeft` / `setMaxSourceTop`, issues #148 -and #191): on MPO-enabled machines the NVIDIA driver packs DWM's magnification translation into a -16-bit field per axis, so `|src*level| > 32767` wraps and TDRs. The wall bounds the *center* so -lens, sprite, and click point all stop together, and it bounds the eased center too, because -during a zoom ramp at the right edge the wall moves inward with the level. `main.cpp` feeds the -walls per tick and lifts them only when the MPO-buster ghost is verifiably settled -(`TransformModel::mpoGhostSettled`, fail-closed). - -## The free-cursor view model (issue #205) - -This is the most important recent change, and the reason the transform engine no longer wobbles. -Native Magnifier's geometry was **measured, not assumed**: `tools/mag_formula_probe.ps1` and -`tools/mag_trackmode_probe.ps1` drove the real Magnifier and read back what it wrote via -`MagGetFullscreenTransform`. The result: +| `srcLeft/srcTop` | Float top-left of the source rect (`ComputeOffsetF`) | Both engines' view | +| `cursorScreenX/Y` | Where the lens centre displays | Sprite and crosshair | +| `clickDesktopX/Y` | The centre rounded to a pixel | `SetCursorPos` weld target | +| `centerX/Y` | The unrounded centre | Transform anchor | + +**The click point is the smoothed lens centre.** The drawn cursor and the view come from it, so a +click lands on what the user sees. Do not move the click point to the unsmoothed target. + +The mapper also holds the MPO pan walls (`setMaxSourceLeft/Top`); it bounds the centre, so lens, +sprite and click point stop together. See [05](05-transform-engine.md). + +## Free cursor (transform sessions) + +The built-in Magnifier's view is a pure function of the cursor, measured by reading back its +transform: ``` offset = clamp(cursor - screen/(2*level), 0, screen - screen/level) ``` -and it tracks the pointer continuously, 1:1 (45/45 twelve-pixel steps moved the view by exactly -twelve). Native's view position is a **pure function of the current cursor position**, with no -integration, no smoothing, no state. - -Wind's older model was structurally different: integrate per-tick deltas into a smoothed center, -then weld the pointer back to that center with `SetCursorPos`. The cursor position then depended -on the center and the center depended on cursor deltas: a feedback loop. That loop is what issue -#169 chased and what the long-standing wobble was; native has no loop to oscillate. See -[../WOBBLE-CAPTURE-2026-08-21.md](../WOBBLE-CAPTURE-2026-08-21.md) for the companion capture work -(it also exposed a *second* wobble source: native Magnifier stomping the shared input-transform -slot, guarded in `TransformModel::present`). - -The fix (`txFreeCursor`, ships 1, hot): in `main.cpp` `RunTick`, when a **transform** session is -free (not Inspect, not detector-locked), the mapper is pinned to the real cursor every tick, -`t.mapper.reset(cursorPos)` followed by `update(0, 0, lvl)`, which reproduces native's formula -exactly (the mapper already clamps the same way), and the weld is suppressed -(`ex.suppressCursorSync = dragFollow || freeCursor`). The pointer is the input; there is no delta -to scale and no target to ease, so `cursorSensitivity` and `cursorSmoothing` deliberately do not -apply while it is on. The formula also lives in one shared pure header, -`wind::ComputeFreeCursorSrc` (src/hook_geometry.h), because issue #206 briefly added a second -writer (the mouse hook writing the transform inline, `src/hook_transform.*`); `txHookWrite` ships -0, parked, after the field showed that writing 4-5 times per composited frame makes the cursor -swim against the content. The lesson recorded in `src/config.h` is worth internalizing: latency -is not the metric that matters, frame coherence is. - -The free-cursor gate is transform-only (`dynamic_cast` in `main.cpp`). Render -sessions still run the delta-integration + weld model, because the render engine hides the real -pointer and draws its own centered cursor, so there is no visible pointer for the view to be a -function of. - -## The weld and the measured-baseline law (issue #169) - -Where the weld still runs (render sessions always, transform sessions with `txFreeCursor=0`), -both engines park the real pointer at the lens point each tick: `RenderEngine::renderFrame` -(src/render_engine.cpp) and the weld block in `TransformModel::present` -(src/transform_model.cpp). Both are **deduped**, `SetCursorPos` fires only when the target pixel -changed, so an idle tick injects no synthetic mouse move, and both **report** whether the call -really ran this frame: `RenderEngine::parkedLastFrame()` and -`TransformModel::weldedLastFrame()`. - -That report exists because of the #169 law, spelled out at the baseline bookkeeping in -`main.cpp` `RunTick`: **the oracle baseline is measured, never assumed.** The park can be deduped -(unchanged center pixel), suppressed (drag-follow, free cursor, quiesce hold), or skipped -(`gatePresent` / fps-cap skip ticks). If the code assumes the park landed anyway and baselines on -the lens center, the next delta measures hand motion *plus* the pointer-to-center gap; the mapper -integrates the gap, the center overshoots the pointer, the sign flips, and the loop oscillates -with amplitude proportional to hand speed. That unstable servo was the #169 window-drag flicker -and, before the transform weld was recognized at this site, the #181 corner drift. So the -baseline is: the park point when the engine says it parked, otherwise this tick's start-of-tick -`GetCursorPos` read. Never a fresh post-present read: `Present` blocks about a frame at vsync, -and a read taken after it swallows the hand motion that happened during the block, so the lens -pans slower than the hand (the first shipped version of the fix had exactly that bug). - -## Drag-follow (src/drag_follow.h) - -While a mouse button is physically held in a free welded session, the pointer *is* the -interaction: a window drag or a text selection consumes its position directly. A per-tick weld -then fights the hand, and the dragged content flickers between the two positions -(probe-measured, ~85 px square wave). `wind::ShouldDragFollow` (pure, unit-tested) suspends the -weld for exactly the button-hold, and the lens follows the pointer 1:1 **unscaled**; scaling -would desync the lens from the pointer that owns the drag. Click alignment is correct by -construction: the press landed under the welded cursor (the weld was live until the button went -down), and the release lands where pointer and content both are. On release, -`RenderEngine::renderFrame` invalidates its park dedupe (`suppressCursorSync` resets -`lastClickX/Y`) so the first post-release frame re-parks even onto an unchanged pixel. Locked and -Inspect regimes never drag-follow; they have their own cursor policy. +Wind's older model integrated deltas into a smoothed centre and welded the pointer back to it: a +feedback loop, and the source of the transform wobble. With `txFreeCursor=1` (default, hot), a free +transform session pins the mapper to the real cursor every tick (`reset(cursorPos)` then +`update(0, 0, lvl)`) and suppresses the weld. `cursorSensitivity` and `cursorSmoothing` do not apply +there. The formula lives in `ComputeFreeCursorSrc` (`src/hook_geometry.h`). + +Render sessions keep delta integration plus the weld, because the render engine hides the real +pointer and draws its own. + +## The weld and the measured baseline + +Where the weld runs (render sessions; transform with `txFreeCursor=0`), the engine parks the real +pointer at the lens point each tick. Both weld sites are deduped (no `SetCursorPos` when the pixel +is unchanged) and report whether the park really ran: `parkedLastFrame()`, `weldedLastFrame()`. + +**The oracle baseline is measured, never assumed.** The park can be deduped, suppressed +(drag-follow, free cursor, quiesce) or skipped (gated or fps-capped ticks). Baselining on the lens +centre anyway makes the next delta include the pointer-to-centre gap; the mapper integrates it and +the loop oscillates with hand speed (issue #169). So the baseline is the park point when the engine +parked, otherwise the start-of-tick `GetCursorPos`. Never a post-present read, which swallows the +hand motion made during the blocking `Present`. ## The oracle and cursorSensitivity -In welded free sessions, panning speed auto-matches the real OS cursor without reimplementing -ballistics: each tick reads the OS cursor's own movement since the last place *we* put it -(`cur - t.lastSetVirtual` in `RunTick`), which already has Windows' pointer acceleration applied, -then scales by `cursorSensitivity` (default 1.0 = exact match). `GetCursorPos` works as this -oracle only because it is read *before* the pointer is re-set each tick. Raw mickeys are still -collected in parallel to feed the `LockDetector`, to drive panning while locked (also scaled by -`cursorSensitivity`; game input is relative, so OS acceleration does not apply), and to drive -Inspect's ballistics cooking. Both regimes integrate a delta into the same accumulator, so a -free/locked switch never snaps position (the old issue #3 Tracker flicker). - -## Hiding the real pointer: blanker + sprite - -When a transform session is zoomed, the real pointer must vanish (it would draw unmagnified at -its raw desktop position) and a stand-in must appear on the content it addresses. Two pieces: - -**`CursorBlanker`** (src/cursor_blanker.*) swaps the 14 standard system cursors for fully -transparent ones via `SetSystemCursor`, keeping copies of the originals. Its constructor first -reloads the user's scheme (`SPI_SETCURSORS`): if a previous Wind was hard-killed while blanked, -the desktop still has blank shared cursors, and capturing those as "originals" would make the -blank state permanent. `MagShowSystemCursor(FALSE)` covers the whole plane wholesale for -app-custom cursors. The blank runs in `TransformModel::setActive(true)` *before* the -magnification context exists (issue #189): under a live context every cursor change any process -makes costs a DWM re-composite, so running 14 swaps inside the fresh context stacked visible -hitch onto the ~36 ms context build. - -**`CursorSprite`** (src/cursor_sprite.*) is a small layered window that repaints the current -cursor shape (or the Inspect crosshair) into its bitmap (`refreshShape`; shapes it cannot render -faithfully report `Unsupported` and the code falls back to the system pointer). It is created -through `wind::CreateBandedWindow` (src/band_window.h) and exposes `usedBand()` so a refused band -request is never silent. Field-measured on this Windows build: DWM's fullscreen transform *does* -magnify layered windows, so the sprite lives in **desktop coordinates at the lens point** -(`clickDesktop`); the transform displays it at screen center, and it grows with zoom exactly like -native Magnifier's pointer. That growth contradicts the constant-size product rule; the -`spriteBand16` experiment (band 16 + screen-space positioning, `TransformModel::present`) exists -to get one field verdict on whether high-band windows escape the transform, because two -historical measurements contradict each other. `keepOnTop()` re-asserts `HWND_TOPMOST` only when -actually displaced, throttled, since the sprite competes in real z-order with menus and flyouts. - -Two handoff subtleties, both field-verified (issue #221 round of polish): - -- **Zoom-in**: the blank hides the pointer instantly, but the sprite's first composite is a - context build plus a reveal away, a visible cursor-less blink. So `setActive(true)` stands the - sprite up at the pointer's position *before* blanking (at ~1x the transform is identity, so it - lands exactly on the pointer), verifies the shape rendered, and runs **two** `DwmFlush` passes: - the first can latch a composite that began before the `ShowWindow` reached DWM, the second is - guaranteed to include the sprite. One flush measurably still blinked on a still pointer. The - bridge is skipped when the app is hiding its own cursor (mouselook); flashing a sprite there - would be its own blink. -- **Zoom-out**: after `blanker_->restore()`, Windows repaints the pointer plane only on the next - cursor *event*, so a restored-but-still pointer stays invisible until the hand moves. A 1px - `SetCursorPos` nudge and back generates that event invisibly (`TransformModel::setActive(false)`). - -The render engine has its own, simpler policy: it draws the cursor into its D3D scene -(`cursor_decode`/`crosshair` textures) and hides the OS cursor with `MagShowSystemCursor` through -the refcounted `wind::MagApiAcquire` host, see [Engines](03-engines.md) and the shared-runtime -gotcha in CLAUDE.md. - -## Lock detection (src/lock_detector.*) - -A mouselook game owns the pointer (clips it, freezes it, or warps it back to a recenter point), -so `GetCursorPos` stops being the truth and panning must come from raw mickeys instead. -`LockDetector` is the pure, hysteresis-protected arbiter of that switch, fed per-tick Win32 -signals by `RunTick`. Its tells, in order of reliability: - -1. **Confined clip.** A `ClipCursor` rect meaningfully smaller than the monitor is a direct lock - signal. "Meaningfully" is `ClipRectConfines` (lock_detector.h): smaller than 90% of the - monitor in either dimension. The threshold exists because of a trap on the dev rig: a - machine-wide *work-area* clip (desktop minus taskbar, ~95%, set by an external utility) meant - `GetClipCursor` never returned the full desktop, and the old any-clip test ran every zoomed - desktop session on the locked path, which is what masked the #169 defects. -2. **Raw-active-but-frozen.** Mouse moving at the HID level while the OS cursor does not move: - 6 consecutive ticks lock (`kLockTicks`); 3 consecutive ticks of the cursor tracking input - unlock (`kFreeTicks`). The hysteresis means a single contrary tick never flips the state. -3. **The #221 tells**, gated behind `warpLock` because engaging mid-fight reads as "the magnifier - hitches then gets good", so the smart tells are opt-in: - - **Warp-anchor**: field-traced on DOOM The Dark Ages, which *warps* the pointer back to one - pixel every frame (58 returns to one pixel at apparent speeds of 13k-80k px/s). That defeats - both classic tells at once: the clip is the full monitor, and the warp keeps the cursor - moving, which the frozen tell reads as free. So a big jump (>= 100 px) landing within 6 px - of the same anchor repeatedly is lock evidence, and a recent warp landing suppresses the - free streak (a 30 fps game warps only every ~5 ticks at 144 Hz; the genuine hand motion in - between must not unlock). - - **Confinement box**: gentle mouselook warps too softly for the anchor tell, but the - signature holds: lots of raw mickeys (>= 400 in a ~170 ms window) while every cursor - position stays inside a 30 px box. Precise desktop work never trips it, because ballistics - map slow careful motion roughly 1:1, producing proportionally few mickeys. - - **Hidden-cursor zoom-in seeding**: any motion tell needs a wiggle as evidence, so a - motionless zoom-in over mouselook would start free. Zooming in over a covering foreground - whose cursor is already hidden *by the app* (the same signal game-inspect trusts, valid at - that instant because the session has hidden nothing yet) calls `seedLock()`: start locked so - raw-mickey panning works from the first tick. A wrong seed over fullscreen video self-heals - in ~100 ms once the pointer reappears and tracks the hand. - -**`lockApps`** (config.h, hot) is the deterministic per-app answer: comma-separated exe names -whose sessions run locked outright while foreground, no heuristics. The list *is* the feature -(empty = off); `warpLock=1` additionally enables the smart tells globally for unlisted games. -Critically, the force is **routed through the detector** (`t.detector.seedLock()` in `RunTick`), -not a tick-local boolean: the free-cursor gate reads `t.detector.locked()`, and a local-only -force left the transform view pinned to the warped pointer, a real field regression where the -list "did nothing". `lockForce=1` is the diagnostic that locks everywhere; its config comment -documents why locked can never be the default (no ballistics, no drag-follow, weaker click -guarantee). - -**LockDetector state machine (simplified; warp tells active only under warpLock).** +In welded free sessions, panning matches the OS cursor without reimplementing ballistics: each tick +reads the cursor's own movement since Wind last placed it (`cur - t.lastSetVirtual`), with Windows' +acceleration already applied, times `cursorSensitivity` (1.0 = exact). This works only because the +read comes before the pointer is re-set. Raw mickeys are collected in parallel for the lock +detector, locked panning and Inspect. Free and locked both integrate into the same accumulator, so +a regime switch never snaps. + +## Drag-follow + +While a mouse button is held, the pointer is the interaction (window drag, text selection), and a +per-tick weld fights the hand: the dragged content flickered ~85 px between two positions. +`ShouldDragFollow` (`src/drag_follow.h`) suspends the weld for exactly the button-hold, and the +lens follows the pointer 1:1, unscaled. The press landed under the welded cursor; the release lands +where pointer and content are. On release `renderFrame` invalidates its park dedupe so the next +frame re-parks. Locked and Inspect sessions never drag-follow. + +## The cursor grows with the zoom + +**The cursor grows with the zoom in every engine** (issue #253). The transform sprite lives in +desktop space and DWM magnifies it; the render engine scales its drawn cursor to match. A render +cursor kept at desktop size read as tiny next to the transform. `cursorConstantSize=1` is the +render-only opt-in for the old constant size. `cursorScaleWithZoom` is retired and ignored. + +## Hiding the real pointer: blanker and sprite + +In a zoomed transform session the real pointer would draw unmagnified at its raw position, so it +is hidden and a stand-in drawn. + +- **`CursorBlanker`** (`src/cursor_blanker.*`) swaps the 14 system cursors for transparent ones and + keeps the originals. It first reloads the user's scheme, so a previously killed Wind's blanks are + never captured as originals. `MagShowSystemCursor(FALSE)` covers app-custom cursors. The blank + runs before the magnification context exists, because each swap under a live context costs a + re-composite. +- **`CursorSprite`** (`src/cursor_sprite.*`) is a small layered window painting the current shape, + or the Inspect crosshair. It sits at the lens point in desktop coordinates, so DWM shows it at the + view's centre, magnified. `keepOnTop()` re-asserts topmost only when displaced. +- **Zoom-in handoff.** `setActive(true)` stands the sprite up on the pointer before blanking and + runs two `DwmFlush` passes; one flush still blinked. Skipped when the app hides its own cursor. +- **Zoom-out handoff.** Windows repaints the restored pointer only on the next cursor event, so a + 1 px `SetCursorPos` nudge and back makes it appear without moving the hand. + +**Bands.** A UIAccess sprite lands in band 2; Start, taskbar thumbnails and tray flyouts are band +16; the Snipping Tool overlay is band 17. `cursorBandAuto=1` (default) keeps twin sprites: the +band-16 one shows unless the foreground window's band is above 16, when the low one shows instead +(pure rule: `src/sprite_layer.h`). The sprite is excluded from capture (otherwise it is frozen into +snip screenshots) and from Aero Peek. `spriteBand16=1` (restart) is an experiment, off by default: +a band-16 screen-space sprite for a constant-size cursor. Its field test was negative (band-16 +windows are magnified too, [../NATIVE-MAGNIFIER-STOMP.md](../NATIVE-MAGNIFIER-STOMP.md)). +`tools/testenv/dualcursor.ps1` turns on +the hidden `spriteCapturable=1` because it measures the sprite from captures. + +The render engine draws its cursor into the D3D scene and hides the OS cursor through the shared +Magnification runtime ([04](04-render-engine.md)). + +## Lock detection + +A mouselook game owns the pointer (clips, freezes or warps it), so panning must come from raw +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 | +| 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` | +| `lockApps` | Listed exes run locked outright while foreground | Always | + +- **A clip is a lock signal only when meaningfully smaller than the monitor.** A machine-wide + work-area clip (desktop minus taskbar, ~95%) is common; any-clip ran every desktop session locked. +- Tick counts derive from the refresh rate (`setTickRate`). +- **Forced locks go through the detector** (`seedLock()`), because the free-cursor gate reads + `t.detector.locked()`; a tick-local flag once left the view pinned to the warped pointer. +- `lockForce=1` locks everywhere, for diagnosis only: locked mode has no ballistics or drag-follow. ```mermaid stateDiagram-v2 [*] --> Free: reset() at zoom-in / recenter / retarget - Free --> Locked: confined clip (ClipRectConfines) + Free --> Locked: confined clip Free --> Locked: 6 ticks raw-active + cursor frozen - Free --> Locked: 4 warp landings on one anchor - Free --> Locked: box tell (400 mickeys, 30px span) - Free --> Locked: seedLock() (lockApps / hidden-cursor zoom-in) - Locked --> Free: 3 ticks cursor tracking input, and no warp landing in the last 12 ticks - Locked --> Locked: warp landing (clears the free streak) + Free --> Locked: warp anchor or box tell (warpLock) + Free --> Locked: seedLock() (lockApps, hidden-cursor zoom-in) + Locked --> Free: 3 ticks tracking input, no recent warp landing ``` -## The three regimes - -Every active tick, `RunTick` resolves one pan delta and one cursor policy from three mutually -exclusive regimes. Note the free regime itself forks: transform sessions ride the free-cursor -pure function, render sessions ride the oracle + weld. - -**Per-tick pan and cursor-policy resolution in RunTick.** +## The regimes ```mermaid flowchart TD T[active tick] --> I{Inspect on?} - I -- yes --> IN[pan look point from cooked raw mickeys
ballistics + sub-pixel carry, pointer frozen] - I -- no --> L{detector.locked or lockApps?} - L -- yes --> LK[pan from raw mickeys * cursorSensitivity] - L -- no --> F{transform engine + txFreeCursor?} - F -- yes --> FC[mapper pinned to real cursor
view = pure function, no weld] + I -- yes --> IN[look point from cooked raw mickeys, pointer frozen] + I -- no --> L{locked?} + L -- yes --> LK[raw mickeys * cursorSensitivity] + L -- no --> F{transform + txFreeCursor?} + F -- yes --> FC[mapper pinned to the real cursor, no weld] F -- no --> D{mouse button held?} - D -- yes --> DF[drag-follow: lens tracks pointer 1:1, weld suspended] - D -- no --> OR[oracle delta * cursorSensitivity, weld to lens point] + D -- yes --> DF[drag-follow 1:1, weld suspended] + D -- no --> OR[oracle delta * cursorSensitivity, weld] ``` -## Inspect mode end to end - -Inspect (`cursorLockVk`) is a freeze-cursor + free-look reticle, driven entirely in `RunTick`; -the mouse hook's only involvement is swallowing clicks. - -**Entry** (`inspectEnter` in `RunTick`): the real cursor is frozen where it is with a 1px -`ClipCursor` at `t.frozenCursor` and hidden, so any hover or tooltip under it stays alive. The -look point (which *is* the mapper center) starts there. Because the frozen pointer makes the -oracle read ~0, the look point pans from raw mickeys cooked through Windows pointer ballistics -per `WM_INPUT` packet (`src/mouse_ballistics`, pure + tested): exact pointer-speed multiplier -plus the SmoothMouse curve, normalized so slow motion is 1:1, at reduced acceleration strength -because coalesced HID reports over-accelerate. `RunTick` drains the cooked delta with a sub-pixel -carry, still scaled by `cursorSensitivity`, so the reticle moves at desktop-cursor speed. The -crosshair is drawn at `cursorScreen`: the render engine draws it when -`RenderFrameParams.cursorLocked`; the transform repaints the sprite via -`CursorSprite::showCrosshair` and parks it on the look point (the sprite used to keep drawing -the arrow at the frozen point, with no crosshair at all, before that branch existed). The -overlay stays active while Inspect is on (`active = zoomed || inspect`), so the reticle persists -and roams the full screen at 1x and never snaps across the zoom boundary. - -**Clicks** are routed to the look point, not the frozen pointer: the `WH_MOUSE_LL` hook swallows -the real left/right press and its matching up (per-button *counts*, so a fast double-click is not -lost), and `RunTick` fires clean absolute injected clicks at the look point via `SendInput`. The -1px freeze is released for `clickReleaseTicks` around the click so the injection is not clamped -back to the frozen pixel, then re-asserted, deduped through a `GetClipCursor` read because -`ClipCursor` is a win32k cursor-subsystem write, the #148 TDR class under a live transform. The -injected click carries `LLMHF_INJECTED` (the hook skips it) and its absolute move is ignored by -the raw accumulator, so the look point is undisturbed. Transform sessions additionally pause -transform writes for ~3 ticks around the injection (`ex.pauseWrites`), serializing the two -proven-racy channels. Inspect stays on after a click; auto-exit was a prior regression. - -**Game-inspect** (issue #144): a raw-input game's camera cannot be blocked by any user-mode hook, -so when `wind::ShouldGameInspect` (src/inspect_focus.h, pure) says a mouselook game holds the -mouse, `RunTick` steals foreground to an invisible 1x1 helper window; a backgrounded game stops -receiving raw input, so its camera freezes while Wind's `RIDEV_INPUTSINK` pan keeps working. The -tell is an app-hidden cursor, trustworthy only when Wind has not hidden it too, which is why the -predicate takes `magnifierHidCursor`; a detector lock also engages on its own, and the zoomed -path must *not* be detector-only (issue #158: a raw-input game like RDR2 never clips or recenters -the pointer, so the detector reads free right through mouselook). The steal is deferred one step -so the reveal logic still sees the game as foreground, re-asserted if the game re-grabs -foreground (an alt-tab to a third app is respected), and clicks are drained but discarded (a -synthesized click would re-activate the game mid-inspect). A failed steal (unsigned dev build) -logs and falls back to normal inspect rather than leaving inconsistent state. - -**Every exit path releases the clip.** Toggle-off-while-zoomed and teardown-to-idle both run -`EndGameInspect`, `ClipCursor(nullptr)`, drain swallowed-click counts (a stale count would fire a -phantom injected click at the lens center on some future activation), and warp the cursor to the -look point, because the crosshair is the aim the user just spent the mode establishing; snapping -back to the pre-Inspect position throws it away. Beyond `RunTick`, the clip is also released on -device-lost recovery, `shutdown()`, the crash filter, and the `atexit` input-state restore, so -the pointer is never stranded pinned to one pixel (the render-engine teardown gotchas in -CLAUDE.md enumerate the same paths for cursor visibility). - -## Tracking: caret, focus, mouse edge mode (issue #276) - -Besides the mouse, the view can follow the text caret (`trackCaret`, default on) and keyboard focus -(`trackFocus`, default off). `src/focus_track.*` runs its own COM MTA thread (WinEvents + -GetGUIThreadInfo + UIA) and publishes a snapshot; the tick never calls UIA. `StepViewOwner` -(`src/view_target.h`) picks one owner per tick: the most recent caret/focus change wins, real mouse -motion (3 px in 100 ms) or a button takes it back. While caret/focus owns the view, the view is -DETACHED: `DetachedMap` (`src/detached_view.h`) builds a `MapResult` whose view is the glided centre -while the cursor fields report the real pointer, and the weld is off. On a mouse-movement takeover -the pointer is placed in the view (the view stays). The glide is a critically damped spring -(`SpringToward`, `src/view_glide.h`, 200 ms). - -Java apps (IntelliJ, PyCharm) expose the caret only through the Java Access Bridge -(`src/java_bridge.*`, issue #281). The tracker loads the Authenticode-signed client DLL from the Java -app's own folder, starts the bridge on its thread (the bridge's hidden windows get a narrow UIPI -allowance, since Wind is UIAccess), and reads the caret only after bridge caret/focus callbacks, -never on the 60 Hz poll. It also enables the bridge in `%USERPROFILE%\.accessibility.properties`. - -Mouse edge mode (`mouseAlign=1`, free-pointer sessions only; mouselook and Inspect stay centred) -uses the same detached path every tick: the pointer is real and unwelded, and `EdgePanCenter` -(`src/edge_pan.h`) moves the view only when the cursor's visible body leaves the band -(`mouseMarginPct`). Pointer pinned against a screen edge is hidden from the lock detector -(`PointerPinnedAtEdge`). Field history: [../TRACKING-FINDINGS.md](../TRACKING-FINDINGS.md). - -## Keyboard panning: the Keys owner (issue #287) - -`ViewOwner::Keys` is a fourth detached owner next to Caret and Focus. `KeyPan` -(`src/keyboard_pan.h`, pure) turns the held pan keys into a view delta: a press starts panning at -once at `panSpeed x 1.25` screen widths per second at high zoom, scaled by `ZoomRateScale`: one corner-free curve, a power -law `(level / 7)^0.6` at low zoom easing into full speed through a soft minimum (47% at 2x, 93% at -7.5x, 98% at 10x; #305: a constant screen rate crossed the whole desktop in under a second at low -zoom, and clamped or piecewise curves were felt as speed changing at certain spots), the same in every direction (so up/down match left/right), with a ~150 ms ease-in; a press released within 250 ms -is a tap, which stops that axis and tops the move up to exactly 1/8 of the screen (a quick ~90 ms -glide); a longer hold never adds that step (field test: a step at the start of a hold read as a -jump) and glides out in ~120 ms. All in screen space, so the feel does not change with the zoom. -While KeyPan is active it owns the view ahead of any caret event; RunTick -adds the delta to the detached centre, clamps it to the monitor and to the MPO wall (the same -`kMaxSafeTxMagnitude / level` limit mouse edge mode uses), and draws with `DetachedMap`. The -pointer never moves while panning; the next real mouse movement places it in the view -(`warpPointer`), a button press gives the view back without a warp. It works with caret and focus -tracking switched off. - -## Shell input panels: the real pointer, frozen (issue #283) - -The emoji picker, clipboard history and touch keyboard are composed by the shell above every window -band, so the sprite goes under them. While one is open (`FocusTracker::shellPanelOpen`, from -TextInputHost's `IME` window UNCLOAKED/CLOAKED events) RunTick enters the panel regime (`panelPointer`, -default 1): the transform model hides the sprite, restores the real pointer and makes ONE public -`MagSetFullscreenTransform` write, after which DWM draws the pointer magnified. The pointer is frozen -with a 1px clip and moved only by Wind, from ballistics-cooked raw input (`InputState::cookActive`), -right after the view write, so DWM never draws one without the other. The freeze is hidden from the -lock detector, tracking and edge mode pause, and the saved clip is restored on close. Evidence and the -rejected hook-write variant: [../SHELL-PANEL-CURSOR-FINDINGS.md](../SHELL-PANEL-CURSOR-FINDINGS.md). - -## Pointers - -Key sources: - -- `src/cursor_mapper.h` / `.cpp`: the lens-center mapper and pan walls -- `src/hook_geometry.h`: the measured free-cursor formula (`ComputeFreeCursorSrc`) -- `src/hook_transform.h` / `.cpp`: the parked #206 hook write path -- `src/drag_follow.h`: `ShouldDragFollow` -- `src/lock_detector.h` / `.cpp`: `ClipRectConfines`, hysteresis, the #221 tells, `seedLock` -- `src/cursor_sprite.h` / `.cpp` and `src/cursor_blanker.h` / `.cpp`: the stand-in pointer -- `src/inspect_focus.h`: `ShouldGameInspect` -- `src/mouse_ballistics.h` / `.cpp`: Inspect's speed matching -- `src/main.cpp` (`RunTick`): regime resolution, oracle baseline, Inspect lifecycle -- `src/transform_model.cpp` / `src/render_engine.cpp`: the weld sites and `weldedLastFrame` / - `parkedLastFrame` - -History and evidence: [../WOBBLE-CAPTURE-2026-08-21.md](../WOBBLE-CAPTURE-2026-08-21.md) (the -wobble capture that closed #205/#217), [../POINTER-HITTEST-FINDINGS.md](../POINTER-HITTEST-FINDINGS.md) -(the input-transform hover fix), [../HITCH-FINDINGS.md](../HITCH-FINDINGS.md) (the original weld-TDR -bisect). Related chapters: [Engines](03-engines.md) for the render/transform split this chapter's -cursor policies attach to. +Tracking (below) can then detach the view from all of these. + +## Inspect mode + +Inspect (`cursorLockVk`) freezes the cursor and adds a free-look crosshair. It runs in `RunTick`; +the mouse hook only swallows clicks. + +- **Entry.** The real cursor is frozen in place with a 1 px `ClipCursor` (`t.frozenCursor`) and + hidden, so a hover or tooltip under it stays alive. The look point is the mapper centre and pans + from ballistics-cooked raw mickeys ([06](06-input.md)). +- **Crosshair.** Render draws it when `cursorLocked`; the transform repaints the sprite + (`showCrosshair`). The overlay stays active while Inspect is on, so the crosshair roams the whole + screen at 1x. +- **Clicks** go to the look point: the hook swallows the real press, `RunTick` injects an absolute + click there. The freeze clip is released for `clickReleaseTicks` around it, and re-asserts are + deduped through `GetClipCursor` (a clip write under a live transform is in the TDR class). + Transform writes pause ~3 ticks around the click. Inspect stays on after a click. +- **Game-inspect.** A raw-input game's camera cannot be blocked by a hook, so when + `ShouldGameInspect` (`src/inspect_focus.h`) sees a mouselook game, Wind moves foreground to an + invisible 1x1 helper window: the backgrounded game stops receiving raw input and its camera + freezes, while Wind's `RIDEV_INPUTSINK` pan keeps working. The tell is a cursor hidden by the app + while Wind is not hiding it; a detector lock also engages it. Do not make the zoomed path + detector-only: a raw-input game may never clip or recentre the pointer (issue #158). Clicks are + discarded, and foreground is handed back on every exit. +- **Every exit releases the clip**: toggle-off, teardown to idle, device-lost recovery, `shutdown`, + the crash filter and the `atexit` restore. Exits warp the cursor to the look point and drain + pending click counts. + +## Tracking: caret, focus and mouse edge mode + +The view can follow the text caret (`trackCaret`, default on) and keyboard focus (`trackFocus`, +default off). `FocusTracker` (`src/focus_track.*`) runs on its own thread with WinEvents, +`GetGUIThreadInfo` and UI Automation, and publishes a snapshot; the tick never calls UIA. + +- **Tracking never moves the pointer.** While caret or focus owns the view, the view is detached + (`t.viewDetached`, weld off): `DetachedMap` (`src/detached_view.h`) maps the glided centre while + the cursor fields report the real pointer. On a mouse-move takeover the pointer is placed into the + view; the view does not jump to the pointer. +- `StepViewOwner` (`src/view_target.h`) picks one owner per tick: the latest caret or focus change + wins; real mouse motion or a button takes it back. +- **Only keyboard-driven caret moves are followed.** The first caret after a focus change is a + baseline. After a click there is a 1 s quiet period, ended early by a fresh non-modifier key down + (`src/typing_key.h`), never by key-ups, auto-repeat or Ctrl/Shift. +- Caret rects are corrected in `src/caret_rect.h` (tall Chromium rects trimmed to the line, a + whole-line rect recognised). +- The glide is a critically damped spring (`SpringToward`, `src/view_glide.h`, `trackGlideMs`). +- **Java apps** (IntelliJ, PyCharm) expose the caret only through the Java Access Bridge + (`src/java_bridge.*`). UIPI drops the JVM's handshake to a UIAccess process, so the bridge's + hidden windows get a narrow `ChangeWindowMessageFilterEx` allowance. Never poll the bridge (each + read runs on the Java app's UI thread); read only after bridge callbacks. Load only + Authenticode-signed bridge DLLs. +- **Mouse edge mode** (`mouseAlign=1`, free-pointer sessions only): the pointer is unwelded and + `EdgePanCenter` (`src/edge_pan.h`) moves the view only when the cursor's visible body leaves the + margin band (`mouseMarginPct`). Edge-pinned motion is hidden from the lock detector + (`PointerPinnedAtEdge`), or corners fling the pointer. `trackAlign` does the same for the caret. + +Field notes: [../TRACKING-FINDINGS.md](../TRACKING-FINDINGS.md). + +## Keyboard panning + +`ViewOwner::Keys` is a detached owner next to caret and focus. `KeyPan` (`src/keyboard_pan.h`, pure) +turns held pan keys into a view delta in screen space, so the feel does not change with zoom: + +- A hold pans at `panSpeed x 1.25` screen widths per second at high zoom, scaled down at low zoom + by `ZoomRateScale`, with a ~150 ms ease-in and a ~120 ms glide out. +- A press released within 250 ms is a tap: it moves exactly 1/8 of the screen. +- While panning, KeyPan owns the view ahead of caret events. The delta is clamped to the monitor + and the MPO wall. The pointer does not move; the next mouse move places it in the view. + +## Shell input panels + +The emoji picker, clipboard history and touch keyboard are composed above every window band, so +the sprite goes under them. While one is open (`FocusTracker::shellPanelOpen`, from TextInputHost +cloak events) and `panelPointer=1` (default), the transform hides the sprite, restores the real +pointer and makes one public `MagSetFullscreenTransform` write, after which DWM draws the pointer +magnified. The pointer is frozen with a 1 px clip and moved only by Wind, right after each view +write, so the two never drift. + +- Do not write from the hook thread for this: runtime ownership marshals every write onto the input + thread and hitches. +- The freeze is hidden from the lock detector (a flapping lock flickers); tracking and edge mode + pause; the saved clip is restored on close. + +Evidence and the rejected hook-write variant: +[../SHELL-PANEL-CURSOR-FINDINGS.md](../SHELL-PANEL-CURSOR-FINDINGS.md). diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 4c4cd3af..8440fbb3 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -1,280 +1,173 @@ # 08. Config and profiles -Wind's entire configuration is one INI file, `magnifier.ini`, and that file is also the only -IPC between the two binaries: `WindConfig.exe` writes it, `Wind.exe` dir-watches it and -hot-reloads. There is no pipe, no shared memory, no window messages for settings, which keeps the -settings app at zero performance coupling to the magnifier loop. Profiles (issue #178) sit on top -as full-snapshot copies of that same file, one `.ini` per profile, with a handful of global keys -that never travel. This chapter covers where the file lives, how it is parsed and sanitized, what -hot-reloads versus what needs a restart, and the complete profile machinery. - -## One file, two processes - -The contract is deliberately primitive: `WindConfig.exe` (the WebView2 host in -`src/config_ui/main.cpp`) handles every `setConfig` bridge message by rewriting one key in the -ini text (`wind::UpdateIniText`) and writing the file atomically. `Wind.exe` never receives a -message about it; its tick loop notices the file changed and reloads. The two processes cannot -disagree about state because there is only one state, on disk, and both resolve it through the -same function (`wind::ResolveIniPath`, `src/config_path.h`). - -Atomicity matters because both processes write the same file: the tray's profile switch in -`Wind.exe` and every `setConfig` in `WindConfig.exe` go through -`wind::WriteTextFileAtomic` (`src/profiles_io.h`), which writes a temp file and -`MoveFileExW(MOVEFILE_REPLACE_EXISTING)`s it into place. The temp name embeds the writer's -process id, because a shared `.tmp` would let the two processes clobber each other's -in-flight writes. - -**The full write/read flow: settings and profiles all funnel through one file on disk.** +All configuration is one file, `magnifier.ini`. It is also the only settings channel between the +processes: `WindConfig.exe` and `WindTray.exe` write it, `Wind.exe` watches and hot-reloads it. +Profiles are full copies of the same file, one per profile. This chapter covers where the file +lives, parsing, hot versus restart keys, profiles, and the key reference. + +## The ini as IPC + +**Settings travel only through `magnifier.ini`.** `Wind.exe` runs a paced loop where one stalled +millisecond shows as a pan hitch, so the settings GUI (a whole browser engine) lives in a separate +process. The two never message each other about settings: there is one state, on disk, and both +resolve it through `wind::ResolveIniPath()`. The settings app can crash or restart without touching +the magnifier, and the processes run at different integrity levels (`Wind.exe` is UIAccess, the +others are not). + +- Every writer uses `wind::WriteTextFileAtomic` (`src/profiles_io.h`): write a temp file, then + `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`. The temp name embeds the process id, so two writers + never clobber each other's temp file. +- The only cross-process kernel objects are the single-instance mutexes and the + `Local\Wind_QuitRequest` event (quit, restart handshake, installer). A window message would not + work: UIPI drops `PostMessage` from a normal process to a UIAccess one. +- The tray's status block (`Local\Wind_TrayState_v1`) carries status, not settings + ([01](01-overview.md)). ```mermaid flowchart LR - subgraph config [WindConfig.exe] - SV[Svelte settings app] -->|setConfig key,value| WM[HandleWebMessage\nconfig_ui/main.cpp] - end - subgraph disk [Disk] - INI[(magnifier.ini\nResolveIniPath)] - PROF[(profiles/Name.ini\none per profile)] - end - subgraph core [Wind.exe] - WATCH[dir-change watch\n~4 Hz check] --> RELOAD[StripUiOnlyKeys fingerprint\nthen LoadConfig] - RELOAD --> TICK[RunTick uses new Config] - TRAY[tray Profiles submenu\ntray_app/tray_menu.cpp SwitchToProfile] - end - WM -->|UpdateIniText + atomic write| INI - WM -->|mirror: MakeProfileText| PROF - TRAY -->|MakeLiveText + atomic write| INI - TRAY -->|MirrorLiveToActiveProfile| PROF - INI --> WATCH + SV[Settings app] -->|setConfig| HOST[WindConfig.exe] + TRAY[WindTray.exe flyout] -->|atomic write| INI + HOST -->|UpdateIniText + atomic write| INI[(magnifier.ini)] + HOST -->|Save: MakeProfileText| PROF[(profiles/Name.ini)] + INI -->|dir watch, fingerprint, LoadConfig| CORE[Wind.exe] ``` -## Where the file lives: `ResolveIniPath` - -`wind::ResolveIniPath()` (`src/config_path.h`) is the single answer to "which magnifier.ini", -used by both exes so they always touch the same file. It probes whether the exe's own directory -is writable by creating a sentinel file with `FILE_FLAG_DELETE_ON_CLOSE` (so the probe leaves no -trace). If the write succeeds, the ini lives next to the exe: the dev and portable layout, where -editing the file in the repo directory is convenient. If it fails, we are in a read-only install -(in practice `C:\Program Files\Wind`), and the path falls back to -`%LOCALAPPDATA%\Wind\magnifier.ini`, creating the directory if needed. On the first fall-back -run, if a template ini exists next to the exe it is copied over as a seed, so deploy-time -defaults carry to the writable location; the current deploy ships no template, so -`LoadConfig` (`src/config.cpp`) simply creates the file from built-in defaults, with a long -commented header so the user has something readable to hand-edit. - -The reason this helper exists, and the reason it must always be used instead of a hardcoded -`L"magnifier.ini"`, is the Program-Files-read-only law: the deployed UIAccess build lives in -Program Files, `WindConfig.exe` runs as a normal user, and a write next to the exe there fails -silently. Historically that single mistake broke Apply, live keybind capture, and WebView2 -initialization on the deployed build, each time as a "works in dev, dead in Program Files" bug. -`wind::ResolveLogDir` in the same header applies the identical probe for logs and crash dumps, -and the WebView2 user-data folder is likewise explicitly pointed at `%LOCALAPPDATA%\Wind\WebView2` -for the same reason. The rule generalizes: anything the runtime writes goes to a per-user -location, never next to the exe. - -## Parsing: defaults, clamps, forbidden binds - -`wind::ParseConfig` (`src/config.cpp`, declared in `src/config.h`) is pure, windows.h-free, and -unit-tested. Every field of `wind::Config` carries its default as a struct initializer, so a -missing or malformed key silently keeps the default; parsing never fails. After reading the -key=value lines it sanitizes: - -- Numeric ranges are clamped (`maxLevel`, speeds, `magnifyStep` to Windows' own 5..400, the - transform diagnostics knobs, and so on), so a hand-edited ini cannot push a value into a range - the runtime was never tested at. -- `model` must be one of `render`, `magnify`, `transform`, `hybrid`; anything else becomes - `hybrid`, the product default ("Auto" in the UI). Note: a comment in - `src/config_ui/main.cpp` (`DoSwitchProfile`) still describes an older fallback; the code in - `ParseConfig` is the truth. -- Every keybind VK is passed through `sanitizeVk`, which unbinds any key - `wind::IsForbiddenBindVk` rejects: left/right mouse button (VK 1/2), Backspace (8), and both - Windows keys (0x5B/0x5C). A bound key is swallowed system-wide by the LL hooks - (see [The input pipeline](06-input.md)), so binding one of these would cost the user a key - they cannot live without. The ban is enforced in three independent places, deliberately: - the hook never swallows these keys, `ParseConfig` strips them from the ini, and the settings - UI's keybind capture refuses them. Defense in depth, because the failure mode is "the user - cannot click anymore". - -`LoadConfig` is the thin I/O wrapper (read file, `ParseConfig`, or create the commented default -file when absent), excluded from the test build via `WIND_TESTS` so the pure half stays -desktop-free. - -## Hot-reload and the `StripUiOnlyKeys` fingerprint - -The reload mechanism itself lives in `RunTick` (`src/main.cpp`) and is described in -[The tick loop](02-tick-loop.md): a `FindFirstChangeNotification` watch on the ini's directory, -checked non-blockingly about four times a second, with an mtime compare before anything is -re-read. What matters here is the guard behind it. - -Not every write to the ini should reload the core. The settings app owns three keys the core -never consumes (`uiTheme`, `showAdvanced`, `onboarded`), and a real reload is not free: it -resets the `ZoomController` and rebuilds the `CursorMapper`, so it collapses an active zoom to -1x. Before the fingerprint existed, toggling the app theme while zoomed did exactly that (Max -field report). So on every mtime change the core computes -`wind::StripUiOnlyKeys(iniText)` (`src/config.cpp`): the ini text with the UI-only lines -removed, everything else verbatim. It compares that stripped form against the one it stored at -the last reload (`t.lastCoreIni`), and skips the reload entirely when they match. `profile` -deliberately stays IN the fingerprint: the core mirrors settings into the active profile, so a -profile switch must reload even though `profile` itself is a global key. - -When the fingerprint does differ, the reload path re-binds anything captured outside the -`Config` struct: the mouse hook's button mapping (`g_input.setButtons`), the keyboard hook's -swallowed-key set (`g_input.setKeys`), the `RegisterHotKey` registrations for hide-cursor and -quick zoom, and the transform model's idle-release timeout (`TransformModel::setIdleReleaseMs`). -Then `t.cfg = nc` and the per-tick consumers just see new values. - -## Hot versus restart knobs - -There is no formal registry of which keys are hot; the rule falls out of how a value is -consumed, and the comment on each `Config` field states it. The heuristic for reading the code: - -- **Hot**: anything read from `t.cfg` per tick or per zoom-in. The reload swaps `t.cfg`, so the - next consumer sees the new value. Examples: `dwmFlush`, `cursorSensitivity`, `outline*`, - `desktopTransform`, `txMaxStepPct` (the per-tick relative level-step cap, shipped 25 per - mille after the issue #219 ramp-stall soaks), `lockApps` and `warpLock` (the issue #221 - pointer-warping-game lock tells), `multiMonitor` (applies at the next zoom-in), - `txIdleReleaseMs` (pushed into the live model by the reload path), and `magnifyStep` - (live-written to the Magnifier registry). -- **Restart**: anything read once during initialization and baked into constructed state. - `model` is the canonical case: the model object is built at launch, so switching it requires - a process restart (see the eviction handshake below). `gpuPriority` applies at D3D device - build; `spriteBand16` at sprite-window creation; `zorderBand` at overlay creation. - -The settings UI encodes the same split: every row applies live to the session (the live ini), -Save writes the session into the active profile, keybinds also persist at once, and a `model` -change goes through an explicit restart -(see [The settings UI](09-settings-ui.md)). +## Where the file lives -## Profiles +`wind::ResolveIniPath()` (`src/config_path.h`) probes whether the exe's directory is writable +(a sentinel file opened with `FILE_FLAG_DELETE_ON_CLOSE`). -Profiles (issue #178, spec -[2026-08-12-profiles-design.md](../superpowers/specs/2026-08-12-profiles-design.md)) are named, -switchable, full snapshots of the settings, keybinds included. The design principle is that the -live `magnifier.ini` stays the single config both exes use; a profile is just a saved copy of -its profile-scoped content, stored as `profiles\.ini` next to the resolved ini -(`wind::ProfilesDirFromIni`, `src/profiles_io.h`), plus a `profile=` pointer in the live -ini saying which one is active. - -The logic/I-O split mirrors the rest of the codebase: `src/profiles.h`/`.cpp` is pure -(no windows.h, doctested), `src/profiles_io.h` is the thin Win32 layer shared by both exes. - -### Global keys never travel - -`wind::IsGlobalProfileKey` (`src/profiles.cpp`) names the four keys that are machine/app state, -not settings: `profile` (the active-profile pointer itself), `onboarded`, `uiTheme`, and -`showAdvanced`. Switching profiles must not replay someone's onboarding or flip the app theme, -so these lines are stripped from every profile file (`MakeProfileText`) and carried over from -the old live text on every switch (`MakeLiveText`). Both transforms work line-by-line and keep -everything else verbatim, comments and ordering included, so profile files stay hand-editable -exactly like the live ini. - -### Switching: `MakeLiveText` and the model restart - -A switch, whether from the tray (`SwitchToProfile`, `src/tray_app/tray_menu.cpp`, in `WindTray.exe`) or the settings-UI titlebar -dropdown (`DoSwitchProfile`, `src/config_ui/main.cpp`), is the same sequence: - -1. Validate the profile file. `wind::ProfileTextError` rejects binary content, absurd size, and - text that has non-comment lines yet parses to zero keys, so a corrupt or locked file can - never be silently applied. Crucially, a read FAILURE is distinguished from an EMPTY file, - because empty is legitimate (see below). -2. The settings UI asks Save / Discard / Cancel before a switch when the session has unsaved - changes (live differs from the profile). A tray switch carries no prompt and discards the - outgoing profile's unsaved changes by design. -3. `wind::MakeLiveText(profileText, oldLiveText, name)` builds the new live ini: the profile's - text with any smuggled global-key lines stripped, plus the globals carried from the old live - text, plus `profile=`. Written atomically over `magnifier.ini`. -4. The core's dir-watch hot-reloads everything except `model`. Both switch surfaces compare - `ParseConfig(oldLive).model` against `ParseConfig(newLive).model` (parsed, not raw text, so - canonicalization is shared) and, when they differ, relaunch `Wind.exe`. If the relaunch - fails, both surfaces write the OLD model back into the new live ini, preserving the - invariant "ini model == running model" while keeping the rest of the switch. - -The relaunch works through the **eviction handshake** rather than any kill: the new instance's -`AcquireSingleInstance` (`src/main.cpp`) finds the single-instance mutex held, signals the named -event `Local\Wind_QuitRequest`, and waits for the incumbent to exit cleanly before taking over. -Only the clean exit restores the OS cursor, releases `ClipCursor`, and restores the native -Magnifier registry backup, which is also why the installer uses the same event instead of -`taskkill`. A kernel event is used instead of a window message because the deployed `Wind.exe` -is UIAccess and UIPI silently drops `PostMessage` from the normal-IL config host. - -**Profile switch, end to end (settings-UI surface; the tray path is the same shape).** +- Writable (dev build): the ini sits next to the exe. +- Read-only (`C:\Program Files\Wind`): `%LOCALAPPDATA%\Wind\magnifier.ini`, seeded from a template + next to the exe if one exists, otherwise created by `LoadConfig` with a commented header. +- **Never hardcode `L"magnifier.ini"`.** Program Files is read-only for the non-admin runtime, and a + write next to the exe fails silently. The same applies to logs (`ResolveLogDir`) and the WebView2 + user-data folder (`%LOCALAPPDATA%\Wind\WebView2`). -```mermaid -sequenceDiagram - participant UI as WindConfig.exe - participant P as profiles/Name.ini - participant I as magnifier.ini - participant W as Wind.exe (running) - participant W2 as Wind.exe (new) - UI->>P: read + ProfileTextError check - UI->>P: MirrorLiveToActiveProfile (outgoing profile) - UI->>I: write MakeLiveText(profile, oldLive, name) - I-->>W: dir-watch fires, fingerprint differs, hot-reload - alt model changed - UI->>W2: LaunchWind (ShellExecute) - W2->>W: signal Local\Wind_QuitRequest - W->>W2: clean exit releases mutex, W2 takes over - end -``` +## Parsing + +`wind::ParseConfig` (`src/config.cpp`) is pure and tested. Every `Config` field carries its default +as an initializer, so a missing or malformed key keeps the default; parsing never fails. + +- Numeric values are clamped to tested ranges. +- `model` must be `render`, `transform` or `hybrid`; anything else becomes `hybrid`. +- Unsafe binds read as unbound (`src/keybind_rules.h`, [06](06-input.md)). +- `LoadConfig` is the I/O wrapper, excluded from the test build by `WIND_TESTS`. + +## Hot-reload and the UI-only fingerprint + +The reload mechanics are in [02](02-tick-loop.md). A reload rebuilds `ZoomController`, so it +collapses an active zoom; therefore `StripUiOnlyKeys` removes the four keys the core never reads +(`uiTheme`, `uiPalette`, `showAdvanced`, `onboarded`) and the reload is skipped when the stripped +text is unchanged. `profile` stays in the fingerprint, so a profile switch reloads. + +**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), `txHookWrite` (runtime thread ownership), `gpuPriority` (device build), `spriteBand16` +(sprite creation), `zorderBand` (overlay creation). The comment on each `Config` field says which. + +## Profiles -### Session model: live ini = session, profile file = saved - -Since 0.16.0 (#303) profiles are no longer live-bound. Every settings change writes only the live -ini (the session) and the core hot-reloads it; the active profile's file changes only on Save -(`MakeProfileText(live)`) or when a keybind is captured (`setConfigPersist` updates that one key in -both). "Unsaved" means `wind::SessionDiffers(live, profile)` (`src/profiles.*`): any profile-scoped -key differs, global keys ignored, a key missing on one side compares as missing, values trimmed. -The session resets when Wind closes: at start the core calls `ResetSessionToProfile` -(`src/profiles_io.h`) and rewrites the live ini from the active profile via `MakeLiveText`, unless -`%LOCALAPPDATA%\Wind\session.keep` exists, which a self-triggered restart (engine change, profile -switch with a model change) leaves behind and which is consumed once. The tray's Quit also compares -files with `SessionDiffers` and prompts Save / Discard / Cancel. See -[The settings UI](09-settings-ui.md#session-model-instant-apply-explicit-save). - -### Empty file = factory defaults - -An empty (or comment-only) profile file is the legitimate representation of factory defaults: -`MakeLiveText` of empty text yields a live ini holding only the globals, and every -profile-scoped key then falls back to its `ParseConfig` struct default. That is literally how -"create new profile" works: the host writes a near-empty file (one comment plus `model=hybrid`, -seeded explicitly so every surface agrees on the product default) and switches to it. This is -also why the read-versus-empty distinction in step 1 above is load-bearing: treating a locked -file as empty would wipe the user's settings to defaults. - -### Seeding: `EnsureProfilesSeeded` - -`wind::EnsureProfilesSeeded` (`src/profiles_io.h`) runs at `Wind.exe` startup, before the tick -loop records the ini mtime (so the seed write never triggers a spurious hot-reload). On the -first launch after the profiles update there is no `profiles\` directory; the seed creates it, -captures the user's CURRENT settings as `Default.ini`, and writes `profile=Default` into the -live ini, so existing installs get a Default profile with zero user action. The directory's -existence is the idempotency latch, which is why a failed `Default.ini` write rolls the -still-empty directory back: otherwise a half-migrated state would persist forever. The config -host calls the same function defensively before creating a profile, so creating one on a -pre-migration install cannot trip the latch without capturing Default first. - -### Validation and naming - -Profile names become NTFS file names, so `wind::ProfileNameError` rejects path characters, -control characters, leading/trailing dots and spaces, Windows reserved device names, and -anything over 40 characters; the bridge refuses any name that fails it before it can reach -`ProfilePath`. Identity is case-insensitive everywhere (`SameProfileName`, -`ProfileNameTaken`), matching NTFS, and the filesystem itself is the final authority on -collisions since its Unicode case folding is broader than the pure ASCII check. `NextCopyName` -generates "Name copy", "Name copy 2", ... for duplication, truncating to fit the cap. - -## Pointers - -- `src/config.h` / `src/config.cpp`: the `Config` struct with per-key defaults and hot/restart - comments, `ParseConfig`, `IsForbiddenBindVk`, `StripUiOnlyKeys`, `LoadConfig`. -- `src/config_path.h`: `ResolveIniPath`, `ResolveLogDir`, the writability probe. -- `src/profiles.h` / `src/profiles.cpp`: pure profile logic (global keys, name validation, - `MakeProfileText` / `MakeLiveText`, `ProfileTextError`). -- `src/profiles_io.h`: profile file I/O, `WriteTextFileAtomic`, `MirrorLiveToActiveProfile`, - `EnsureProfilesSeeded`. -- `src/config_ui/main.cpp`: the bridge (`HandleWebMessage`), `DoSwitchProfile`, the setConfig - mirror; `src/tray_app/tray_menu.cpp`: the tray switch surface. -- Spec: [2026-08-12-profiles-design.md](../superpowers/specs/2026-08-12-profiles-design.md). -- Related chapters: [The tick loop](02-tick-loop.md) (the watch/reload mechanics), - [The settings UI](09-settings-ui.md) (the other side of the bridge), - [The input pipeline](06-input.md) (why forbidden binds exist), - [Build, test, release](11-build-test-release.md) (the installer's use of the quit event). +A profile is a named full snapshot of the settings, keybinds included, stored as +`profiles\.ini` next to the resolved ini, with `profile=` in the live ini. Pure logic is +in `src/profiles.*` (tested); I/O in `src/profiles_io.h`. + +**Global keys never travel with a profile.** `IsGlobalProfileKey` covers `profile`, `onboarded`, +`uiTheme`, `uiPalette`, `showAdvanced` and the five tray layout keys (`trayPerf`, `traySliders`, +`traySliderOrder`, `trayToggles`, `trayToggleOrder`). `MakeProfileText` strips them from profile +files; `MakeLiveText` carries them over from the old live text. Both work line by line and keep +comments and order. + +**Session model: the live ini is the session, the profile file is the saved state.** + +- Every settings change writes only the live ini, and the core hot-reloads it. +- The profile file changes on Save (`saveSession`) or when a keybind is captured + (`setConfigPersist`, which writes that key to both). +- Unsaved means `SessionDiffers(live, profile)`: a profile-scoped key differs; globals ignored. +- At start the core runs `ResetSessionToProfile`, so unsaved changes never survive a restart, + unless `%LOCALAPPDATA%\Wind\session.keep` marks a restart Wind triggered itself (engine change, + profile switch with a model change). It is consumed once. +- Tray Quit compares the files and prompts Save, Discard or Cancel. + +**Switching** (Settings or the tray flyout, the same sequence): + +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. +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 + matches the running engine. + +**The relaunch uses the eviction handshake, never a kill.** The new instance finds the +single-instance mutex held, sets `Local\Wind_QuitRequest` and waits for the old one to exit. Only a +clean exit restores the OS cursor and releases `ClipCursor`. + +**An empty profile file means factory defaults**: every profile key falls back to its `ParseConfig` +default. "New profile" writes a near-empty file (a comment plus `model=hybrid`) and switches to it. + +**Seeding.** `EnsureProfilesSeeded` runs at startup before the ini mtime is recorded. With no +`profiles\` directory it creates one, saves the current settings as `Default.ini` and writes +`profile=Default`. The directory is the latch, so a failed `Default.ini` write removes it again. + +**Names** become file names: `ProfileNameError` rejects path and control characters, leading or +trailing dots and spaces, reserved device names and names over 40 characters. Matching is +case-insensitive (`SameProfileName`). `NextCopyName` produces "Name copy", "Name copy 2". + +## Key reference + +Every key works in the ini whether or not Settings shows it. Keys hot-reload unless marked. + +**Binds.** All ship unbound until the first-run setup. + +| Keys | Meaning | +|---|---| +| `zoomInVk`/`zoomInMods`, `zoomInButton`/`zoomInButtonMods`, and the `*2` alternates; same for `zoomOut*` | Hold to zoom. Buttons: 1/2 side buttons, 3/4/5 left/right/middle click (with modifiers), 6/7 wheel up/down | +| `panLeftVk`, `panRightVk`, `panUpVk`, `panDownVk` + `*Mods`, `panKeysOn` | Keyboard panning while zoomed | +| `hideCursorVk`/`hideCursorMods`, `hideCursorOn` | Toggle the pointer while zoomed | +| `cursorLockVk`/`cursorLockMods`, `cursorLockOn` | Inspect mode | +| `recenterVk` | Recentre the view on the cursor | +| `quickZoomHotkeyMode`, `quickZoomModifier` (default Ctrl), `quickZoomVk`/`quickZoomMods`, `quickZoomDefault` (4.0) | Quick zoom: modifier + a zoom key (mode 0) or a dedicated hotkey (mode 1) toggles between 1x and the remembered level | +| `noSwallowApps` | Exes where the keyboard hook is dropped | + +**Zoom and view.** + +| Keys | Meaning | +|---|---| +| `maxLevel` (12), `zoomInSpeed`, `zoomOutSpeed`, `zoomEaseOutMs`, `smoothZoom*` | Range and feel of the zoom | +| `cursorSensitivity` (1.0), `cursorSmoothing`, `panSpeed` (1.0) | Mouse and arrow-key pan speed and inertia | +| `mouseAlign` (0 centred, 1 within the edges), `mouseMarginPct` | Where the pointer sits while the view moves | +| `trackCaret` (1), `trackFocus` (0), `trackAlign`, `trackGlideMs` (200) | Follow the text caret and keyboard focus | +| `lockApps`, `warpLock` (0) | Games whose sessions pan from raw mouse motion; heuristics for unlisted games | +| `multiMonitor` (0) | 1 follows the cursor's monitor at each zoom-in | + +**Image and cursor.** + +| Keys | Meaning | +|---|---| +| `txSamplingMode` (0) | Transform sampling: 0 nearest, 1 smooth ("High resolution cursor", coupled to MPO, [05](05-transform-engine.md)) | +| `bilinear`, `sharpness`, `brightness`, `hdrTonemap` (1) | Render engine image | +| `cursorConstantSize` (0), `cursorVisibility` (`auto`) | Render cursor size and when it is drawn | +| `colorWarmPct` (0), `colorDimPct` (100) | Warmth and brightness filter | +| `outline*` | Zoom outline | + +**Engine.** `model` needs a restart. + +| Keys | Meaning | +|---|---| +| `model` | `hybrid` (Auto, default), `render` or `transform`; anything else reads as `hybrid` | +| `engineGame`, `engineAcrylic`, `engineDesktop`, `engineOther` | `auto`, `transform` or `render` per window category, Auto only | +| `desktopTransform` (1) | Let Auto use the transform on the desktop when UIAccess is available | +| `transformExclude`, `renderExclude` | Exes that never get Transform, or never get Render | +| `vsync` (1), `dwmFlush` (0), `gameFpsCap`, `gpuPriority` (restart) | Render pacing and game coexistence | +| `zorderBand` (0, restart) | 16 covers the shell in the UIAccess build, at the cost of the Snipping Tool ([04](04-render-engine.md)) | + +**Global and UI.** `profile`, `onboarded`, `uiPalette` (`grey`, `ember`, `ocean`, `hicon`), +`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`. diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index 94273c2f..13a203ab 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -1,409 +1,226 @@ # 09. The settings UI -Wind's settings live in a second, entirely separate process: `WindConfig.exe`, a thin C++ -WebView2 host (`src/config_ui/main.cpp`) that loads a built Svelte app from `ui/dist/`. It talks -to the magnifier core only by writing `magnifier.ini`, which the core dir-watches and -hot-reloads, so the settings window has zero performance coupling to the tick loop and needs no -IPC channel of its own. This chapter covers the host, the bridge message set, the schema-driven -Svelte app, the session model (instant apply, explicit Save), profiles, theming, onboarding, accessibility, and how the UI -is tested headlessly with a Playwright mock of the WebView2 bridge. - -## Why a second process - -The core (`Wind.exe`) runs a paced tick loop where a single stalled millisecond is visible as a -pan hitch, so nothing interactive or heavyweight is allowed to live in that process. The settings -GUI is the opposite kind of program: rarely open, UI-rich, and best written in web tech. Splitting -them means the config app can embed a whole browser engine without the magnifier ever paying for -it, and it means the two can run at different integrity levels (the deployed `Wind.exe` is -UIAccess, `WindConfig.exe` is a normal-IL process). See [Overview](01-overview.md) for the -two-binaries product framing. - -The price of the split is that the two processes must agree on state without talking to each -other. The design answer is radical: they do not talk. `magnifier.ini` is the single shared -artifact. The UI writes it (`WriteFileAtomic` in `src/config_ui/main.cpp`, delegating to -`wind::WriteTextFileAtomic` so both processes use the same per-process temp naming and cannot -clobber each other's in-flight writes), and the core's directory watch picks the change up within -a tick (see [The tick loop](02-tick-loop.md) and [Config and profiles](08-config-profiles.md)). -The only exceptions are two kernel objects: the `Local\Wind_QuitRequest` event (used by -`quitWind` and by the model-restart handshake) and the single-instance mutexes. A window message -would not work here, and the comment in `HandleWebMessage`'s `quitWind` branch says why: UIPI -silently drops `PostMessage` from a normal-IL process to a UIAccess one, while a named kernel -event is not gated by UIPI. - -One consequence of the ini-as-IPC design is that the core must not overreact to writes it does -not care about. The core keeps a fingerprint of the ini with UI-owned keys stripped -(`wind::StripUiOnlyKeys`, `src/config.cpp`, applied in `RunTick`'s reload path in -`src/main.cpp`) and skips the hot-reload when the stripped text is unchanged. Before that guard, -toggling the app theme wrote `uiTheme`, the core reloaded, the reload reset the `ZoomController`, -and a theme flip mid-zoom collapsed the zoom to 1x (a Max field report; the fingerprint is also -seeded at startup so the first write of a session gets the same treatment). `profile` stays in -the fingerprint on purpose: profile switches must still reload. - -## The host: a frameless window around WebView2 - -`wWinMain` in `src/config_ui/main.cpp` is deliberately small. It enforces a single instance -(`WindConfig_SingleInstance` mutex; a second launch focuses the existing `WindConfigWnd` -window), decides between settings mode and onboarding mode, creates a frameless `WS_POPUP` -window with its own hit-testing (`WM_NCCALCSIZE` / `WM_NCHITTEST` in `WndProc`, with WebView2's -non-client region support so the web page's `app-region: drag` CSS drives window dragging), and -spins up WebView2. - -Two host details are traps a contributor will hit if they touch this code: - -- **The WebView2 user-data folder is explicit.** The default location is next to the exe - (`\WindConfig.exe.WebView2`), which works in dev and silently fails under - `C:\Program Files\Wind`, where non-admin processes cannot write: environment creation fails - and the window paints as an empty shell. The host therefore always passes - `%LOCALAPPDATA%\Wind\WebView2` to `CreateCoreWebView2EnvironmentWithOptions`. This is one - instance of the general Program-Files-is-read-only rule in - [Config and profiles](08-config-profiles.md); the ini path itself goes through - `wind::ResolveIniPath()` (`src/config_path.h`) for the same reason. -- **The UI is served from a virtual host, not `file://`.** `SetVirtualHostNameToFolderMapping` - maps `https://wind.config/` onto `\ui\dist`, and the host navigates to - `https://wind.config/index.html` (with `?mode=onboard` appended for onboarding). A failed - environment creation (missing WebView2 Runtime) is caught, logged, explained in a message box, - and the process exits rather than leaving a dead shell. - -The host also owns a one-second **watchdog timer** (`kWindWatchTimerId` in `WndProc`): the -settings window should not exist when the magnifier is gone (quit from the tray, Ctrl+Alt+Q, or -a crash), because there would be nothing left to apply settings to. The decision logic is pure -and unit-testable: `wind::ShouldCloseOnWindGone` (`src/config_ui/wind_watchdog.h`) requires Wind -to have been *observed running first* (so the onboarding-after-failed-launch window is never -closed by its own watchdog) and requires **two** consecutive misses (so one transient -`CreateToolhelp32Snapshot` failure inside `WindRunning()` never closes the user's window). The -liveness probe itself is a Toolhelp process-name scan rather than a mutex open or a process -handle wait, because both of those can be access-denied against a higher-integrity UIAccess -process. The same timer also polls the ini for an externally switched profile (the tray can -rewrite `profile=` under us) and pushes a refreshed profile list to the web side with -`push:true`. - -**Launch routing: how one exe serves both onboarding and settings.** +Settings run in `WindConfig.exe`, a thin C++ WebView2 host (`src/config_ui/main.cpp`) that loads +a built Svelte app from `ui/dist/`. It talks to the core only through `magnifier.ini` +([08](08-config-profiles.md) explains why). This chapter covers the host, the bridge, the +schema-driven app, the session model, the tray menu page, profiles, themes, onboarding, +accessibility and testing. + +## The host + +`wWinMain` enforces one instance (`WindConfig_SingleInstance`; a second launch focuses the +window), picks settings or onboarding mode, creates a frameless `WS_POPUP` with its own hit-testing +(the page's `app-region: drag` CSS drives dragging) and starts WebView2. + +- **The WebView2 user-data folder is explicit**: `%LOCALAPPDATA%\Wind\WebView2`. The default, next + to the exe, is read-only in Program Files; environment creation then fails and the window paints + empty. +- **The UI is served from a virtual host**: `https://wind.config/` maps onto `\ui\dist` + (`?mode=onboard` for onboarding). A missing WebView2 Runtime is logged, explained in a message + box, and the process exits. +- **Crash recovery.** RTSS's global hook can crash the WebView2 browser process at start (it reads + a `dxgi.dll` that WebView2 already unloaded). On `ProcessFailed` the host recreates the engine, + at most 3 times a minute (`src/config_ui/webview_recover.h`), and hands back the page's unapplied + edits. +- **Watchdog.** A 1 s timer closes Settings when Wind is gone. `ShouldCloseOnWindGone` + (`src/config_ui/wind_watchdog.h`, pure) requires Wind to have been seen running first and two + consecutive misses. The probe is a Toolhelp process-name scan, because a mutex open or process + wait can be access-denied against a UIAccess process. The same timer notices a profile switched + from the tray and pushes the new list. ```mermaid flowchart TD A[WindConfig.exe starts] --> B{--onboard flag?} - B -->|yes| O[Show onboarding UI] - B -->|no| C{onboarded=1 in ini?} + B -->|yes| O[Show onboarding] + B -->|no| C{onboarded=1?} C -->|no| D{Launch Wind.exe ok?} - D -->|yes| E[Exit: Wind re-spawns us with --onboard] + D -->|yes| E[Exit: Wind starts us with --onboard] D -->|no| O C -->|yes| F{Wind running?} - F -->|no| G[Launch Wind.exe] --> S[Show settings UI] + F -->|no| G[Launch Wind.exe] --> S[Show settings] F -->|yes| S ``` -The rule encoded here: settings never runs without the magnifier, and the config page is never -shown against a not-yet-onboarded config. The `--onboard` guard on the first branch prevents a -launch loop. +Settings never runs without the magnifier and never shows over a config that has not been +onboarded. ## The bridge -The web side posts JSON messages via `window.chrome.webview.postMessage`; the host handles them -in `HandleWebMessage` (`src/config_ui/main.cpp`), which is the **authoritative list** of the -message set. `ui/src/bridge.js` is the JS mirror: it wraps each message in a small helper, and -request/reply pairs become promises that resolve on the matching reply type. The host parses the -JSON with hand-rolled `JsonField`/`JsonEscape`/`JsonUnescape` helpers rather than a JSON library; -the escaping is complete over the control-character set because one unescaped newline in an -ini value would make the reply invalid JSON, `PostWebMessageAsJson` would reject it, and the UI -would hang waiting for a config that never arrives (the comment on `JsonEscape` records exactly -this failure mode). - -| Message | Direction | What it does | +The page posts JSON with `window.chrome.webview.postMessage`; `HandleWebMessage` is the +authoritative message list, and `ui/src/bridge.js` mirrors it (request/reply pairs become +promises). The host's JSON escaping covers every control character: one unescaped newline makes +the reply invalid and the page waits forever. + +| Message | Kind | Effect | |---|---|---| -| `getConfig` | request/reply `config` | Dump every ini key/value to the UI | -| `setConfig` | fire-and-forget | Atomic write of one key into the live ini (the session). Never touches the profile file | -| `setConfigPersist` | fire-and-forget | Same, and also writes that single key into the active profile file (`UpdateProfileKey`). Used by keybind captures | -| `saveSession` | request/reply `sessionSaved` | Save: write `MakeProfileText(live)` over the active profile file | -| `discardSession` | request/reply `config` | Discard: rewrite the live ini from the profile (`MakeLiveText`) and reply with fresh `values`+`saved` | -| `ready` | fire-and-forget | Posted two animation frames after App mounts; the host logs launch-to-first-paint | -| `window` | fire-and-forget | `minimize` / `close` (with `force`) / `quitWind` / `restartWind` (writes `session.keep` first, see below) | -| `dirty` | fire-and-forget | Mirror the unsaved flag into the host so `WM_CLOSE` (Alt+F4, system menu) can raise the Save / Discard / Keep prompt | -| `openIni` | fire-and-forget | Open `magnifier.ini` with the registered `.ini` handler, Notepad fallback | -| `exportDiagnostics` | fire-and-forget | Zip `%LOCALAPPDATA%\Wind\logs` to the Desktop and reveal it | -| `pickExe` | request/reply `exePicked` | Native file picker; replies with the bare exe **name**, never a path, because the core matches app lists by file name (`IsExeInList`) | -| `mpoState` | request/reply `mpoState` | Read-only HKLM probe: registry value, plus what DWM actually loaded at boot (`wind::MpoStateAtBoot`) | -| `setMpoDisabled` | request/reply `mpoApplied` | Elevated registry write (UAC); replies with the re-read state so a cancelled prompt reverts the toggle | -| `rebootNow` | fire-and-forget | `shutdown.exe /r /t 0` (no `/f`, so other apps can object) | -| `listProfiles` / `switchProfile` / `createProfile` / `renameProfile` / `duplicateProfile` / `deleteProfile` | request/reply `profiles` | Profile file ops; every mutation replies with the refreshed list so the UI never guesses. The UI sends only `switchProfile`, `createProfile` and `deleteProfile`: the New profile dialog duplicates the current settings by `createProfile` plus a write of the snapshot, so `listProfiles`, `renameProfile` and `duplicateProfile` are host-only (kept, the tests mock them) | - -Two protocol details matter. First, every profile reply carries the full refreshed -`{names, active}` state; the host can also send the same `profiles` message *unsolicited* when -the watchdog timer notices a tray-side profile switch, marked `push:true` so -`bridge.js`'s `profileRequest` helper never mistakes it for the reply to an in-flight request. -Second, profile names arriving over the bridge become file paths, so the host validates every -one through the pure `wind::ProfileNameError` before `ProfilePath` ever sees it (traversal -characters, reserved names, and dots are rejected; see -[Config and profiles](08-config-profiles.md) for the profile machinery itself). - -## The Svelte app: schema-driven rows - -The entire settings page is generated from one data structure: `groups` in -`ui/src/settings-schema.js` (redesign #303). There are eight task-based groups, each with a label, -sidebar icon, banner description and a list of cards; each card holds rows, and each row is a plain -object naming its ini key, row type, label, description, and default. `ui/src/Settings.svelte` is -the shell: it renders the `shell/` pieces (title bar, sidebar, banner, save capsule, cards), routes -search through `search/`, and renders every row through `controls/SettingRow.svelte`, which -switches on `row.type`: - -| Row type | Widget | Notes | +| `getConfig` | reply `config` | Every live key and value, plus the saved profile values | +| `setConfig` | fire | Atomic write of one key to the live ini | +| `setConfigPersist` | fire | Same, and the key in the active profile file (keybind captures) | +| `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) | +| `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 | +| `pickExe` | reply `exePicked` | File picker; replies with the bare exe name, because app lists match by name | +| `mpoState` | reply `mpoState` | Registry value and the state DWM loaded at boot | +| `setMpoDisabled` | reply `mpoApplied` | Elevated registry write; replies with the re-read state, so a cancelled UAC reverts | +| `rebootNow` | fire | `shutdown.exe /r /t 0`, without `/f` | +| `listProfiles`, `switchProfile`, `createProfile`, `renameProfile`, `duplicateProfile`, `deleteProfile` | reply `profiles` | Every reply carries the full `{names, active}`. The page uses switch, create and delete | + +- An unsolicited `profiles` message (tray switch) is marked `push:true`, so it is never taken for a + pending reply. +- Profile names from the bridge become file paths, so the host validates each with + `ProfileNameError` first. + +## The schema-driven app + +The page is generated from `groups` in `ui/src/settings-schema.js`. Seven groups: Hotkeys, Zoom, +View, Screen, then below a divider Preferences, Tray menu and About. Each group has a label, an +icon, a banner description and cards of rows; each row names its ini key, type, label, description +and default. `Settings.svelte` is the shell (title bar, sidebar, banner, save capsule, search), and +`controls/SettingRow.svelte` renders every row by type: + +| Type | Widget | Notes | |---|---|---| -| `keybind` | `lib/KeybindCapture.svelte` + `controls/Keycaps.svelte` | State lives under `buttonKey`/`vkKey`/`modsKey` sibling ini keys, not `row.key` (a `__`-prefixed placeholder); zoom in/out carry a second slot (`*2` keys) that the core OR-combines | -| `toggle` | `controls/Toggle.svelte` | Writes `1`/`0` | -| `slider` | `controls/Slider.svelte` | `min`/`max`/`step`/`unit`; `unit` also feeds `aria-valuetext` | -| `select` | `controls/Select.svelte` | `options` + `optionLabels` (e.g. `hybrid` shown as "Auto") | -| `applist` | `controls/AppList.svelte` | One comma-separated ini string; the host's `pickExe` feeds it bare exe names | -| `highres` | `controls/HighRes.svelte` | The combined high-resolution-cursor/MPO toggle (issue #242); keeps its confirm step (UAC plus restart prompt) as an inline action | -| `engine` | `controls/EngineRow.svelte` | The Magnifier engine select; `model` is read once at launch, so it applies on an inline "Restart Wind" button, not on selection | -| `palette` | `prefs/ThemePicker.svelte` | The Theme row: one row of four mini window preview cards (name under each, selected one outlined, no scrolling or arrows), right-aligned like the other controls, writes `uiPalette`, a global key | -| `profiles` | `prefs/ProfilePicker.svelte` | A dropdown of the profiles plus New; a trash per profile in the open list (none when one is left, none on Default). Dialogs: `prefs/NewProfileDialog.svelte`, delete confirm via `Prompt` | -| `button`, `about` | `SettingRow`, `controls/About.svelte` | Actions (open ini, export diagnostics) and the logo hero | - -**Search** (#317, `ui/src/search/`): `search.js` is a pure, unit-tested ranker. Every labelled row is -scored best field first (label exact > word-prefix > substring/fuzzy, then the row's `keywords`, then -its description, then card caption and tab name); each query word must match something, fuzzy (one typo -for 4-7 letters, two for 8+, same first letter, a few spelling folds like ight/ite), and the scores add. -The result is ONE ranked list, not grouped by tab. `Results.svelte` renders each hit with the page's own -`SettingRow` (tab name beside the label), so a result is edited in place with the same session/persist -behaviour and clicking it goes nowhere; advanced rows always show there, a row gated by `showIf` shows -dimmed. Every labelled schema row carries `keywords` (synonyms, never displayed; a test enforces it). The -Tray menu's item lists are not schema rows, so they are never searchable; its Performance switch is. - -Visual tokens (colours, radii, the aurora banner) live in `ui/src/design/tokens.css`, taken from -`docs/design/settings-2026-10/FINAL-v10-grey.html`; the reference render is `FINAL-reference.png` -in the same folder. - -Row *visibility and gating* are schema flags, all evaluated in the render condition in -`Settings.svelte`: - -- Advanced rows live in the Advanced group, which is always present. The old "Show advanced - settings" row is gone; `showAdvanced` stays a parsed-and-ignored global key. Search reaches - Advanced rows too. -- `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: 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` -entry: no new Svelte is involved unless the row needs a new widget type. - -### The 2026-08-21 cleanup - -The schema's header comment is the changelog of record: the settings page was pruned with Max -deciding every row (issue #221 branch). Removed outright from the UI: quick zoom -(mode/modifier/hotkey), the smooth-zoom toggle (always on now; its two shape sliders survive as -advanced), scale-cursor-with-zoom, `magnifyStep`, `desktopTransform`, bilinear, sharpness, -brightness, `hdrTonemap`, `multiMonitor`, the whole outline family, and `cursorVisibility` -(broken in the transform model: `main.cpp` collapses it to `drawCursor = mode != 2`, so only -"never" did anything, and the hide-cursor hotkey already covers that). The crucial rule: -**removed from the UI does not mean removed from the product**. Every one of those ini keys is -still parsed by the core; the UI just stopped advertising them. The same is true in the other -direction: keys like `txMaxStepPct` (default 25, pinned in `tests/test_config.cpp`) and the -`warpLock` lock-tell experiments never had rows at all. The "Edit config file" path (`openIni`) -is the escape hatch for all of them. The same pass rewrote the copy: plain language, no toggle -labels starting with "Enable", no description that restates its label. - -Two rows the cleanup *added* are worth knowing: `noSwallowApps` (Keybinds, advanced) suspends -the keyboard hook per app, trading key interception for smooth panning (issue #156), and -`lockApps` (Cursor, advanced) is the issue #221 zoom-lock-detection list for games like DOOM -that pin the mouse to the screen center, which would otherwise pin the zoomed view there too; -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). - -## Session model: instant apply, explicit Save - -Before 0.16.0 every row staged behind an Apply/Discard footer and the active profile was -live-bound (each write mirrored into its profile file). The redesign (#303, spec -`docs/superpowers/specs/2026-10-01-settings-redesign-design.md`) replaced that with a session: - -- **The live ini is the session.** Every change writes `magnifier.ini` at once (`setConfig`) and - the core hot-reloads it, so a slider takes effect as it moves. It is not mirrored into the - profile file. -- **The profile file is the saved state.** Save (`saveSession`) writes `MakeProfileText(live)` - over the active profile file; Discard (`discardSession`) rewrites the live ini from it - (`MakeLiveText`, globals kept). -- **Unsaved = the live ini differs from the profile** in profile-scoped keys. The host computes - `values` (live) and `saved` (profile) in `SessionPayload` and sends both; the page derives the - save capsule from `changedKeys(values, saved)` in `ui/src/session.js` (pure, defaults filled in - on both sides so an absent key never counts as a change). The C++ twin is - `wind::SessionDiffers` in `src/profiles.*`, which the tray and the core use. -- **Reset when Wind closes.** At start the core calls `ResetSessionToProfile` - (`src/profiles_io.h`), rewriting the live ini from the active profile before parsing it, so - unsaved changes never survive a restart or a crash (Windows shutdown and logoff have no - prompt; the next start resets). A restart Wind triggers itself (engine change, profile switch - with a model change) writes `%LOCALAPPDATA%\Wind\session.keep` first; the starting core - consumes it, skips the reset once and the session survives. -- **Keybinds persist at once.** `KeybindCapture` writes `setConfigPersist`, which updates the live - ini and the single key in the profile file, so a capture survives Discard and a later Save - loses nothing while other changes stay unsaved. Global keys (`profile`, `onboarded`, `uiTheme`, - `showAdvanced`) are written directly and never count as unsaved. -- **Three prompts** (`ui/src/prompts/Prompt.svelte`, focus-trapped via `lib/dialog.js`): - closing Settings with unsaved changes offers Save / Discard / Keep for this session (the - window closes, the changes stay live until Wind quits; Esc cancels); switching profile offers - Save / Discard / Cancel. Quitting Wind from the tray decides from the files, not from the UI: - `ConfirmQuit` in `src/tray_app/tray_menu.cpp` compares live against the profile with - `SessionDiffers` and shows a TaskDialog (Save / Discard / Cancel) even when Settings is closed. - -The `model` engine switch and the MPO registry value keep their special handling. `model` is read -once at launch, so the engine row writes the ini, then `restartWind` makes the host write -`session.keep` and launch `Wind.exe` again; the new instance evicts the incumbent through the -`Local\Wind_QuitRequest` handshake in `src/main.cpp`. On `restartFailed` the UI reverts the -dropdown and the ini to the running model, preserving the invariant that the ini's model matches -the running process. MPO is a registry value, not an ini key, and `highres` tracks three booleans -whose conflation is a documented bug class: `mpoLive` (what the registry says), `mpoStaged` (what -the toggle shows) and `mpoBoot` (what DWM loaded at boot, the only honest basis for "requires -restart"). The elevated write is awaited; a cancelled UAC prompt comes back as the re-read -unchanged state and reverts the toggle. - -**Sequence of one slider change, then Save.** +| `keybind` | `lib/KeybindCapture.svelte` + `controls/Keycaps.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` | +| `applist` | `controls/AppList.svelte` | One comma-separated string of exe names | +| `highres` | `controls/HighRes.svelte` | High resolution cursor + MPO, with its UAC and restart step | +| `engine` | `controls/EngineRow.svelte` | `model`; applies on an inline Restart Wind button | +| `palette` | `prefs/ThemePicker.svelte` | Four mini window cards; writes `uiPalette` | +| `profiles` | `prefs/ProfilePicker.svelte` | Dropdown plus New | +| `button`, `about` | `SettingRow`, `controls/About.svelte` | Actions and the logo | + +- **Advanced rows** (`adv: true`) show inline while the global `showAdvanced` key is on + (Preferences > Show advanced settings). Search always finds them. +- **`showIf: {key, eq}`** hides a row unless another key has that value; the per-category engine + rows show only while `model` is `hybrid`. +- **Removed from the UI is not removed from the product.** Many keys have no row (quick zoom + setup, outline, `bilinear`, `sharpness`, `multiMonitor`, the `tx*` knobs). The core still parses + them, and Open settings file is the way to edit them. +- Adding a setting is normally a schema row plus a `ParseConfig` entry. +- Copy rules (in the schema header): plain language, no toggle label starting with "Enable", no + description that restates its label. + +**Search** (`ui/src/search/`). `search.js` is a pure ranker: label exact, word prefix, substring +or fuzzy (one typo for 4–7 letters, two for 8+), then the row's `keywords`, description, card and +group. Every query word must match. The result is one ranked list; each hit renders with the real +`SettingRow`, so it is edited in place. Every labelled row carries `keywords`, and a test enforces +it. The tray item lists are not schema rows and are not searchable. + +**Visual tokens** live in `ui/src/design/tokens.css`, taken from +`docs/design/settings-2026-10/FINAL-v10-grey.html` (reference render `FINAL-reference.png`). + +## Session model + +The live ini is the session and the profile file is the saved state ([08](08-config-profiles.md)). + +- Every change writes the live ini at once (`setConfig`) and the core hot-reloads it. +- Save writes the session into the profile; Discard rewrites the live ini from it. +- The page derives the Save capsule from `changedKeys(values, saved)` (`ui/src/session.js`, pure, + defaults filled on both sides). The C++ twin is `SessionDiffers`. +- Keybind captures use `setConfigPersist`, so a capture survives Discard. +- Global keys are written directly and never count as unsaved. +- **Prompts** (`ui/src/prompts/Prompt.svelte`, focus-trapped by `lib/dialog.js`): closing with + unsaved changes offers Save, Discard or Keep for this session; switching profile offers Save, + Discard or Cancel. Tray Quit decides from the files (`ConfirmQuit` in + `src/tray_app/tray_menu.cpp`), even with Settings closed. + +**Engine and MPO keep special handling.** `model` is read once at launch, so the engine row writes +the ini, then `restartWind` writes `session.keep` and starts `Wind.exe`, which evicts the old +instance. On `restartFailed` the page reverts the row and the ini. MPO is a registry value, and +`highres` tracks three states that must not be conflated: `mpoLive` (registry), `mpoStaged` (what +the toggle shows) and `mpoBoot` (what DWM loaded, the only honest basis for "restart required"). ```mermaid sequenceDiagram - participant U as User - participant S as Settings.svelte - participant H as WindConfig host
HandleWebMessage - participant I as magnifier.ini (session) - participant P as active profile file (saved) - participant W as Wind.exe RunTick - U->>S: drag slider + participant S as Settings page + participant H as WindConfig host + participant I as magnifier.ini + participant P as profile file + participant W as Wind.exe S->>H: setConfig(key, value) - H->>I: WriteFileAtomic(UpdateIniText(...)) - W->>I: dir-watch fires, read ini - W->>W: StripUiOnlyKeys fingerprint changed? yes: reload, keep zoom center - S->>S: values differ from saved: capsule shows unsaved - U->>S: click Save + H->>I: atomic write + W->>I: watch fires, fingerprint differs, reload + S->>S: values differ from saved: capsule shows S->>H: saveSession - H->>P: WriteTextFileAtomic(MakeProfileText(live)) - H->>S: sessionSaved ok - S->>S: saved = values + H->>P: MakeProfileText(live) + H->>S: sessionSaved ``` -No acknowledgment flows back for `setConfig` itself (a failed write posts `configWriteFailed`, -which the page surfaces). The core's hot-reload is the delivery mechanism. +## Tray menu page -## Tray menu tab (issue #313) -A TRAY sidebar section holds one page, `ui/src/tray/TrayMenuPage.svelte` (pure list logic in -`trayModel.js`, mirroring `src/tray_items.*`). A toggle row turns the Performance header on; two -cards, "Sliders" (N of 4) and "Toggles", list the eligible items with a drag handle, the line icon -(no background box), name, description and a checkmark button. Rows reorder only inside their card -(pointer drag with a ~150 ms glide, or Space, arrows, Space on the keyboard). The keys are global, -not profile, so `IsGlobalProfileKey` lists them. Changes are ordinary session changes. Item list -and limits: `docs/superpowers/specs/2026-10-01-tray-flyout-design.md`. Playwright: `ui/tests/tray.spec.js`. +`ui/src/tray/TrayMenuPage.svelte` (list logic in `trayModel.js`, mirroring `src/tray_items.*`). A +toggle turns the Performance header on; two cards, Sliders (up to 4) and Toggles, list the eligible +items with a drag handle, icon, name, description and a check button. Rows reorder inside their +card by pointer drag, or with the keyboard: Space to pick up and drop, arrows to move. The keys are +global, not profile keys. Spec: +[../specs/2026-10-01-tray-flyout-design.md](../specs/2026-10-01-tray-flyout-design.md). ## Profiles -The Profile row on the Preferences page (`ui/src/prefs/ProfilePicker.svelte`) is a dropdown of the profiles plus one -New button. Each profile in the open list has a trash icon (hidden when one profile is left, and on Default, which the -host protects); the trash opens a confirm prompt ("Delete profile", Cancel / Delete). New opens -`prefs/NewProfileDialog.svelte`: a name (checked by `prefs/profileName.js`, the host's file-name rules) and three buttons, -Cancel, New (the defaults) and Duplicate current (a copy of the current settings); Enter in the name field duplicates. Rename and duplicate have no UI (the bridge messages remain). -The interesting logic is in `Settings.svelte`'s `profileAction`: operations that replace the live settings wholesale -(switch, create from the defaults, delete of the *active* profile) route through the unsaved-changes prompt -(Save / Discard / Cancel), while deleting an inactive profile skips it. A new profile that starts from the CURRENT -settings skips it too: the host creates every profile from the defaults (`createProfile`), so the page then writes the -keys that differ with `setConfigPersist` (live ini and profile file), which carries unsaved changes into the new -profile and leaves nothing unsaved. -After a mutating operation the UI reloads the whole config (`load`), deliberately *before* -checking the reply's `ok`, because a failed operation can still have rewritten the live ini (a -switch that landed but whose model restart failed) and stale values would then be compared -against the wrong profile. A `push:true` profiles message (tray switch under an open window) -reloads `values`/`saved`; a tray switch rewrites the live ini from the -new profile, so unsaved edits are gone by then and the capsule resets. - -## Theme, onboarding, accessibility - -**Theme.** The UI is dark only since 0.20.0 (#324). There is no Mode row and no `theme.js`; the legacy -`ui/src/theme.css` holds one dark palette, and `uiTheme` is an ignored legacy key (still a global, UI-only -key, so `StripUiOnlyKeys` and the profile code keep treating it that way and old inis load; the UI never -writes it, and `uiTheme=light` still renders dark). The window title bar carries no theme button. - -**Built-in themes (#318).** `uiPalette` is a second global UI-only key: `grey ember ocean hicon` -(Wind grey, Ember, Deep ocean, High contrast, always last; the core's `kUiPalettes`; anything else, including the -removed cyber/mono/slate/carbon, reads as `grey`). `Settings.svelte` puts it on the root as `data-palette`, and `ui/src/design/themes.css` has one dark token block per theme -(`.wnd[data-palette]`) over the defaults in `design/tokens.css`. themes.css and `design/themes.js` are -GENERATED by `ui/tools/gen-themes.cjs` from Max's mockup palettes (`wind-settings-mockups/ia/palettes08/-b1/-b2.cjs`); -edit the generator, not the output. Wind grey takes tokens.css's own values, so its look is exactly -today's. High contrast uses the sharp -radii (`--rc --rad --srad --rp --rsw --rkn`). Components use the tokens (`--sel`/`--selfg` for selections, `--onfill`, -`--focus`, `--danger*`, `--scrim`, the radii), never fixed colours. The Theme row is `prefs/ThemePicker.svelte` (mockup option A, Max 2026-10-02): -four cards in one row, each a tiny `.wnd` carrying its own palette so it shows the theme's real tokens; Left/Right -(also Up/Down, Home, End) moves and applies, the focus ring shows for the keyboard only. The tray flyout reads the same -ids from `src/tray_app/flyout_palettes.h` (generated by `ui/tools/gen-flyout-palettes.cjs`), and WindConfig's pre-paint -window colour follows `uiPalette` and is always dark (`ThemeBackground` in `src/config_ui/main.cpp`, a small table of the -themes' `--bg`; keep it in step when a theme is added). - -**Onboarding.** `ui/src/App.svelte` routes on `getMode()` (the `?mode=onboard` query the host -appends). `ui/src/Onboarding.svelte` is a three-step wizard: the wind-trails-into-logo intro, -zoom-key capture (the same `KeybindCapture` component, writing live), and done. On mount it -*actually clears* the keybind keys in the ini rather than just displaying "Unbound", because a -previously halted onboarding may have written real keys and showing blank over live bindings -lies. Finishing or skipping writes `onboarded=1` (a global key that never travels with -profiles) and switches to Settings in place; closing the window with X instead sends -`quitWind`, ending the whole app, since a user who abandons setup has not opted into a -magnifier running in the tray. - -**Accessibility (issue #201).** The A11y work is best read through its living spec, -`ui/tests/a11y.spec.js`, whose header tells the origin story: every control in the settings -list was anonymous, because labels and descriptions are sibling `
`s of their controls, so -a screen reader announced "checkbox, checked" with no clue which of ~24 settings it had -reached. The fix concentrates in `controls/SettingRow.svelte`, which the whole schema flows through, so wiring -ids there named every row at once: controls whose text is not their name get -`aria-labelledby` pointing at the row label; controls whose text is their *value* (select -trigger, keycap, "Manage list") get labelledby listing both label and value ids. On top of -that: a single polite `aria-live` region in `Settings.svelte` announces everything that changes -the page without moving focus (model swaps, Save/Discard, profile -switches), with a zero-width-space trick so repeating the same message still re-announces; rail -navigation moves focus to the target section's `tabindex="-1"` heading; every modal uses the -`ui/src/lib/dialog.js` action (focus trap, Escape, restore); and the segmented widget is a real -radiogroup with roving tabindex. The a11y suite asserts directly against the accessibility tree -(the "no unnamed controls" test enumerates every control under `.scroll`), which is the right -altitude: none of it is visible in a screenshot. - -## Testing: Playwright against a mocked bridge - -The UI tests (`ui/tests/*.spec.js`: settings, session, shell, search, schema, a11y, onboarding, keybind-rules) run the real -Svelte app in a real Chromium via Playwright, with the one Windows-specific piece replaced: an -`addInitScript` installs a fake `window.chrome.webview` whose `postMessage` implements the -host's half of the bridge in-page. The mock answers `getConfig` with a canned config -(a live and a saved snapshot, so unsaved states can be set up), records every message into -`window.__msgs` for assertions like "a slider writes the live ini at once", and simulates the failure modes the C++ host can produce: -`__restartFail` for a failed model relaunch, `__mpoOk = false` for a dismissed UAC prompt, -`__profileFail` for any profile op, and `__pick` for what the "file picker" returns. -`bridge.js` itself needs no test shim beyond this because it touches nothing but -`window.chrome.webview` (plus a `window.__windMock` hook for ad-hoc harnesses). This is the -project's verification-loop rule applied to the UI: the session model, the MPO three-state -dance, the profile guard, and the a11y contract are all asserted headlessly by `npm test` in -`ui/` (Playwright starts the Vite dev server itself), with no magnifier and no WebView2 -involved. CI currently runs only the doctest suite; the UI suite is a local pre-commit gate. What the mock cannot -cover is the host itself; its only pure logic (`ShouldCloseOnWindGone`) is a header compiled -into the doctest build instead. - -## Pointers - -- `src/config_ui/main.cpp`: the WebView2 host, `HandleWebMessage` (the authoritative bridge - message set), the watchdog timer, launch routing. -- `src/config_ui/wind_watchdog.h`: pure close-on-Wind-gone decision, unit-tested. -- `src/config_ui/mpo.h`, `src/mpo_boot.h`: MPO registry read/write and the boot-state record. -- `ui/src/settings-schema.js`: every row on the page, plus the 2026-08-21 cleanup changelog. -- `ui/src/Settings.svelte`: the shell, save/discard, profiles, announcements, prompts. -- `ui/src/session.js`: pure unsaved-change comparison (`changedKeys`). -- `ui/src/controls/SettingRow.svelte`: the row-type switch and the accessible-naming rules. -- `ui/src/design/tokens.css`: the visual tokens from the design reference. -- `ui/src/bridge.js`, `ui/src/theme.js`, `ui/src/Onboarding.svelte`. -- `ui/tests/a11y.spec.js`: the living a11y spec; `ui/tests/settings.spec.js`: the bridge mock. -- Specs: [config UI polish + onboarding](../superpowers/specs/2026-05-27-config-ui-polish-onboarding-design.md), - [profiles](../superpowers/specs/2026-08-12-profiles-design.md), - [settings redesign and session model](../superpowers/specs/2026-10-01-settings-redesign-design.md). -- Related chapters: [Overview](01-overview.md), [The tick loop](02-tick-loop.md) (hot reload), - [Config and profiles](08-config-profiles.md) (ini resolution, profile file machinery), - [Build, test, release](11-build-test-release.md) (`build.bat config`, the npm build). +The Profile row (`prefs/ProfilePicker.svelte`) is a dropdown plus New. Each profile except the last +one and Default has a delete button with a confirm prompt. New +(`prefs/NewProfileDialog.svelte`) takes a name (checked by `prefs/profileName.js`, the host's rules) +and offers New (defaults) or Duplicate current; Enter duplicates. Rename and duplicate have no UI. + +- Operations that replace the live settings (switch, new from defaults, deleting the active + profile) go through the unsaved-changes prompt. +- Duplicate current creates the profile from defaults, then writes the differing keys with + `setConfigPersist`, so nothing is left unsaved. +- After a mutation the page reloads the config before checking `ok`: a failed operation can still + have rewritten the live ini. + +## Themes + +- **Always dark.** There is no light mode or Mode row; `uiTheme` is an ignored legacy key, still + global and UI-only so old inis load. +- **`uiPalette`** (global, UI-only): `grey`, `ember`, `ocean`, `hicon` (High contrast, always + last). Unknown ids read as `grey`. `Settings.svelte` sets `data-palette` on the root, and + `ui/src/design/themes.css` holds one token block per theme over `tokens.css`. +- `themes.css` and `design/themes.js` are generated by `ui/tools/gen-themes.cjs`, and the tray's + `src/tray_app/flyout_palettes.h` by `ui/tools/gen-flyout-palettes.cjs`. Edit the generators, not + the output. +- Components use tokens (`--sel`, `--onfill`, `--focus`, `--danger*`, `--scrim`, the radii), never + fixed colours. +- WindConfig's pre-paint window colour follows `uiPalette` (`ThemeBackground` in + `src/config_ui/main.cpp`); update it when a theme is added. + +## Onboarding + +`App.svelte` routes on `?mode=onboard`. `Onboarding.svelte` has three steps: the intro animation, +zoom-key capture (the same `KeybindCapture`, writing live) and done. + +- On mount it clears the zoom keys in the ini, because an earlier abandoned onboarding may have + written real ones. +- Finishing or skipping writes `onboarded=1` and switches to Settings in place. Closing the window + sends `quitWind`: a user who abandons setup has not opted into a magnifier in the tray. + +## Accessibility + +Labels and descriptions are sibling elements of their controls, so naming is wired once in +`SettingRow.svelte`: controls get `aria-labelledby` pointing at the row label, and controls whose +text is their value (select, keycap, app list) list both label and value. + +- One polite `aria-live` region announces changes that do not move focus (engine restarts, + Save/Discard, profile switches). +- Sidebar navigation moves focus to the section heading. +- Every modal uses `lib/dialog.js` (focus trap, Escape, focus restore). +- `ui/tests/a11y.spec.js` asserts against the accessibility tree, including "no unnamed controls". + +## Testing + +`ui/tests/` holds 12 Playwright specs that run the real app in Chromium with a fake +`window.chrome.webview` installed by `addInitScript`. The mock answers `getConfig` with a live and +a saved snapshot, records every message in `window.__msgs`, and simulates host failures: +`__restartFail`, `__mpoOk = false`, `__profileFail`, `__pick`. Run `npx playwright test` in `ui/` +(Playwright starts the Vite server). CI runs only the doctest suite; the UI suite is the local gate +for UI changes. The host's own pure logic is compiled into the doctest build instead. + +Specs: [../specs/2026-05-27-config-ui-polish-onboarding-design.md](../specs/2026-05-27-config-ui-polish-onboarding-design.md), +[../specs/2026-08-12-profiles-design.md](../specs/2026-08-12-profiles-design.md), +[../specs/2026-10-01-settings-redesign-design.md](../specs/2026-10-01-settings-redesign-design.md). diff --git a/docs/architecture/10-magnify-model.md b/docs/architecture/10-magnify-model.md deleted file mode 100644 index dfe97ba3..00000000 --- a/docs/architecture/10-magnify-model.md +++ /dev/null @@ -1,225 +0,0 @@ -# 10. The magnify model - -> **Dormant since 0.18.0.** `model=magnify` is no longer selectable: Settings and the tray offer -> only Auto, Render and Transform, and config parse maps an old `model=magnify` to `hybrid` -> (`src/config.cpp`). The code described below still exists but never runs. Kept as the record of -> the design and its measured dead ends. - -`model=magnify` is Wind's DRM-safe fallback: instead of magnifying pixels itself, Wind launches -the native Windows Magnifier (Magnify.exe) and drives it the way a user would, by injecting its -own keyboard shortcut. DRM-protected video (Netflix and friends) blanks under the render model's -Desktop Duplication capture, but Magnifier's DWM-internal fullscreen transform magnifies it fine. -The model's defining trait is maximum simplicity, arrived at the hard way: three smarter designs -were implemented, measured, and deleted in a single day (issue #146), and the survivor is the one -where Wind holds no zoom state at all. - -## Why this model exists - -The render model (chapter [Engines](03-engines.md)) captures the desktop with DXGI Desktop -Duplication and re-presents it magnified. Protected-content surfaces are excluded from that -capture by the OS, so a magnified Netflix window is a black rectangle. Windows Magnifier does not -capture anything: it asks DWM to scale its own composition output, which includes protected -surfaces. Wind's transform model uses the same DWM channel, but it is Wind's own machinery with -Wind's own trade-offs; the magnify model instead delegates everything, view, panning, easing, -cursor drawing, to the OS implementation, and keeps Wind's job down to "press the buttons". - -The result is a model with almost no code. `src/magnify_model.h` stubs out nearly the entire -`IMagnifierModel` surface: `hideSystemCursor`, `setActive`, `onActivate`, and `present` are empty -bodies, because Magnifier owns the view and the cursor and Wind's overlay never activates. -`supportsInspect()` returns false (there is no frozen-cursor reticle to draw when another process -owns the magnified view; `RunTick` in `src/main.cpp` logs "Inspect not available in the magnify -model" and ignores the toggle). `coversShell()` returns true, since Magnifier magnifies the Start -menu and taskbar natively, something Wind's own overlay only manages in the banded UIAccess -configuration. - -## selfDrivenZoom: bypassing the whole level pipeline - -The one non-trivial hook is `selfDrivenZoom()` on `IMagnifierModel` (`src/magnifier_model.h`). -When it returns true, `RunTick` (`src/main.cpp`) takes an early exit before any of the level -machinery runs: the `ZoomController` stays pinned at 1x, the overlay never activates, quick zoom, -recenter, the `CursorMapper`, and Inspect never execute. Instead, every tick, `RunTick` drains the -raw-input accumulator (so mickeys never pile up) and calls -`nativeZoomTick(dir, cfg)` with the held direction: `+1` while a zoom-in button is held, `-1` for -zoom-out, `0` when idle. That single signed integer is the entire interface between Wind's input -system and the magnify model. - -This is a deliberate inversion of how the other models work. Render and transform receive a -smooth, ramped level from the `ZoomController` and are told exactly what to show. The magnify -model receives only intent, because Magnifier cannot be told a level, it can only be nudged, and -every attempt to keep Wind's idea of the level synchronized with Magnifier's lost a race (see the -dead-end ledger below). - -**Per-tick flow: RunTick hands raw intent to the model, Magnifier does the rest** - -```mermaid -flowchart LR - A[RunTick] -->|selfDrivenZoom true| B[drain raw input] - B --> C["nativeZoomTick(dir, cfg)"] - C --> D{magnifyStep changed?} - D -->|yes| E[write ZoomIncrement] - D -->|no| F{dir != 0 and 60ms elapsed?} - E --> F - F -->|Magnifier gone| G[relaunch Magnify.exe] - F -->|yes| H[inject Ctrl+Alt+wheel notch] - F -->|no| I[done this tick] -``` - -## nativeZoomTick: the wheel-notch drive - -`MagnifyModel::nativeZoomTick` (`src/magnify_model.cpp`) does three things: - -1. **Live-apply `magnifyStep`.** The ini key `magnifyStep` (parsed and clamped to 5..400 in - `src/config.cpp`, matching Windows Settings' own range; default 50) maps directly to - Magnifier's `ZoomIncrement` registry value under - `HKCU\Software\Microsoft\ScreenMagnifier`. It is written only on change (`lastStepPct_` - dedupes), so adjusting the step in the settings UI takes effect on the next notch without a - restart. Note a comment-versus-code drift inside `magnify_model.cpp` itself: the comment above - `kSnapshotValues` says the model "deliberately does NOT touch ZoomIncrement", which described - an earlier revision; the code both writes it here and snapshots it for restore, and the code is - the truth. -2. **Gate the cadence.** Notches are injected at most every 60 ms (`kNotchIntervalMs`). This - number is measured, not guessed: at 60 ms spacing, Magnifier registers notches 1:1 with no - backlog and the view settles about 150 ms after the last one (probe 7 in the spec's amendment - trail). Faster is unmeasured territory; slower feels sluggish. -3. **Inject one notch.** `InjectZoomNotch` sends a single `SendInput` batch: Ctrl down, Alt down, - one wheel event (`WHEEL_DELTA` signed by direction), Alt up, Ctrl up. Ctrl+Alt+wheel is - Magnifier's own wheel-zoom shortcut and the only injection channel that works: injected - Win+wheel is completely inert (measured), and Win+Plus chord bursts drop about half their - events. The modifiers are held only for the microseconds around the wheel event so they can - never leak onto the user's concurrent clicks or keystrokes. - -Magnifier does everything downstream of the notch natively: it steps by `ZoomIncrement`, eases -each step with its own animation, pans to follow the mouse, and draws the cursor. Wind never -reads the resulting level and never needs to. - -If the user manually closed Magnifier mid-session, `nativeZoomTick` detects it (the `MagUIClass` -window vanishes, checked by `MagnifierWindowPresent`) and relaunches it instead of injecting into -nothing, with a 2-second backoff (`lastLaunchMs_`) so the launch has time to appear. - -## The keyboard hook must skip injected events - -Wind's own low-level keyboard hook (chapter [Input](06-input.md), `src/input_router.cpp`) -swallows bound keys so they never double-fire into the focused app. In magnify mode that would be -self-defeating: NumPad +/- and other keys Wind injects as part of its chords are bindable zoom -keys, so the hook could swallow Wind's own Ctrl/Alt/Esc injections before they reach Magnifier. -`main.cpp` therefore calls `g_input.setIgnoreInjectedKeys(true)` right where the `MagnifyModel` -is constructed; the hook then passes through any event carrying `LLKHF_INJECTED`. The mouse hook -never inspects wheel events, so the injected wheel notch needs no such exemption. - -## Lifecycle: initialize, run, shutdown - -**Session lifecycle: snapshot first, restore last, survive crashes in between** - -```mermaid -sequenceDiagram - participant W as Wind (MagnifyModel) - participant R as HKCU ScreenMagnifier - participant M as Magnify.exe - W->>R: snapshot 4 values to magnifier_backup.ini (only if absent) - W->>R: MagnificationMode=2, toolbar minimized - W->>R: Magnification=100 - W->>M: ShellExecute magnify.exe (minimized, no activate) - loop while a zoom button is held - W->>M: Ctrl+Alt+wheel notch every 60ms - M->>M: step, ease, pan, draw cursor - end - W->>M: Win+Esc (Magnifier quits) - W->>R: restore snapshotted values - W->>W: delete magnifier_backup.ini -``` - -`MagnifyModel::initialize` first writes a one-shot snapshot of the user's Magnifier settings to -`%LOCALAPPDATA%\Wind\magnifier_backup.ini` (`ResolveBackupPath`; the same per-user directory the -ini fallback and logs use, never next to the exe, since Program Files is read-only for the -non-admin runtime). The snapshot covers exactly the values the model modifies: `Magnification`, -`MagnificationMode`, `MagnifierUIWindowMinimized`, and `ZoomIncrement` (`kSnapshotValues`). Two -details make it crash-safe: - -- **It is written only if the file does not already exist.** If a previous Wind crashed before - restoring, the old snapshot still holds the user's real values; re-snapshotting now would - capture Wind's own writes as if they were the user's, and the eventual restore would "restore" - Wind's values. Keeping the stale file means the next clean shutdown restores correctly no - matter how many crashes happened in between. -- **Absent values are recorded as -1** and skipped on restore rather than invented. - -After the snapshot, initialize preps Magnifier's startup-read settings, fullscreen mode -(`MagnificationMode=2`) and toolbar minimized, then launches Magnify.exe via `launchMagnifier`, -which writes `Magnification=100` first so Magnifier never launches into a leftover zoom level -from a previous native session. The launch uses `SW_SHOWMINNOACTIVE` so it does not steal focus. - -`MagnifyModel::shutdown` (called on Wind quit and on a model swap) injects Win+Esc, Magnifier's -own quit shortcut, via `InjectWinChord` (the vk press inside the chord keeps the Win tap from -opening the Start menu), then replays the snapshot into the registry and deletes the backup file. -Only after a successful restore is the crash insurance consumed. This restore path is also why -the installer's upgrade flow must let Wind exit cleanly rather than `taskkill` it (see the -installer notes in the project `CLAUDE.md`): a killed process never runs this restore. - -## The dead-end ledger: do not re-attempt - -The shipped design is amendment 3 of the spec -([2026-07-22-magnify-model-design.md](../superpowers/specs/2026-07-22-magnify-model-design.md)), -and the spec preserves the full measurement trail. The conclusions, all field-measured on this -rig, are load-bearing enough to restate here, because each earlier design looks obviously better -on paper: - -| Attempt | What happened (measured) | -| --- | --- | -| Injected Win+Plus/Minus chord bursts | Magnifier drops roughly half of a rapid burst (10 chords at 5 ms -> ~5 applied) and animates each survivor: the zoom lagged Wind's ramp 4-5x and kept zooming for seconds after release from the queued backlog. | -| Streaming `Magnification` registry writes per tick | Magnifier consumes registry changes at ~280 ms animation-window boundaries; writes arriving faster degenerate into instant ~40% snaps at each boundary. ONE write eases beautifully over ~280 ms, that part is real, but a stream cannot ride it. | -| Hybrid: Wind drives `MagSetFullscreenTransform` during ramps, hands off to one registry write at settle | Glass smooth mid-ramp and the transform sticks while Magnify.exe runs, but Magnifier stomps its stale belief within ~7 ms of any wake with queued mouse moves, and its registry handler animates from a stale cached actual for writes queued while suspended. Every resume/sync ordering tried still flickered or released at a racy level. Suspending Magnify.exe mid-ramp is latency-safe but did not fix the belief races. | - -Two standalone registry traps from the same probes, encoded so no future code path relies on the -opposite: - -- `Magnification` writes above 1600 are **silently ignored**, not clamped. Any writer must clamp - itself. -- A same-value `Magnification` write **fires no change notification**. No design may depend on a - redundant write making Magnifier act. (This is also why `nativeZoomTick`'s - write-`ZoomIncrement`-on-change-only dedupe is safe rather than merely tidy.) - -The evolution, compressed: - -**Design evolution: each smarter drive was measured dead before the dumb one shipped** - -```mermaid -flowchart TD - A[Win+Plus chord bursts] -->|drops half, backlog zoom| B[registry write streaming] - B -->|~40% snaps at 280ms boundaries| C[hybrid MagSet + registry handoff] - C -->|belief-sync races, flicker| D[native Ctrl+Alt+wheel notches, Wind holds no state] -``` - -If you are tempted to make the magnify model smoother, the burden of proof is a new measurement -that invalidates one of the rows above, not a cleaner-looking implementation of the same idea. - -## What the model deliberately does not do - -Because Magnifier owns everything visual, most Wind features are documented no-ops here: no -cursor hide or drawn cursor, no cursor-sensitivity scaling, no Inspect mode, no zoom outline, no -quick zoom, no multi-monitor retarget (`main.cpp` gives the model the primary monitor and notes -the targeting is a no-op), and no participation in the hybrid model's engine picking, `magnify` -is only ever an explicit `model=` choice, never auto-selected. The settings UI shows only the -rows relevant to the active model, so `magnifyStep` is the model's one user-facing knob. Recent -transform/render work (the `txMaxStepPct` write cap, `lockApps`, the `warpLock` lock tells) -likewise never touches a magnify session; the model's isolation from the level pipeline is what -keeps it immune to churn in the other engines. - -One historical note to avoid confusion when reading old issues: the magnify model originally -**replaced** the transform model (the spec above is titled accordingly), and the transform model -was later revived as a first-class engine for issue #148. There is no aliasing between them: a -`model=transform` ini value runs the real transform model, and missing or unknown values fall -back to `hybrid`. - -## Pointers - -- `src/magnify_model.h` / `src/magnify_model.cpp`, the entire model. -- `src/magnifier_model.h`, the `IMagnifierModel` interface, including `selfDrivenZoom` and - `nativeZoomTick`. -- `src/main.cpp`, the `selfDrivenZoom` early exit in `RunTick` and the - `setIgnoreInjectedKeys(true)` call at model construction. -- `src/input_router.h`, `setIgnoreInjectedKeys` and the injected-event skip. -- `src/config.cpp` / `src/config.h`, `magnifyStep` parsing and clamping. -- Spec with the full measurement trail: - [2026-07-22-magnify-model-design.md](../superpowers/specs/2026-07-22-magnify-model-design.md). -- Related chapters: [Engines](03-engines.md) for the render/transform/hybrid models this one - falls back from, [Input](06-input.md) for the hook architecture the injected events must pass - through. diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index 40273702..3c631703 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -1,114 +1,133 @@ # 11. Build, test, release -Wind builds with a single batch script, tests with a desktop-free doctest binary plus a Playwright suite for the settings UI, and ships through an NSIS installer that GitHub Actions rebuilds and republishes on every push to `main`. This chapter covers the build targets, the test philosophy (what is pure and why), the signing and UIAccess deploy flow for local testing, the installer's elevation traps, and the release automation and the incident that made it mechanical. +Wind builds with one batch script, tests with a desktop-free doctest binary plus a Playwright +suite for the settings UI, and ships an NSIS installer that GitHub Actions rebuilds on every push +to `main`. This chapter covers the build targets, the pure/Win32 test split, the local UIAccess +deploy, the installer's elevation rules and the release workflows. ## build.bat targets -Everything native goes through `build.bat` at the repo root. It locates MSVC via vswhere, calls `vcvars64.bat`, then dispatches on its first argument: +`build.bat` locates MSVC with vswhere, runs `vcvars64.bat` and dispatches on its first argument: -| target | output | what it is | +| Target | Output | What it is | |---|---|---| -| (none) | `Wind.exe` + `WindTray.exe` | the normal app: `uiAccess=false` manifest (`Wind.manifest`), runs from anywhere | -| `test` | `wind_tests.exe` | the doctest binary over the pure-logic sources; runs it and returns its exit code | -| `check` | (none) | compile-only pass over `src\*.cpp`, no link; catches type errors fast | -| `uiaccess` | `Wind.exe` + `WindTray.exe` | same app with `Wind.uiaccess.manifest` (`uiAccess=true`) and `/DWIND_UIACCESS`; only useful signed and in Program Files. `WindTray.exe` keeps the plain manifest (issue #291) | -| `tray` | `WindTray.exe` | the tray helper alone (`src/tray_app/` plus the shared profile/config/logging sources), objects in `src\tray_app\` so they never collide with Wind.exe's | -| `config` | `WindConfig.exe` | npm-builds the Svelte app under `ui/` to `ui/dist/`, then compiles `src/config_ui/main.cpp` against the vendored WebView2 SDK (`third_party/webview2`) | -| `installer` | `dist\Wind-Setup-x64-.exe` | compiles `installer\wind.nsi` with makensis `/WX` and runs `tools\installer_check.ps1` | - -One toolchain wrinkle worth knowing before it costs you an hour: this machine runs VS 2026 Community on a prerelease channel, and `vswhere -latest` does not find prerelease installs. `build.bat` therefore calls vswhere with `-all -prerelease` and captures the path via a temp file rather than a `for /f` loop (a quoted path containing `(x86)` breaks cmd's parser). If you ever rewrite the locator, keep both workarounds. - -The installer target treats NSIS warnings as failures (`/WX`). The compiler already aborts on a missing `File` source, but it only warns about an unreferenced define or a shadowed function, and in an installer built from generated rectangle data those warnings are exactly how a page ends up wired to nothing. - -## The test philosophy: pure vs Win32 - -Wind's core rule is that anything with real logic in it compiles without ``. The `test` target builds `tests\*.cpp` plus only the pure sources, with `/DWIND_TESTS` and `/I third_party` for the vendored `third_party/doctest.h` (`tests/test_main.cpp` is nothing but `DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN`). The current pure set, straight from `build.bat`: - -`src/transform.cpp`, `src/zoom_controller.cpp`, `src/config.cpp`, `src/profiles.cpp`, `src/cursor_mapper.cpp`, `src/lock_detector.cpp`, `src/cursor_lock.cpp`, `src/mouse_ballistics.cpp`, `src/crosshair.cpp`, `src/config_ui/ini_edit.cpp`, `src/logging.cpp`. - -On top of those, a number of pure header-only modules ride into the tests through their test files: `src/engine_pick.h` (the hybrid model's engine decision), `src/drag_follow.h`, `src/hdr_scale.h`, `src/inspect_focus.h`, `src/config_ui/wind_watchdog.h`, and others. The `tests/` directory has one file per module (`test_transform.cpp`, `test_engine_pick.cpp`, `test_profiles.cpp`, ...), so when you add a pure module you add its test file and, if it is a `.cpp`, append it to the `:test` source list in `build.bat`. - -Why this split matters: the magnifier itself cannot be driven headlessly (it needs a desktop, a GPU, and a real cursor), so the unit tests are the only verification loop that runs everywhere, including CI. Anything testable therefore has to live on the pure side. The Win32 half (`src/render_engine.cpp`, `src/input_router.cpp`, `src/transform_model.cpp`, `src/tray_app/`, `src/main.cpp`) is kept as thin as the OS allows and is verified by deploying and using it (see the deploy section below). - -Some files straddle the line, and the pattern for those is an `#ifndef WIND_TESTS` block. `src/config.cpp` is the canonical example: the parsing half (`ParseConfig`, `IsForbiddenBindVk`, `StripUiOnlyKeys`) is pure and fully tested in `tests/test_config.cpp`, while `LoadConfig` and the default-ini writer live below `#ifndef WIND_TESTS`, which is where the file's only `#include ` sits. `src/logging.cpp` uses the same split (pure formatting helpers above, the Win32 file backend below, both halves labeled in `src/logging.h`). If you add file or OS access to a pure file, put it under the guard or the test build stops compiling desktop-free, which is the point of the guard. - -A recent example of logic deliberately pushed to the pure side: `StripUiOnlyKeys` (src/config.cpp) removes `uiTheme`, `showAdvanced`, and `onboarded` from ini text so `main.cpp`'s hot-reload can fingerprint whether a change is core-relevant. Before it existed, flipping the settings UI theme rewrote the ini, the core saw "config changed", and a live zoom session collapsed. The fingerprint is string-in string-out, so it got tests instead of a field regression the second time around. - -### The UI suite: Playwright with a webview mock - -The Svelte settings app has its own suite under `ui/tests/` (`settings.spec.js`, `onboarding.spec.js`, `a11y.spec.js`), run with `npm test` inside `ui/` (`ui/playwright.config.js` starts the Vite dev server on port 5173 itself, so it is one command). - -The app's only channel to the C++ host is `window.chrome.webview.postMessage` plus a message listener, so the tests do not need WebView2 or the host at all: a `page.addInitScript` in each spec's `beforeEach` installs a fake `window.chrome.webview` that answers `getConfig`, records `setConfig` calls into `window.__sets`, and stands in for the host's native surfaces (the `pickExe` file picker, the `mpoState`/`setMpoDisabled` registry bridge, the six profile file operations). Test knobs like `window.__profileFail` and `window.__restartFail` force the failure replies the real host can produce. When you add a bridge message to `HandleWebMessage` in `src/config_ui/main.cpp`, extend the mock in the same change, otherwise the new UI path is untestable and the suite drifts from the host contract. - -One trap documented in the mock itself: rows marked `advanced: true` or carrying a `showIf` condition never render unless the mock config enables them (`showAdvanced: '1'`, `model: 'render'`), and a test asserting on a hidden row times out with no useful error. Set the gating keys in the mock config, not in the test body. - -## Signing, UIAccess, and the deploy loop - -The `uiaccess` build exists because a few features need the UIAccess privilege: the opt-in band-16 overlay z-order (issue #162), keybinds over elevated windows, and the transform model's `MagSetInputTransform` publish that fixes desktop hover dead zones (see [Engines](03-engines.md) and ../POINTER-HITTEST-FINDINGS.md). Windows only grants UIAccess to a binary that is Authenticode-signed with a locally trusted certificate AND runs from a secure location, in practice `C:\Program Files\Wind`. An unsigned `uiaccess` build, or a signed one launched from the repo, silently gets no privilege, and `transform_model.cpp` then probes `TokenUIAccess` at init and disables the desktop-transform pick, so the app degrades rather than breaks. - -`tools/uiaccess_setup.ps1` is the whole local flow in one elevated script: it stops any running Wind/WindConfig, runs `build.bat uiaccess` and `build.bat config`, finds or creates a self-signed "Wind Dev Test Cert" in `Cert:\LocalMachine\My`, trusts it (Root + TrustedPublisher), signs the exes (WindConfig.exe and WindTray.exe need no UIAccess but an unsigned fresh build trips a Defender Wacatac false positive and gets quarantined, issue #86), and copies `Wind.exe`, `WindTray.exe`, `WindConfig.exe`, and `ui\dist` to Program Files. It deliberately does NOT deploy a `magnifier.ini`: the app resolves its ini to `%LOCALAPPDATA%\Wind\magnifier.ini` via `wind::ResolveIniPath` (src/config_path.h) because Program Files is read-only for the non-admin processes, and the script removes any stale Program Files copy. It transcript-logs to `tools\uiaccess_setup.log`; verify a deploy by checking that log for `status=Valid` and `DONE`. - -Two rules around the script: - -- Run it elevated with an ABSOLUTE `-File` path. The elevated process starts in System32, so a relative `tools\...` path silently launches nothing. -- Launch the deployed copy from a NORMAL, non-elevated shell (`Start-Process "C:\Program Files\Wind\Wind.exe"`). UIAccess is an integrity-level elevation that the loader applies at process start; launching from an elevated shell gives you an admin token instead, which changes which `%LOCALAPPDATA%` the app resolves and does not test what users run. - -Project standing rule (CLAUDE.md, "Deploy for testing"): any change with a runtime surface gets deployed this way so it can actually be verified. The magnifier has no headless mode; deploying IS the verification loop for the Win32 half. +| (none) | `Wind.exe` + `WindTray.exe` | The app with the `uiAccess=false` manifest; runs from anywhere | +| `test` | `wind_tests.exe` | The doctest binary over the pure sources; runs it and returns its exit code | +| `check` | – | Compile-only pass over `src\*.cpp` | +| `uiaccess` | `Wind.exe` + `WindTray.exe` | `Wind.uiaccess.manifest` and `/DWIND_UIACCESS`; useful only signed and in Program Files. `WindTray.exe` always keeps the plain manifest | +| `tray` | `WindTray.exe` | The tray helper alone, objects in `src\tray_app\` | +| `config` | `WindConfig.exe` | Builds the Svelte app to `ui/dist/`, then the WebView2 host against `third_party/webview2` | +| `installer` | `dist\Wind-Setup-x64-.exe` | `makensis /WX installer\wind.nsi`, then `tools\installer_check.ps1` | + +The installer treats NSIS warnings as errors: an unreferenced define in generated layout data is +how a page ends up wired to nothing. + +## Pure versus Win32 + +**Anything with real logic compiles without ``.** The magnifier cannot be driven +headlessly, so the unit tests are the only verification loop that runs everywhere, including CI. + +- The `test` target compiles `tests\*.cpp` with `/DWIND_TESTS` against only the pure sources. The + authoritative list is the `:test` target in `build.bat`; today it is `transform`, + `zoom_controller`, `config`, `profiles`, `cursor_mapper`, `lock_detector`, `cursor_lock`, + `mouse_ballistics`, `crosshair`, `config_ui/ini_edit`, `logging` and `tray_items`. +- Header-only pure modules (`engine_pick.h`, `drag_follow.h`, `hdr_scale.h`, `keybind_rules.h`, + `config_ui/wind_watchdog.h` and others) ride in through their test files. +- One test file per module (`tests/test_.cpp`). A new pure `.cpp` also goes into the + `:test` list. +- Files that straddle the line put their OS half under `#ifndef WIND_TESTS`. `src/config.cpp` is + the example: `ParseConfig` and `StripUiOnlyKeys` above, `LoadConfig` and the only + `#include ` below. +- `tests/fixtures/keybind_cases.txt` is shared by the C++ and the JavaScript bind rules. + +**UI suite.** `ui/tests/` holds 12 Playwright specs, run with `npx playwright test` in `ui/` (the +config starts the Vite server). A fake `window.chrome.webview` answers the host's messages and +records them in `window.__msgs`. When you add a bridge message to `HandleWebMessage`, extend the +mock in the same change. Details in [09](09-settings-ui.md). + +**CI** (`.github/workflows/build.yml`, on pull requests and pushes to `main`) builds the app and +runs the doctest suite. It does not run the UI suite; that is a local gate for UI changes. + +## Local UIAccess deploy + +UIAccess is needed for the input-transform publish (transform on the desktop), binds over +elevated windows and the opt-in band-16 overlay. Windows grants it only to a binary signed with a +locally trusted certificate and run from a secure location, in practice `C:\Program Files\Wind`. +Without it, Wind reads `TokenUIAccess` at init and Auto keeps the desktop on Render. + +`tools/uiaccess_setup.ps1` (elevated) stops Wind, builds `uiaccess` and `config`, creates or reuses +a self-signed "Wind Dev Test Cert", trusts it, signs all three exes (unsigned fresh builds trip a +Defender false positive) and copies them with `ui\dist` to Program Files. It does not deploy an +ini. It logs to `tools\uiaccess_setup.log`; a good deploy ends with `status=Valid` and `DONE`. + +- Pass an **absolute** `-File` path: the elevated process starts in System32. +- Launch the deployed copy from a **non-elevated** shell. An elevated launch gives an admin token, + which changes which `%LOCALAPPDATA%` the app uses. ## The installer -The installer is NSIS with a fully custom-drawn UI: a looping video background decoded frame-by-frame through GDI+ with alpha-blended overlay screens on top, modeled on Prism's installer. `installer/README.md` explains the piece-by-piece layout (`wind.nsi` entry point, `app.nsh` for the Wind-specific logic, `over.html` as the single source of copy, the `make-over.mjs`/`make-loop.mjs` generators); the design spec is [2026-08-20-installer-design.md](../superpowers/specs/2026-08-20-installer-design.md). There is intentionally no install-location chooser: UIAccess is only granted in a secure location, so an install to `D:\Apps\Wind` would silently disable the features the per-machine install exists for. +NSIS with a custom-drawn UI (a looping video background with overlay screens). Layout, generators +and local signing are described in `installer/README.md`; the design spec is +[../specs/2026-08-20-installer-design.md](../specs/2026-08-20-installer-design.md). There is no +install-location chooser, because UIAccess is granted only in a secure location. -Three elevation traps live in `installer/app.nsh` and are worth internalizing, because each one was measured the hard way: +**Elevation rules** (`installer/app.nsh`): -1. **HKCU and `%LOCALAPPDATA%` belong to the wrong user under elevation.** The installer runs elevated, and an elevated process's HKCU is whichever hive the elevated TOKEN owns, an admin account's whenever a standard user elevated with different credentials. Autostart therefore goes in HKLM `...\CurrentVersion\Run`, never HKCU. -2. **Launch through explorer.exe, not `Exec`.** A plain `Exec` at the end of setup hands Wind the ADMIN token, and `ResolveIniPath()` then puts the ini, profiles, and logs in the administrator's profile where the user never finds them. `Exec '"$WINDIR\explorer.exe" "$INSTDIR\Wind.exe"'` hands the launch to the running shell, which owns the user's token (rig-probed both ways: plain launch = elevated, via explorer = not). -3. **Upgrades stop Wind by asking, not killing.** The `WIND_QUIT_RUNNING` macro sets Wind's auto-reset quit event (`Local\Wind_QuitRequest`, opened in `src/main.cpp`) so Wind exits CLEANLY: only the clean path restores the OS cursor, releases any ClipCursor, releases the shared Magnification runtime, and restores the native-Magnifier registry backup. The macro then waits on the single-instance mutex (`Local\Wind_Magnifier_SingleInstance`) as a fast first signal, but the mutex comes free ~3 ms into teardown, well before the process is gone, so it also polls tasklist for the PROCESS before falling back to `taskkill` for a Wind that ignored the request. Killing between "mutex released" and "shutdown finished" is exactly the damage the event exists to avoid. +1. **HKCU and `%LOCALAPPDATA%` belong to the wrong user under elevation.** An elevated process's + HKCU is the elevated token's, which is an admin's whenever a standard user elevated with other + credentials. Autostart goes in HKLM `...\CurrentVersion\Run`. +2. **Launch Wind through explorer.exe, not `Exec`.** `Exec` hands Wind the admin token, and + `ResolveIniPath()` then puts the ini, profiles and logs in the admin's profile. +3. **Stop Wind by asking, not killing.** `WIND_QUIT_RUNNING` sets `Local\Wind_QuitRequest` and waits + for the process to exit; only a clean exit restores the OS cursor and releases `ClipCursor`. The + single-instance mutex frees ~3 ms into teardown, long before the process is gone, so the macro + polls for the process before falling back to `taskkill`. -`build.bat installer` finishes by running `tools/installer_check.ps1`, a gate for what a compile cannot see: every `File` source exists (including `/nonfatal` ones NSIS skips silently), every control rectangle the screens hit-test was actually generated into `over.nsh` (renaming a `data-a` attribute in `over.html` compiles fine and then clicks against nothing), and a silent install/uninstall round-trips. The round-trip check needs elevation and skips itself from an ordinary shell; CI's runner is administrator, so there it really executes. +`tools/installer_check.ps1` checks what a compile cannot: every `File` source exists, every +hit-tested rectangle was generated into `over.nsh`, and a silent install/uninstall round-trips +(needs elevation; skipped from a normal shell, runs in CI). ## Release automation -Releases are owned by `.github/workflows/release.yml` and are mechanical on purpose. The reason is the v0.1.0 staleness incident, written into the workflow's own header comment: v0.1.0 shipped, the #209 fix landed on `main`, and the release kept serving the old installer. That stale build was then installed over the dev box and silently removed a working fix whose branch was unmerged. The lesson became two standing rules: every push to `main` that can change the binary republishes the installer, and nothing deployed locally is safe until it is on `main`. - -**CI pipeline, push to published installer:** +**Every push to `main` that can change the binary rebuilds and republishes the installer** +(`.github/workflows/release.yml`). v0.1.0 kept serving an old installer after a fix landed, and +that stale build later overwrote a working fix on the dev machine; the rule is therefore +mechanical. ```mermaid flowchart TD - P[push to main] --> F{paths-ignore:\n*.md, docs/, issue templates?} - F -- docs only --> S[skip: docs cannot change the binary] - F -- code --> V[read version from src/version.h] - V --> N[choco install nsis] - N --> T[build.bat test] - T --> B[pwsh tools/release.ps1\nWind.exe + WindConfig.exe + ui/dist + installer + installer_check] - B --> R{gh release view v<ver>\nexists?} - R -- no --> C[gh release create: NEW release] - R -- yes --> U[gh release edit + upload --clobber:\nrefresh asset in place] - C --> D[published Wind-Setup-x64-<ver>.exe + Wind-Setup-x64.exe + sha256 notes] - U --> D + P[push to main] --> F{only docs, LICENSE or tools/testenv?} + F -- yes --> S[skip] + F -- no --> V[read version from src/version.h] + V --> T[build.bat test] + T --> B[pwsh tools/release.ps1] + B --> R{release v<ver> exists?} + R -- no --> C[gh release create] + R -- yes --> U[gh release upload --clobber] ``` -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.10.4. -- **Every release carries the installer twice** (issue #343): `Wind-Setup-x64-.exe`, the primary asset, and a stable-named copy `Wind-Setup-x64.exe`, so `https://github.com/Maxaubert/Wind/releases/latest/download/Wind-Setup-x64.exe` (the README's download link) always serves the newest release without ever being edited. Both are uploaded together, and `--clobber` refreshes both. -- **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 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. - -## Pointers - -- `build.bat` - all native build targets; the vswhere `-all -prerelease` note lives in its header comments -- `tests/` - one doctest file per pure module; `tests/test_main.cpp` is the doctest main -- `src/config.cpp` - the `#ifndef WIND_TESTS` pure/IO split; `StripUiOnlyKeys` and `IsForbiddenBindVk` on the pure side -- `ui/tests/` + `ui/playwright.config.js` - the settings-UI Playwright suite and the `window.chrome.webview` mock -- `tools/uiaccess_setup.ps1` - the elevated build-sign-deploy script; log at `tools/uiaccess_setup.log` -- `installer/wind.nsi`, `installer/app.nsh`, `installer/README.md` - the installer; spec at [2026-08-20-installer-design.md](../superpowers/specs/2026-08-20-installer-design.md) -- `tools/installer_check.ps1` - the post-compile installer gate -- `.github/workflows/release.yml` + `tools/release.ps1` + `src/version.h` - the release pipeline -- Related chapters: [Engines](03-engines.md) for what UIAccess actually unlocks, [Config and profiles](08-config-profiles.md) for `ResolveIniPath` and the hot-reload fingerprint, [The settings UI](09-settings-ui.md) for the bridge messages the Playwright mock stands in for +- **`src/version.h` is the only version declaration.** Bumping it cuts a new release `v`; a + push that leaves it alone refreshes the current release's assets in place with `--clobber`, in + this repo. +- Each release carries the installer twice: `Wind-Setup-x64-.exe` and the stable name + `Wind-Setup-x64.exe`, so `releases/latest/download/Wind-Setup-x64.exe` always serves the newest. +- Release notes come from `.github/release-notes.md`; the workflow fills `__VERSION__`, + `__SHA256__` and `__COMMIT__`. +- Tests gate the build. A concurrency group serializes runs. +- **Never hand-upload a release asset.** The workflow owns them. + +**Signing.** `tools/release.ps1` is the shared build driver for CI and local releases. Signing is +environment-driven: `WIND_SIGN_THUMBPRINT`, or `WIND_SIGN_PFX` plus `WIND_SIGN_PASSWORD`. + +- With a certificate it signs the three exes and the installer, signing the payload + before makensis packs it (UIAccess is granted on `Wind.exe`'s own signature). +- Without one (CI today) it packs both variants: the ordinary build and the UIAccess build as + `WindUA.exe`. Setup signs `WindUA.exe` on each PC with a per-machine certificate whose private key + it deletes at once (`installer/local-sign.ps1`); if that fails, setup installs the ordinary build. +- **Never put the self-signed dev certificate in CI**: it is trusted by nobody and looks worse than + no signature. + +**Alpha channel** (`.github/workflows/alpha.yml`, run by hand from Actions). It builds an installer +from any branch and publishes a pre-release `v-alpha.`, which GitHub's Latest +pointer ignores, so the stable download is unaffected. It does not bump `src/version.h` and keeps +the newest 5 alphas. Its build steps mirror `release.yml`; change both together. diff --git a/docs/architecture/12-instrumentation.md b/docs/architecture/12-instrumentation.md index 668b11f0..138a5026 100644 --- a/docs/architecture/12-instrumentation.md +++ b/docs/architecture/12-instrumentation.md @@ -1,331 +1,105 @@ # 12. Instrumentation and field method -Wind's engineering culture is one sentence: measure, never assume. Every major bug in this -codebase, the transform TDRs, the hover dead zones, the cursor wobble, the acrylic zoom-in -freezes, the "magnifier has to wake up" feel, was settled by building an instrument, not by -reasoning about the code. This chapter documents the three layers of that practice: the -always-on logging built into the binaries (`src/logging.*`), the diagnostic knobs compiled into -Wind itself, and the standalone PowerShell harness under `tools/` that drives and measures the -magnifier from outside. - -## The field-verdict method - -A magnifier cannot be verified headlessly: its output is compositor state on a real display, -its input is a real mouse, and its worst bugs involve DWM, the GPU driver, and other processes. -So the loop that settles a question is always the same, and it always ends with a run on the -rig (4K at 225% DPI, RTX 5090, VRR panel, MPO disabled): - -**The field-verdict loop: every hypothesis gets an instrument before it gets a fix.** - -```mermaid -flowchart TD - A[Symptom or hypothesis] --> B[Build an instrument that measures THE ARTIFACT itself] - B --> C[A/B on the rig: Wind vs native, knob on vs off, or before vs after] - C --> D{Numbers separate the cases?} - D -- no --> B - D -- yes --> E[Verdict recorded in a findings doc under docs/] - E --> F[Fix or knob lands, guarded by the same instrument] -``` - -Two rules inside that loop have earned their capital letters: - -1. **Measure the artifact, not a proxy.** `tools/mag_wobble_probe.ps1` opens with the war - story: three builds measured write latency and write rate, looked great, and still felt - wrong, because the wobble is the cursor's deviation from screen center during a pan, not - either of those numbers. The probe that finally caught it samples pointer position and - transform offset together and reports the deviation directly. -2. **Verify the experiment engaged.** [HITCH-FINDINGS](../HITCH-FINDINGS.md) calls a dead - keybind silently faking a clean result "the single biggest source of false positives in - this work"; every zoom test there checks the `txsession ... maxLevel=` log line before - trusting the run. Similarly `tools/zen_backdrop.ps1` exists because an "acrylic off" control - run was once still acrylic, the setting had not taken effect. Verify, then test. - -The verdicts live in findings docs (listed at the end of this chapter) so a conclusion is -never re-litigated from memory. The pattern to follow when you hit a new mystery: write the -hypothesis down, write a script under `tools/` that would falsify it, run both arms on the -rig, and commit the numbers. - -## The logging system (src/logging.*) - -Both binaries log through one small backend in `src/logging.cpp`. The pure half (line -formatting, rotation policy, the system-snapshot renderer) has no `` and is unit -tested; the Win32 half is excluded from the `WIND_TESTS` build. - -- **Files.** `wind::LogInit` (src/logging.cpp) resolves the log directory via - `wind::ResolveLogDir` (src/config_path.h), which is `%LOCALAPPDATA%\Wind\logs\`. Wind.exe - writes `wind-core.log`, WindConfig.exe writes `wind-config.log`. Files rotate at 1 MiB - (`kLogMaxBytes`) through three generations (`wind-core.log`, `.1.log`, `.2.log`, - `kLogGenerations` in src/logging.h). If a second instance cannot open the shared log during - the brief single-instance-refusal overlap, it falls back to a per-PID file - (`wind-core-.log`) so its startup trail is still captured; strays and old crash dumps - are pruned to `kCrashKeep` at the next `LogInit`. -- **Lines.** `wind::Log(level, category, fmt, ...)` produces - `2026-05-31T08:14:22.137Z WARN render ` (`FormatLogLine`). It is thread safe, - flushes on Warn/Error, and must NEVER be called from the per-frame path; the per-second - aggregators below exist precisely so hot paths log summaries, not events. -- **Startup snapshot.** `LogSystemSnapshot` writes a labelled block: Wind version and build - flavor (normal/uiaccess), true OS build via `RtlGetVersion`, CPU, RAM, every DXGI adapter - with driver version, every monitor with resolution/refresh/DPI/rotation, and a full dump of - the live config as `key=value` lines. When a field log arrives, the environment and settings - it ran under are in the first screenful. -- **Crash dumps.** `WriteCrashReport` runs inside the `SetUnhandledExceptionFilter` handler: - it builds paths heap-free from a directory pre-resolved at `LogInit`, writes a minidump - (`wind-crash-.dmp`) plus a text summary with the exception code, address, and faulting - module name (`wind-crash-.txt`). -- **Export diagnostics.** `ExportDiagnosticsToDesktop` zips the whole log directory to - `Wind-diagnostics-.zip` on the Desktop; the tray menu and the settings UI both expose - it, so "send me your diagnostics" is a one-click ask. `ZipLogDir` stage-copies the files to - a temp dir first (the live log handles use `FILE_SHARE_READ`, so `CopyFileW` can read them - while both processes run) and only then runs `Compress-Archive`, so the export never touches - a live handle. - -### The per-second stat lines - -The interesting runtime telemetry is aggregated per second and logged only when something is -worth reading, so an idle log stays quiet and a field log points straight at the anomaly: - -| category | source | what it says | +Wind's output is compositor state on a real display and its input is a real mouse, so most +questions are settled by measurement on hardware. This chapter covers the logging built into the +binaries (`src/logging.*`), the diagnostic knobs compiled into Wind, and the scripts in `tools/`. + +## Field method + +- **Measure the artifact, not a proxy.** Write latency and write rate looked fine on three builds + that still wobbled; the wobble is the cursor's deviation from the view centre during a pan, and + `tools/mag_wobble_probe.ps1` measures exactly that. +- **Verify the experiment engaged.** A dead bind or a setting that did not apply fakes a clean + result. Check the `txsession ... maxLevel=` log line before trusting a zoom run, and verify a + control's backdrop or ini state before an A/B. + +Write the conclusion into a findings file under `docs/` (index: +[README](README.md#docs-index)). + +## The proving ground + +`tools/testenv/` is the reusable automated environment: `run.ps1` restarts Wind with per-tick +telemetry (`src/test_telemetry.h`, `WIND_TESTLOG` or `%LOCALAPPDATA%\Wind\testlog.txt`), drives +scenarios (backdrop x zoom x movement) and compares pacing, ramp back-steps, cursor jitter and RAM +against `baselines.json`; `-CI` exits nonzero on regression. Suites run from ~1 to ~8 minutes. +Details: `tools/testenv/README.md`. + +## Logging + +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, + `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`. +- **Lines.** `wind::Log(level, category, fmt, ...)` gives + `2026-05-31T08:14:22.137Z WARN render `. Thread-safe, flushes on Warn and Error. + **Never log from the per-frame path**; hot paths log per-second summaries. +- **Startup snapshot.** Version and build flavour, OS build (`RtlGetVersion`), CPU, RAM, every + adapter with driver version, every monitor with resolution, refresh, DPI and rotation, and the + live config. +- **Crash dumps.** The unhandled-exception filter writes `wind-crash-.dmp` and a text summary + (code, address, faulting module), heap-free from a directory resolved at `LogInit`. +- **Export diagnostics** (Settings > Preferences) zips the log folder to the Desktop. Files are + stage-copied first, so the export never touches a live handle. The tray has no export. +- `diagnostics=1` adds the chattier frame-pacing trace in `%TEMP%\wind_diag.log`. + +**Per-second and edge lines** (logged only when worth reading): + +| Category | Source | Content | |---|---|---| -| `txwrite` | `TransformModel::noteWrite` (src/transform_model.cpp) | transform write count, avg/max ms, writes over 5 ms, failures; emitted only when max > 5 ms or a write failed. A `fails` streak is the classic shared-runtime tell (see the MagInitialize gotcha in [Engines](03-engines.md)). | -| `ixwrite` | `TransformModel::noteIxWrite` | input-transform publish cadence and timing (issue #189), plus `stomps`; same only-when-interesting gate. | -| `cursor` | RunTick in src/main.cpp, behind `diagnostics=1` | `divergence max=..px dragFollowTicks=.. lvl=..`, how far the pointer sat from the lens center at the weld instant. Note the caveat proven in [WOBBLE-CAPTURE-2026-08-21](../WOBBLE-CAPTURE-2026-08-21.md): this samples AT the weld, so it is blind to the between-tick staleness the eye sees; the wobble probes exist because this number can read 0-9 px while the on-screen lag is ~276 px. | -| `lock` | RunTick in src/main.cpp | lock-state edges, not per-tick spam: `detector LOCKED/free`, with a `(warp-anchor)` suffix when the warp tell produced the lock, and `seeded LOCKED at zoom-in ...` when the `warpLock` zoom-in seed fired. These edges are how a field log shows which regime a bad pan ran in. | -| `hybrid` | RunTick | engine-pick decisions per session (`transform session (welded cursor)` etc.). | -| `snapshot` | `LogSystemSnapshot` | the startup block above, one line per event so it never truncates. | - -Separately from the unified log, the `diagnostics=1` ini knob also enables the frame-pacing -trace to `%TEMP%\wind_diag.log` (`Config::diagnostics`, src/config.h), a much chattier -per-frame record used for pacing investigations only. - -One logging-adjacent piece of instrumentation discipline worth knowing: the hot-reload path -fingerprints the ini through `wind::StripUiOnlyKeys` (src/config.cpp) before deciding whether -the core must react, so UI-only churn (theme, onboarding flags) can never fake a core config -change; a field report of a zoom collapsing on an unrelated Settings save is what forced that -(comment at the `lastCoreIni` seed in src/main.cpp). - -## Diagnostics compiled into Wind - -These are env vars and ini knobs that ship in the binary, zero-cost when off, and exist so a -field question can be answered without a special build: - -- **`WIND_SELFTEST=1`** (src/main.cpp): drives the real integrated render path headlessly and - dumps `wind_selftest.png`. This is the ONLY way to screenshot the render overlay, it is - capture-excluded (`WDA_EXCLUDEFROMCAPTURE`), so external screenshots cannot see it. -- **`WIND_PACINGTEST=1`** (src/main.cpp): runs the real present-paced render path at a forced - cadence; it is how the blt microstutter was proven to be DWM phase mismatch and not our loop. -- **`WIND_NOPARK=1`** (src/render_engine.cpp): disables overlay parking for A/B; parking is - the fix for the idle overlay demoting fullscreen games off independent flip, and this knob - reproduces the bad case on demand. -- **`WIND_NOHOOK`**: skips the low-level hook install so the polling fallback path can be - exercised (src/input_router.cpp / src/main.cpp). -- **`tdrTest`** (ini, hot; `Config::tdrTest`, src/config.h): the issue #148 field harness. - Nonzero forces the transform engine past the churny-app list; mode 2 probes the |tx| clamp, - mode 4 disables the MPO pan wall. Modes 1 and 3 are retired. This knob is how the NVIDIA - 16-bit-overflow TDR was bisected on a live game. -- **`probeClicks`** (ini; src/main.cpp RunTick): the dead-zone annotator from the - pointer-hit-test hunt. Mode 1: each click logs every coordinate space in the chain (pointer, - weld target, applied DWM transform, `WindowFromPoint` hit-test target); plain click means - "hover works here", Ctrl+click means "dead here", so the field annotates the map and the - divergent space names itself. Mode 2 adds a continuous ~36 Hz `ptrace` of the physical - pre-weld cursor vs the weld target, the stream pointer-framework apps actually perceive. - See [POINTER-HITTEST-FINDINGS](../POINTER-HITTEST-FINDINGS.md) for the hunt it decided. -- **`lockForce=1`** (ini, hot; `Config::lockForce`): forces the LOCKED pan regime - (raw-mickey panning) regardless of what `LockDetector` thinks, isolating "is the detector - wrong" from "is the locked path wrong" in one toggle. -- **`warpLock`** (ini; `Config::warpLock`) gates the issue #221 lock tells for - pointer-warping mouselook engines, and each tell logs its own edge: the warp-anchor tell - (`LockDetector::update` warp variant, src/lock_detector.h: a big jump repeatedly LANDING on - one anchor pixel is lock evidence, field-traced as 58 returns to one pixel at apparent - 13k-80k px/s), and the zoom-in seed (`LockDetector::seedLock`, logged as - `seeded LOCKED at zoom-in`). The `lockApps` list (`Config::lockApps`) is the no-heuristics - escape hatch: a listed foreground exe runs LOCKED outright, and the `lock` log shows which - mechanism engaged. `LockDetector::warpLocked()` exists purely so diagnostics can attribute - the state. -- **`txMaxStepPct`** (ini; default 25, `Config::txMaxStepPct`; applied in - `TransformModel`, src/transform_model.cpp): caps the per-tick relative level change the - transform applies. This default is itself a field verdict: the issue #219 cycle soak - ([PERF-ACRYLIC-PARITY-2026-08-21](../PERF-ACRYLIC-PARITY-2026-08-21.md)) measured uncapped - ramps freezing 35-43 ms mid-zoom then snapping 1.2-1.9 levels at once. - -## The tools/ harness - -Everything in `tools/` is a standalone PowerShell script (plus one vendored binary) that -P/Invokes the same public APIs Wind uses, `MagGetFullscreenTransform` in particular reads the -ONE desktop magnification state whoever wrote it, which is what makes Wind and native -Magnifier directly comparable. A recurring caveat, stated in `mag_wobble_monitor.ps1`: -reading the transform requires `MagInitialize`, so a monitor itself holds a magnification -context and pays the cursor-change tax while it runs. - -**Which instrument to reach for, by symptom.** +| `txwrite` | `TransformModel::noteWrite` | Write count, avg/max ms, writes over 5 ms, failures; only when max > 5 ms or a write failed. A `fails` streak is the shared-runtime tell ([05](05-transform-engine.md)) | +| `ixwrite` | `TransformModel::noteIxWrite` | Input-transform publish cadence and timing, plus `stomps` | +| `txsession` | `TransformModel` | `session end maxLevel=..` per transform session | +| `cursor` | `RunTick`, `diagnostics=1` | Pointer distance from the lens centre at the weld instant. Blind to between-tick lag; use the wobble probes for that | +| `lock` | `RunTick` | Lock edges and the tell that caused them (`warp-anchor`, `seeded LOCKED at zoom-in`) | +| `hybrid` | `RunTick` | Engine pick per session | +| `snapshot` | `LogSystemSnapshot` | The startup block | -```mermaid -flowchart LR - A[cursor wobbles or trails] --> W[mag_wobble_probe / monitor / repro + grid_window] - B[pan or zoom hitches] --> P[mag_perf_run + acrylic_window / test_target_window] - C[feels laggy vs native] --> L[mag_latency_probe / mag_ab_controlled] - D[slow to wake after idle] --> K[dwm_wake_probe -> mag_wake_latency -> mouse_wake_probe] - E[dwm.exe crashed] --> M[dwm_memprobe + magtrace] - F[game fps drops near Wind] --> G[flipwatch / gpu_breakdown / gpu_nvsmi] -``` +## Diagnostic knobs -### The wobble family (issue #206 and after) +Zero cost when off. -- **`mag_wobble_probe.ps1`**: the active measurement. Pans at a known CONSTANT speed - (deviation scales with speed, so a constant makes runs comparable), samples pointer and - transform offset together at high rate, and reports how far the cursor strays from screen - center, the staleness sawtooth that IS the visible wobble. `-Driver wind|native`, and it - forces both drivers to the SAME level because deviation also scales with level. -- **`mag_wobble_monitor.ps1`**: the passive counterpart. Injects nothing; the user drives - Wind with the real mouse while it logs one CSV line per second: level, pointer speed, - cursor-deviation median/p95, `staleMs` (deviation normalized by speed and level, so - freehand pans become comparable), transform writes per second and how many moved - BACKWARDS against the pan, sprite-center deviation, the `MagGetInputTransform` state, and - the foreground process, so the second a session degenerates is visible in the series and - correlatable with what the user was doing. -- **`mag_wobble_repro.ps1`**: scripted repro arms for the stale-input-transform hypothesis: - a control run (zoom Wind and pan), `-PoisonWm` (open native Magnifier, zoom it, kill it - mid-zoom dirty, then run Wind), and `-WmOpen` (native merely running unzoomed alongside). - It pans with RELATIVE mickeys, an earlier probe's 1 kHz absolute moves fought the weld and - measured something else, and prints the input-transform state at each stage. -- **`grid_window.ps1`**: a maximized 50 px grid window (heavier line every 250 px). Over a - solid background there is no reference to see cursor-vs-content motion; the grid makes - wobble obvious. Title bar kept deliberately so the hybrid pick treats it as desktop. - -### Performance and cadence - -- **`mag_perf_run.ps1`**: the controlled perf run built for issue #219. Modes: `pan`, - `ramp` (zoom in/out), `cycle` (the focus-swap repro), `rezoom` (session-start bounce), - and `zigzag` (Max's protocol: zoom at the bottom, zig-zag climb to the top, zoom out). - It measures compositor pacing via a `DwmFlush` loop because - `DwmGetCompositionTimingInfo` fails on this VRR panel (0x88980090 at every struct size), - plus per-process GPU 3D-engine utilization (via the child sampler), CPU/working set, write - cadence, and cursor-vs-center deviation. The `native` driver backs up and restores the - Magnifier registry and expects Wind quit first. -- **`gpu_sampler.ps1`**: `mag_perf_run`'s child; appends `pid,value` GPU-engine counter - samples so the parent can aggregate per process after the pan. -- **`mag_ab_controlled.ps1`**: Wind vs native against an IDENTICAL solid full-screen target, - with the environment verified before each run and recorded with the numbers, built after - A/Bs against "whatever was on screen" (acrylic for one arm, Mica for the other) turned out - to measure the content, not the magnifier. Reports both latency and cadence. -- **`mag_latency_probe.ps1`**: cursor-move to transform-write latency: inject one absolute - move, spin-poll the transform at ~4 kHz until the offset changes, randomize rests so - sampling is not phase-locked. Its header states the honest caveat: this is time-to-write, - not time-to-photons, but compositing is downstream of both magnifiers equally. -- **`wind_cadence_ab.ps1`**: automated A/B of Wind's write behavior across ini configs: hot - writes the ini, scripts a whole zoom-and-pan session via injected side buttons, and reports - the observed cadence plus Wind's own `txwrite` timings from wind-core.log. -- **`wind_drive_probe.ps1`**: the prerequisite probe that proved injected `SendInput` - XBUTTON2 IS delivered through Raw Input to Wind's zoom binding (level rose above 1.0 while - held), which is what makes every scripted session above possible. - -### The "wake" chain (one report, three instruments, each ruling out a layer) - -- **`dwm_wake_probe.ps1`**: samples DWM's real composition cadence via `DwmFlush` returns - across an idle-then-pan; verdict: the compositor never stalls (median 6.94 ms, zero missed - frames). -- **`mag_wake_latency.ps1`**: warm vs cold move-to-write through Wind's own path; verdict: - 0.59 ms difference, a tenth of a frame, not the bug. -- **`mouse_wake_probe.ps1`**: the layer the other two CANNOT see, both inject via - `SendInput`, which enters above the device, so a wireless mouse dropping its report rate - while idle would be invisible to them and would look exactly like the report (coarse first - steps, multiplied by the zoom). - -### DWM, flip state, and GPU - -- **`dwm_memprobe.ps1`**: detached watcher for the dwmcore `STATUS_FATAL_MEMORY_EXHAUSTION` - crash (0xc00001ad): samples DWM's memory from outside, survives the compositor dying, - records the respawn moment, and plays an alarm. It measured DWM's VRAM doubling to - ~5.27 GB inside ~145 ms during a zoom ramp over acrylic. -- **`magtrace.ps1`**: records what the ACTIVE magnifier is writing, level, offsets, cadence, - since there is exactly one desktop magnification state; point it at native, then at Wind, - and diff. This is how native's ~64 writes/s coarse stepping was established. -- **`flipwatch.ps1`**: PresentMon-based presentation-mode recorder (Hardware Independent - Flip vs Composed: Flip) for the transform-model stickiness question: does a magnification - session demote a game's swapchain, and does the demotion outlive the context. Uses the - vendored **`PresentMon.exe`**. -- **`gpu_breakdown.ps1`**: per-process GPU counters for Wind (redraw) vs dwm.exe (overlay - composite), at 1x vs zoomed-and-panning, isolating blt's cost from background noise. -- **`flip_breakdown.ps1`**: sets `flipPresent=1`, relaunches Wind, and reruns the breakdown, - the A/B for whether flip present removes the DWM overlay-composite cost. -- **`gpu_nvsmi.ps1`**: whole-GPU utilization via nvidia-smi (matches the on-screen overlay) - for idle / zoomed-still / zoomed-panning. - -### Reverse-engineering native Magnifier (issue #205) - -- **`mag_formula_probe.ps1`**: derives native's offset formula EMPIRICALLY, drive the real - Magnifier, move the cursor to a grid of known points, read back what it wrote, fit the - formula, because the remembered disassembly note it replaced was "a claim, not a - measurement, and building on it unverified is how the last three days went". -- **`mag_trackmode_probe.ps1`**: distinguishes native's centered vs within-edges tracking - modes with SMALL steps inside the view (the grid probe's big jumps forced a recenter in - both modes and could not tell them apart). - -### Visual test targets and controls - -- **`acrylic_window.ps1`**: a borderless screen-covering window with a real DWM acrylic - backdrop, the expensive-geometry case for magnified compositing, so "zoom over acrylic" - tests do not depend on which app happens to be themed that way. -- **`test_target_window.ps1`**: the deliberately boring control: solid color, backdrop - explicitly set to none (not left at "auto"), so an A/B measures the magnifier and not the - content. -- **`zen_backdrop.ps1`**: reads `DWMWA_SYSTEMBACKDROP_TYPE` for a process's windows to verify - a control's backdrop state before trusting the run; its header is careful about what the - attribute proves (`none` is solid proof of off; `ACRYLIC` alone is not proof DWM is doing - the blur work). - -### Build and deploy scripts (not instruments, listed for completeness) - -- **`uiaccess_setup.ps1`**: elevated build+sign+deploy of the UIAccess pair to Program Files; - logs to `tools/uiaccess_setup.log`. -- **`installer_check.ps1`**: post-build sanity checks on the NSIS installer. -- **`release.ps1`**: builds the release installer artifact into `dist/`. -- **`make_icon.mjs`**: generates the app icon assets. - -## The findings docs - -Each doc under `docs/` is a closed (or explicitly open) verdict. Read the doc before -re-opening its question; the conclusions below are the load-bearing part, the docs hold the -evidence. - -| doc | what it settled | +| Knob | Effect | |---|---| -| [HITCH-FINDINGS](../HITCH-FINDINGS.md) | Issue #148 hitching: a LIVE magnification context taxes every cursor visibility/shape change any app makes (13-24 spike frames per 14 wheel-clicks with Wind merely idle, 0 without a context); writing level 1.0 does not leave the mode, only releasing the runtime does. Also the weld/rounding/MPO TDR bisect. | -| [POINTER-HITTEST-FINDINGS](../POINTER-HITTEST-FINDINGS.md) | The transform-desktop hover dead zones: pointer-input frameworks consume `MagSetInputTransform` for MOUSE hit-testing (MSDN's pen/touch scoping is wrong); publishing the source-rect input transform per change fixes hover at every level, and requires UIAccess. Includes the wrong intermediate verdict, kept on purpose. | -| [WOBBLE-CAPTURE-2026-08-21](../WOBBLE-CAPTURE-2026-08-21.md) | Live capture of the degenerate wobble state: a constant-velocity sprite lag of ~276 screen px (~36 ms at 900 px/s), constant during the pan and collapsing at rest, exactly the reported "inertia" feel; and proof that Wind's own `cursor divergence` line is blind to it because it samples at the weld instant. | -| [PERF-ACRYLIC-PARITY-2026-08-21](../PERF-ACRYLIC-PARITY-2026-08-21.md) | Issue #219: steady-state Wind vs native over acrylic is IDENTICAL; the real artifact is sporadic DWM-internal 35-46 ms ramp freezes caught only by a 20-cycle soak, native's tail is worse but its coarse ease masks it. Produced the `txMaxStepPct` cap. | -| [PERFORMANCE-FINDINGS](../PERFORMANCE-FINDINGS.md) | Historical (marked SUPERSEDED for the desktop case): the Magnification-API ceiling analysis and the PresentMon methodology; its "own renderer won't help" conclusion was later proven wrong by shipping one. | -| [PERFORMANCE-AUDIT-2026-05-26](../PERFORMANCE-AUDIT-2026-05-26.md) / [THREADING](../PERFORMANCE-AUDIT-THREADING-2026-05-26.md) | Early loop and threading audits of the tick pipeline. | -| [KNOWN-ISSUES](../KNOWN-ISSUES.md) | The living list of accepted limitations and open anomalies. | -| [VERIFICATION](../VERIFICATION.md) | How to verify a build hands-on (what to click, what to look for). | - -Deeper war-story records live in the specs directory (`docs/superpowers/specs/`), and the -scratchpad-era harnesses referenced by HITCH-FINDINGS (`bench.ps1`, `cursorwatch.exe`, -`rtssread.exe`, `maglab.exe`) were deliberately not vendored; `tools/` holds only the -instruments worth keeping. - -## Pointers - -- `src/logging.h` / `src/logging.cpp`, the logging backend, rotation, snapshot, crash dumps, - diagnostics export -- `src/transform_model.cpp`, `noteWrite` / `noteIxWrite` (the `txwrite` / `ixwrite` lines) -- `src/main.cpp` RunTick, the `cursor`, `lock`, `hybrid`, and `ptrace` log sites; - `WIND_SELFTEST` / `WIND_PACINGTEST` entry points -- `src/lock_detector.h`, the lock tells the `lock` lines attribute -- `src/config.h`, every diagnostic knob (`diagnostics`, `tdrTest`, `probeClicks`, - `lockForce`, `warpLock`, `lockApps`, `txMaxStepPct`) -- `tools/`, the harness inventory above -- Related chapters: [Engines](03-engines.md) for the mechanisms these instruments measure - -## The proving ground (tools/testenv, issue #225) - -The reusable automated test environment: `run.ps1` restarts Wind with per-tick telemetry -(`src/test_telemetry.h`, enabled via the `%LOCALAPPDATA%\Wind\testlog.txt` control file or the -`WIND_TESTLOG` env var), forces a zoom-out reset, plays a start tone (880 Hz), then drives -scenarios - backdrop window (solid / white / light acrylic / heavy acrylic / animated grid; -borderless vs captioned selects the engine class) x zoom x movement program (zigzag, pans, -precision drift, dead-stop hold, rezoom cycles) - and ends with a stop tone (440 Hz). Those -two tones are the environment's entire sound vocabulary. Suites: rapid (~1 min) / quick -(~2 min) / full (~8 min) / soak; the iteration gate is rapid-or-quick by risk, then full, -then PR. Verdicts come from the telemetry (pacing p95/p99, ramp back-steps, cursor-centre -jitter) plus RAM deltas, compared against `baselines.json` with `-CI` exiting nonzero on -regression. Full docs: `tools/testenv/README.md`. +| `WIND_SELFTEST=1` | Renders headlessly and writes `wind_selftest.png`; the only way to capture the render overlay | +| `WIND_PACINGTEST=1` | Runs the render path at a forced cadence (proved the blt microstutter is DWM phase, not the loop) | +| `WIND_NOPARK=1` | Disables overlay parking for A/B | +| `WIND_NOHOOK` | Skips the LL hooks to exercise the polling fallback | +| `tdrTest` (hot) | Field harness: >0 forces Transform past the churny list; 2 probes the clamp; 4 lifts the MPO wall | +| `probeClicks` | 1 logs every coordinate space per click (Ctrl+click marks a dead spot); 2 adds a ~36 Hz pointer trace | +| `lockForce=1` (hot) | Forces the locked pan regime, to separate detector bugs from locked-path bugs | +| `txWobbleCage` | Draws bars around the cursor that flash on a screen-space wobble (`src/wobble_cage.*`) | +| `diagnostics=1` | Also logs render frame-build time apart from time blocked in `Present` (`RenderEngine::debugPerf`) | + +## tools/ + +Standalone PowerShell scripts that call the same APIs Wind uses. `MagGetFullscreenTransform` reads +the one desktop magnification state whoever wrote it, so Wind and the built-in Magnifier are +directly comparable. Reading it needs `MagInitialize`, so a monitor script holds a context and pays +the cursor-change tax while it runs. + +| Script | Measures | +|---|---| +| `mag_wobble_probe.ps1` | Cursor deviation from centre at a constant pan speed, Wind or built-in, same level | +| `mag_wobble_monitor.ps1` | Passive per-second log while a person drives: deviation, stale ms, backward writes, input-transform state | +| `mag_wobble_repro.ps1` | Scripted repro of a stale input transform left by the built-in Magnifier | +| `mag_perf_run.ps1` | Controlled pan, ramp, cycle, rezoom and zig-zag runs: compositor pacing, GPU, CPU, cadence (child: `gpu_sampler.ps1`) | +| `mag_ab_controlled.ps1` | Wind versus built-in on the same solid target, environment verified per run | +| `mag_latency_probe.ps1` | Cursor-move to transform-write latency (time-to-write, not photons) | +| `zoom_response_ab.ps1` | Zoom response as the user sees it, desktop or a named game | +| `pan_wake_probe.ps1` | Hitch on the first movement after a pause | +| `warm_cadence_sweep.ps1` | Scores `txWarmHz` values: pan-start hitch against GPU cost at rest | +| `gpu_ab.ps1` | dwm.exe and Wind.exe GPU per scenario, Wind versus built-in | +| `plane_race_probe.ps1` | Whether a session pulls a fullscreen game off its overlay plane | +| `wind_cadence_ab.ps1` | Wind's write cadence across ini configs, with `txwrite` timings | +| `wind_drive_probe.ps1` | Confirms injected side-button input reaches Wind's zoom bind | +| `magtrace.ps1` | Level, offsets and cadence of whichever magnifier is active | +| `flipwatch.ps1` | Presentation mode via PresentMon (independent flip versus composed) | +| `dwm_memprobe.ps1` | DWM memory from outside, surviving a dwm.exe crash | +| `gpu_breakdown.ps1`, `gpu_nvsmi.ps1` | Per-process GPU counters; whole-GPU load via nvidia-smi | +| `grid_window.ps1`, `acrylic_window.ps1`, `test_target_window.ps1` | Test targets: grid, real acrylic backdrop, solid control | + +`PresentMon.exe` is not committed; `flipwatch.ps1` expects it in `tools/`. Build and release +scripts (`uiaccess_setup.ps1`, `installer_check.ps1`, `release.ps1`, `make_icon.mjs`) are covered +in [11](11-build-test-release.md). diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 5804975d..6fdae9fe 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,17 +1,11 @@ -# Wind Architecture +# Wind architecture -The developer book for Wind, a lightweight standalone fullscreen magnifier for Windows. These -chapters are the canonical description of how Wind works, end to end: read them in order for a -guided tour, or jump straight to the subsystem you are touching. Historical design specs and -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.10.3. +Developer documentation for Wind. The code is authoritative; specs and findings files are history. ## The system at a glance -**One paced tick loop drives two processes worth of machinery: input flows in through hooks and -Raw Input, a pure mapper turns it into a view, and one of four engines puts that view on screen.** +One paced tick loop in `Wind.exe` reads input from hooks and Raw Input, turns it into a view with +pure mapper logic, and hands the view to one of two engines. Auto picks the engine per session. ```mermaid flowchart LR @@ -22,48 +16,58 @@ flowchart LR subgraph core [Wind.exe tick loop] IR --> RT[RunTick\nmain.cpp] ZC[ZoomController] --> RT - CM[CursorMapper\npure view math] --> RT - LD[LockDetector\nfree vs game-locked] --> RT + CM[CursorMapper] --> RT + LD[LockDetector] --> RT CFG[(magnifier.ini\nhot reload)] --> RT end subgraph engines [Engines] RT --> PICK{engine pick\nengine_pick.h} - PICK --> REN[Render engine\nDDA + D3D11 overlay] - PICK --> TX[Transform engine\nDWM fullscreen transform] - PICK --> MAG[Magnify model\ndrives native Magnifier] + PICK --> REN[Render\nDDA + D3D11 overlay] + PICK --> TX[Transform\nDWM fullscreen transform] end - subgraph ui [WindConfig.exe] - SV[Svelte settings app] --> WV[WebView2 host] - WV -->|writes| CFG + subgraph apps [Other processes] + SV[WindConfig.exe\nsettings] -->|writes| CFG + TR[WindTray.exe\ntray flyout] -->|writes| CFG end REN --> SCREEN[(Screen)] TX --> DWM[DWM compositor] --> SCREEN - MAG --> NM[Magnify.exe] --> DWM ``` +There are three engines (Auto, Render, Transform); Auto is the default and switches between the +other two. + ## Chapters -| # | Chapter | One line | -|---|---------|----------| -| 01 | [Overview](01-overview.md) | What Wind is, its product rules, the two binaries, the repo map | -| 02 | [The tick loop](02-tick-loop.md) | RunTick's phases, pacing, and config hot-reload | -| 03 | [Engines and the hybrid pick](03-engines.md) | The four engines and the pure predicate that chooses between them | -| 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, 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 | -| 11 | [Build, test, release](11-build-test-release.md) | build.bat, the pure-test split, signing, the release pipeline | -| 12 | [Instrumentation and field method](12-instrumentation.md) | The measurement harnesses and the measure-don't-assume culture | +| # | Chapter | Covers | +|---|---------|--------| +| 01 | [Overview](01-overview.md) | Product rules, the three binaries, the pure/Win32 split, source map | +| 02 | [The tick loop](02-tick-loop.md) | `RunTick` phases, pacing, idle sleep, config hot-reload, threads | +| 03 | [Engines and the hybrid pick](03-engines.md) | The engine interface, the pick, handover, why Wind does not drive the built-in Magnifier | +| 04 | [The render engine](04-render-engine.md) | Capture, overlay, reveal gating, HDR, colour filters | +| 05 | [The transform engine](05-transform-engine.md) | The Magnification runtime, write channels and cadence, MPO, the input transform | +| 06 | [The input pipeline](06-input.md) | Hooks, swallowing, bind rules, safety nets, Raw Input limits | +| 07 | [The cursor system](07-cursor.md) | Mapper, weld, sprite, lock detection, Inspect, tracking, keyboard panning | +| 08 | [Config and profiles](08-config-profiles.md) | The ini as IPC, parsing, hot vs restart, profiles, key reference | +| 09 | [The settings UI](09-settings-ui.md) | WebView2 host, bridge, schema, session model, themes, tests | +| 11 | [Build, test, release](11-build-test-release.md) | `build.bat`, the test split, deploy, installer, release and alpha workflows | +| 12 | [Instrumentation](12-instrumentation.md) | Logging, diagnostic knobs, measurement scripts | + +`CLAUDE.md` at the repo root holds the rules for coding agents and points here. + +## Docs index -## How this book relates to the other docs +| File | What it is | State | +|---|---|---| +| [../VERIFICATION.md](../VERIFICATION.md) | Hands-on release checklist | Live | +| [../HITCH-FINDINGS.md](../HITCH-FINDINGS.md) | Transform hitching: the live-context tax, the pan-start hitch, vetted configs, zoom response | Open (#310) | +| [../NATIVE-MAGNIFIER-STOMP.md](../NATIVE-MAGNIFIER-STOMP.md) | A running built-in Magnifier fights the transform engine; the stomp guard | Closed, follow-up parked | +| [../POINTER-HITTEST-FINDINGS.md](../POINTER-HITTEST-FINDINGS.md) | Hover dead zones on the transform desktop; the source-rect input transform | Closed | +| [../PERF-ACRYLIC-PARITY-2026-08-21.md](../PERF-ACRYLIC-PARITY-2026-08-21.md) | Zoom-in stalls over acrylic; the `txMaxStepPct` cap | Closed | +| [../TRACKING-FINDINGS.md](../TRACKING-FINDINGS.md) | Caret, focus and edge tracking per app | Live | +| [../SHELL-PANEL-CURSOR-FINDINGS.md](../SHELL-PANEL-CURSOR-FINDINGS.md) | The cursor over the emoji picker and other shell panels | Closed | +| [../COLOUR-FILTER-FINDINGS.md](../COLOUR-FILTER-FINDINGS.md) | Warmth and brightness: mechanism, HDR maths, rejected filters | Live | +| [../specs/](../specs/) | Original design specs of shipped features | History | +| `../../tools/testenv/README.md` | The automated proving ground | Live | +| `../../installer/README.md` | The installer layout and build | Live | -- **`CLAUDE.md`** (repo root) is the compressed working-notes version of the same knowledge, - optimized for density. This book is the readable version, optimized for understanding. -- **`docs/superpowers/specs/`** hold the original design documents per feature. They are - historical: amendments live in the code and here. -- **`docs/*.md` findings files** (WOBBLE-CAPTURE, POINTER-HITTEST-FINDINGS, HITCH-FINDINGS, - PERF-ACRYLIC-PARITY, ...) are field investigations: the raw evidence behind conclusions this - book states in one sentence. +Plans and open work are tracked in GitHub issues. diff --git a/docs/design/settings-2026-10/FINAL-v10-grey-light.png b/docs/design/settings-2026-10/FINAL-v10-grey-light.png deleted file mode 100644 index 806369d9..00000000 Binary files a/docs/design/settings-2026-10/FINAL-v10-grey-light.png and /dev/null differ diff --git a/docs/design/settings-2026-10/FINAL-v10-grey.html b/docs/design/settings-2026-10/FINAL-v10-grey.html index d51ce732..f0d477a2 100644 --- a/docs/design/settings-2026-10/FINAL-v10-grey.html +++ b/docs/design/settings-2026-10/FINAL-v10-grey.html @@ -395,7 +395,7 @@

Settings lab

const ALL=NAV.concat(EXP); // ---------- options ---------- -// Max's baseline (2026-10-01). Fixed; only the OPTS below are still open. +// Owner's baseline (2026-10-01). Fixed; only the OPTS below are still open. const BASE={exp:'bottom',icons:'terminal',isz:'16',font:'cascadia',w:'500',case:'none',hl:'grey',rad:'8',target:'sub',rail:'progress',side:'black',counts:'off',themectl:'icons',addkey:'filled',subs:'off',slider:'grey',hicon:'terminal',head:'large',sfill:'teal',sknob:'teal',strack:'dark',themeloc:'titlebar'}; // Slider colours: [dark theme, light theme]. const FILL={grey:['#8a8a8a','#7a7a7a'],white:['#f2f2f2','#0a0a0a'],silver:['#bdbdbd','#5a5a5a'],dim:['#555555','#b5b5b5'], @@ -513,7 +513,7 @@

Settings lab

['capsule','Upright capsule'],['bracket','Brackets [ ]'],['cap','No knob, bright end cap'],['none','No knob, plain fill'], ['thick','Thick bar, no knob'],['segments','Segmented blocks'],['glow','Soft glowing dot'],['small','Small solid dot'], ]; -// Round 9: Max picked v10-soft-combo with the end-cap knob. Only the aurora colour is open. +// Round 9: the owner picked v10-soft-combo with the end-cap knob. Only the aurora colour is open. Object.assign(BASE,{kshape:'cap'}); const OPTS=[ ['still','Northern lights colour',(window.AURORA_COLOURS||[['c-teal','Teal (current)']]),'select'], @@ -639,7 +639,7 @@

Settings lab

const L=S.theme==='light'?1:0, col=(BANDBG.find(b=>b[0]===S.wbg)||BANDBG[0])[L?3:2]; // The still sits on a ::before layer so "how subtle" (opacity) and "where it starts" (mask) apply to it alone. const look='band', art=' imgbg'; - const style='background-color:'+col+';--bimg:url(./'+S.still+'.jpg);--bop:'+Math.min(1,(+S.wsub)*0.55)+';--bfrom:'+(+S.wfrom*100)+'%'; + const style='background-color:'+col+';--bimg:url(../../../ui/public/'+S.still+'.jpg);--bop:'+Math.min(1,(+S.wsub)*0.55)+';--bfrom:'+(+S.wfrom*100)+'%'; document.getElementById('main').innerHTML=''+body+ '
2 unsaved changes
'; return; diff --git a/docs/design/settings-2026-10/built-light.png b/docs/design/settings-2026-10/built-light.png deleted file mode 100644 index b18f0f99..00000000 Binary files a/docs/design/settings-2026-10/built-light.png and /dev/null differ diff --git a/docs/design/settings-2026-10/c-grey.jpg b/docs/design/settings-2026-10/c-grey.jpg deleted file mode 100644 index 1e5cefdc..00000000 Binary files a/docs/design/settings-2026-10/c-grey.jpg and /dev/null differ diff --git a/docs/design/settings-themes-2026-10-02/prefs-ember-light.png b/docs/design/settings-themes-2026-10-02/prefs-ember-light.png deleted file mode 100644 index b7a6e719..00000000 Binary files a/docs/design/settings-themes-2026-10-02/prefs-ember-light.png and /dev/null differ diff --git a/docs/design/settings-themes-2026-10-02/prefs-grey-light.png b/docs/design/settings-themes-2026-10-02/prefs-grey-light.png deleted file mode 100644 index 693a4b3b..00000000 Binary files a/docs/design/settings-themes-2026-10-02/prefs-grey-light.png and /dev/null differ diff --git a/docs/design/settings-themes-2026-10-02/prefs-hicon-light.png b/docs/design/settings-themes-2026-10-02/prefs-hicon-light.png deleted file mode 100644 index 3fb0de24..00000000 Binary files a/docs/design/settings-themes-2026-10-02/prefs-hicon-light.png and /dev/null differ diff --git a/docs/design/settings-themes-2026-10-02/prefs-ocean-light.png b/docs/design/settings-themes-2026-10-02/prefs-ocean-light.png deleted file mode 100644 index 115e9a7f..00000000 Binary files a/docs/design/settings-themes-2026-10-02/prefs-ocean-light.png and /dev/null differ diff --git a/docs/design/settings-themes-2026-10-02/tray-ember-light.png b/docs/design/settings-themes-2026-10-02/tray-ember-light.png deleted file mode 100644 index c8314771..00000000 Binary files a/docs/design/settings-themes-2026-10-02/tray-ember-light.png and /dev/null differ diff --git a/docs/design/settings-themes-2026-10-02/tray-grey-light.png b/docs/design/settings-themes-2026-10-02/tray-grey-light.png deleted file mode 100644 index 995be538..00000000 Binary files a/docs/design/settings-themes-2026-10-02/tray-grey-light.png and /dev/null differ diff --git a/docs/design/settings-themes-2026-10-02/tray-hicon-light.png b/docs/design/settings-themes-2026-10-02/tray-hicon-light.png deleted file mode 100644 index 3f33ec10..00000000 Binary files a/docs/design/settings-themes-2026-10-02/tray-hicon-light.png and /dev/null differ diff --git a/docs/design/settings-themes-2026-10-02/tray-ocean-light.png b/docs/design/settings-themes-2026-10-02/tray-ocean-light.png deleted file mode 100644 index 1fa4c074..00000000 Binary files a/docs/design/settings-themes-2026-10-02/tray-ocean-light.png and /dev/null differ diff --git a/docs/design/tray-2026-10/built-flyout-light.png b/docs/design/tray-2026-10/built-flyout-light.png deleted file mode 100644 index 2cd32645..00000000 Binary files a/docs/design/tray-2026-10/built-flyout-light.png and /dev/null differ diff --git a/docs/design/tray-2026-10/built-tab-light.png b/docs/design/tray-2026-10/built-tab-light.png deleted file mode 100644 index f5970cd2..00000000 Binary files a/docs/design/tray-2026-10/built-tab-light.png and /dev/null differ diff --git a/docs/design/tray-2026-10/j01-calm-tint-light-hover.png b/docs/design/tray-2026-10/j01-calm-tint-light-hover.png deleted file mode 100644 index dca4944c..00000000 Binary files a/docs/design/tray-2026-10/j01-calm-tint-light-hover.png and /dev/null differ diff --git a/docs/design/tray-2026-10/j01-calm-tint-light.png b/docs/design/tray-2026-10/j01-calm-tint-light.png deleted file mode 100644 index dca4944c..00000000 Binary files a/docs/design/tray-2026-10/j01-calm-tint-light.png and /dev/null differ diff --git a/docs/design/tray-2026-10/j01-calm-tint.html b/docs/design/tray-2026-10/j01-calm-tint.html index df762734..18a890c4 100644 --- a/docs/design/tray-2026-10/j01-calm-tint.html +++ b/docs/design/tray-2026-10/j01-calm-tint.html @@ -23,7 +23,7 @@ .task .clk{font-size:11px;line-height:1.25;text-align:right;color:var(--tray);margin-left:6px} .menu{position:absolute;right:10px;bottom:56px;width:300px;background:var(--menu);border:1px solid var(--menub);border-radius:10px;box-shadow:var(--shadow);padding:0 8px 8px;overflow:hidden} .head{margin:0 -8px;padding:18px 20px 22px;position:relative;background:var(--band);overflow:hidden;font-family:var(--m)} -.head::before{content:"";position:absolute;inset:0;background:url(../settings-2026-10/c-grey.jpg) right center/cover no-repeat;opacity:var(--ac);pointer-events:none;-webkit-mask-image:linear-gradient(90deg,transparent 15%,#000 70%);mask-image:linear-gradient(90deg,transparent 15%,#000 70%)} +.head::before{content:"";position:absolute;inset:0;background:url(../../../ui/public/c-grey.jpg) right center/cover no-repeat;opacity:var(--ac);pointer-events:none;-webkit-mask-image:linear-gradient(90deg,transparent 15%,#000 70%);mask-image:linear-gradient(90deg,transparent 15%,#000 70%)} html[data-theme=light] .head::before{filter:invert(1) hue-rotate(180deg) saturate(1.6) contrast(1.25)} .head::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,transparent 40%,var(--scrim) 100%),linear-gradient(90deg,var(--scrim),transparent 85%)} .head>*{position:relative;z-index:1} diff --git a/docs/design/tray-2026-10/k01-sliders-toggles-light.png b/docs/design/tray-2026-10/k01-sliders-toggles-light.png deleted file mode 100644 index 2be5b135..00000000 Binary files a/docs/design/tray-2026-10/k01-sliders-toggles-light.png and /dev/null differ diff --git a/docs/design/tray-2026-10/k01-sliders-toggles.html b/docs/design/tray-2026-10/k01-sliders-toggles.html index d1c6089c..b7ca858b 100644 --- a/docs/design/tray-2026-10/k01-sliders-toggles.html +++ b/docs/design/tray-2026-10/k01-sliders-toggles.html @@ -456,7 +456,7 @@

Settings lab

const ALL=NAV.concat(EXP); // ---------- options ---------- -// Max's baseline (2026-10-01). Fixed; only the OPTS below are still open. +// Owner's baseline (2026-10-01). Fixed; only the OPTS below are still open. const BASE={exp:'bottom',icons:'terminal',isz:'16',font:'cascadia',w:'500',case:'none',hl:'grey',rad:'8',target:'sub',rail:'progress',side:'black',counts:'off',themectl:'icons',addkey:'filled',subs:'off',slider:'grey',hicon:'terminal',head:'large',sfill:'teal',sknob:'teal',strack:'dark',themeloc:'titlebar'}; // Slider colours: [dark theme, light theme]. const FILL={grey:['#8a8a8a','#7a7a7a'],white:['#f2f2f2','#0a0a0a'],silver:['#bdbdbd','#5a5a5a'],dim:['#555555','#b5b5b5'], @@ -574,7 +574,7 @@

Settings lab

['capsule','Upright capsule'],['bracket','Brackets [ ]'],['cap','No knob, bright end cap'],['none','No knob, plain fill'], ['thick','Thick bar, no knob'],['segments','Segmented blocks'],['glow','Soft glowing dot'],['small','Small solid dot'], ]; -// Round 9: Max picked v10-soft-combo with the end-cap knob. Only the aurora colour is open. +// Round 9: the owner picked v10-soft-combo with the end-cap knob. Only the aurora colour is open. Object.assign(BASE,{kshape:'cap'}); const OPTS=[ ['still','Northern lights colour',(window.AURORA_COLOURS||[['c-teal','Teal (current)']]),'select'], @@ -702,7 +702,7 @@

Settings lab

const L=S.theme==='light'?1:0, col=(BANDBG.find(b=>b[0]===S.wbg)||BANDBG[0])[L?3:2]; // The still sits on a ::before layer so "how subtle" (opacity) and "where it starts" (mask) apply to it alone. const look='band', art=' imgbg'; - const style='background-color:'+col+';--bimg:url(../settings-2026-10/'+S.still+'.jpg);--bop:'+Math.min(1,(+S.wsub)*0.55)+';--bfrom:'+(+S.wfrom*100)+'%'; + const style='background-color:'+col+';--bimg:url(../../../ui/public/'+S.still+'.jpg);--bop:'+Math.min(1,(+S.wsub)*0.55)+';--bfrom:'+(+S.wfrom*100)+'%'; const hdr=''; if(g.id==='tray'){ document.getElementById('main').innerHTML='
'+hdr+body+'
'; diff --git a/docs/performance-audit-request-2026-05-29.md b/docs/performance-audit-request-2026-05-29.md deleted file mode 100644 index 4d592183..00000000 --- a/docs/performance-audit-request-2026-05-29.md +++ /dev/null @@ -1,120 +0,0 @@ -# Performance audit request - -This PR exists so a multi-agent code review (`/ultrareview`) can do a full performance pass -on the Wind magnifier's runtime. There are no functional code changes; only this brief. - -## Priorities (calibrated) - -- **Scope:** broad sweep across all three regimes - rank findings by absolute impact, not by - category. The goal is making the whole program as fast as it can reasonably be. - 1. **In-zoom smoothness / consistency** under a real workload (4K HDR over a game). - 2. **Zoom-in latency** (key press to first correct magnified frame). - 3. **Idle (1x) cost** when Wind is just sitting in the tray. -- **Target workload:** **high-end desktop, 4K HDR, often over a game.** A heavy GPU copy - is the realistic per-frame cost; anything that scales with `zoom^2 * 4K * BGRA16` matters - much more than micro-CPU savings. The dev display is 144 Hz. -- **Scope of acceptable fixes:** **anything goes, including big pivots.** Reviewers should - not limit themselves to tactical patches - propose architectural changes (capture on a - dedicated thread, compute-shader magnify, double-buffered DDA pipeline, etc.) if the win - is clear. -- **Pivots explicitly on the table** (the kind of suggestion to make, not avoid): - - **DirectComposition + flip-model swapchain.** This was tried in #11 and reverted because - `WS_EX_LAYERED` (required for cross-process click-through) is incompatible with flip-model, - and dropping the layer broke clicks to other apps. CLAUDE.md has the full constraint - writeup. If the reviewers can find a way to keep cross-process click-through WITHOUT - `WS_EX_LAYERED` (alternative click-through hit-testing, message-only routing, - DirectComposition + `SetWindowCompositionAttribute`, etc.), this is the single biggest - potential win (eliminates the BLT-model + DWM phase mismatch microstutter). - - **Pipelined capture vs render** (DDA `AcquireNextFrame` on its own thread feeding a - double-buffered staging texture). Issue #47 deferred this (A). Worth revisiting. - - **Compute-shader magnify** (instead of the current pixel-shader full-screen pass) if it - saves measurable cycles on 4K HDR. - - **Restructured tick loop** if the current single-thread pattern is the bottleneck for - the 4K HDR / 144 Hz / game-on-top scenario. - -## Measurement bias - -Wind has a built-in objective measurement: `WIND_PACINGTEST=1 Wind.exe` runs the real -present-paced render path at a forced zoom for ~4s with a simulated pan and logs interval -stats (avg/max dt, hitch counts) to `%TEMP%\wind_diag.log`. `diagnostics=1` in -`magnifier.ini` gives a 2 s sliding-window stats line during normal use. Suggested fixes -that are measurable via either path are preferred; fixes that need a new benchmark, please -say so and propose how. - -## What to look for - -Heavy / wasteful runtime behavior in the magnifier's hot path: - -- Continuous loops or per-frame work that does more than it needs to. -- Bad algorithms (anything worse than O(N) on per-tick state, missed early-outs, redundant copies). -- Missed parallelism opportunities (single-threaded work that could pipeline, e.g. capture vs render). -- Per-tick syscalls (kernel transitions, registry / file stats, anything that should be cached). -- Per-tick allocations (`std::string` / `std::wstring` / `std::vector` / `std::stringstream` etc. - in the tick path; transient `ComPtr` rounds; heap churn). -- Sync points, lock contention, false sharing across the hook thread / tick thread / GPU. -- Wasted re-renders or redundant GPU work (full-frame copies when only a rect changed; multiple - shader passes that could fold into one; needless texture re-creations). -- Anything that makes the program heavier than it needs to be at idle (1x) or while zoomed. - -## Files in scope (where the hot path lives) - -- `src/main.cpp` - - `RunTick()` (per-frame state machine: config hot-reload check, key polling, raw input drain, - free-vs-locked lock detector, mapper update, render call, frame-pacing diagnostics). - - main loop (`while (running)` in `wWinMain`): pacing branches (vsync / DwmFlush / timer), - `PeekMessageW` drain, optional `DwmFlush()` after the tick. -- `src/render_engine.cpp` / `.h` - DXGI Desktop Duplication capture + D3D11 magnify + adaptive - sharpen render. Includes the BLT-model swapchain pacing, the cursor draw, and the per-frame - topmost re-assert (the comments in CLAUDE.md explain the constraints). -- `src/input_router.cpp` - WH_MOUSE_LL hook on its own dedicated thread (issue #46), raw-input - delta accumulator (atomics), hot-reloadable button mapping (`setButtons`). -- `src/zoom_controller.cpp` / `.h` - zoom level state machine (linear + smooth-zoom acceleration - ramp, dt-clamped to prevent hitch jumps). -- `src/cursor_mapper.cpp` / `.h` - sub-pixel pan + cursor placement smoothing (`cx_` smoothed - center; everything else derives from it). -- `src/lock_detector.cpp` / `.h` - clip / raw-active hysteresis state machine. -- `src/tray.cpp` - tray menu + the `WM_TIMER` tick fallback while `TrackPopupMenu` owns the - thread. -- `src/transform.cpp` / `.h` - the pure-logic `ComputeOffsetF` math. - -## Already in place (do not re-recommend) - -- Dedicated WH_MOUSE_LL hook thread (issue #46) - the hook services events on a thread that does - nothing but `GetMessage`, so per-frame batching is gone. -- Directory-change-notification config hot-reload (issue #40) - the tick does no per-second - filesystem stat; only re-checks magnifier.ini when the dir actually changed. Falls back to a - timed poll if the watch handle is unavailable. -- Crop-capture on full-screen repaints (issue #44 partial) - when DDA reports a near-full repaint - the capture copies only the magnified source region, not the whole frame. Cuts the GPU copy - roughly by `zoom^2` at 4K HDR. -- Adaptive sharpening folded into the magnify pixel shader (no extra pass). -- `IDXGIDevice1::SetMaximumFrameLatency(1)` on the swapchain. -- Frame pacing is hot-reloadable (`dwmFlush=0|1`, `vsync=0|1`) and the engine handles both. -- `WIND_PACINGTEST` env var runs a real present-paced render with simulated pan and logs interval - stats so microstutter can be measured objectively. -- `ZoomController::tick` dt-clamped to 50ms so a single long frame can't jump the zoom level - mid-ramp. -- Per-frame topmost re-assert (`HWND_TOPMOST`) - cheap and intentional; do not flag. - -## Open performance issues already filed (context, not "audit me again") - -- #42 `perf: trim per-tick syscalls (cache virtual-screen metrics; displaced-only topmost re-assert)` -- #44 `perf: copy only DDA dirty rects instead of full-screen CopyResource` (cropCapture is the partial) -- #46 `In-game mouse microstutter caused by Wind (WH_MOUSE_LL hook batches input per frame)` (fix landed) -- #47 audit notes: parallelism (A capture thread) deferred, (B) done - -## Explicitly out of scope - -- `src/config_ui/` (WindConfig.exe / Svelte UI). Separate process, on-demand, no perf coupling - to the magnifier core. Do not flag UI weight, bundle size, etc. -- Onboarding flow. One-shot, irrelevant for steady-state performance. -- The mockup HTML files under `mockups/`. Throwaway design artifacts. -- The pure-logic doctest harness (`tests/`). Not on the hot path. - -## What a good finding looks like - -- Concrete file path + line/symbol. -- One-sentence "why it's slow" (syscall per tick, allocation in inner loop, redundant copy, etc.). -- Suggested fix and rough impact (idle CPU%, in-zoom GPU%, latency). -- "Don't bother" notes welcome - call out things that look bad but are intentional (with reference - to the CLAUDE.md gotcha if applicable). diff --git a/docs/superpowers/specs/2026-05-25-own-renderer-design.md b/docs/specs/2026-05-25-own-renderer-design.md similarity index 98% rename from docs/superpowers/specs/2026-05-25-own-renderer-design.md rename to docs/specs/2026-05-25-own-renderer-design.md index c797b62e..1ee4dfeb 100644 --- a/docs/superpowers/specs/2026-05-25-own-renderer-design.md +++ b/docs/specs/2026-05-25-own-renderer-design.md @@ -1,7 +1,7 @@ # Wind - Own Capture + GPU Renderer (Design Spec) **Date:** 2026-05-25 -**Status:** Approved for planning +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. **Branch:** to be created off the current work (e.g. `feat/own-renderer`) ## Goal diff --git a/docs/superpowers/specs/2026-05-26-configurable-zoom-design.md b/docs/specs/2026-05-26-configurable-zoom-design.md similarity index 98% rename from docs/superpowers/specs/2026-05-26-configurable-zoom-design.md rename to docs/specs/2026-05-26-configurable-zoom-design.md index e4e8697d..b4353f5b 100644 --- a/docs/superpowers/specs/2026-05-26-configurable-zoom-design.md +++ b/docs/specs/2026-05-26-configurable-zoom-design.md @@ -1,7 +1,7 @@ # Configurable Zoom Experience - Design **Branch:** `feat/zoom-config` -**Status:** approved (design), ready for implementation plan +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. ## Goal diff --git a/docs/superpowers/specs/2026-05-27-config-ui-polish-onboarding-design.md b/docs/specs/2026-05-27-config-ui-polish-onboarding-design.md similarity index 99% rename from docs/superpowers/specs/2026-05-27-config-ui-polish-onboarding-design.md rename to docs/specs/2026-05-27-config-ui-polish-onboarding-design.md index 3e38ba93..e918f339 100644 --- a/docs/superpowers/specs/2026-05-27-config-ui-polish-onboarding-design.md +++ b/docs/specs/2026-05-27-config-ui-polish-onboarding-design.md @@ -2,7 +2,7 @@ **Issue:** #57 **Branch:** `feat/config-ui-polish` (off `main`, builds on the merged MVP #53 / #55). -**Status:** design, pending user review. +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. ## Goal diff --git a/docs/superpowers/specs/2026-08-12-profiles-design.md b/docs/specs/2026-08-12-profiles-design.md similarity index 98% rename from docs/superpowers/specs/2026-08-12-profiles-design.md rename to docs/specs/2026-08-12-profiles-design.md index 1ff206c7..edd32903 100644 --- a/docs/superpowers/specs/2026-08-12-profiles-design.md +++ b/docs/specs/2026-08-12-profiles-design.md @@ -1,7 +1,7 @@ # Profiles - design Date: 2026-08-12 -Status: approved +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. ## Purpose diff --git a/docs/superpowers/specs/2026-08-20-installer-design.md b/docs/specs/2026-08-20-installer-design.md similarity index 99% rename from docs/superpowers/specs/2026-08-20-installer-design.md rename to docs/specs/2026-08-20-installer-design.md index bc905ae0..112df190 100644 --- a/docs/superpowers/specs/2026-08-20-installer-design.md +++ b/docs/specs/2026-08-20-installer-design.md @@ -1,6 +1,6 @@ # Wind installer design -Status: proposed +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. Issue: TBD (opened alongside this spec) Related: Prism's `build/installer/` (the presentation layer this borrows) diff --git a/docs/superpowers/specs/2026-09-28-tracking-modes-design.md b/docs/specs/2026-09-28-tracking-modes-design.md similarity index 99% rename from docs/superpowers/specs/2026-09-28-tracking-modes-design.md rename to docs/specs/2026-09-28-tracking-modes-design.md index 6547b8b7..e3a64ec4 100644 --- a/docs/superpowers/specs/2026-09-28-tracking-modes-design.md +++ b/docs/specs/2026-09-28-tracking-modes-design.md @@ -1,6 +1,8 @@ # Tracking modes: caret, keyboard focus, mouse edge mode (issue #276) -Date: 2026-09-28. Owner: Max. Status: awaiting approval (spec + plan together). +Date: 2026-09-28. + +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. Research behind it (codebase map, Windows APIs, reference products): session briefs summarised in section 7. diff --git a/docs/superpowers/specs/2026-09-29-tray-process-design.md b/docs/specs/2026-09-29-tray-process-design.md similarity index 97% rename from docs/superpowers/specs/2026-09-29-tray-process-design.md rename to docs/specs/2026-09-29-tray-process-design.md index 11d70be7..cb042bc2 100644 --- a/docs/superpowers/specs/2026-09-29-tray-process-design.md +++ b/docs/specs/2026-09-29-tray-process-design.md @@ -1,6 +1,8 @@ # Tray icon and menu in their own process (issue #291) -Date: 2026-09-29. Owner: Max. Status: awaiting approval (spec + plan together). +Date: 2026-09-29. + +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. ## 1. Problem diff --git a/docs/superpowers/specs/2026-10-01-settings-redesign-design.md b/docs/specs/2026-10-01-settings-redesign-design.md similarity index 96% rename from docs/superpowers/specs/2026-10-01-settings-redesign-design.md rename to docs/specs/2026-10-01-settings-redesign-design.md index 156ad41a..2c261639 100644 --- a/docs/superpowers/specs/2026-10-01-settings-redesign-design.md +++ b/docs/specs/2026-10-01-settings-redesign-design.md @@ -1,11 +1,13 @@ # Settings redesign (issue #303), 2026-10-01 -Rebuild the Settings window (WindConfig.exe) to the design Max chose after nine mockup rounds, and +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. + +Rebuild the Settings window (WindConfig.exe) to the design the owner chose after nine mockup rounds, and change how settings are kept: changes apply instantly but only for the session until saved. ## The target (match it, do not approximate it) -- `docs/design/settings-2026-10/FINAL-reference.png`: Max's screenshot of the final design. The built +- `docs/design/settings-2026-10/FINAL-reference.png`: the owner's screenshot of the final design. The built UI is reviewed against it side by side at the same window size, element by element (sizes, spacing, colours, fonts, icons, radii). "A similar look" is a failed review. - `docs/design/settings-2026-10/FINAL-v10-grey.html`: the mockup source, the authority for exact diff --git a/docs/superpowers/specs/2026-10-01-tray-flyout-design.md b/docs/specs/2026-10-01-tray-flyout-design.md similarity index 94% rename from docs/superpowers/specs/2026-10-01-tray-flyout-design.md rename to docs/specs/2026-10-01-tray-flyout-design.md index 4ea1914d..3ab0417c 100644 --- a/docs/superpowers/specs/2026-10-01-tray-flyout-design.md +++ b/docs/specs/2026-10-01-tray-flyout-design.md @@ -1,5 +1,7 @@ # Tray flyout with quick controls (issue #313), 2026-10-01 +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. + Replace the owner-drawn tray menu with a small flyout window in the new Settings style, with pinnable quick controls (like Control Center), and add a "Tray menu" tab to Settings to choose and order them. Stacks on the Settings redesign (#303, PR #312). @@ -54,7 +56,7 @@ order them. Stacks on the Settings redesign (#303, PR #312). | Toggle | Keep within the edges | `mouseAlign` AND `trackAlign` together | pointer inside a frame | | Dropdown | Magnifier engine | `model` (restarts Wind), a wide chip | engine glyph (added by #315) | -Chosen by Max item by item (2026-10-01). "Keep within the edges" is ONE toggle for both alignment +Chosen by the owner item by item (2026-10-01). "Keep within the edges" is ONE toggle for both alignment settings: on writes `mouseAlign=1` and `trackAlign=1` ("Within the edges"), off writes both 0 ("Centred"); it shows ON only when both are 1. The Settings page keeps its two separate rows. A slider whose setting is not shown in Settings is never offered (all eight above are shown today: @@ -83,9 +85,9 @@ another"). The parser enforces the same cap (extra enabled sliders beyond it are ## Decisions (2026-10-01) 1. Quick-control changes are session changes (unsaved until Save, reset at Wind start, counted by - the Quit prompt), consistent with #303 (the recommendation; Max approved without overriding it). + the Quit prompt), consistent with #303 (the recommendation; the owner approved without overriding it). 2. Names: tab "Tray menu"; lists "Sliders" and "Toggles". -3. Available items: the table above, chosen by Max. Limit: 4 sliders; toggles uncapped (3). +3. Available items: the table above, chosen by the owner. Limit: 4 sliders; toggles uncapped (3). ## Out of scope New settings, UI Automation for the flyout (follow-up), animations beyond hover/press and the diff --git a/docs/superpowers/specs/2026-10-02-tray-tools-design.md b/docs/specs/2026-10-02-tray-tools-design.md similarity index 91% rename from docs/superpowers/specs/2026-10-02-tray-tools-design.md rename to docs/specs/2026-10-02-tray-tools-design.md index f566061b..d00a6aaf 100644 --- a/docs/superpowers/specs/2026-10-02-tray-tools-design.md +++ b/docs/specs/2026-10-02-tray-tools-design.md @@ -1,7 +1,9 @@ # Tray tools: main-engine dropdown and segmented toggle group (issue #315), 2026-10-02 +**Status:** shipped. Historical design record; the code and `docs/architecture/` are authoritative. + Follow-up to the tray flyout (#313, PR #314). Stacks on PR #314. This file was rewritten on -2026-10-02 after Max reviewed the first build and changed the scope (see "Decisions"). +2026-10-02 after the owner reviewed the first build and changed the scope (see "Decisions"). ## Scope 1. An **engine dropdown** under the toggle group that picks the MAIN engine. @@ -15,7 +17,7 @@ no tooltips) is unchanged. a **dropdown**, not a button and not a toggle. It is never disabled. - It sets the main engine ini key `model`, with the options, order and labels of the Settings "Magnifier engine" row (`ui/src/settings-schema.js`): **Auto** (`hybrid`), **Render**, **Transform**. - System (`magnify`) was dropped from the tray AND Settings by Max (2026-10-02); the core now reads + System (`magnify`) was dropped from the tray AND Settings by the owner (2026-10-02); the core now reads `model=magnify` as Auto. A missing or unknown value reads as Auto, like the core. - Look (v02): a full content-width, 32 DIP field in `--off`: engine glyph at the left, the current value as mono 12 text ("Transform") next to it, a chevron at the right that flips up while the list is open, @@ -39,18 +41,18 @@ no tooltips) is unchanged. - Code: pure options/labels/index/pick rule in `src/tray_app/flyout_tools.h` (tested), Win32 in `src/tray_app/engine_dropdown.cpp`. -## 2. Layout v02: segmented toggle group (Max picked mockup v02, 2026-10-02) +## 2. Layout v02: segmented toggle group (the owner picked mockup v02, 2026-10-02) Reference: `wind-settings-mockups/keepers/tray/v02.html` (pixel truth, dark and light). - The enabled toggles are ONE group: a full content-width (258 DIP) bar, 32 DIP tall, 8 DIP radius on the OUTER ends only, segments joined by a 1 px separator (`--segline`: #000 dark, #fff light). Each segment shows the toggle icon centred; off = `--off`, on = `--on` (calm teal) with the `--onic` icon, hover - `--offh` / `--onh`. Segments STRETCH to fill the width whatever the count (3, 2 or 1; Max chose + `--offh` / `--onh`. Segments STRETCH to fill the width whatever the count (3, 2 or 1; the owner chose stretched over fixed-size): `LayoutSegments` in `flyout_tools.h`, leftover pixels go one each to the first segments. Hidden entirely when no toggle is enabled. - The engine dropdown sits 8 DIP below the group (6 DIP below the last slider, 14 DIP of padding under the last control). `ComputeGeometry` gives `segBar`, `seg[]` and `engine`; hit testing, the focus rect and the painter all use those rectangles. -- NO zoom readout in the control area (Max: the zoom is already in the performance panel). The +- NO zoom readout in the control area (owner: the zoom is already in the performance panel). The mockup's second column (`2.4x` reset button) is deliberately not built. - Keyboard: Left/Right move inside the group (stop at the ends); Up/Down move between the group, the dropdown and the bottom row (`VerticalNeighbor`; Up from the group goes to the last slider, and on a @@ -59,7 +61,7 @@ Reference: `wind-settings-mockups/keepers/tray/v02.html` (pixel truth, dark and centred chips, and the press-scale on toggles. ## Removed -Max rejected the earlier ideas from this issue, so none of them exist: **Mouse lock** (`fixLock`), +The owner rejected the earlier ideas from this issue, so none of them exist: **Mouse lock** (`fixLock`), **Pass keys** (`fixPass`) and **Pause Wind** (`pause`), with the listen-and-chime state machine, tray icon pulse and paused badge, the core's pause gating and `Local\Wind_TrayCommand`, the chime WAVs and their generator, and the per-window-kind engine dropdown with the core's `fgCategory` publishing. @@ -68,7 +70,7 @@ disagree about the block layout. Unknown keys a user's ini may still list (`tray `trayToggleOrder` with `fixPass,pause`) are dropped silently by `ParseTrayLayout` (tested) and by the Settings page model (Playwright). -## Decisions (Max, 2026-10-02) +## Decisions (owner) 1. The engine item is the MAIN engine (`model`), a real dropdown, not per window kind; picking restarts Wind automatically. It is a session change exactly like Settings. 2. Remove Mouse lock, Pass keys and Pause Wind entirely. @@ -91,7 +93,7 @@ lists the engine item and none of the removed ones. Render test: `WindTray.exe - Manual: tray, engine dropdown, pick Transform: Wind restarts, the dropdown reads Transform, Settings shows the unsaved capsule; Discard returns to the saved engine. -## Keep cursor centred (Max, 2026-10-02) +## Keep cursor centred The `keepEdges` toggle was renamed "Keep cursor centred" because "Keep within the edges" did not say it chooses between a centred cursor and one that moves freely to the edges. Its meaning is inverted to match: ON = `mouseAlign` and `trackAlign` both 0 (centred); a mixed hand-edited state reads OFF; a click writes diff --git a/docs/superpowers/plans/2026-05-24-wind-magnifier.md b/docs/superpowers/plans/2026-05-24-wind-magnifier.md deleted file mode 100644 index 0beba0e2..00000000 --- a/docs/superpowers/plans/2026-05-24-wind-magnifier.md +++ /dev/null @@ -1,1371 +0,0 @@ -# Wind Magnifier Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build "Wind", a lightweight standalone Windows fullscreen magnifier that replaces `Magnify.exe`, with smooth continuous zoom and a lens that keeps tracking the mouse even when games hide/clip/center-lock the cursor. - -**Architecture:** A small C++ Win32 app. Pure logic (zoom ramp, cursor tracking, offset math, config parsing) is isolated into header/cpp pairs with no Win32 dependency and is unit-tested with doctest. A thin I/O shell wraps the Windows Magnification API (zoom), Raw Input + a low-level mouse hook (input that survives cursor lock), an INI config, and a tray icon. A tick thread paced by `DwmFlush` reads the shared state each compositor frame and calls `MagSetFullscreenTransform`. - -**Tech Stack:** C++17, MSVC `cl.exe` located via `vswhere`, Windows Magnification API (`Magnification.lib`), Raw Input, `WH_MOUSE_LL`, DWM (`Dwmapi.lib`), doctest (single-header tests). - ---- - -## PR / issue grouping - -Per the repo's GitHub workflow, land the plan as four issues → branches → PRs: - -- **PR1 (Task 1-3):** Repo scaffold + live verification loop (build + passing test). -- **PR2 (Task 4-7):** Pure logic modules, TDD. -- **PR3 (Task 8-10):** I/O modules (Magnification engine, input, config file). -- **PR4 (Task 11-13):** Wiring, tray, manual verification + tuning. - -Open one GitHub issue per PR, branch from `main` (in a worktree), and reference the issue in the PR. - -## File structure - -``` -Wind/ -├── CLAUDE.md # build/run/test commands, stack, gotchas -├── README.md # what it is, usage, config -├── build.bat # locate MSVC via vswhere; build Wind.exe and tests -├── .gitignore -├── Wind.manifest # Per-Monitor-V2 DPI awareness -├── config/magnifier.ini # default config shipped next to exe -├── .claude/settings.json # permission allowlist + build/test hooks -├── third_party/doctest.h # single-header test framework (vendored) -├── src/ -│ ├── transform.h / .cpp # PURE: (center,level,screen) -> offset -│ ├── zoom_controller.h / .cpp # PURE: hold-to-zoom ramp + ResolveDirection -│ ├── tracker.h / .cpp # PURE: free/locked blend, delta integration, clamp -│ ├── config.h / .cpp # PURE parse + I/O load/save + mtime hot-reload -│ ├── magnifier_engine.h / .cpp # I/O: Magnification API wrapper -│ ├── input_router.h / .cpp # I/O: Raw Input + WH_MOUSE_LL, shared atomics -│ ├── tray.h / .cpp # I/O: tray icon + menu -│ └── main.cpp # WinMain, single-instance, msg loop, tick thread -└── tests/ - ├── test_main.cpp # #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN - ├── test_transform.cpp - ├── test_zoom_controller.cpp - ├── test_tracker.cpp - └── test_config.cpp -``` - -**Design boundary:** `transform`, `zoom_controller`, `tracker`, and the parse half of `config` include only ``/``/`` - never `` - so tests compile and run without a desktop session. All Win32 calls live in `magnifier_engine`, `input_router`, `tray`, the I/O half of `config`, and `main`. - ---- - -## Task 1: Repo skeleton + GitHub issue/branch (PR1) - -**Files:** -- Create: `.gitignore`, `README.md`, `CLAUDE.md` - -- [ ] **Step 1: Open the issue and branch** - -```bash -gh issue create --title "PR1: repo scaffold + verification loop" \ - --body "Scaffold Wind: .gitignore, README, CLAUDE.md, .claude settings, build.bat, doctest, first passing test." -git checkout -b feat/scaffold -``` -(If `gh repo create` has not been run yet, create the remote first: `gh repo create Wind --private --source . --remote origin`. This publishes a private repo - confirm with the user before running.) - -- [ ] **Step 2: Write `.gitignore`** - -```gitignore -# Build output -*.obj -*.exe -*.pdb -*.ilk -*.lib -*.exp -build/ -# Editor / OS -.vs/ -*.user -Thumbs.db -# Runtime config copy (the shipped default lives in config/) -/magnifier.ini -``` - -- [ ] **Step 3: Write `README.md`** - -```markdown -# Wind - -A lightweight fullscreen magnifier for Windows - "light as air". A replacement for -the built-in Magnifier, with smooth continuous zoom that keeps tracking the mouse -even when games hide, clip, or center-lock the cursor. - -## Controls (default, configurable in `magnifier.ini`) -- Hold **mouse forward button (XButton2)** - zoom in (smooth ramp). -- Hold **mouse back button (XButton1)** - zoom out (smooth ramp). -- Release - zoom stays at the current level. - -## Build -Requires Visual Studio 2022+ Build Tools. Run `build.bat` from any shell. -- `build.bat` - builds `Wind.exe`. -- `build.bat test` - builds and runs unit tests. - -## Run -`Wind.exe` - runs from the system tray. Right-click the tray icon to edit config or quit. - -## Scope -v1 covers the desktop, normal apps, and **borderless / windowed-fullscreen** games. -Exclusive-fullscreen games are out of scope for v1 (set the game to borderless). -``` - -- [ ] **Step 4: Write `CLAUDE.md`** - -```markdown -# Wind - fullscreen magnifier - -Lightweight standalone Windows fullscreen magnifier replacing Magnify.exe. -Design spec: `docs/superpowers/specs/2026-05-24-magnifier-design.md`. - -## Commands -- Build app: `build.bat` (locates MSVC via vswhere, emits `Wind.exe`) -- Build + run tests: `build.bat test` (runs the doctest binary; exit 0 = pass) - -## Stack -C++17, MSVC cl.exe. Windows Magnification API (`Magnification.lib`), Raw Input, -`WH_MOUSE_LL`, DWM (`Dwmapi.lib`). Tests: vendored `third_party/doctest.h`. - -## Architecture -Pure logic (no ``): `src/transform`, `src/zoom_controller`, `src/tracker`, -parse half of `src/config`. Win32 I/O: `magnifier_engine`, `input_router`, `tray`, -`main`. A `DwmFlush`-paced tick thread reads shared atomics and calls -`MagSetFullscreenTransform(level, xOffset, yOffset)` each frame. - -## IMPORTANT gotchas -- Pure-logic files MUST NOT include `` - keeps unit tests desktop-free. -- Declare Per-Monitor-V2 DPI awareness (`Wind.manifest`) or offset pixel math is wrong - on scaled displays. -- Always reset to `MagSetFullscreenTransform(1.0,0,0)` + `MagUninitialize` on exit - - never leave the screen zoomed. -- The lens-must-move-when-cursor-locked behavior is THE core feature. It relies on - Raw Input deltas (HID-level, unaffected by ShowCursor/ClipCursor/SetCursorPos), - NOT GetCursorPos, when a lock is detected. Do not "simplify" this away. -- `MagSetInputTransform` is intentionally NOT used (needs UIAccess). Visual-only. - -## Workflow -Feature/fix work: GitHub issue → branch → PR. README-only changes commit directly. -``` - -- [ ] **Step 5: Commit** - -```bash -git add .gitignore README.md CLAUDE.md -git commit -m "chore: repo skeleton (gitignore, README, CLAUDE.md)" -``` - ---- - -## Task 2: `.claude/settings.json` (permissions + hooks) (PR1) - -**Files:** -- Create: `.claude/settings.json` - -- [ ] **Step 1: Write settings with a build/test permission allowlist and a post-edit build-test hook** - -```json -{ - "permissions": { - "allow": [ - "Bash(build.bat)", - "Bash(build.bat test)", - "Bash(git add *)", - "Bash(git commit *)", - "Bash(git checkout *)", - "Bash(git status)", - "Bash(git diff *)", - "Bash(gh issue *)", - "Bash(gh pr *)" - ], - "ask": [], - "deny": [] - }, - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "cmd /c \"if exist build.bat (build.bat test) else (echo no build yet)\"" - } - ] - } - ] - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add .claude/settings.json -git commit -m "chore: claude permissions allowlist + post-edit build-test hook" -``` - ---- - -## Task 3: doctest, manifest, build.bat, first passing test (PR1) - -**Files:** -- Create: `third_party/doctest.h` (downloaded), `Wind.manifest`, `build.bat`, `tests/test_main.cpp`, `tests/test_smoke.cpp` - -- [ ] **Step 1: Vendor doctest** - -```bash -curl -L -o third_party/doctest.h https://raw.githubusercontent.com/doctest/doctest/v2.4.11/doctest/doctest.h -``` -Expected: a ~7000-line header at `third_party/doctest.h`. - -- [ ] **Step 2: Write `Wind.manifest` (Per-Monitor-V2 DPI awareness)** - -```xml - - - - - PerMonitorV2 - - - -``` - -- [ ] **Step 3: Write `build.bat`** - -```bat -@echo off -setlocal enabledelayedexpansion -set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -if not exist "%VSWHERE%" (echo vswhere not found & exit /b 1) -for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSPATH=%%i" -if "%VSPATH%"=="" (echo VC tools not found & exit /b 1) -call "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" >nul - -if /i "%1"=="test" goto :test - -cl /nologo /std:c++17 /EHsc /O2 /W4 /DUNICODE /D_UNICODE ^ - src\transform.cpp src\zoom_controller.cpp src\tracker.cpp src\config.cpp ^ - src\magnifier_engine.cpp src\input_router.cpp src\tray.cpp src\main.cpp ^ - /Fe:Wind.exe ^ - /link Magnification.lib Dwmapi.lib user32.lib shell32.lib gdi32.lib ^ - /MANIFEST:EMBED /MANIFESTINPUT:Wind.manifest /SUBSYSTEM:WINDOWS -exit /b %errorlevel% - -:test -cl /nologo /std:c++17 /EHsc /W4 /I third_party ^ - tests\test_main.cpp tests\test_smoke.cpp tests\test_transform.cpp ^ - tests\test_zoom_controller.cpp tests\test_tracker.cpp tests\test_config.cpp ^ - src\transform.cpp src\zoom_controller.cpp src\tracker.cpp src\config.cpp ^ - /Fe:wind_tests.exe -if errorlevel 1 exit /b 1 -wind_tests.exe -exit /b %errorlevel% -``` -Note: the `test` target compiles only the pure-logic `.cpp` files (no Win32 sources), so tests build and run without a desktop session. - -- [ ] **Step 4: Write `tests/test_main.cpp`** - -```cpp -#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN -#include "doctest.h" -``` - -- [ ] **Step 5: Write a smoke test so the loop is provably alive** - -`tests/test_smoke.cpp`: -```cpp -#include "doctest.h" -TEST_CASE("test harness runs") { - CHECK(1 + 1 == 2); -} -``` - -- [ ] **Step 6: Create empty placeholder pure sources so the test target links** - -Create `src/transform.cpp`, `src/zoom_controller.cpp`, `src/tracker.cpp`, `src/config.cpp`, each containing only: -```cpp -// implemented in later tasks -``` - -- [ ] **Step 7: Run the test target** - -Run: `build.bat test` -Expected: compiles, runs `wind_tests.exe`, output shows `test harness runs` passing, exit code 0. - -- [ ] **Step 8: Commit and open PR1** - -```bash -git add third_party/doctest.h Wind.manifest build.bat tests/ src/ -git commit -m "build: doctest harness, build.bat, DPI manifest, smoke test" -gh pr create --fill --base main -``` - ---- - -## Task 4: `transform` - center+level+screen → offset (PR2, TDD) - -**Files:** -- Create: `src/transform.h`, `tests/test_transform.cpp` -- Modify: `src/transform.cpp` - -- [ ] **Step 1: Branch + issue** - -```bash -gh issue create --title "PR2: pure logic modules" --body "transform, zoom_controller, tracker, config parse - TDD." -git checkout main && git pull && git checkout -b feat/pure-logic -``` - -- [ ] **Step 2: Write the failing test** (`tests/test_transform.cpp`) - -```cpp -#include "doctest.h" -#include "../src/transform.h" - -TEST_CASE("centers the view at 2x") { - wind::Offset o = wind::ComputeOffset(960, 540, 2.0, 1920, 1080); - CHECK(o.x == 480); - CHECK(o.y == 270); -} -TEST_CASE("clamps to top-left edge") { - wind::Offset o = wind::ComputeOffset(0, 0, 2.0, 1920, 1080); - CHECK(o.x == 0); - CHECK(o.y == 0); -} -TEST_CASE("clamps to bottom-right edge") { - wind::Offset o = wind::ComputeOffset(1920, 1080, 2.0, 1920, 1080); - CHECK(o.x == 960); - CHECK(o.y == 540); -} -TEST_CASE("level 1.0 always offsets to origin") { - wind::Offset o = wind::ComputeOffset(500, 500, 1.0, 1920, 1080); - CHECK(o.x == 0); - CHECK(o.y == 0); -} -``` - -- [ ] **Step 3: Write the header** (`src/transform.h`) - -```cpp -#pragma once -namespace wind { -struct Offset { int x; int y; }; -// center: virtual lens center in screen pixels. level >= 1.0. -// screenW/H: monitor size in pixels. Returns top-left of the magnified source -// region, clamped so the view stays on screen. -Offset ComputeOffset(double centerX, double centerY, double level, int screenW, int screenH); -} -``` - -- [ ] **Step 4: Run the test to verify it fails** - -Run: `build.bat test` -Expected: FAIL (linker error: `ComputeOffset` unresolved). - -- [ ] **Step 5: Implement** (`src/transform.cpp`) - -```cpp -#include "transform.h" -#include -#include -namespace wind { -Offset ComputeOffset(double centerX, double centerY, double level, int screenW, int screenH) { - if (level < 1.0) level = 1.0; - double viewW = screenW / level; - double viewH = screenH / level; - int x = static_cast(std::lround(centerX - viewW / 2.0)); - int y = static_cast(std::lround(centerY - viewH / 2.0)); - int maxX = screenW - static_cast(std::lround(viewW)); - int maxY = screenH - static_cast(std::lround(viewH)); - x = std::min(std::max(x, 0), std::max(maxX, 0)); - y = std::min(std::max(y, 0), std::max(maxY, 0)); - return Offset{ x, y }; -} -} -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `build.bat test` -Expected: PASS, all `transform` cases green. - -- [ ] **Step 7: Commit** - -```bash -git add src/transform.h src/transform.cpp tests/test_transform.cpp -git commit -m "feat: transform - center/level/screen to clamped magnifier offset" -``` - ---- - -## Task 5: `zoom_controller` - ramp + direction resolution (PR2, TDD) - -**Files:** -- Create: `src/zoom_controller.h`, `tests/test_zoom_controller.cpp` -- Modify: `src/zoom_controller.cpp` - -- [ ] **Step 1: Write the failing test** (`tests/test_zoom_controller.cpp`) - -```cpp -#include "doctest.h" -#include "../src/zoom_controller.h" -using namespace wind; - -TEST_CASE("ResolveDirection maps physical button state") { - CHECK(ResolveDirection(false, false) == ZoomDir::None); - CHECK(ResolveDirection(true, false) == ZoomDir::In); - CHECK(ResolveDirection(false, true ) == ZoomDir::Out); - CHECK(ResolveDirection(true, true ) == ZoomDir::None); // both held = freeze -} -TEST_CASE("ramps in to max over full-range seconds") { - ZoomController z(1.0, 8.0, 1.2); - z.setDirection(ZoomDir::In); - z.tick(1.2); - CHECK(z.level() == doctest::Approx(8.0)); -} -TEST_CASE("ramps out to min") { - ZoomController z(1.0, 8.0, 1.2); - z.setDirection(ZoomDir::In); z.tick(1.2); // at 8.0 - z.setDirection(ZoomDir::Out); z.tick(1.2); - CHECK(z.level() == doctest::Approx(1.0)); -} -TEST_CASE("half the time gives multiplicative midpoint") { - ZoomController z(1.0, 8.0, 1.2); - z.setDirection(ZoomDir::In); - z.tick(0.6); - CHECK(z.level() == doctest::Approx(2.8284).epsilon(0.001)); -} -TEST_CASE("freezes when direction is None") { - ZoomController z(1.0, 8.0, 1.2); - z.setDirection(ZoomDir::In); z.tick(0.3); - double held = z.level(); - z.setDirection(ZoomDir::None); z.tick(5.0); - CHECK(z.level() == doctest::Approx(held)); -} -TEST_CASE("clamps and never exceeds bounds with many small ticks") { - ZoomController z(1.0, 8.0, 1.2); - z.setDirection(ZoomDir::In); - for (int i = 0; i < 1000; ++i) z.tick(0.01); - CHECK(z.level() == doctest::Approx(8.0)); -} -TEST_CASE("reset returns to 1.0 and None") { - ZoomController z(1.0, 8.0, 1.2); - z.setDirection(ZoomDir::In); z.tick(0.5); - z.reset(); - CHECK(z.level() == doctest::Approx(1.0)); - CHECK(z.direction() == ZoomDir::None); -} -``` - -- [ ] **Step 2: Write the header** (`src/zoom_controller.h`) - -```cpp -#pragma once -namespace wind { -enum class ZoomDir { None, In, Out }; - -// Pure: given which side buttons are physically held, what should the zoom do. -// Both held is ambiguous, so freeze. -ZoomDir ResolveDirection(bool inHeld, bool outHeld); - -class ZoomController { -public: - ZoomController(double minLevel, double maxLevel, double fullRangeSeconds); - void setDirection(ZoomDir d); - ZoomDir direction() const { return dir_; } - void tick(double dtSeconds); // ramp level multiplicatively toward bound - double level() const { return level_; } - void reset(); // level=min, dir=None -private: - double minLevel_, maxLevel_, fullRangeSeconds_; - double level_; - ZoomDir dir_ = ZoomDir::None; -}; -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `build.bat test` -Expected: FAIL (unresolved `ResolveDirection` / `ZoomController`). - -- [ ] **Step 4: Implement** (`src/zoom_controller.cpp`) - -```cpp -#include "zoom_controller.h" -#include -#include -namespace wind { -ZoomDir ResolveDirection(bool inHeld, bool outHeld) { - if (inHeld == outHeld) return ZoomDir::None; // neither, or both - return inHeld ? ZoomDir::In : ZoomDir::Out; -} -ZoomController::ZoomController(double minLevel, double maxLevel, double fullRangeSeconds) - : minLevel_(minLevel), maxLevel_(maxLevel), - fullRangeSeconds_(fullRangeSeconds), level_(minLevel) {} -void ZoomController::setDirection(ZoomDir d) { dir_ = d; } -void ZoomController::tick(double dt) { - if (dir_ == ZoomDir::None || dt <= 0.0) return; - double f = std::pow(maxLevel_ / minLevel_, dt / fullRangeSeconds_); - if (dir_ == ZoomDir::In) level_ *= f; - else level_ /= f; - level_ = std::min(maxLevel_, std::max(minLevel_, level_)); -} -void ZoomController::reset() { level_ = minLevel_; dir_ = ZoomDir::None; } -} -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `build.bat test` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/zoom_controller.h src/zoom_controller.cpp tests/test_zoom_controller.cpp -git commit -m "feat: zoom_controller - multiplicative hold-to-zoom ramp + direction resolve" -``` - ---- - -## Task 6: `tracker` - free/locked blend with delta integration (PR2, TDD) - -**Files:** -- Create: `src/tracker.h`, `tests/test_tracker.cpp` -- Modify: `src/tracker.cpp` - -- [ ] **Step 1: Write the failing test** (`tests/test_tracker.cpp`) - -```cpp -#include "doctest.h" -#include "../src/tracker.h" -using namespace wind; - -TEST_CASE("free mode follows the OS cursor when it moves") { - Tracker t(1920, 1080, 1.0); - t.update(100, 100, 0, 0); - CHECK(t.centerX() == doctest::Approx(100)); - CHECK(t.centerY() == doctest::Approx(100)); - t.update(300, 200, 0, 0); - CHECK(t.centerX() == doctest::Approx(300)); - CHECK(t.centerY() == doctest::Approx(200)); -} -TEST_CASE("locked mode integrates raw deltas when cursor is frozen") { - Tracker t(1920, 1080, 1.0); - t.update(960, 540, 0, 0); // establish position - t.update(960, 540, 5, -3); // cursor frozen, raw movement arrives - CHECK(t.centerX() == doctest::Approx(965)); - CHECK(t.centerY() == doctest::Approx(537)); -} -TEST_CASE("sensitivity scales locked-mode panning") { - Tracker t(1920, 1080, 2.0); - t.update(960, 540, 0, 0); - t.update(960, 540, 10, 0); - CHECK(t.centerX() == doctest::Approx(980)); // 10 * 2.0 -} -TEST_CASE("returns to free mode and snaps to cursor when it moves again") { - Tracker t(1920, 1080, 1.0); - t.update(960, 540, 0, 0); - t.update(960, 540, 50, 0); // locked -> 1010 - t.update(400, 400, 0, 0); // cursor moved -> free, snap - CHECK(t.centerX() == doctest::Approx(400)); - CHECK(t.centerY() == doctest::Approx(400)); -} -TEST_CASE("holds still when neither cursor nor raw move") { - Tracker t(1920, 1080, 1.0); - t.update(700, 700, 0, 0); - t.update(700, 700, 0, 0); - CHECK(t.centerX() == doctest::Approx(700)); -} -TEST_CASE("clamps center to screen bounds in locked mode") { - Tracker t(1920, 1080, 1.0); - t.update(10, 10, 0, 0); - t.update(10, 10, -1000, -1000); - CHECK(t.centerX() == doctest::Approx(0)); - CHECK(t.centerY() == doctest::Approx(0)); -} -TEST_CASE("recenter snaps to screen center") { - Tracker t(1920, 1080, 1.0); - t.update(100, 100, 0, 0); - t.recenter(); - CHECK(t.centerX() == doctest::Approx(960)); - CHECK(t.centerY() == doctest::Approx(540)); -} -``` - -- [ ] **Step 2: Write the header** (`src/tracker.h`) - -```cpp -#pragma once -namespace wind { -// Pure tracking state. Caller samples GetCursorPos and the summed raw-input deltas -// since the last tick, then calls update() once per tick. -class Tracker { -public: - Tracker(int screenW, int screenH, double sensitivity); - // cursorX/Y: latest GetCursorPos. rawDx/Dy: summed WM_INPUT deltas since last call. - void update(int cursorX, int cursorY, int rawDx, int rawDy); - void recenter(); // snap to screen center - double centerX() const { return cx_; } - double centerY() const { return cy_; } -private: - void clamp(); - int screenW_, screenH_; - double sensitivity_; - double cx_, cy_; - int lastCursorX_, lastCursorY_; - bool haveCursor_ = false; -}; -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `build.bat test` -Expected: FAIL (unresolved `Tracker`). - -- [ ] **Step 4: Implement** (`src/tracker.cpp`) - -```cpp -#include "tracker.h" -#include -namespace wind { -Tracker::Tracker(int screenW, int screenH, double sensitivity) - : screenW_(screenW), screenH_(screenH), sensitivity_(sensitivity), - cx_(screenW / 2.0), cy_(screenH / 2.0), - lastCursorX_(0), lastCursorY_(0) {} - -void Tracker::clamp() { - cx_ = std::min(std::max(cx_, 0.0), static_cast(screenW_)); - cy_ = std::min(std::max(cy_, 0.0), static_cast(screenH_)); -} - -void Tracker::update(int cursorX, int cursorY, int rawDx, int rawDy) { - bool cursorMoved = !haveCursor_ || cursorX != lastCursorX_ || cursorY != lastCursorY_; - if (cursorMoved) { - cx_ = cursorX; // free mode: follow OS cursor - cy_ = cursorY; - } else if (rawDx != 0 || rawDy != 0) { - cx_ += rawDx * sensitivity_; // locked mode: integrate raw movement - cy_ += rawDy * sensitivity_; - } - clamp(); - lastCursorX_ = cursorX; - lastCursorY_ = cursorY; - haveCursor_ = true; -} - -void Tracker::recenter() { - cx_ = screenW_ / 2.0; - cy_ = screenH_ / 2.0; -} -} -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `build.bat test` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/tracker.h src/tracker.cpp tests/test_tracker.cpp -git commit -m "feat: tracker - free/locked blend with raw-delta integration and clamp" -``` - ---- - -## Task 7: `config` - parse (pure) + load/save/hot-reload (PR2 pure half, PR3 I/O half) - -**Files:** -- Create: `src/config.h`, `tests/test_config.cpp` -- Modify: `src/config.cpp` - -- [ ] **Step 1: Write the failing test** (`tests/test_config.cpp`) - pure parse only - -```cpp -#include "doctest.h" -#include "../src/config.h" -using namespace wind; - -TEST_CASE("defaults when text is empty") { - Config c = ParseConfig(""); - CHECK(c.zoomInButton == 2); // XBUTTON2 - CHECK(c.zoomOutButton == 1); // XBUTTON1 - CHECK(c.maxLevel == doctest::Approx(8.0)); - CHECK(c.fullRangeSeconds == doctest::Approx(1.2)); - CHECK(c.sensitivity == doctest::Approx(1.0)); -} -TEST_CASE("parses overrides and ignores comments/blank lines") { - const char* ini = - "; comment\n" - "maxLevel = 12.5\n" - "\n" - "zoomInButton=1\n" - "sensitivity = 0.5\n"; - Config c = ParseConfig(ini); - CHECK(c.maxLevel == doctest::Approx(12.5)); - CHECK(c.zoomInButton == 1); - CHECK(c.sensitivity == doctest::Approx(0.5)); - CHECK(c.fullRangeSeconds == doctest::Approx(1.2)); // untouched default -} -TEST_CASE("malformed lines are ignored, keep defaults") { - Config c = ParseConfig("garbage line\nmaxLevel\n=5\n"); - CHECK(c.maxLevel == doctest::Approx(8.0)); -} -``` - -- [ ] **Step 2: Write the header** (`src/config.h`) - -```cpp -#pragma once -#include -namespace wind { -struct Config { - int zoomInButton = 2; // 1 = XBUTTON1 (back), 2 = XBUTTON2 (forward) - int zoomOutButton = 1; - int recenterVk = 0; // 0 = unbound - double maxLevel = 8.0; - double fullRangeSeconds = 1.2; - double sensitivity = 1.0; - int tickHzCap = 144; -}; -// Pure: parse INI text (key=value, ';' or '#' comments) into a Config, keeping -// defaults for missing/malformed keys. -Config ParseConfig(const std::string& text); - -// I/O (implemented for PR3): read file -> ParseConfig; create with defaults if absent. -Config LoadConfig(const std::wstring& path); -// I/O: last write time as a comparable tick count; 0 if missing. -unsigned long long ConfigMTime(const std::wstring& path); -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `build.bat test` -Expected: FAIL (unresolved `ParseConfig`). - -- [ ] **Step 4: Implement the pure parser** (`src/config.cpp`) - -```cpp -#include "config.h" -#include -#include -namespace wind { -static std::string trim(const std::string& s) { - size_t a = s.find_first_not_of(" \t\r\n"); - if (a == std::string::npos) return ""; - size_t b = s.find_last_not_of(" \t\r\n"); - return s.substr(a, b - a + 1); -} -Config ParseConfig(const std::string& text) { - Config c; - std::istringstream in(text); - std::string line; - while (std::getline(in, line)) { - std::string t = trim(line); - if (t.empty() || t[0] == ';' || t[0] == '#') continue; - size_t eq = t.find('='); - if (eq == std::string::npos) continue; - std::string key = trim(t.substr(0, eq)); - std::string val = trim(t.substr(eq + 1)); - if (key.empty() || val.empty()) continue; - try { - if (key == "zoomInButton") c.zoomInButton = std::stoi(val); - else if (key == "zoomOutButton") c.zoomOutButton = std::stoi(val); - else if (key == "recenterVk") c.recenterVk = std::stoi(val); - else if (key == "maxLevel") c.maxLevel = std::stod(val); - else if (key == "fullRangeSeconds") c.fullRangeSeconds = std::stod(val); - else if (key == "sensitivity") c.sensitivity = std::stod(val); - else if (key == "tickHzCap") c.tickHzCap = std::stoi(val); - } catch (...) { /* keep default on bad value */ } - } - return c; -} -} -``` -(The `LoadConfig`/`ConfigMTime` I/O functions are added in Task 10; they are not part of the test build.) - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `build.bat test` -Expected: PASS. This completes PR2. - -- [ ] **Step 6: Commit + open PR2** - -```bash -git add src/config.h src/config.cpp tests/test_config.cpp -git commit -m "feat: config - pure INI parser with defaults" -gh pr create --fill --base main -``` - ---- - -## Task 8: `magnifier_engine` - Magnification API wrapper (PR3) - -**Files:** -- Create: `src/magnifier_engine.h`, `src/magnifier_engine.cpp` - -This module is Win32 I/O; it is verified by the manual checklist in Task 13, not unit tests. - -- [ ] **Step 1: Branch + issue** - -```bash -gh issue create --title "PR3: I/O modules" --body "Magnification engine, input router, config file I/O." -git checkout main && git pull && git checkout -b feat/io-shell -``` - -- [ ] **Step 2: Write the header** (`src/magnifier_engine.h`) - -```cpp -#pragma once -namespace wind { -class MagnifierEngine { -public: - bool initialize(); // MagInitialize - void setTransform(double level, int xOffset, int yOffset); - void shutdown(); // reset to 1x then MagUninitialize - bool ready() const { return ready_; } -private: - bool ready_ = false; -}; -} -``` - -- [ ] **Step 3: Implement** (`src/magnifier_engine.cpp`) - -```cpp -#include "magnifier_engine.h" -#include -#include -namespace wind { -bool MagnifierEngine::initialize() { - ready_ = MagInitialize() ? true : false; - return ready_; -} -void MagnifierEngine::setTransform(double level, int xOffset, int yOffset) { - if (!ready_) return; - MagSetFullscreenTransform(static_cast(level), xOffset, yOffset); -} -void MagnifierEngine::shutdown() { - if (!ready_) return; - MagSetFullscreenTransform(1.0f, 0, 0); // never leave the screen zoomed - MagUninitialize(); - ready_ = false; -} -} -``` - -- [ ] **Step 4: Build the app to confirm it compiles/links** - -Run: `build.bat` -Expected: `Wind.exe` builds (it won't do anything useful until `main` is wired in Task 11). If `main.cpp`/other sources don't exist yet, temporarily allow the build to fail on those only; this step just checks `magnifier_engine` compiles. (Alternatively defer this build check to Task 11.) - -- [ ] **Step 5: Commit** - -```bash -git add src/magnifier_engine.h src/magnifier_engine.cpp -git commit -m "feat: magnifier_engine - Magnification API fullscreen-transform wrapper" -``` - ---- - -## Task 9: `input_router` - Raw Input + low-level mouse hook (PR3) - -**Files:** -- Create: `src/input_router.h`, `src/input_router.cpp` - -Captures mouse-movement deltas via Raw Input (survives cursor lock) and side-button -state via `WH_MOUSE_LL` (so the buttons can be swallowed and don't fire browser -back/forward). Shared state is exposed as atomics the tick thread reads. - -- [ ] **Step 1: Write the header** (`src/input_router.h`) - -```cpp -#pragma once -#include -namespace wind { -// Holds input state shared between the hook/raw-input callbacks and the tick thread. -struct InputState { - std::atomic rawDx{0}; // summed since last drain - std::atomic rawDy{0}; - std::atomic inHeld{false}; // zoom-in side button physically down - std::atomic outHeld{false}; - std::atomic recenter{false}; -}; - -class InputRouter { -public: - // inButtonId/outButtonId: 1 = XBUTTON1, 2 = XBUTTON2. swallow: block the buttons - // from reaching other apps while running. - bool start(int inButtonId, int outButtonId, bool swallow); - void stop(); - InputState& state() { return state_; } - // Atomically read and zero the accumulated raw deltas. - void drainRaw(int& dx, int& dy); -private: - InputState state_; -}; -} -``` - -- [ ] **Step 2: Implement** (`src/input_router.cpp`) - -```cpp -#include "input_router.h" -#include -namespace wind { -static InputRouter* g_router = nullptr; -static int g_inButtonId = 2, g_outButtonId = 1; -static bool g_swallow = true; -static HHOOK g_mouseHook = nullptr; - -static int xbuttonIdFromHook(WPARAM wParam, LPARAM lParam) { - auto* mi = reinterpret_cast(lParam); - if (wParam == WM_XBUTTONDOWN || wParam == WM_XBUTTONUP) { - WORD hi = HIWORD(mi->mouseData); // XBUTTON1 or XBUTTON2 - return (hi == XBUTTON1) ? 1 : (hi == XBUTTON2 ? 2 : 0); - } - return 0; -} - -static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { - if (code == HC_ACTION && g_router) { - int id = xbuttonIdFromHook(wParam, lParam); - bool down = (wParam == WM_XBUTTONDOWN); - bool up = (wParam == WM_XBUTTONUP); - if (id != 0 && (down || up)) { - if (id == g_inButtonId) g_router->state().inHeld.store(down); - if (id == g_outButtonId) g_router->state().outHeld.store(down); - if (g_swallow && (id == g_inButtonId || id == g_outButtonId)) - return 1; // swallow so back/forward don't fire - } - } - return CallNextHookEx(g_mouseHook, code, wParam, lParam); -} - -bool InputRouter::start(int inButtonId, int outButtonId, bool swallow) { - g_router = this; g_inButtonId = inButtonId; g_outButtonId = outButtonId; g_swallow = swallow; - g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, MouseProc, GetModuleHandleW(nullptr), 0); - return g_mouseHook != nullptr; - // NOTE: Raw Input registration (RIDEV_INPUTSINK) and WM_INPUT handling live in - // main.cpp's message-only window, which calls back into accumulateRaw(). -} -void InputRouter::stop() { - if (g_mouseHook) { UnhookWindowsHookEx(g_mouseHook); g_mouseHook = nullptr; } - g_router = nullptr; -} -void InputRouter::drainRaw(int& dx, int& dy) { - dx = state_.rawDx.exchange(0); - dy = state_.rawDy.exchange(0); -} -} -``` - -- [ ] **Step 3: Add a helper main.cpp will call from WM_INPUT** - append to `input_router.cpp` - -```cpp -namespace wind { -// Called from main.cpp's WM_INPUT handler with decoded relative deltas. -void AccumulateRaw(InputRouter& r, int dx, int dy) { - r.state().rawDx.fetch_add(dx); - r.state().rawDy.fetch_add(dy); -} -} -``` -And declare it in `input_router.h`: -```cpp -namespace wind { class InputRouter; void AccumulateRaw(InputRouter& r, int dx, int dy); } -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/input_router.h src/input_router.cpp -git commit -m "feat: input_router - WH_MOUSE_LL side-button capture + raw-delta accumulator" -``` - ---- - -## Task 10: `config` file I/O + hot-reload helpers (PR3) - -**Files:** -- Modify: `src/config.cpp` (add `LoadConfig`, `ConfigMTime`) - -- [ ] **Step 1: Append I/O functions** to `src/config.cpp` - -```cpp -#include -#include -namespace wind { -Config LoadConfig(const std::wstring& path) { - std::ifstream f(path); - if (!f) { - // Write defaults so the user has something to edit. - std::ofstream out(path); - out << "; Wind magnifier config\n" - "zoomInButton=2\nzoomOutButton=1\nrecenterVk=0\n" - "maxLevel=8.0\nfullRangeSeconds=1.2\nsensitivity=1.0\ntickHzCap=144\n"; - return Config{}; - } - std::string text((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - return ParseConfig(text); -} -unsigned long long ConfigMTime(const std::wstring& path) { - WIN32_FILE_ATTRIBUTE_DATA d{}; - if (!GetFileAttributesExW(path.c_str(), GetFileExInfoStandard, &d)) return 0ULL; - ULARGE_INTEGER u; u.LowPart = d.ftLastWriteTime.dwLowDateTime; - u.HighPart = d.ftLastWriteTime.dwHighDateTime; - return u.QuadPart; -} -} -``` -Note: these use `` but live in the same `config.cpp`. Keep them **below** the pure parser and guarded so the test build excludes them: wrap this block in `#ifndef WIND_TESTS ... #endif`, and add `/DWIND_TESTS` to the `:test` `cl` line in `build.bat`. - -- [ ] **Step 2: Add the guard** - wrap the Step 1 block: - -```cpp -#ifndef WIND_TESTS -// ... the LoadConfig / ConfigMTime block ... -#endif -``` -And in `build.bat` `:test`, add `/DWIND_TESTS` to the `cl` flags. - -- [ ] **Step 3: Run tests to confirm the pure build still passes** - -Run: `build.bat test` -Expected: PASS (I/O block excluded by `WIND_TESTS`). - -- [ ] **Step 4: Commit + open PR3** - -```bash -git add src/config.cpp build.bat -git commit -m "feat: config file load (with default write) + mtime for hot-reload" -gh pr create --fill --base main -``` - ---- - -## Task 11: `main.cpp` - wire everything, tick thread, lifecycle (PR4) - -**Files:** -- Create: `src/main.cpp` - -- [ ] **Step 1: Branch + issue** - -```bash -gh issue create --title "PR4: wiring, tray, verification" --body "main loop, tick thread, tray icon, manual verification + tuning." -git checkout main && git pull && git checkout -b feat/wiring -``` - -- [ ] **Step 2: Implement** (`src/main.cpp`) - -```cpp -#include -#include -#include -#include -#include "config.h" -#include "magnifier_engine.h" -#include "input_router.h" -#include "transform.h" -#include "tracker.h" -#include "zoom_controller.h" -#include "tray.h" - -using namespace wind; - -static InputRouter g_input; -static std::atomic g_running{true}; - -// Message-only window receives WM_INPUT (raw mouse) and tray messages. -static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { - if (msg == WM_INPUT) { - UINT size = 0; - GetRawInputData((HRAWINPUT)lp, RID_INPUT, nullptr, &size, sizeof(RAWINPUTHEADER)); - BYTE buf[64]; - if (size <= sizeof(buf) && - GetRawInputData((HRAWINPUT)lp, RID_INPUT, buf, &size, sizeof(RAWINPUTHEADER)) == size) { - auto* ri = reinterpret_cast(buf); - if (ri->header.dwType == RIM_TYPEMOUSE && - (ri->data.mouse.usFlags & MOUSE_MOVE_RELATIVE) == MOUSE_MOVE_RELATIVE) { - AccumulateRaw(g_input, ri->data.mouse.lLastX, ri->data.mouse.lLastY); - } - } - return 0; - } - if (Tray::HandleMessage(hwnd, msg, wp, lp)) return 0; // tray menu/quit - return DefWindowProcW(hwnd, msg, wp, lp); -} - -int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { - // Single instance - HANDLE mtx = CreateMutexW(nullptr, TRUE, L"Wind_Magnifier_SingleInstance"); - if (GetLastError() == ERROR_ALREADY_EXISTS) return 0; - - Config cfg = LoadConfig(L"magnifier.ini"); - - // Message-only window - WNDCLASSW wc{}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; - wc.lpszClassName = L"WindMagnifierWnd"; - RegisterClassW(&wc); - HWND hwnd = CreateWindowW(wc.lpszClassName, L"Wind", 0, 0, 0, 0, 0, - HWND_MESSAGE, nullptr, hInst, nullptr); - - // Raw Input for the mouse, delivered even when a game is foreground. - RAWINPUTDEVICE rid{}; rid.usUsagePage = 0x01; rid.usUsage = 0x02; // generic mouse - rid.dwFlags = RIDEV_INPUTSINK; rid.hwndTarget = hwnd; - RegisterRawInputDevices(&rid, 1, sizeof(rid)); - - if (!g_input.start(cfg.zoomInButton, cfg.zoomOutButton, /*swallow=*/true)) return 1; - - MagnifierEngine engine; - if (!engine.initialize()) { - Tray::Notify(L"Wind", L"Magnification API failed to initialize."); - return 1; - } - Tray::Add(hwnd, hInst); - - int sw = GetSystemMetrics(SM_CXSCREEN); - int sh = GetSystemMetrics(SM_CYSCREEN); - ZoomController zoom(1.0, cfg.maxLevel, cfg.fullRangeSeconds); - Tracker tracker(sw, sh, cfg.sensitivity); - - // Tick thread: paced by DwmFlush (≈ refresh rate), reads shared state, transforms. - std::thread tick([&]{ - LARGE_INTEGER freq, prev; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&prev); - while (g_running.load()) { - DwmFlush(); // wait for next compositor frame (cheap pacing) - LARGE_INTEGER now; QueryPerformanceCounter(&now); - double dt = double(now.QuadPart - prev.QuadPart) / double(freq.QuadPart); - prev = now; - - // Watchdog-safe: derive direction from current physical button state each tick. - zoom.setDirection(ResolveDirection(g_input.state().inHeld.load(), - g_input.state().outHeld.load())); - zoom.tick(dt); - - if (g_input.state().recenter.exchange(false)) tracker.recenter(); - - POINT p; GetCursorPos(&p); - int dx, dy; g_input.drainRaw(dx, dy); - tracker.update(p.x, p.y, dx, dy); - - Offset o = ComputeOffset(tracker.centerX(), tracker.centerY(), - zoom.level(), sw, sh); - engine.setTransform(zoom.level(), o.x, o.y); - } - }); - - MSG msg; - while (GetMessageW(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessageW(&msg); } - - g_running.store(false); - if (tick.joinable()) tick.join(); - engine.shutdown(); // resets to 1x - never leave the screen zoomed - g_input.stop(); - Tray::Remove(); - ReleaseMutex(mtx); - return 0; -} -``` - -- [ ] **Step 3: Build** - -Run: `build.bat` -Expected: `Wind.exe` builds and links (Task 12 provides `tray.*`; if building before Task 12, stub `tray.h`/`tray.cpp` first - see Task 12). - -- [ ] **Step 4: Commit** - -```bash -git add src/main.cpp -git commit -m "feat: main - wire modules, raw-input window, DwmFlush tick thread, lifecycle" -``` - ---- - -## Task 12: `tray` - system tray icon + menu (PR4) - -**Files:** -- Create: `src/tray.h`, `src/tray.cpp` - -- [ ] **Step 1: Write the header** (`src/tray.h`) - -```cpp -#pragma once -#include -namespace wind { -namespace Tray { - void Add(HWND hwnd, HINSTANCE hInst); // add icon - void Remove(); // delete icon - void Notify(const wchar_t* title, const wchar_t* text); // balloon - bool HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); // true if handled -} -} -``` - -- [ ] **Step 2: Implement** (`src/tray.cpp`) - -```cpp -#include "tray.h" -#include -namespace wind { namespace Tray { -static NOTIFYICONDATAW g_nid{}; -static const UINT WM_TRAY = WM_APP + 1; -static const UINT ID_EDIT = 1001, ID_QUIT = 1002; - -void Add(HWND hwnd, HINSTANCE hInst) { - g_nid.cbSize = sizeof(g_nid); - g_nid.hWnd = hwnd; - g_nid.uID = 1; - g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; - g_nid.uCallbackMessage = WM_TRAY; - g_nid.hIcon = LoadIconW(nullptr, IDI_APPLICATION); - lstrcpyW(g_nid.szTip, L"Wind magnifier"); - Shell_NotifyIconW(NIM_ADD, &g_nid); -} -void Remove() { Shell_NotifyIconW(NIM_DELETE, &g_nid); } -void Notify(const wchar_t* title, const wchar_t* text) { - g_nid.uFlags = NIF_INFO; - lstrcpyW(g_nid.szInfoTitle, title); - lstrcpyW(g_nid.szInfo, text); - Shell_NotifyIconW(NIM_MODIFY, &g_nid); - g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; -} -bool HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { - if (msg == WM_TRAY && (lp == WM_RBUTTONUP || lp == WM_LBUTTONUP)) { - POINT pt; GetCursorPos(&pt); - HMENU m = CreatePopupMenu(); - AppendMenuW(m, MF_STRING, ID_EDIT, L"Edit config"); - AppendMenuW(m, MF_STRING, ID_QUIT, L"Quit"); - SetForegroundWindow(hwnd); - int cmd = TrackPopupMenu(m, TPM_RETURNCMD | TPM_RIGHTBUTTON, pt.x, pt.y, 0, hwnd, nullptr); - DestroyMenu(m); - if (cmd == ID_EDIT) ShellExecuteW(nullptr, L"open", L"notepad.exe", L"magnifier.ini", nullptr, SW_SHOW); - else if (cmd == ID_QUIT) PostMessageW(hwnd, WM_CLOSE, 0, 0); - return true; - } - if (msg == WM_CLOSE) { DestroyWindow(hwnd); return true; } - if (msg == WM_DESTROY) { PostQuitMessage(0); return true; } - return false; -} -}} -``` - -- [ ] **Step 3: Build the full app** - -Run: `build.bat` -Expected: `Wind.exe` builds and links cleanly at `/W4`. - -- [ ] **Step 4: Commit** - -```bash -git add src/tray.h src/tray.cpp -git commit -m "feat: tray - icon, right-click menu (edit config / quit), balloon" -``` - ---- - -## Task 13: Manual verification, tuning, hot-reload, PR4 close (PR4) - -**Files:** -- Create: `docs/VERIFICATION.md` -- Modify: `src/main.cpp` (config hot-reload poll), `config/magnifier.ini` - -- [ ] **Step 1: Add config hot-reload to the tick thread** - in `main.cpp`, before the tick loop add an mtime poll roughly once per second and re-apply tunable fields: - -```cpp -// inside the tick lambda, after computing dt: -static unsigned long long lastMtime = ConfigMTime(L"magnifier.ini"); -static double sinceCheck = 0; sinceCheck += dt; -if (sinceCheck > 1.0) { - sinceCheck = 0; - unsigned long long m = ConfigMTime(L"magnifier.ini"); - if (m != lastMtime) { - lastMtime = m; - Config nc = LoadConfig(L"magnifier.ini"); - zoom = ZoomController(1.0, nc.maxLevel, nc.fullRangeSeconds); - tracker = Tracker(sw, sh, nc.sensitivity); - } -} -``` -(Capture `zoom`/`tracker` by reference in the lambda - they already are.) - -- [ ] **Step 2: Ship a default `config/magnifier.ini`** - -```ini -; Wind magnifier config. Edit and save; changes apply within ~1s. -zoomInButton=2 -zoomOutButton=1 -recenterVk=0 -maxLevel=8.0 -fullRangeSeconds=1.2 -sensitivity=1.0 -tickHzCap=144 -``` - -- [ ] **Step 3: Build** - -Run: `build.bat` -Expected: clean build. - -- [ ] **Step 4: Write `docs/VERIFICATION.md` checklist and run it** - -```markdown -# Wind manual verification - -Run `Wind.exe`. Then verify: - -## Desktop -- [ ] Hold forward (XButton2): screen zooms in smoothly (no steps), follows the cursor. -- [ ] Hold back (XButton1): zooms out smoothly; stops at 1.0x (screen back to normal). -- [ ] Release mid-zoom: level stays put. -- [ ] Move the mouse while zoomed: the lens follows the cursor. -- [ ] Quit from tray: screen returns to 1x (never left zoomed). -- [ ] Edit magnifier.ini (set maxLevel=4.0), save: new max applies within ~1s. - -## In a borderless-fullscreen game (cursor hidden / center-locked) -- [ ] Hold forward: game view zooms in. -- [ ] Move mouse: the lens PANS even though the game hides/locks the cursor (core feature). -- [ ] Side buttons do not trigger anything in-game unexpectedly. - -## Performance -- [ ] Task Manager: Wind CPU stays near 0% idle-zoomed; low while panning. -- [ ] No stutter added to the game. -``` - -Run through the checklist manually; record results. - -- [ ] **Step 5: Commit + open PR4** - -```bash -git add docs/VERIFICATION.md config/magnifier.ini src/main.cpp -git commit -m "feat: config hot-reload, default ini, manual verification checklist" -gh pr create --fill --base main -``` - ---- - -## Self-review (completed by plan author) - -**Spec coverage:** -- Fullscreen magnification → Task 8 (`MagSetFullscreenTransform`). ✓ -- Responsive → DwmFlush-paced tick, Task 11. ✓ -- Light → Magnification API (no per-frame copy) + atomic state; performance check in Task 13. ✓ -- Smooth gradual zoom → multiplicative ramp, Task 5; float transform, Task 8. ✓ -- Works in games / movable under cursor lock → Raw Input (Task 9, 11) + Tracker free/locked blend (Task 6). ✓ -- Hold-to-zoom, forward=in / back=out, persist on release → `ResolveDirection` + `ZoomController` (Task 5), `WH_MOUSE_LL` capture (Task 9), polled each tick (Task 11). ✓ -- Config / tray / single instance / DPI / graceful shutdown → Tasks 7, 10, 11, 12, 13 + manifest Task 3. ✓ -- Verification loop day one → Task 3 smoke test + `build.bat test`. ✓ -- Non-goals (exclusive fullscreen, injection) → explicitly excluded; documented in README/CLAUDE.md. - -**Placeholder scan:** Empty pure-source files in Task 3 are intentional link stubs, replaced in Tasks 4-7. The Task 8 Step 4 partial-build note is a sequencing convenience, fully resolved by Task 12. No "TBD"/"add error handling"/unshown code remain. - -**Type consistency:** `ComputeOffset`, `Offset`, `ZoomDir`, `ResolveDirection`, `ZoomController`, `Tracker`, `Config`, `ParseConfig`, `LoadConfig`, `ConfigMTime`, `MagnifierEngine`, `InputRouter`, `InputState`, `AccumulateRaw`, `Tray::*` are used with identical signatures across tasks and `main.cpp`. ✓ -``` diff --git a/docs/superpowers/plans/2026-05-25-interaction-fixes.md b/docs/superpowers/plans/2026-05-25-interaction-fixes.md deleted file mode 100644 index e0297bb0..00000000 --- a/docs/superpowers/plans/2026-05-25-interaction-fixes.md +++ /dev/null @@ -1,388 +0,0 @@ -# Wind Interaction-Bug Fixes Implementation Plan - -> **For agentic workers:** TDD, bite-sized steps, frequent commits. Steps use checkbox -> (`- [ ]`) syntax for tracking. - -**Goal:** Kill the magnified-view flicker / mis-click bugs on the desktop (Issues 2 and -3 in `docs/KNOWN-ISSUES.md`) and make the zoom side-buttons work over elevated windows -(Issue 1), without regressing the core game cursor-lock feature. - -**Architecture:** Two changes. -1. **Tracker lock detector (pure logic, fully unit-tested).** Replace the single-tick - free/locked heuristic with a hysteresis detector: only treat the cursor as locked - after `kLockEngageTicks` consecutive frozen-cursor-with-raw ticks, and hold (never - jump) the lens while unconfirmed. This removes the off-centre/recenter oscillation - (flicker, Issue 3) and the resulting cursor-vs-view divergence that broke clicking and - the I-beam (Issue 2), while preserving lens-follows-locked-cursor for games. -2. **Raw-Input side-button detection (Win32).** Read XBUTTON state from the existing - `WM_INPUT` stream in addition to the `WH_MOUSE_LL` hook. Raw Input is delivered even - when an elevated window is foreground, where a medium-IL low-level hook is not invoked - (UIPI). Additive and idempotent with the hook. - -**Tech Stack:** C++17, MSVC, doctest. Pure logic in `src/tracker.*`; Win32 in -`src/main.cpp`. Build/test via `build.bat` / `build.bat test`. - ---- - -## File Structure - -- `src/tracker.h` - add `kLockEngageTicks`, `locked()` accessor, lock state fields. -- `src/tracker.cpp` - rewrite `update()` with the hysteresis lock detector. -- `tests/test_tracker.cpp` - rewrite locked-mode tests (they currently encode the - buggy single-tick lock), add flicker-regression tests. -- `src/main.cpp` - decode XBUTTON from `WM_INPUT`; set zoom-button ids from config; - add a locked-tick counter to the diagnostics line for real-world verification. - -No new files. No changes to `input_router.*` (the hook keeps swallowing on normal -windows; raw input only adds a second, elevation-proof state source). - ---- - -## Task 1: Tracker hysteresis lock detector (Issues 2 + 3) - -**Files:** -- Modify: `src/tracker.h` -- Modify: `src/tracker.cpp` -- Test: `tests/test_tracker.cpp` - -- [ ] **Step 1: Replace the tracker tests with corrected behavior (write failing tests first)** - -Overwrite `tests/test_tracker.cpp` with: - -```cpp -#include "doctest.h" -#include "../src/tracker.h" -using namespace wind; - -// --- Free mode (normal desktop) ------------------------------------------------ - -TEST_CASE("free mode follows the OS cursor when it moves") { - Tracker t(1920, 1080, 1.0); - t.update(100, 100, 0, 0); - CHECK(t.centerX() == doctest::Approx(100)); - CHECK(t.centerY() == doctest::Approx(100)); - t.update(300, 200, 0, 0); - CHECK(t.centerX() == doctest::Approx(300)); - CHECK(t.centerY() == doctest::Approx(200)); - CHECK_FALSE(t.locked()); -} - -TEST_CASE("holds still when neither cursor nor raw move") { - Tracker t(1920, 1080, 1.0); - t.update(700, 700, 0, 0); - t.update(700, 700, 0, 0); - CHECK(t.centerX() == doctest::Approx(700)); -} - -// --- Flicker regression (the reported bug) ------------------------------------- - -TEST_CASE("a lone frozen-cursor tick with raw movement does NOT jump the lens") { - // During free movement an occasional tick samples an unchanged GetCursorPos while - // raw deltas arrive (sampling alias). Integrating it would jump the lens off-centre - // then snap back next tick = the flicker. The lens must hold instead. - Tracker t(1920, 1080, 1.0); - t.update(960, 540, 0, 0); - t.update(960, 540, 50, 0); // lone alias tick - CHECK(t.centerX() == doctest::Approx(960)); // held, NOT 1010 - CHECK_FALSE(t.locked()); -} - -TEST_CASE("interleaved moved/frozen ticks track the cursor without oscillating") { - Tracker t(1920, 1080, 1.0); - t.update(500, 500, 0, 0); - t.update(504, 500, 40, 0); // moved -> snap, resets lock counter - CHECK(t.centerX() == doctest::Approx(504)); - t.update(504, 500, 40, 0); // alias tick -> hold (not 544) - CHECK(t.centerX() == doctest::Approx(504)); - t.update(508, 500, 40, 0); // moved -> snap to true cursor - CHECK(t.centerX() == doctest::Approx(508)); - CHECK_FALSE(t.locked()); // never falsely locked -} - -// --- Locked mode (games that hide/clip/lock the cursor) ------------------------ - -TEST_CASE("lock engages only after sustained freeze, then integrates raw deltas") { - Tracker t(1920, 1080, 1.0); - t.update(960, 540, 0, 0); // establish - for (int i = 0; i < Tracker::kLockEngageTicks - 1; ++i) - t.update(960, 540, 5, 0); // ramp: held, not integrated - CHECK(t.centerX() == doctest::Approx(960)); - CHECK_FALSE(t.locked()); - t.update(960, 540, 5, 0); // threshold tick: locks + integrates - CHECK(t.locked()); - CHECK(t.centerX() == doctest::Approx(965)); - t.update(960, 540, 10, 0); // stays locked, keeps integrating - CHECK(t.centerX() == doctest::Approx(975)); -} - -TEST_CASE("sensitivity scales locked-mode panning") { - Tracker t(1920, 1080, 2.0); - t.update(960, 540, 0, 0); - for (int i = 0; i < Tracker::kLockEngageTicks - 1; ++i) - t.update(960, 540, 1, 0); // ramp (held) - CHECK(t.centerX() == doctest::Approx(960)); - t.update(960, 540, 10, 0); // engages + integrates: 10 * 2.0 - CHECK(t.centerX() == doctest::Approx(980)); -} - -TEST_CASE("returns to free mode and snaps to cursor when it moves again") { - Tracker t(1920, 1080, 1.0); - t.update(960, 540, 0, 0); - for (int i = 0; i < Tracker::kLockEngageTicks; ++i) - t.update(960, 540, 50, 0); // engage + pan in locked mode - CHECK(t.locked()); - t.update(400, 400, 0, 0); // OS cursor moved -> free, snap - CHECK(t.centerX() == doctest::Approx(400)); - CHECK(t.centerY() == doctest::Approx(400)); - CHECK_FALSE(t.locked()); -} - -TEST_CASE("clamps center to screen bounds in locked mode") { - Tracker t(1920, 1080, 1.0); - t.update(10, 10, 0, 0); - for (int i = 0; i < Tracker::kLockEngageTicks - 1; ++i) - t.update(10, 10, -1, -1); // ramp (held) - t.update(10, 10, -1000, -1000); // engages + integrates, clamps - CHECK(t.centerX() == doctest::Approx(0)); - CHECK(t.centerY() == doctest::Approx(0)); -} - -TEST_CASE("recenter snaps to screen center") { - Tracker t(1920, 1080, 1.0); - t.update(100, 100, 0, 0); - t.recenter(); - CHECK(t.centerX() == doctest::Approx(960)); - CHECK(t.centerY() == doctest::Approx(540)); -} -``` - -- [ ] **Step 2: Run tests, verify the flicker/lock tests FAIL on current code** - -Run: `build.bat test` -Expected: the new "lone frozen-cursor", "interleaved", and "lock engages only after -sustained freeze" cases FAIL (current code locks on a single tick, so the lens jumps). - -- [ ] **Step 3: Add lock state + threshold to `src/tracker.h`** - -Replace the class body so it reads: - -```cpp -#pragma once -namespace wind { -// Pure tracking state. Caller samples GetCursorPos and the summed raw-input deltas -// since the last tick, then calls update() once per tick. -// -// Two modes chosen by a hysteresis lock detector: -// Free - OS cursor moving normally -> lens follows GetCursorPos. -// Locked - OS cursor frozen (a game hid/clipped/locked it) while the hand keeps -// moving (raw deltas arrive) -> lens pans by raw deltas. Wind's core feature. -// -// A *single* tick that sees an unchanged GetCursorPos while raw deltas arrive is NOT a -// lock: during free movement that is a sampling alias, and integrating it makes the lens -// jump off-centre then snap back (visible flicker). The lock engages only after -// kLockEngageTicks consecutive frozen-cursor-with-raw ticks and disengages immediately -// when the OS cursor moves again. -class Tracker { -public: - // Consecutive frozen-cursor + raw ticks needed to treat the cursor as locked. At a - // 60-144 Hz tick this is ~40-100 ms: far longer than any free-movement sampling - // alias, and a one-time imperceptible delay when a real game lock engages. - static constexpr int kLockEngageTicks = 6; - - Tracker(int screenW, int screenH, double sensitivity); - void update(int cursorX, int cursorY, int rawDx, int rawDy); - void recenter(); - double centerX() const { return cx_; } - double centerY() const { return cy_; } - bool locked() const { return locked_; } -private: - void clamp(); - int screenW_, screenH_; - double sensitivity_; - double cx_, cy_; - int lastCursorX_, lastCursorY_; - bool haveCursor_ = false; - bool locked_ = false; - int stationaryRawTicks_ = 0; -}; -} -``` - -- [ ] **Step 4: Rewrite `Tracker::update` in `src/tracker.cpp`** - -Replace the `update` function body with: - -```cpp -void Tracker::update(int cursorX, int cursorY, int rawDx, int rawDy) { - bool cursorMoved = !haveCursor_ || cursorX != lastCursorX_ || cursorY != lastCursorY_; - bool rawMoved = (rawDx != 0 || rawDy != 0); - - if (cursorMoved) { - // OS cursor moving freely: follow it and abandon any lock at once. - locked_ = false; - stationaryRawTicks_ = 0; - cx_ = cursorX; - cy_ = cursorY; - } else if (rawMoved) { - // OS cursor frozen but the hand is moving: candidate for a cursor lock. - if (!locked_ && ++stationaryRawTicks_ >= kLockEngageTicks) - locked_ = true; - if (locked_) { - cx_ += rawDx * sensitivity_; // pan the lens with raw movement - cy_ += rawDy * sensitivity_; - } - // While not yet locked: hold the centre (do NOT jump). Removes the flicker. - } else { - // Neither cursor nor hand moved: hold. Reset the engage counter so only - // *consecutive* frozen+raw ticks arm a lock; an established lock_ stays on - // (a locked game cursor remains frozen when you briefly stop moving). - stationaryRawTicks_ = 0; - } - clamp(); - lastCursorX_ = cursorX; - lastCursorY_ = cursorY; - haveCursor_ = true; -} -``` - -- [ ] **Step 5: Run tests, verify all pass** - -Run: `build.bat test` -Expected: SUCCESS, all cases pass (25 cases total). - -- [ ] **Step 6: Commit** - -```bash -git add src/tracker.h src/tracker.cpp tests/test_tracker.cpp -git commit -m "fix: tracker lock detector uses hysteresis, killing desktop flicker" -``` - ---- - -## Task 2: Raw-Input side-button detection (Issue 1, best-effort) - -**Files:** -- Modify: `src/main.cpp` - -Not unit-testable (Win32 input over elevated windows requires live testing). Implement -the reasoned fix; it compiles and is additive/idempotent with the existing hook. - -- [ ] **Step 1: Add zoom-button id statics + a setter above `WndProc` in `src/main.cpp`** - -After `static InputRouter g_input;` add: - -```cpp -static int g_zoomInBtnId = 2; // XBUTTON id: 1 = XBUTTON1, 2 = XBUTTON2 (set from cfg) -static int g_zoomOutBtnId = 1; - -// Set side-button state from a Raw Input transition. Mirrors the hook's mapping so the -// two state sources are interchangeable and idempotent. -static void SetZoomButton(int xbuttonId, bool down) { - if (xbuttonId == g_zoomInBtnId) g_input.state().inHeld.store(down); - if (xbuttonId == g_zoomOutBtnId) g_input.state().outHeld.store(down); -} -``` - -- [ ] **Step 2: Decode XBUTTON flags in the `WM_INPUT` handler** - -Replace the mouse-handling block in `WndProc` (the `if (ri->header.dwType == RIM_TYPEMOUSE ...)` section) with: - -```cpp - if (ri->header.dwType == RIM_TYPEMOUSE) { - const RAWMOUSE& m = ri->data.mouse; - if ((m.usFlags & MOUSE_MOVE_ABSOLUTE) == 0) { - AccumulateRaw(g_input, m.lLastX, m.lLastY); - } - // Side-button state via Raw Input. This path is delivered even when an - // elevated window (Task Manager, UAC) is foreground, where the - // WH_MOUSE_LL hook is not invoked for a medium-IL process (UIPI). The - // hook still runs for normal windows (and swallows the buttons there); - // setting the same state from both sources is idempotent. - USHORT bf = m.usButtonFlags; - if (bf & RI_MOUSE_BUTTON_4_DOWN) SetZoomButton(1, true); - if (bf & RI_MOUSE_BUTTON_4_UP) SetZoomButton(1, false); - if (bf & RI_MOUSE_BUTTON_5_DOWN) SetZoomButton(2, true); - if (bf & RI_MOUSE_BUTTON_5_UP) SetZoomButton(2, false); - } -``` - -- [ ] **Step 3: Set the button ids from config in `wWinMain`** - -Immediately after `Config cfg = LoadConfig(L"magnifier.ini");` add: - -```cpp - g_zoomInBtnId = cfg.zoomInButton; - g_zoomOutBtnId = cfg.zoomOutButton; -``` - -- [ ] **Step 4: Build the app, verify it compiles** - -Run: `build.bat` -Expected: compiles, `Wind.exe` produced, no errors. - -- [ ] **Step 5: Commit** - -```bash -git add src/main.cpp -git commit -m "fix: read zoom side-buttons from Raw Input so they work over elevated windows" -``` - ---- - -## Task 3: Diagnostics lock counter + docs (verification aid) - -**Files:** -- Modify: `src/main.cpp` -- Modify: `docs/KNOWN-ISSUES.md` - -- [ ] **Step 1: Count locked ticks per diagnostics window in `src/main.cpp`** - -Add a `winLockedTicks` counter alongside the other window accumulators, increment it -when `tracker.locked()` each tick (inside the `if (diag)` block), emit it on the diag -line as `lockedTicks=/`, and reset it with the others. This lets a desktop -session confirm locked ticks stay ~0 (no false locks) and a game session confirm they -climb (lock engages). - -- [ ] **Step 2: Build app, verify compiles** - -Run: `build.bat` -Expected: compiles cleanly. - -- [ ] **Step 3: Update `docs/KNOWN-ISSUES.md` statuses** - -Mark Issues 2 and 3 as fixed-and-unit-tested (root cause confirmed: the tracker -single-tick heuristic), Issue 1 as implemented-pending-live-verification, with the exact -user test steps. - -- [ ] **Step 4: Commit** - -```bash -git add src/main.cpp docs/KNOWN-ISSUES.md docs/superpowers/plans/2026-05-25-interaction-fixes.md -git commit -m "diag: per-window locked-tick counter; update KNOWN-ISSUES status" -``` - ---- - -## Self-Review - -- **Spec coverage:** Issue 3 (flicker) -> Task 1 hysteresis. Issue 2 (mis-click / no - I-beam) -> resolved by Task 1 (center stays on the true cursor, so visual-only mapping - is correct again); confirmed by reasoning, needs user click-test. Issue 1 (elevated - windows) -> Task 2 Raw Input. Issue 4 (game FPS) -> out of scope (API ceiling, separate - decision). Covered. -- **No placeholders:** all code is concrete except Task 3 Step 1, described precisely - (counter + emit + reset) - implement inline. -- **Type consistency:** `kLockEngageTicks`, `locked()`, `SetZoomButton`, - `g_zoomInBtnId/g_zoomOutBtnId` used consistently across tasks. -- **Core feature preserved:** locked-mode raw integration still happens; only its - engage condition changed (hysteresis), verified by the locked-mode tests. - -## Verification & limits - -- Tasks 1 fully proven by deterministic unit tests (no live testing needed). -- Task 2 cannot be auto-verified (needs the user to test zoom over Task Manager). The - reasoning: Raw Input INPUTSINK delivery is not gated by the UIPI rule that suppresses a - medium-IL low-level hook over elevated windows. Risk: theoretical chance the LL-hook - swallow suppresses the cooked message but not the raw report (expected: it does not). -- Issue 2's real-world resolution depends on the same root cause as Issue 3; if the user - still sees mis-clicks after the tracker fix, re-open with new evidence. diff --git a/docs/superpowers/plans/2026-05-25-own-renderer.md b/docs/superpowers/plans/2026-05-25-own-renderer.md deleted file mode 100644 index a74197da..00000000 --- a/docs/superpowers/plans/2026-05-25-own-renderer.md +++ /dev/null @@ -1,627 +0,0 @@ -# Own Capture + GPU Renderer - Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build an own Desktop-Duplication + Direct3D 11 renderer that magnifies the desktop with true sub-pixel pan and a perfectly smooth centered cursor, selectable against the existing Magnification-API engine by config flag. - -**Architecture:** A pure, unit-tested `cursor_mapper` integrates raw-input deltas into a float lens center and reports the float source rect, the on-screen cursor position, and the desktop click point. A `render_engine` (Win32/D3D I/O) captures the desktop via DXGI Desktop Duplication, scales the float source rect to a fullscreen click-through overlay with bilinear sampling, stamps the real DDA cursor sprite at the centered position, hides the OS cursor, and syncs `SetCursorPos` for clicks. `main.cpp` picks the engine via `engine=render|mag`. - -**Tech Stack:** C++17, MSVC `cl.exe`, DXGI Desktop Duplication, Direct3D 11 (`d3d11.lib`, `dxgi.lib`, `dxguid.lib`, `d3dcompiler.lib`), Magnification API (`MagShowSystemCursor` for cursor hide), Raw Input, doctest. - -**Spec:** `docs/superpowers/specs/2026-05-25-own-renderer-design.md` - GitHub issue #4. - ---- - -## File structure - -- `src/transform.{h,cpp}` (modify) - add `OffsetF` + `ComputeOffsetF` (double, sub-pixel, clamped). -- `src/cursor_mapper.{h,cpp}` (create, **pure**) - float lens-center integration, centered cursor + edge-shift, click point. Unit-tested. -- `src/config.{h,cpp}` (modify) - add `engine`, `cursorSensitivity`, `cursorScaleWithZoom`, `bilinear` (filter). -- `src/render_engine.{h,cpp}` (create, **I/O**) - DXGI capture + D3D11 + overlay window + present + cursor draw + cursor hide. -- `src/main.cpp` (modify) - engine selection, wire `render_engine` + `cursor_mapper`, remove `cursor_overlay`. -- `src/cursor_overlay.{h,cpp}` (delete) - failed fake-cursor experiment. -- `tests/test_transform.cpp` (modify), `tests/test_cursor_mapper.cpp` (create), `tests/test_config.cpp` (modify). -- `build.bat` (modify) - link D3D libs for the app; keep test build pure. - -**Engine relationship:** `magnifier_engine.{h,cpp}` stays untouched and selectable. The render engine does NOT use `MagSetFullscreenTransform`; it only borrows `MagShowSystemCursor` (which hides the OS cursor without altering its shape, so DDA still reports the real sprite). - ---- - -## Task 0: Cursor-hide spike (de-risk the #1 unknown) - -**Files:** -- Create: `tools/cursorhide_spike.cpp` (throwaway; not part of the app or test build) - -Goal: confirm `MagShowSystemCursor(FALSE)` hides the OS cursor without changing its shape, and that we can restore it cleanly. Visual confirmation (one cursor vs two) is the human's job on return; this spike confirms the API mechanics + clean restore + that `GetCursorInfo`/DDA still see the real shape. - -- [ ] **Step 1: Write the spike** - -```cpp -// Build: cl /nologo /std:c++17 /EHsc /DUNICODE /D_UNICODE tools\cursorhide_spike.cpp ^ -// /Fe:cursorhide_spike.exe /link Magnification.lib user32.lib -#include -#include -#include -static void report(const char* tag) { - CURSORINFO ci{ sizeof(ci) }; - GetCursorInfo(&ci); - printf("%s: CURSOR_SHOWING=%d hCursor=%p\n", tag, - (ci.flags & CURSOR_SHOWING) ? 1 : 0, (void*)ci.hCursor); -} -int main() { - if (!MagInitialize()) { printf("MagInitialize FAILED err=%lu\n", GetLastError()); return 1; } - report("before-hide"); - BOOL h1 = MagShowSystemCursor(FALSE); - printf("MagShowSystemCursor(FALSE) ret=%d err=%lu\n", h1, GetLastError()); - Sleep(1500); // window to eyeball the screen - report("during-hide"); - // Identity-transform fallback: activate the runtime, then hide again. - BOOL t = MagSetFullscreenTransform(1.0f, 0, 0); - BOOL h2 = MagShowSystemCursor(FALSE); - printf("with identity transform: setT=%d showCursor=%d\n", t, h2); - Sleep(1500); - report("during-hide+identity"); - MagShowSystemCursor(TRUE); - MagSetFullscreenTransform(1.0f, 0, 0); - MagUninitialize(); - report("after-restore"); - printf("DONE\n"); - return 0; -} -``` - -- [ ] **Step 2: Build and run, capture output** - -Run: `cl /nologo /std:c++17 /EHsc /DUNICODE /D_UNICODE tools\cursorhide_spike.cpp /Fe:cursorhide_spike.exe /link Magnification.lib user32.lib && cursorhide_spike.exe` -Expected: `MagInitialize` succeeds; `MagShowSystemCursor(FALSE)` returns non-zero; `after-restore` shows the cursor back. Record whether `CURSOR_SHOWING` changes (informational - it may not, since the hide is magnification-internal). The Sleep windows let a human watching the screen confirm the cursor vanished and returned. - -- [ ] **Step 3: Decide & document** - -Record the result in `docs/KNOWN-ISSUES.md` under a new "Own renderer" section: which call hides the cursor (bare vs identity-transform), and that restore works. The render engine's `hideSystemCursor()` will use whichever path worked. If NEITHER hides it, fall back plan: draw our cursor and accept the small real cursor shows at the desktop point (flag for user) - do not block the rest of the build. - -- [ ] **Step 4: Clean up** - delete `cursorhide_spike.exe` and its `.obj` (keep the `.cpp` in `tools/` for reference). Commit: - -```bash -git add tools/cursorhide_spike.cpp docs/KNOWN-ISSUES.md -git commit -m "spike: confirm MagShowSystemCursor hides OS cursor for own renderer (#4)" -``` - ---- - -## Task 1: Float sub-pixel transform - -**Files:** -- Modify: `src/transform.h`, `src/transform.cpp` -- Test: `tests/test_transform.cpp` - -- [ ] **Step 1: Write the failing tests** (append to `tests/test_transform.cpp`) - -```cpp -TEST_CASE("ComputeOffsetF centers sub-pixel at 2x") { - wind::OffsetF o = wind::ComputeOffsetF(960.25, 540.0, 2.0, 1920, 1080); - CHECK(o.x == doctest::Approx(480.25)); - CHECK(o.y == doctest::Approx(270.0)); -} -TEST_CASE("ComputeOffsetF clamps to top-left edge (float)") { - wind::OffsetF o = wind::ComputeOffsetF(0.0, 0.0, 2.0, 1920, 1080); - CHECK(o.x == doctest::Approx(0.0)); - CHECK(o.y == doctest::Approx(0.0)); -} -TEST_CASE("ComputeOffsetF clamps to bottom-right edge (float)") { - wind::OffsetF o = wind::ComputeOffsetF(1e9, 1e9, 4.0, 1920, 1080); - CHECK(o.x == doctest::Approx(1920.0 - 1920.0/4.0)); // 1440 - CHECK(o.y == doctest::Approx(1080.0 - 1080.0/4.0)); // 810 -} -TEST_CASE("ComputeOffsetF at 1x is origin") { - wind::OffsetF o = wind::ComputeOffsetF(500.0, 500.0, 1.0, 1920, 1080); - CHECK(o.x == doctest::Approx(0.0)); - CHECK(o.y == doctest::Approx(0.0)); -} -``` - -- [ ] **Step 2: Run, verify fail** - -Run: `build.bat test` -Expected: FAIL - `OffsetF` / `ComputeOffsetF` undeclared. - -- [ ] **Step 3: Implement** - add to `src/transform.h` (after `Offset`): - -```cpp -struct OffsetF { double x; double y; }; -// Float (sub-pixel) source-region top-left, clamped on screen. level >= 1.0. -OffsetF ComputeOffsetF(double centerX, double centerY, double level, int screenW, int screenH); -``` - -Add to `src/transform.cpp`: - -```cpp -OffsetF ComputeOffsetF(double centerX, double centerY, double level, int screenW, int screenH) { - if (level < 1.0) level = 1.0; - double viewW = screenW / level; - double viewH = screenH / level; - double x = centerX - viewW / 2.0; - double y = centerY - viewH / 2.0; - double maxX = screenW - viewW; - double maxY = screenH - viewH; - if (maxX < 0) maxX = 0; - if (maxY < 0) maxY = 0; - if (x < 0) x = 0; else if (x > maxX) x = maxX; - if (y < 0) y = 0; else if (y > maxY) y = maxY; - return OffsetF{ x, y }; -} -``` - -- [ ] **Step 4: Run, verify pass** - -Run: `build.bat test` -Expected: PASS (all transform tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/transform.h src/transform.cpp tests/test_transform.cpp -git commit -m "feat: sub-pixel ComputeOffsetF for the own renderer (#4)" -``` - ---- - -## Task 2: cursor_mapper (pure, centered-mode math) - -**Files:** -- Create: `src/cursor_mapper.h`, `src/cursor_mapper.cpp` -- Test: `tests/test_cursor_mapper.cpp` -- Modify: `build.bat` (add `src\cursor_mapper.cpp` to the `:test` source list) - -Semantics: holds a float lens center in desktop pixels. `update` integrates raw-input deltas (scaled by `sensitivity/level`, so on-screen pan speed stays consistent across zoom), clamps the center to the desktop, and returns the float source top-left, the on-screen cursor position (centered, shifting toward an edge when the view is clamped), and the integer desktop click point (== lens center, the point under the drawn cursor). - -- [ ] **Step 1: Write the failing tests** (`tests/test_cursor_mapper.cpp`) - -```cpp -#include "doctest.h" -#include "../src/cursor_mapper.h" - -using wind::CursorMapper; - -TEST_CASE("centered: cursor sits at screen center, no raw movement") { - CursorMapper m(1920, 1080, 1.0); - m.reset(960, 540); - auto r = m.update(0, 0, 2.0); - CHECK(r.srcLeft == doctest::Approx(480.0)); // 960 - (1920/2)/2 - CHECK(r.srcTop == doctest::Approx(270.0)); - CHECK(r.cursorScreenX == doctest::Approx(960.0)); // dead center - CHECK(r.cursorScreenY == doctest::Approx(540.0)); - CHECK(r.clickDesktopX == 960); - CHECK(r.clickDesktopY == 540); -} - -TEST_CASE("centered: raw movement pans the world, cursor stays centered") { - CursorMapper m(1920, 1080, 1.0); // sensitivity 1.0 - m.reset(960, 540); - auto r = m.update(20, 0, 2.0); // 20 raw * 1.0 / 2.0 level = +10 desktop px - CHECK(m.centerX() == doctest::Approx(970.0)); - CHECK(r.srcLeft == doctest::Approx(490.0)); // 970 - 480 - CHECK(r.cursorScreenX == doctest::Approx(960.0)); // still centered - CHECK(r.clickDesktopX == 970); // click point tracks lens center -} - -TEST_CASE("edge: cursor shifts off-center when the view clamps at the desktop edge") { - CursorMapper m(1920, 1080, 1.0); - m.reset(10, 540); // near the left edge - auto r = m.update(0, 0, 4.0); // viewW = 480, srcLeft clamps to 0 - CHECK(r.srcLeft == doctest::Approx(0.0)); - CHECK(r.cursorScreenX == doctest::Approx(40.0)); // (10 - 0) * 4 - CHECK(r.clickDesktopX == 10); -} - -TEST_CASE("lens center clamps to the desktop bounds") { - CursorMapper m(1920, 1080, 1.0); - m.reset(0, 0); - m.update(-500, -500, 2.0); // pushes past the top-left - CHECK(m.centerX() == doctest::Approx(0.0)); - CHECK(m.centerY() == doctest::Approx(0.0)); - m.reset(1920, 1080); - m.update(500, 500, 2.0); // pushes past the bottom-right - CHECK(m.centerX() == doctest::Approx(1920.0)); - CHECK(m.centerY() == doctest::Approx(1080.0)); -} - -TEST_CASE("sensitivity scales lens movement; higher zoom moves the lens less per raw count") { - CursorMapper slow(1920, 1080, 0.5); - slow.reset(960, 540); - slow.update(40, 0, 2.0); // 40 * 0.5 / 2.0 = +10 - CHECK(slow.centerX() == doctest::Approx(970.0)); - - CursorMapper z(1920, 1080, 1.0); - z.reset(960, 540); - z.update(40, 0, 8.0); // 40 * 1.0 / 8.0 = +5 - CHECK(z.centerX() == doctest::Approx(965.0)); -} - -TEST_CASE("reset overrides the accumulated center") { - CursorMapper m(1920, 1080, 1.0); - m.reset(100, 100); - m.update(50, 50, 2.0); - m.reset(800, 400); - CHECK(m.centerX() == doctest::Approx(800.0)); - CHECK(m.centerY() == doctest::Approx(400.0)); -} -``` - -- [ ] **Step 2: Add to test build** - in `build.bat`, append `src\cursor_mapper.cpp` to the `:test` cl source list (the line listing `src\transform.cpp src\zoom_controller.cpp src\tracker.cpp src\config.cpp`). - -- [ ] **Step 3: Run, verify fail** - -Run: `build.bat test` -Expected: FAIL - `cursor_mapper.h` not found. - -- [ ] **Step 4: Implement** (`src/cursor_mapper.h`) - -```cpp -#pragma once -namespace wind { -// One frame's mapping result for the own renderer (centered cursor mode). -struct MapResult { - double srcLeft, srcTop; // float top-left of the source region (desktop px) - double cursorScreenX, cursorScreenY;// where to draw the cursor sprite (screen px) - int clickDesktopX, clickDesktopY;// where to SetCursorPos for click hit-testing -}; -// Pure centered-mode mapper. Integrates raw-input deltas into a float lens center -// (desktop px), so the world pans with sub-pixel precision while the cursor stays at -// screen center (shifting toward an edge only when the view clamps at the desktop edge). -class CursorMapper { -public: - CursorMapper(int screenW, int screenH, double sensitivity); - void reset(double centerX, double centerY); // pin lens center (e.g. on zoom-in) - MapResult update(int rawDx, int rawDy, double level); - double centerX() const { return cx_; } - double centerY() const { return cy_; } -private: - int sw_, sh_; - double sens_; - double cx_, cy_; -}; -} -``` - -`src/cursor_mapper.cpp`: - -```cpp -#include "cursor_mapper.h" -#include "transform.h" -namespace wind { -CursorMapper::CursorMapper(int screenW, int screenH, double sensitivity) - : sw_(screenW), sh_(screenH), sens_(sensitivity), - cx_(screenW / 2.0), cy_(screenH / 2.0) {} - -void CursorMapper::reset(double centerX, double centerY) { cx_ = centerX; cy_ = centerY; } - -MapResult CursorMapper::update(int rawDx, int rawDy, double level) { - if (level < 1.0) level = 1.0; - // Sub-pixel lens integration. /level keeps on-screen pan speed consistent across zoom. - cx_ += rawDx * sens_ / level; - cy_ += rawDy * sens_ / level; - if (cx_ < 0) cx_ = 0; else if (cx_ > sw_) cx_ = sw_; - if (cy_ < 0) cy_ = 0; else if (cy_ > sh_) cy_ = sh_; - - OffsetF o = ComputeOffsetF(cx_, cy_, level, sw_, sh_); - MapResult r; - r.srcLeft = o.x; r.srcTop = o.y; - r.cursorScreenX = (cx_ - o.x) * level; // center normally; edge-shift when clamped - r.cursorScreenY = (cy_ - o.y) * level; - r.clickDesktopX = (int)(cx_ + 0.5); - r.clickDesktopY = (int)(cy_ + 0.5); - return r; -} -} -``` - -- [ ] **Step 5: Run, verify pass** - -Run: `build.bat test` -Expected: PASS (all cursor_mapper tests + existing suite). - -- [ ] **Step 6: Commit** - -```bash -git add src/cursor_mapper.h src/cursor_mapper.cpp tests/test_cursor_mapper.cpp build.bat -git commit -m "feat: pure cursor_mapper for centered sub-pixel renderer (#4)" -``` - ---- - -## Task 3: Config extensions - -**Files:** -- Modify: `src/config.h`, `src/config.cpp` -- Test: `tests/test_config.cpp` - -- [ ] **Step 1: Write the failing tests** (append to `tests/test_config.cpp`) - -```cpp -TEST_CASE("parses engine selection and renderer knobs") { - auto c = wind::ParseConfig( - "engine=render\ncursorSensitivity=1.5\ncursorScaleWithZoom=0\nbilinear=1\n"); - CHECK(c.engine == "render"); - CHECK(c.cursorSensitivity == doctest::Approx(1.5)); - CHECK(c.cursorScaleWithZoom == 0); - CHECK(c.bilinear == 1); -} -TEST_CASE("engine defaults to render; renderer knobs have sane defaults") { - auto c = wind::ParseConfig(""); - CHECK(c.engine == "render"); - CHECK(c.cursorSensitivity == doctest::Approx(1.0)); - CHECK(c.cursorScaleWithZoom == 1); - CHECK(c.bilinear == 1); -} -``` - -- [ ] **Step 2: Run, verify fail** - -Run: `build.bat test` -Expected: FAIL - no `engine` member. - -- [ ] **Step 3: Implement** - add to `Config` in `src/config.h`: - -```cpp - std::string engine = "render"; // "render" = own GPU renderer, "mag" = Magnification API - double cursorSensitivity = 1.0; // lens pan speed per raw count (scaled by 1/level) - int cursorScaleWithZoom = 1; // 1 = draw cursor scaled by zoom, 0 = native size - int bilinear = 1; // 1 = bilinear sampling (smooth), 0 = point -``` - -Add parse cases in `src/config.cpp` `ParseConfig` (inside the try): - -```cpp - else if (key == "engine") c.engine = val; - else if (key == "cursorSensitivity") c.cursorSensitivity = std::stod(val); - else if (key == "cursorScaleWithZoom")c.cursorScaleWithZoom = std::stoi(val); - else if (key == "bilinear") c.bilinear = std::stoi(val); -``` - -Add the new keys to the default-file writer string in `LoadConfig` (so a fresh `magnifier.ini` documents them): - -```cpp - "engine=render\n" - "cursorSensitivity=1.0\ncursorScaleWithZoom=1\nbilinear=1\n" -``` - -- [ ] **Step 4: Run, verify pass** - -Run: `build.bat test` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/config.h src/config.cpp tests/test_config.cpp -git commit -m "feat: config flags for engine selection + renderer knobs (#4)" -``` - ---- - -## Task 4: render_engine - D3D11 device, overlay window, present - -**Files:** -- Create: `src/render_engine.h`, `src/render_engine.cpp` -- Modify: `build.bat` (app link line: add `d3d11.lib dxgi.lib dxguid.lib d3dcompiler.lib`) - -This task stands up the GPU + window and clears to a solid color so we can confirm the pipeline before adding capture. Not unit-tested (Win32/D3D I/O); verified by build + run. - -Interface (`src/render_engine.h`): - -```cpp -#pragma once -namespace wind { -struct RenderFrameParams { - double level; // current zoom (>= 1.0) - double srcLeft, srcTop; // float source top-left (from CursorMapper) - double cursorScreenX, cursorScreenY;// where to draw the cursor sprite - bool cursorScaleWithZoom; - bool bilinear; -}; -class RenderEngine { -public: - bool initialize(int screenW, int screenH); // create D3D device, overlay window, swapchain - bool renderFrame(const RenderFrameParams& p);// capture (if changed) + scale + cursor + present - void hideSystemCursor(bool hide); // MagShowSystemCursor wrapper (see Task 0 result) - void shutdown(); // restore cursor, destroy everything - bool ready() const { return ready_; } - // Debug: capture the last presented back-buffer to a 32bpp BGRA PNG (verification only). - bool dumpBackbufferPng(const wchar_t* path); -private: - bool ready_ = false; - // D3D/DXGI/DDA members added across tasks 4-8. -}; -} -``` - -- [ ] **Step 1: Implement device + overlay window + swapchain + clear/present.** Key sequence in `render_engine.cpp` (`initialize`): - 1. `D3D11CreateDevice` (hardware, `D3D11_CREATE_DEVICE_BGRA_SUPPORT`, feature level 11_0), keep `ID3D11Device`, `ID3D11DeviceContext`. - 2. Register + create the overlay window: `WS_POPUP`, ex-styles `WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW`, covering `(0,0)-(screenW,screenH)`. Call `SetLayeredWindowAttributes(hwnd, 0, 255, LWA_ALPHA)`. `ShowWindow(SW_SHOWNOACTIVATE)`. - 3. Create the flip swapchain via `IDXGIFactory2::CreateSwapChainForHwnd` with `DXGI_SWAP_EFFECT_FLIP_DISCARD`, `BufferCount=2`, format `DXGI_FORMAT_B8G8R8A8_UNORM`, `Scaling=DXGI_SCALING_STRETCH`. - 4. Create an RTV from back-buffer 0. - -- [ ] **Step 2: Implement `renderFrame` minimal** - `OMSetRenderTargets`, `ClearRenderTargetView` to opaque dark blue `{0,0,0.2,1}`, `Present(1,0)`. Set viewport to full screen. - -- [ ] **Step 3: Implement `dumpBackbufferPng`** - copy back-buffer to a `D3D11_USAGE_STAGING` texture, `Map`, write a PNG. Use WIC (`IWICImagingFactory`, `CreateBitmapFromMemory`, `IWICBitmapEncoder` with `GUID_ContainerFormatPng`). Link `windowscodecs.lib` (add to app link line). - -- [ ] **Step 4: Temporary main hook to smoke-test** - add a guarded `#ifdef WIND_RENDER_SMOKE` block in `render_engine.cpp` with its own `wWinMain` that inits, renders ~10 frames, dumps `render_smoke.png`, sleeps 800 ms, shuts down. Build it standalone: - -Run: `cl /nologo /std:c++17 /EHsc /O2 /DUNICODE /D_UNICODE /DWIND_RENDER_SMOKE src\render_engine.cpp /Fe:render_smoke.exe /link d3d11.lib dxgi.lib dxguid.lib d3dcompiler.lib windowscodecs.lib user32.lib Magnification.lib && render_smoke.exe` -Expected: exits 0, writes `render_smoke.png` showing a dark-blue fullscreen frame. Read the PNG to confirm. Then delete `render_smoke.exe`/`.obj`. - -- [ ] **Step 5: Update build.bat app link line** - add `d3d11.lib dxgi.lib dxguid.lib d3dcompiler.lib windowscodecs.lib` to the app `/link` line (Task 9 wires render_engine into the app; this makes the libs available). Run `build.bat check` to confirm all sources still compile. - -- [ ] **Step 6: Commit** - -```bash -git add src/render_engine.h src/render_engine.cpp build.bat -git commit -m "feat: render_engine D3D11 device + click-through overlay + present (#4)" -``` - ---- - -## Task 5: Desktop Duplication capture - -**Files:** -- Modify: `src/render_engine.cpp`, `src/render_engine.h` - -- [ ] **Step 1: Add duplication setup** in `initialize`: from the `ID3D11Device`, get `IDXGIDevice` -> `IDXGIAdapter` -> `EnumOutputs(0)` -> `IDXGIOutput1::DuplicateOutput(device)` -> store `IDXGIOutputDuplication`. Store the output desktop dimensions. - -- [ ] **Step 2: Add `captureFrame()`** (private): `AcquireNextFrame(timeoutMs, &frameInfo, &resource)`. On success, `QueryInterface` the resource to `ID3D11Texture2D` (the desktop image, no cursor), keep it for this frame. Save `frameInfo.PointerPosition` and, when `frameInfo.PointerShapeBufferSize > 0`, call `GetFramePointerShape` and cache the shape bytes + `DXGI_OUTDUPL_POINTER_SHAPE_INFO` (decoded in Task 7). Always pair with `ReleaseFrame()` after using the texture. - -- [ ] **Step 3: Handle `DXGI_ERROR_ACCESS_LOST` / `DXGI_ERROR_WAIT_TIMEOUT`** - on `ACCESS_LOST` (or `_INVALID_CALL`), release the duplication and recreate it (retry a few times with a short backoff). On `WAIT_TIMEOUT`, keep the previous desktop texture (nothing changed) and proceed (so we still re-render on pan/zoom). Document: secure desktop (UAC) and resolution changes trigger `ACCESS_LOST`. - -- [ ] **Step 4: Dump the captured desktop to PNG** - extend the smoke block to copy the captured desktop texture (not the clear) into the staging texture and dump `capture_smoke.png`. - -Run: `cl ... /DWIND_RENDER_SMOKE ... && render_smoke.exe` -Expected: `capture_smoke.png` shows the actual desktop. Read it to confirm capture works. Delete the exe/obj. - -- [ ] **Step 5: Commit** - -```bash -git add src/render_engine.cpp src/render_engine.h -git commit -m "feat: DXGI Desktop Duplication capture with ACCESS_LOST recovery (#4)" -``` - ---- - -## Task 6: Magnify shader (scale float source rect to fullscreen) - -**Files:** -- Modify: `src/render_engine.cpp`, `src/render_engine.h` - -- [ ] **Step 1: Add an embedded HLSL shader** compiled at runtime with `D3DCompile`. A full-screen triangle vertex shader plus a pixel shader sampling the desktop SRV. Pass the source rect as normalized UV bounds via a constant buffer: `uvMin = (srcLeft/W, srcTop/H)`, `uvMax = ((srcLeft + W/level)/W, (srcTop + H/level)/H)`; interpolate `uv = lerp(uvMin, uvMax, screenUV)`. Sampler: `D3D11_FILTER_MIN_MAG_MIP_LINEAR` when `bilinear` else `..._POINT`, `CLAMP` address mode. - -- [ ] **Step 2: Create the SRV from the captured desktop texture each frame** (or reuse if the texture object is stable). Bind SRV + sampler + constant buffer; `Draw(3,0)` the full-screen triangle; `Present(1,0)`. - -- [ ] **Step 3: Wire `RenderFrameParams` into the constant buffer** - compute `uvMin/uvMax` from `p.srcLeft/p.srcTop/p.level` and the captured texture size. - -- [ ] **Step 4: Smoke-verify magnification** - in the smoke block, hardcode `level=4`, `srcLeft/srcTop` to the screen center region, render, dump `magnify_smoke.png`. - -Run: `cl ... /DWIND_RENDER_SMOKE ... && render_smoke.exe` -Expected: `magnify_smoke.png` shows a 4x-zoomed crop of the desktop center, smooth (bilinear). Read it to confirm. Delete exe/obj. - -- [ ] **Step 5: Commit** - -```bash -git add src/render_engine.cpp src/render_engine.h -git commit -m "feat: D3D11 magnify shader, sub-pixel float source rect (#4)" -``` - ---- - -## Task 7: Cursor sprite decode + draw - -**Files:** -- Modify: `src/render_engine.cpp`, `src/render_engine.h` - -- [ ] **Step 1: Decode the DDA cursor shape** into a 32bpp BGRA texture. Handle the three `DXGI_OUTDUPL_POINTER_SHAPE_INFO.Type` cases: - - `DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR` - copy BGRA rows directly. - - `DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR` - BGRA where the mask bit selects copy vs XOR-with-screen; for v1 treat masked pixels as opaque copy (documented simplification; revisit if cursors look wrong). - - `DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME` - top half AND mask, bottom half XOR mask, height is 2x width; produce black/white/transparent BGRA. Re-upload only when the shape changes (cache by buffer + size). - -- [ ] **Step 2: Draw the cursor quad** - a second draw after the magnify pass: a textured quad at `(p.cursorScreenX, p.cursorScreenY)` with size = sprite size × (`p.cursorScaleWithZoom ? p.level : 1`), alpha-blended (`D3D11_BLEND_SRC_ALPHA`/`INV_SRC_ALPHA`). Position uses the cursor hotspot offset from `PointerPosition`. Quad vertices in clip space computed from screen pixels. - -- [ ] **Step 3: Smoke-verify the cursor** - in the smoke block, set the cursor screen pos to center, render, dump `cursor_smoke.png`. - -Run: `cl ... /DWIND_RENDER_SMOKE ... && render_smoke.exe` -Expected: `cursor_smoke.png` shows the magnified desktop with the arrow drawn at center. Read to confirm. Delete exe/obj. - -- [ ] **Step 4: Commit** - -```bash -git add src/render_engine.cpp src/render_engine.h -git commit -m "feat: decode + draw the real DDA cursor sprite (sub-pixel, centered) (#4)" -``` - ---- - -## Task 8: Cursor hide + OS-cursor sync + safe restore - -**Files:** -- Modify: `src/render_engine.cpp`, `src/render_engine.h` - -- [ ] **Step 1: Implement `hideSystemCursor(bool)`** using the path proven in Task 0 (`MagInitialize` once in `initialize`; `MagShowSystemCursor(FALSE/TRUE)`; identity transform first if Task 0 showed it was needed). Track state so we never double-hide. - -- [ ] **Step 2: Safe-restore net** - in `shutdown`, always `MagShowSystemCursor(TRUE)` + `MagUninitialize`. Additionally register `SetConsoleCtrlHandler` is N/A (GUI); instead install a `SetUnhandledExceptionFilter` that calls `MagShowSystemCursor(TRUE)` and, as a belt-and-braces fallback, `SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, SPIF_SENDCHANGE)` to force the OS to reload visible cursors. Also handle `WM_ENDSESSION`/`WM_CLOSE`. - -- [ ] **Step 3: OS-cursor sync for clicks** - `renderFrame` calls `SetCursorPos(p.clickDesktopX, p.clickDesktopY)` so the (hidden) OS cursor sits under the drawn cursor; clicks pass through the transparent overlay to the app there. Guard against feedback: we drive the lens from raw input, not `GetCursorPos`, so `SetCursorPos` does not perturb tracking. (Add `clickDesktopX/Y` to `RenderFrameParams`.) - -- [ ] **Step 4: Manual verification note** - document in `docs/KNOWN-ISSUES.md`: the single-vs-double-cursor check requires a human; record the expected behavior and the swap point if it shows two cursors. - -- [ ] **Step 5: Commit** - -```bash -git add src/render_engine.cpp src/render_engine.h docs/KNOWN-ISSUES.md -git commit -m "feat: hide OS cursor + sync SetCursorPos for clicks, safe restore (#4)" -``` - ---- - -## Task 9: main.cpp integration + engine selection - -**Files:** -- Modify: `src/main.cpp` -- Delete: `src/cursor_overlay.h`, `src/cursor_overlay.cpp` - -- [ ] **Step 1: Remove cursor_overlay** - delete `src/cursor_overlay.{h,cpp}` and its `#include`/usage in `main.cpp`. - -- [ ] **Step 2: Add engine branch** - after loading config, if `cfg.engine == "render"`, run the render-engine loop; else keep the existing Magnification-API loop. Extract shared setup (raw input, tray, zoom, single-instance) so both branches reuse it. - -- [ ] **Step 3: Render-engine loop** - construct `RenderEngine` (init with `sw,sh`) and `CursorMapper(sw, sh, cfg.cursorSensitivity)`. On each paced tick: - - `zoom.tick(dt)`, get `lvl`. - - On the 1x->zoom transition (`lvl > 1 && prevLvl <= 1`), `POINT p; GetCursorPos(&p); mapper.reset(p.x, p.y); engine.hideSystemCursor(true);`. - - On zoom->1x (`lvl <= 1 && prevLvl > 1`), `engine.hideSystemCursor(false)` and skip rendering (let the normal desktop show; present nothing or hide the overlay window). - - While zoomed: `int dx,dy; g_input.drainRaw(dx,dy); auto r = mapper.update(dx, dy, lvl);` build `RenderFrameParams` (incl. `cursorScaleWithZoom`, `bilinear`, `clickDesktopX/Y`) and `engine.renderFrame(params)`. - - Recenter hotkey: `mapper.reset(centerOfCurrentView)` (or to `GetCursorPos`). - -- [ ] **Step 4: Clean shutdown** - on exit, `engine.shutdown()` (restores cursor), `g_input.stop()`, tray remove. Never leave the overlay up or the cursor hidden. - -- [ ] **Step 5: Build the full app** - -Run: `build.bat` -Expected: `Wind.exe` builds with no errors. - -- [ ] **Step 6: Compile-check + tests still green** - -Run: `build.bat check && build.bat test` -Expected: compile OK; all unit tests PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/main.cpp -git rm src/cursor_overlay.h src/cursor_overlay.cpp -git commit -m "feat: wire render engine into main, engine=render|mag selection; drop cursor_overlay (#4)" -``` - ---- - -## Task 10: Integration verification + docs - -**Files:** -- Modify: `docs/KNOWN-ISSUES.md`, `docs/VERIFICATION.md`, `README` (if present), `CLAUDE.md` (architecture note) - -- [ ] **Step 1: Live smoke (autonomous-safe)** - run `Wind.exe`, then from a script: hold zoom (simulate by temporarily setting a config default level or a debug key), capture the screen region, and dump a PNG via the engine's `dumpBackbufferPng` to confirm the live overlay shows a magnified, smooth image. Confirm clean exit leaves the cursor visible (`GetCursorInfo` shows `CURSOR_SHOWING`). If the desktop session is locked, DDA returns `ACCESS_LOST` - note that visual verification needs an unlocked session. - -- [ ] **Step 2: Record results** - update `docs/KNOWN-ISSUES.md` "Own renderer" section with what was verified (build, tests, capture PNG, magnify PNG, cursor PNG, clean cursor restore) and the one human-only check (single cursor, click alignment, feel vs Magnify). Add the engine flag + new config keys to `docs/VERIFICATION.md`. - -- [ ] **Step 3: Update `CLAUDE.md`** - add the render engine to the Architecture section (DDA + D3D11 path, `engine=render|mag`, `cursor_mapper` pure unit). - -- [ ] **Step 4: Commit + finish branch** - -```bash -git add -A -git commit -m "docs: own renderer verification notes + architecture (#4)" -``` - -Then use **superpowers:finishing-a-development-branch**: verify tests, push `feat/own-renderer`, open a PR referencing #4. - ---- - -## Self-review notes - -- **Spec coverage:** capture (T5), D3D scale/sub-pixel pan (T6), centered cursor from DDA sprite (T7), cursor hide + click sync (T8), engine flag + kept Mag engine (T3/T9), pure cursor_mapper tested (T2), float transform (T1), single-monitor/SDR/DRM-black scope (documented T10), cursor-hide risk spiked first (T0). Covered. -- **Type consistency:** `OffsetF{x,y}`, `MapResult{srcLeft,srcTop,cursorScreenX/Y,clickDesktopX/Y}`, `RenderFrameParams{level,srcLeft,srcTop,cursorScreenX/Y,cursorScaleWithZoom,bilinear,clickDesktopX/Y}`, `Config.engine/cursorSensitivity/cursorScaleWithZoom/bilinear`, `RenderEngine::{initialize,renderFrame,hideSystemCursor,shutdown,dumpBackbufferPng}` - consistent across tasks. -- **Verification realism:** pure logic is fully TDD; D3D/DDA verified by build + PNG dumps I can read; single-cursor/feel is the only human-gated check, flagged explicitly. diff --git a/docs/superpowers/plans/2026-05-26-auto-cursor-sensitivity.md b/docs/superpowers/plans/2026-05-26-auto-cursor-sensitivity.md deleted file mode 100644 index 40a79c7f..00000000 --- a/docs/superpowers/plans/2026-05-26-auto-cursor-sensitivity.md +++ /dev/null @@ -1,674 +0,0 @@ -# Auto-Match Cursor Sensitivity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the magnifier pan at the same speed as the user's real Windows cursor (acceleration included) by reading the OS cursor's own per-tick motion while zoomed, falling back to raw mickeys only when a game locks the cursor. - -**Architecture:** A new pure `LockDetector` (hysteresis) decides free-vs-locked from per-tick signals. `CursorMapper` integrates a resolved pixel delta (the sensitivity multiply leaves the pure mapper). `main.cpp` samples `GetCursorPos`/`GetClipCursor` each zoomed tick, computes the OS-cursor delta from where it last placed the cursor (free) or scales raw mickeys (locked), and feeds the chosen delta to the mapper. Both regimes integrate a delta into the same accumulator, so a regime switch never snaps position. - -**Tech Stack:** C++17, MSVC, Win32 (`GetCursorPos`/`SetCursorPos`/`GetClipCursor`), doctest. Spec: `docs/superpowers/specs/2026-05-26-auto-cursor-sensitivity-design.md`. Branch `feat/auto-sensitivity`, issue #38. - -**Key commands:** -- Build + run unit tests: `build.bat test` (exit 0 = pass). -- App build: `build.bat` (emits `Wind.exe`; exit 0 = success). - -**Conventions:** No em-dashes. Commit trailer on every commit: -``` -Co-Authored-By: Claude Opus 4.7 -``` - ---- - -## File Structure - -- **New** `src/lock_detector.{h,cpp}` - pure free/locked hysteresis state machine. -- **New** `tests/test_lock_detector.cpp` - its unit tests. -- **Modify** `src/cursor_mapper.{h,cpp}` - integrate a resolved pixel delta; drop the `sensitivity` ctor param. -- **Modify** `tests/test_cursor_mapper.cpp` - update for the new ctor/delta meaning. -- **Modify** `src/main.cpp` - oracle wiring (the feature). -- **Modify** `src/config.{h,cpp}` - repurpose the `cursorSensitivity` comments. -- **Modify** `build.bat` - add `src\lock_detector.cpp` to the test build. -- **Modify** `CLAUDE.md` - document the oracle + lock-fallback model. - ---- - -## Task 1: `CursorMapper` integrates a pixel delta (drop `sensitivity`) - -**Files:** Modify `src/cursor_mapper.h`, `src/cursor_mapper.cpp`, `tests/test_cursor_mapper.cpp`, and the 3 ctor call sites in `src/main.cpp` (so the app keeps compiling). - -- [ ] **Step 1: Update the failing tests in `tests/test_cursor_mapper.cpp`** - -Replace the whole file with (every `CursorMapper(...)` drops the sensitivity arg; the sensitivity test becomes a delta-integration test): -```cpp -#include "doctest.h" -#include "../src/cursor_mapper.h" - -using wind::CursorMapper; - -TEST_CASE("centered: cursor sits at screen center, no movement") { - CursorMapper m(1920, 1080); - m.reset(960, 540); - auto r = m.update(0, 0, 2.0); - CHECK(r.srcLeft == doctest::Approx(480.0)); // 960 - (1920/2)/2 - CHECK(r.srcTop == doctest::Approx(270.0)); - CHECK(r.cursorScreenX == doctest::Approx(960.0)); // dead center - CHECK(r.cursorScreenY == doctest::Approx(540.0)); - CHECK(r.clickDesktopX == 960); - CHECK(r.clickDesktopY == 540); -} - -TEST_CASE("a pixel delta pans the world at desktop speed, cursor stays centered") { - CursorMapper m(1920, 1080); - m.reset(960, 540); - auto r = m.update(20, 0, 2.0); // +20 desktop px (zoom-independent) - CHECK(m.centerX() == doctest::Approx(980.0)); - CHECK(r.srcLeft == doctest::Approx(500.0)); // 980 - 480 - CHECK(r.cursorScreenX == doctest::Approx(960.0)); // still centered - CHECK(r.clickDesktopX == 980); // click point tracks lens center -} - -TEST_CASE("edge: cursor shifts off-center when the view clamps at the desktop edge") { - CursorMapper m(1920, 1080); - m.reset(10, 540); // near the left edge - auto r = m.update(0, 0, 4.0); // viewW = 480, srcLeft clamps to 0 - CHECK(r.srcLeft == doctest::Approx(0.0)); - CHECK(r.cursorScreenX == doctest::Approx(40.0)); // (10 - 0) * 4 - CHECK(r.clickDesktopX == 10); -} - -TEST_CASE("lens center clamps to the desktop bounds") { - CursorMapper m(1920, 1080); - m.reset(0, 0); - m.update(-500, -500, 2.0); // pushes past the top-left - CHECK(m.centerX() == doctest::Approx(0.0)); - CHECK(m.centerY() == doctest::Approx(0.0)); - m.reset(1920, 1080); - m.update(500, 500, 2.0); // pushes past the bottom-right - CHECK(m.centerX() == doctest::Approx(1920.0)); - CHECK(m.centerY() == doctest::Approx(1080.0)); -} - -TEST_CASE("update integrates the pixel delta directly; zoom level does NOT change desktop speed") { - CursorMapper m(1920, 1080); - m.reset(960, 540); - m.update(40, 0, 2.0); // delta +40 -> center 1000 (no sensitivity scaling) - CHECK(m.centerX() == doctest::Approx(1000.0)); - // Same delta at a higher zoom moves the lens the SAME desktop amount (world just scrolls - // faster on screen) - zoom-independent. - CursorMapper a(1920, 1080); a.reset(960, 540); a.update(40, 0, 2.0); - CursorMapper b(1920, 1080); b.reset(960, 540); b.update(40, 0, 8.0); - CHECK(a.centerX() == doctest::Approx(1000.0)); - CHECK(b.centerX() == doctest::Approx(1000.0)); -} - -TEST_CASE("smoothing eases the rendered center toward the target (light inertia)") { - CursorMapper m(1920, 1080, 0.5); // alpha = 0.5 - m.reset(960, 540); - m.update(40, 0, 2.0); // target 1000; rendered = 960 + (1000-960)*0.5 = 980 - CHECK(m.centerX() == doctest::Approx(980.0)); - m.update(0, 0, 2.0); // target still 1000; rendered = 980 + (1000-980)*0.5 = 990 - CHECK(m.centerX() == doctest::Approx(990.0)); -} - -TEST_CASE("smoothing 0 snaps instantly (no inertia)") { - CursorMapper m(1920, 1080, 0.0); - m.reset(960, 540); - m.update(40, 0, 2.0); - CHECK(m.centerX() == doctest::Approx(1000.0)); // straight to target -} - -TEST_CASE("reset overrides the accumulated center") { - CursorMapper m(1920, 1080); - m.reset(100, 100); - m.update(50, 50, 2.0); - m.reset(800, 400); - CHECK(m.centerX() == doctest::Approx(800.0)); - CHECK(m.centerY() == doctest::Approx(400.0)); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `build.bat test` -Expected: compile error - `CursorMapper(1920, 1080)` and `(1920,1080,0.5)` do not match the current `CursorMapper(int, int, double sensitivity, double smoothing=0.0)` ctor (the 2-arg form is missing; the 3-arg form binds 0.5 to `sensitivity`, but the delta-integration assertions like center 1000 from `update(40,...)` would fail since the current code multiplies by sensitivity). Fail confirms the change is needed. - -- [ ] **Step 3: Change the `CursorMapper` header** - -In `src/cursor_mapper.h`, replace: -```cpp - CursorMapper(int screenW, int screenH, double sensitivity, double smoothing = 0.0); - void reset(double centerX, double centerY); // pin both target + rendered center - MapResult update(int rawDx, int rawDy, double level); -``` -with: -```cpp - CursorMapper(int screenW, int screenH, double smoothing = 0.0); - void reset(double centerX, double centerY); // pin both target + rendered center - // dx/dy: the pixel delta to apply to the lens center this tick (already resolved by the - // caller - the OS cursor's own motion when free, or scaled raw input when a game locks it). - MapResult update(int dx, int dy, double level); -``` -and remove the `double sens_;` member line (leaving `double alpha_;` and the rest): -```cpp - int sw_, sh_; - double sens_; - double alpha_; // per-frame easing factor (1 - smoothing), clamped -``` -becomes: -```cpp - int sw_, sh_; - double alpha_; // per-frame easing factor (1 - smoothing), clamped -``` -Also update the class doc comment line `// Integrates raw-input deltas into a float lens center` to `// Integrates per-tick pixel deltas into a float lens center`. - -- [ ] **Step 4: Change `CursorMapper` implementation** - -In `src/cursor_mapper.cpp`, replace the constructor: -```cpp -CursorMapper::CursorMapper(int screenW, int screenH, double sensitivity, double smoothing) - : sw_(screenW), sh_(screenH), sens_(sensitivity), - cx_(screenW / 2.0), cy_(screenH / 2.0), tx_(screenW / 2.0), ty_(screenH / 2.0) { - alpha_ = 1.0 - smoothing; - if (alpha_ > 1.0) alpha_ = 1.0; - if (alpha_ < 0.05) alpha_ = 0.05; // never fully stall (keep responsiveness) -} -``` -with: -```cpp -CursorMapper::CursorMapper(int screenW, int screenH, double smoothing) - : sw_(screenW), sh_(screenH), - cx_(screenW / 2.0), cy_(screenH / 2.0), tx_(screenW / 2.0), ty_(screenH / 2.0) { - alpha_ = 1.0 - smoothing; - if (alpha_ > 1.0) alpha_ = 1.0; - if (alpha_ < 0.05) alpha_ = 0.05; // never fully stall (keep responsiveness) -} -``` -Then in `update`, replace the signature and the integration lines: -```cpp -MapResult CursorMapper::update(int rawDx, int rawDy, double level) { - if (level < 1.0) level = 1.0; - // Target moves at *desktop* speed (not divided by zoom): the focus reaches things at the - // same hand-speed whether at 2x or 8x, matching Windows Magnifier. Tune with sensitivity. - tx_ += rawDx * sens_; - ty_ += rawDy * sens_; -``` -with: -```cpp -MapResult CursorMapper::update(int dx, int dy, double level) { - if (level < 1.0) level = 1.0; - // Apply the caller-resolved pixel delta at *desktop* speed (not divided by zoom): the focus - // reaches things at the same hand-speed whether at 2x or 8x, matching Windows Magnifier. - tx_ += dx; - ty_ += dy; -``` -(Leave the rest of `update` - the clamps, easing, and `MapResult` construction - unchanged.) - -- [ ] **Step 5: Fix the 3 `CursorMapper` ctor call sites in `main.cpp`** - -In `src/main.cpp`, replace each of these three lines (they currently pass `cursorSensitivity`): - -In the `TickState` constructor: -```cpp - mapper(m.w, m.h, c.cursorSensitivity, c.cursorSmoothing) {} -``` --> -```cpp - mapper(m.w, m.h, c.cursorSmoothing) {} -``` - -In `RunTick`'s config hot-reload block: -```cpp - t.mapper = CursorMapper(t.mon.w, t.mon.h, nc.cursorSensitivity, nc.cursorSmoothing); -``` --> -```cpp - t.mapper = CursorMapper(t.mon.w, t.mon.h, nc.cursorSmoothing); -``` - -In `RunTick`'s monitor-retarget block: -```cpp - t.mapper = CursorMapper(nt.w, nt.h, t.cfg.cursorSensitivity, t.cfg.cursorSmoothing); -``` --> -```cpp - t.mapper = CursorMapper(nt.w, nt.h, t.cfg.cursorSmoothing); -``` - -(After this task the app still behaves as before for the default config: `main` still passes raw `dx,dy` to `mapper.update`, which now integrates them 1:1 - identical to the old default `sensitivity=1.0`. The oracle + locked scaling arrive in Task 3.) - -- [ ] **Step 6: Run unit tests + app build** - -Run: `build.bat test` then `build.bat` -Expected: both exit 0. Tests pass with the new ctor/delta semantics; app compiles (all 3 ctor sites updated). - -- [ ] **Step 7: Commit** - -```bash -git add src/cursor_mapper.h src/cursor_mapper.cpp tests/test_cursor_mapper.cpp src/main.cpp -git commit -m "refactor: CursorMapper integrates a resolved pixel delta (drop sensitivity param) (#38) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 2: `LockDetector` pure unit (TDD) - -**Files:** Create `src/lock_detector.h`, `src/lock_detector.cpp`, `tests/test_lock_detector.cpp`; modify `build.bat`. - -- [ ] **Step 1: Add `src/lock_detector.cpp` to the test build** - -In `build.bat`, in the `:test` section, find the source list line: -``` - src\transform.cpp src\zoom_controller.cpp src\config.cpp src\cursor_mapper.cpp ^ -``` -and change it to add `lock_detector.cpp`: -``` - src\transform.cpp src\zoom_controller.cpp src\config.cpp src\cursor_mapper.cpp src\lock_detector.cpp ^ -``` - -- [ ] **Step 2: Write the failing tests** - -Create `tests/test_lock_detector.cpp`: -```cpp -#include "doctest.h" -#include "../src/lock_detector.h" - -using wind::LockDetector; - -// Mirrors the constants in lock_detector.cpp: kLockTicks=6, kFreeTicks=3, kRawActive=4, kCursorMoved=1. - -TEST_CASE("starts free") { - LockDetector d; - CHECK(!d.locked()); -} - -TEST_CASE("a confined clip rect locks immediately") { - LockDetector d; - CHECK(d.update(/*clipConfined=*/true, 0, 0)); - CHECK(d.locked()); -} - -TEST_CASE("raw active + cursor frozen for kLockTicks locks (hysteresis: not before)") { - LockDetector d; - for (int i = 0; i < 5; ++i) CHECK(!d.update(false, 10, 0)); // 5 < kLockTicks - CHECK(d.update(false, 10, 0)); // 6th -> locked - CHECK(d.locked()); -} - -TEST_CASE("a single frozen tick does not lock") { - LockDetector d; - d.update(false, 10, 0); - CHECK(!d.locked()); -} - -TEST_CASE("once locked, cursor tracking input for kFreeTicks unlocks (not before)") { - LockDetector d; - for (int i = 0; i < 6; ++i) d.update(false, 10, 0); // -> locked - REQUIRE(d.locked()); - CHECK(d.update(false, 10, 5)); // moving, streak 1 - CHECK(d.update(false, 10, 5)); // streak 2 -> still locked - CHECK(d.locked()); - CHECK(!d.update(false, 10, 5)); // streak 3 == kFreeTicks -> free - CHECK(!d.locked()); -} - -TEST_CASE("idle ticks hold the current state") { - LockDetector d; - for (int i = 0; i < 6; ++i) d.update(false, 10, 0); // locked - REQUIRE(d.locked()); - d.update(false, 0, 0); // idle: no raw, no cursor move - CHECK(d.locked()); // still locked -} - -TEST_CASE("slow desktop motion (cursor moves occasionally) never locks") { - LockDetector d; - // raw active every tick, but the cursor moves >=1px every other tick (accel sub-pixel - // accumulating) - the moving ticks reset the lock streak, so it never reaches kLockTicks. - for (int i = 0; i < 30; ++i) { - int curMag = (i % 2 == 0) ? 0 : 2; - d.update(false, 6, curMag); - CHECK(!d.locked()); - } -} - -TEST_CASE("reset returns to free") { - LockDetector d; - d.update(true, 0, 0); - REQUIRE(d.locked()); - d.reset(); - CHECK(!d.locked()); -} -``` - -- [ ] **Step 3: Run tests to verify they fail** - -Run: `build.bat test` -Expected: compile error - `lock_detector.h` does not exist yet / `wind::LockDetector` undefined. - -- [ ] **Step 4: Create `src/lock_detector.h`** - -```cpp -#pragma once -namespace wind { -// Decides whether the OS cursor is "locked" by a game (so the magnifier must pan from raw mouse -// input rather than the OS cursor's own motion). Pure, with hysteresis so a single contrary tick -// never flips the state - panning never flickers. Fed per-tick Win32 signals by main.cpp. -class LockDetector { -public: - // clipConfined: a smaller-than-virtual-desktop ClipCursor rect is active (direct lock signal). - // rawMag : |rawDx| + |rawDy| this tick (mouse motion at the HID level). - // cursorMag : |cursorDx| + |cursorDy| this tick (how far the OS cursor actually moved). - // Returns the (possibly updated) locked state. - bool update(bool clipConfined, int rawMag, int cursorMag); - bool locked() const { return locked_; } - void reset(); // back to free (call on zoom-in / recenter / monitor retarget) -private: - bool locked_ = false; - int lockStreak_ = 0; // consecutive ticks of (raw active, OS cursor frozen) - int freeStreak_ = 0; // consecutive ticks of (OS cursor moving with input) -}; -} -``` - -- [ ] **Step 5: Create `src/lock_detector.cpp`** - -```cpp -#include "lock_detector.h" -namespace wind { -namespace { -constexpr int kRawActive = 4; // raw magnitude that counts as deliberate mouse motion -constexpr int kCursorMoved = 1; // OS cursor moved at least this many px (it tracked input) -constexpr int kLockTicks = 6; // consecutive raw-active + cursor-frozen ticks -> lock -constexpr int kFreeTicks = 3; // consecutive cursor-moving ticks -> unlock -} - -void LockDetector::reset() { locked_ = false; lockStreak_ = 0; freeStreak_ = 0; } - -bool LockDetector::update(bool clipConfined, int rawMag, int cursorMag) { - // Direct, reliable signal: a confined clip rect means a game has clipped the cursor. - if (clipConfined) { locked_ = true; lockStreak_ = 0; freeStreak_ = 0; return locked_; } - - if (cursorMag >= kCursorMoved) { - // The OS cursor is tracking input -> evidence of free movement. - freeStreak_++; lockStreak_ = 0; - if (freeStreak_ >= kFreeTicks) locked_ = false; - } else if (rawMag >= kRawActive) { - // Mouse moving but OS cursor frozen -> evidence of a lock. - lockStreak_++; freeStreak_ = 0; - if (lockStreak_ >= kLockTicks) locked_ = true; - } else { - // Idle (no significant input, cursor still): neither streak grows; hold current state. - lockStreak_ = 0; freeStreak_ = 0; - } - return locked_; -} -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `build.bat test` -Expected: exit 0, all `LockDetector` cases pass (plus the existing suites). - -- [ ] **Step 7: Commit** - -```bash -git add src/lock_detector.h src/lock_detector.cpp tests/test_lock_detector.cpp build.bat -git commit -m "feat: LockDetector pure free/locked hysteresis unit (#38) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 3: Oracle wiring in `main.cpp` (activates the feature) - -**Files:** Modify `src/main.cpp`. - -- [ ] **Step 1: Include the detector and the math headers** - -In `src/main.cpp`, after the existing `#include "tray.h"` line (the last project include near the top), add: -```cpp -#include "lock_detector.h" -``` -And in the system-include block near the top (which has `#include ` etc.), add: -```cpp -#include -#include -``` - -- [ ] **Step 2: Add the oracle state to `TickState`** - -In `struct TickState`, add these two fields right after the `CursorMapper mapper;` line: -```cpp - LockDetector detector; // free vs game-locked cursor - POINT lastSetVirtual{}; // where we last SetCursorPos'd (virtual px); for the OS-cursor delta -``` - -- [ ] **Step 3: Rename the drained raw deltas in `RunTick`** - -In `RunTick`, replace: -```cpp - int dx, dy; g_input.drainRaw(dx, dy); -``` -with: -```cpp - int rawDx, rawDy; g_input.drainRaw(rawDx, rawDy); -``` - -- [ ] **Step 4: Initialize the oracle baseline on zoom-in and recenter** - -In `RunTick`'s zoom-in block, replace: -```cpp - POINT pt; GetCursorPos(&pt); - t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); // virtual -> local monitor coords - t.renderEngine.hideSystemCursor(true); - t.renderEngine.invalidateCapture(); // grab a live frame, not a stale cached one -``` -with: -```cpp - POINT pt; GetCursorPos(&pt); - t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); // virtual -> local monitor coords - t.lastSetVirtual = pt; // baseline for the OS-cursor delta (first delta = 0) - t.detector.reset(); // start free - t.renderEngine.hideSystemCursor(true); - t.renderEngine.invalidateCapture(); // grab a live frame, not a stale cached one -``` -And replace the recenter line: -```cpp - if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); } -``` -with: -```cpp - if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); t.lastSetVirtual = pt; } -``` - -- [ ] **Step 5: Resolve the pan delta (oracle when free, raw when locked)** - -In `RunTick`, replace this single line: -```cpp - MapResult r = t.mapper.update(dx, dy, lvl); -``` -with: -```cpp - // Resolve the pan delta. FREE: the OS cursor's own motion since we last placed it - Windows' - // pointer acceleration is already applied, so the magnifier matches the real cursor. LOCKED: - // a game has the cursor clipped/recentered, so pan from raw mickeys scaled by cursorSensitivity - // (acceleration doesn't apply to relative-mouse game input). - POINT cur; GetCursorPos(&cur); - int curDx = cur.x - t.lastSetVirtual.x; - int curDy = cur.y - t.lastSetVirtual.y; - RECT clip{}; GetClipCursor(&clip); - int vsx = GetSystemMetrics(SM_XVIRTUALSCREEN), vsy = GetSystemMetrics(SM_YVIRTUALSCREEN); - int vsw = GetSystemMetrics(SM_CXVIRTUALSCREEN), vsh = GetSystemMetrics(SM_CYVIRTUALSCREEN); - bool clipConfined = clip.left > vsx || clip.top > vsy || - clip.right < vsx + vsw || clip.bottom < vsy + vsh; - bool locked = t.detector.update(clipConfined, - std::abs(rawDx) + std::abs(rawDy), - std::abs(curDx) + std::abs(curDy)); - int dx, dy; - if (locked) { - dx = (int)std::lround(rawDx * t.cfg.cursorSensitivity); - dy = (int)std::lround(rawDy * t.cfg.cursorSensitivity); - } else { - dx = curDx; dy = curDy; - } - // Defensive: bound one tick's pan to the monitor span so a stray cursor jump (e.g. the OS - // cursor briefly escaping to another monitor) cannot teleport the lens. cx_ also clamps. - if (dx > t.mon.w) dx = t.mon.w; else if (dx < -t.mon.w) dx = -t.mon.w; - if (dy > t.mon.h) dy = t.mon.h; else if (dy < -t.mon.h) dy = -t.mon.h; - MapResult r = t.mapper.update(dx, dy, lvl); -``` - -- [ ] **Step 6: Record where we placed the cursor, for next tick's delta** - -In `RunTick`, the existing reveal line is: -```cpp - if (zoomIn) t.renderEngine.setVisible(true); -``` -Immediately AFTER `t.renderEngine.renderFrame(p);` (which is a few lines above that, and which does the `SetCursorPos(clickDesktop+origin)` internally), and BEFORE the `if (zoomIn) t.renderEngine.setVisible(true);` line, add: -```cpp - // renderFrame SetCursorPos'd the OS cursor to clickDesktop+origin; remember it so next tick's - // GetCursorPos delta measures only the user's hand motion since. - t.lastSetVirtual.x = r.clickDesktopX + t.mon.x; - t.lastSetVirtual.y = r.clickDesktopY + t.mon.y; -``` -So that region reads, in order: `... FillRenderParams(...); t.renderEngine.renderFrame(p); [the two lastSetVirtual lines]; if (zoomIn) t.renderEngine.setVisible(true);`. - -- [ ] **Step 7: App build + unit tests** - -Run: `build.bat` then `build.bat test` -Expected: both exit 0. (`std::abs`/`std::lround` from ``/``; `POINT`/`RECT`/`GetClipCursor`/`SM_*VIRTUALSCREEN` from ``, already included.) - -- [ ] **Step 8: Commit** - -```bash -git add src/main.cpp -git commit -m "feat: pan from the OS cursor's own motion (auto-match accel); raw only when locked (#38) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 4: Config + docs - -**Files:** Modify `src/config.h`, `src/config.cpp`, `CLAUDE.md`. - -- [ ] **Step 1: Repurpose the `cursorSensitivity` comment in `config.h`** - -In `src/config.h`, replace: -```cpp - double cursorSensitivity = 1.0; // lens pan speed per raw count -``` -with: -```cpp - // Raw-input pan scale used ONLY while a game has locked the cursor (relative-mouse mode). - // Free desktop panning auto-matches the OS cursor (acceleration included) and ignores this. - double cursorSensitivity = 1.0; -``` - -- [ ] **Step 2: Repurpose the default-ini comment in `config.cpp`** - -In `src/config.cpp`, in `LoadConfig`'s default-ini text, replace: -```cpp - "; cursorSensitivity: pan speed per raw count\n" - "cursorSensitivity=1.0\n" -``` -with: -```cpp - "; cursorSensitivity: raw-pan scale while a GAME locks the cursor; free desktop\n" - "; panning auto-matches the OS cursor (incl. acceleration) and ignores this\n" - "cursorSensitivity=1.0\n" -``` - -- [ ] **Step 3: Document the model in `CLAUDE.md`** - -In `CLAUDE.md`, in the `## IMPORTANT gotchas` section, add this bullet at the end of the list: -```markdown -- CURSOR SENSITIVITY auto-matches the real OS cursor: while zoomed (cursor hidden), each tick reads - the OS cursor's own movement since our last `SetCursorPos` (Windows' pointer acceleration already - applied) and pans by that - so panning equals the user's normal cursor without reimplementing - ballistics. `GetCursorPos` is usable as this "oracle" only because we read it BEFORE re-setting it - each tick. Raw mickeys are kept solely to (a) feed `LockDetector` (a game clipping/recentering the - cursor -> `GetClipCursor` confined, or raw-active-but-cursor-frozen with hysteresis) and (b) drive - panning while locked (scaled by `cursorSensitivity`). Both regimes integrate a DELTA into the same - accumulator, so a free/locked switch never snaps position - this is why it avoids the old Tracker - flicker (issue #3). Do not "simplify" back to a fixed sensitivity multiplier. -``` - -- [ ] **Step 4: Build (sanity)** - -Run: `build.bat` then `build.bat test` -Expected: both exit 0 (comment/doc-only changes). - -- [ ] **Step 5: Commit** - -```bash -git add src/config.h src/config.cpp CLAUDE.md -git commit -m "docs: repurpose cursorSensitivity (locked-only) + CLAUDE.md oracle model (#38) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 5: Final gate + push + PR - -**Files:** none (git/GitHub only). The manual single-monitor verification (the acceleration-match feel + game-lock fallback) is performed by the user before the PR opens. - -- [ ] **Step 1: Final build + test gate** - -Run: `build.bat` and `build.bat test` -Expected: both exit 0; all unit suites pass (incl. the new `LockDetector` cases and the updated `CursorMapper` cases). - -- [ ] **Step 2: Push the branch** - -```bash -git push -u origin feat/auto-sensitivity -``` - -- [ ] **Step 3: Open the PR (references issue #38)** - -```bash -gh pr create --base feat/own-renderer --head feat/auto-sensitivity \ - --title "Auto-match cursor sensitivity to the real OS cursor (#38)" \ - --body "Pan the lens by the OS cursor's own per-tick motion (Windows acceleration already applied) so the magnifier matches the user's real cursor, instead of a fixed cursorSensitivity multiplier. Closes #38. - -- New pure LockDetector (GetClipCursor + raw-active/cursor-frozen hysteresis); both free and locked regimes integrate a DELTA into the shared accumulator, so a regime switch never snaps position (avoids the old Tracker flicker, issue #3). -- CursorMapper integrates a resolved pixel delta (drops the sensitivity ctor param). -- main.cpp reads GetCursorPos BEFORE re-setting it each tick to measure the OS cursor's motion (the oracle); falls back to raw mickeys scaled by cursorSensitivity only while a game locks the cursor. -- cursorSensitivity repurposed: locked-mode raw scale only; free desktop panning ignores it. - -Root cause: reporter's pointer slider is neutral but mouse acceleration is ON, so a flat raw*1.0 mismatched the accel'd cursor. - -Verification: new/updated unit tests for LockDetector + CursorMapper; clean /W4 build + build.bat test. User-verified live: slow + fast desktop pans now match the real cursor; a cursor-locking game still pans via raw; clean zoom/recenter. Confirmed the live assumption that per-frame SetCursorPos doesn't perturb Windows' velocity-based acceleration. - -Spec: docs/superpowers/specs/2026-05-26-auto-cursor-sensitivity-design.md -Plan: docs/superpowers/plans/2026-05-26-auto-cursor-sensitivity.md - -🤖 Generated with [Claude Code](https://claude.com/claude-code)" -``` - -- [ ] **Step 4: Report the PR URL to the user.** - ---- - -## Self-Review (completed during planning) - -**Spec coverage:** -- Oracle per-tick flow (GetCursorPos delta, GetClipCursor, detector, resolve, SetCursorPos, lastSetVirtual) -> Task 3. ✓ -- `LockDetector` (clip signal + hysteresis heuristic, reset) -> Task 2. ✓ -- `CursorMapper` integrates resolved delta, drops `sensitivity` -> Task 1. ✓ -- `cursorSensitivity` repurposed (locked-only) -> Task 4 (comments) + Task 3 (used only in the locked branch). ✓ -- Init `lastSetVirtual` + `detector.reset()` on zoom-in/recenter -> Task 3 Step 4. ✓ -- Per-tick delta clamp -> Task 3 Step 5. ✓ -- Tests for both pure units -> Task 1 (mapper) + Task 2 (detector). ✓ -- `build.bat` test-build adds `lock_detector.cpp` -> Task 2 Step 1. ✓ -- CLAUDE.md note -> Task 4 Step 3. ✓ -- issue->branch->PR, gates -> Task 5. ✓ - -**Placeholder scan:** none. Every code step shows full code; the `LockDetector` constants are concrete (4/1/6/3); the tests use literal tick counts matching them. - -**Type consistency:** `CursorMapper(int,int,double smoothing=0.0)` and `update(int dx,int dy,double level)` consistent across Task 1 (def), the test file, and Task 1 Step 5 + Task 3 (call sites). `LockDetector::update(bool,int,int)` / `locked()` / `reset()` consistent across Task 2 def, its tests, and Task 3 usage. `TickState.lastSetVirtual` (POINT) and `TickState.detector` used consistently in Task 3. `cursorSensitivity` referenced only in the locked branch (Task 3 Step 5) + comments (Task 4). The monitor-retarget rebuild (Task 1 Step 5) and config-reload rebuild both drop the sensitivity arg, matching the new ctor. ✓ diff --git a/docs/superpowers/plans/2026-05-26-comptr-raii.md b/docs/superpowers/plans/2026-05-26-comptr-raii.md deleted file mode 100644 index 745180a9..00000000 --- a/docs/superpowers/plans/2026-05-26-comptr-raii.md +++ /dev/null @@ -1,555 +0,0 @@ -# ComPtr RAII Migration (PR-B) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Convert `RenderEngine::State`'s ~20 persistent COM members to `Microsoft::WRL::ComPtr` and delete the hand-maintained `shutdown()` release list, so COM cleanup is automatic. Behavior- and performance-identical; safety/hygiene only. - -**Architecture:** Apply three mechanical rules at every member touch-site - create -> `.ReleaseAndGetAddressOf()`, use-as-value -> `.Get()` / use-as-array-arg -> `.GetAddressOf()`, manual-release -> `.Reset()`; `->` calls stay as-is. `shutdown()` drops its `SafeRelease` block and lets the `State` destructor release everything (device declared first -> released last; windowed BLT swapchain makes post-`DestroyWindow` release safe). Local COM temporaries and `png_dump`/`com_util.h` are untouched. - -**Tech Stack:** C++17, MSVC, Direct3D 11 / DXGI, `` (header-only, no new link lib). Spec: `docs/superpowers/specs/2026-05-26-comptr-raii-design.md`. Branch `feat/comptr-raii` (stacked on `feat/structure-refactor`), issue #36. - -**Key commands:** -- App build: `build.bat` (emits `Wind.exe`; exit 0 = success). -- Build + run unit tests: `build.bat test` (exit 0 = pass; pure files only, unaffected here). - -**Conventions:** No em-dashes. Commit trailer on every commit: -``` -Co-Authored-By: Claude Opus 4.7 -``` - -**IMPORTANT - atomic compile:** A ComPtr member and all its use-sites must change together, so the conversion compiles **only as a whole**. Apply ALL of Task 1's steps, then build once at the end of Task 1. Do not build/commit between Task 1's steps (intermediate states will not compile). This is one commit by design (keeps the change bisectable as a single unit). - -**The three edit rules** (referenced throughout): -- **R1 (create):** a member's address passed to a `CreateX`/`DuplicateOutput*` call (`&s_->m`) -> `s_->m.ReleaseAndGetAddressOf()`. Used uniformly at every create site (always safe; releases any prior object). -- **R2 (use):** a member passed as an `ID3D11X*` value -> `.Get()`; a member passed as a `&m` array argument to `OMSetRenderTargets`/`*SetConstantBuffers`/`*SetShaderResources` -> `.GetAddressOf()`. -- **R3 (release):** `SafeRelease(s_->m)` -> `s_->m.Reset()`. -- Member calls via `->` and bool tests (`!s_->m`, `if (s_->m && ...)`) are unchanged (ComPtr provides `operator->` and explicit `operator bool`). - ---- - -## File Structure - -- **Modify:** `src/render_engine.cpp` only. -- **Unchanged:** `src/com_util.h`, `src/png_dump.{h,cpp}`, `src/main.cpp`, all pure units, `build.bat`. - -Functions with **no member-pointer changes** (only `->` calls / locals, which are unchanged): `selectOutput`, `setVisible`, `renderFrame`, `dumpFrame`, `hideSystemCursor`, `debugInfo`, `debugHdr`, and the `WIND_RENDER_SMOKE` block. Do not edit these. - ---- - -## Task 1: Convert State COM members to ComPtr - -**Files:** Modify `src/render_engine.cpp`. (Apply all steps, then build once at Step 12.) - -- [ ] **Step 1: Add the WRL include and the `ComPtr` alias** - -In `src/render_engine.cpp`, after the line `#include ` (the last system include, ~line 14), add: -```cpp -#include -``` -Then, immediately after the line `namespace wind {` (~line 23), add: -```cpp -using Microsoft::WRL::ComPtr; -``` - -- [ ] **Step 2: Convert the device/context/swapchain/RTV member declarations** - -In `struct RenderEngine::State`, replace: -```cpp - ID3D11Device* device = nullptr; - ID3D11DeviceContext* ctx = nullptr; - IDXGISwapChain* swap = nullptr; // blt-model (layered window needs the redirection surface) - ID3D11RenderTargetView* rtv = nullptr; -``` -with: -```cpp - ComPtr device; - ComPtr ctx; - ComPtr swap; // blt-model (layered window needs the redirection surface) - ComPtr rtv; -``` - -- [ ] **Step 3: Convert the Desktop Duplication member declarations** - -Replace: -```cpp - IDXGIOutputDuplication* dupl = nullptr; - ID3D11Texture2D* desktopCopy = nullptr; // SRV-able copy of the captured desktop (no cursor) - ID3D11ShaderResourceView* desktopSRV = nullptr; -``` -with: -```cpp - ComPtr dupl; - ComPtr desktopCopy; // SRV-able copy of the captured desktop (no cursor) - ComPtr desktopSRV; -``` - -- [ ] **Step 4: Convert the magnify-pass member declarations** - -Replace: -```cpp - ID3D11VertexShader* vs = nullptr; - ID3D11PixelShader* ps = nullptr; - ID3D11Buffer* cb = nullptr; // uvMin/uvMax + motion-blur vector - ID3D11SamplerState* sampLinear = nullptr; - ID3D11SamplerState* sampPoint = nullptr; -``` -with: -```cpp - ComPtr vs; - ComPtr ps; - ComPtr cb; // uvMin/uvMax + motion-blur vector - ComPtr sampLinear; - ComPtr sampPoint; -``` - -- [ ] **Step 5: Convert the cursor-pass member declarations** - -Replace: -```cpp - ID3D11VertexShader* cvs = nullptr; - ID3D11PixelShader* cps = nullptr; - ID3D11Buffer* ccb = nullptr; // posClip/sizeClip for the cursor quad - ID3D11BlendState* blend = nullptr; // alpha blend for normal cursors - ID3D11BlendState* blendInvert = nullptr; // invert blend for I-beam-style cursors - ID3D11Texture2D* cursorTex = nullptr; - ID3D11ShaderResourceView* cursorSRV = nullptr; -``` -with: -```cpp - ComPtr cvs; - ComPtr cps; - ComPtr ccb; // posClip/sizeClip for the cursor quad - ComPtr blend; // alpha blend for normal cursors - ComPtr blendInvert; // invert blend for I-beam-style cursors - ComPtr cursorTex; - ComPtr cursorSRV; -``` - -- [ ] **Step 6: `recreateDupl` - release + create + pass-device sites** - -In `State::recreateDupl`: - -Replace ` SafeRelease(dupl);` (the first line of the function body) with: -```cpp - dupl.Reset(); -``` -Replace: -```cpp - hr = output5->DuplicateOutput1(device, 0, ARRAYSIZE(fmts), fmts, &dupl); -``` -with: -```cpp - hr = output5->DuplicateOutput1(device.Get(), 0, ARRAYSIZE(fmts), fmts, dupl.ReleaseAndGetAddressOf()); -``` -Replace: -```cpp - if (output1) { hr = output1->DuplicateOutput(device, &dupl); output1->Release(); } -``` -with: -```cpp - if (output1) { hr = output1->DuplicateOutput(device.Get(), dupl.ReleaseAndGetAddressOf()); output1->Release(); } -``` -(The `if (SUCCEEDED(hr) && dupl)` tests and `dupl->GetDesc(&dd)` are unchanged.) - -- [ ] **Step 7: `ensureDesktopCopy` - release + create sites** - -In `State::ensureDesktopCopy`, replace: -```cpp - SafeRelease(desktopSRV); - SafeRelease(desktopCopy); -``` -with: -```cpp - desktopSRV.Reset(); - desktopCopy.Reset(); -``` -Replace: -```cpp - if (FAILED(device->CreateTexture2D(&dc, nullptr, &desktopCopy))) { RLog("ensureDesktopCopy: tex fail fmt=%u", fmt); return false; } - if (FAILED(device->CreateShaderResourceView(desktopCopy, nullptr, &desktopSRV))) { RLog("ensureDesktopCopy: srv fail fmt=%u", fmt); return false; } -``` -with: -```cpp - if (FAILED(device->CreateTexture2D(&dc, nullptr, desktopCopy.ReleaseAndGetAddressOf()))) { RLog("ensureDesktopCopy: tex fail fmt=%u", fmt); return false; } - if (FAILED(device->CreateShaderResourceView(desktopCopy.Get(), nullptr, desktopSRV.ReleaseAndGetAddressOf()))) { RLog("ensureDesktopCopy: srv fail fmt=%u", fmt); return false; } -``` -(The `if (desktopCopy && copyFormat == fmt ...)` guard is unchanged.) - -- [ ] **Step 8: `capture` - the ACCESS_LOST release + CopyResource sites** - -In `State::capture`, replace: -```cpp - if (hr == DXGI_ERROR_ACCESS_LOST) { SafeRelease(res); SafeRelease(dupl); return gotThisCall || haveDesktop; } -``` -with: -```cpp - if (hr == DXGI_ERROR_ACCESS_LOST) { SafeRelease(res); dupl.Reset(); return gotThisCall || haveDesktop; } -``` -Replace: -```cpp - ctx->CopyResource(desktopCopy, tex); -``` -with: -```cpp - ctx->CopyResource(desktopCopy.Get(), tex); -``` -(`res` and `tex` are local raw pointers - keep their `SafeRelease`. `dupl->AcquireNextFrame`, `dupl->ReleaseFrame`, the `!dupl`/`desktopCopy` tests are unchanged.) - -- [ ] **Step 9: `initialize` - all create sites + the CreateSwapChain device arg** - -In `RenderEngine::initialize`: - -Replace: -```cpp - &s_->device, &got, &s_->ctx); -``` -with: -```cpp - s_->device.ReleaseAndGetAddressOf(), &got, s_->ctx.ReleaseAndGetAddressOf()); -``` -Replace: -```cpp - hr = factory->CreateSwapChain(s_->device, &scd, &s_->swap); -``` -with: -```cpp - hr = factory->CreateSwapChain(s_->device.Get(), &scd, s_->swap.ReleaseAndGetAddressOf()); -``` -Replace: -```cpp - hr = s_->device->CreateRenderTargetView(back, nullptr, &s_->rtv); -``` -with: -```cpp - hr = s_->device->CreateRenderTargetView(back, nullptr, s_->rtv.ReleaseAndGetAddressOf()); -``` -Replace: -```cpp - hr = s_->device->CreateVertexShader(vsb->GetBufferPointer(), vsb->GetBufferSize(), nullptr, &s_->vs); - HRESULT hr2 = s_->device->CreatePixelShader(psb->GetBufferPointer(), psb->GetBufferSize(), nullptr, &s_->ps); -``` -with: -```cpp - hr = s_->device->CreateVertexShader(vsb->GetBufferPointer(), vsb->GetBufferSize(), nullptr, s_->vs.ReleaseAndGetAddressOf()); - HRESULT hr2 = s_->device->CreatePixelShader(psb->GetBufferPointer(), psb->GetBufferSize(), nullptr, s_->ps.ReleaseAndGetAddressOf()); -``` -Replace: -```cpp - if (FAILED(s_->device->CreateBuffer(&cbd, nullptr, &s_->cb))) { RLog("initialize: CreateBuffer(magnify cb) failed"); return false; } -``` -with: -```cpp - if (FAILED(s_->device->CreateBuffer(&cbd, nullptr, s_->cb.ReleaseAndGetAddressOf()))) { RLog("initialize: CreateBuffer(magnify cb) failed"); return false; } -``` -Replace: -```cpp - HRESULT hr3 = s_->device->CreateVertexShader(cvsb->GetBufferPointer(), cvsb->GetBufferSize(), nullptr, &s_->cvs); - HRESULT hr4 = s_->device->CreatePixelShader(cpsb->GetBufferPointer(), cpsb->GetBufferSize(), nullptr, &s_->cps); -``` -with: -```cpp - HRESULT hr3 = s_->device->CreateVertexShader(cvsb->GetBufferPointer(), cvsb->GetBufferSize(), nullptr, s_->cvs.ReleaseAndGetAddressOf()); - HRESULT hr4 = s_->device->CreatePixelShader(cpsb->GetBufferPointer(), cpsb->GetBufferSize(), nullptr, s_->cps.ReleaseAndGetAddressOf()); -``` -Replace: -```cpp - if (FAILED(s_->device->CreateBuffer(&ccbd, nullptr, &s_->ccb))) { RLog("initialize: CreateBuffer(cursor cb) failed"); return false; } -``` -with: -```cpp - if (FAILED(s_->device->CreateBuffer(&ccbd, nullptr, s_->ccb.ReleaseAndGetAddressOf()))) { RLog("initialize: CreateBuffer(cursor cb) failed"); return false; } -``` -Replace: -```cpp - if (FAILED(s_->device->CreateBlendState(&bd, &s_->blend))) { RLog("initialize: CreateBlendState(alpha) failed"); return false; } -``` -with: -```cpp - if (FAILED(s_->device->CreateBlendState(&bd, s_->blend.ReleaseAndGetAddressOf()))) { RLog("initialize: CreateBlendState(alpha) failed"); return false; } -``` -Replace: -```cpp - if (FAILED(s_->device->CreateBlendState(&ib, &s_->blendInvert))) { RLog("initialize: CreateBlendState(invert) failed"); return false; } -``` -with: -```cpp - if (FAILED(s_->device->CreateBlendState(&ib, s_->blendInvert.ReleaseAndGetAddressOf()))) { RLog("initialize: CreateBlendState(invert) failed"); return false; } -``` -Replace: -```cpp - s_->device->CreateSamplerState(&samp, &s_->sampLinear); - samp.Filter = D3D11_FILTER_MIN_MAG_MIP_POINT; - s_->device->CreateSamplerState(&samp, &s_->sampPoint); -``` -with: -```cpp - s_->device->CreateSamplerState(&samp, s_->sampLinear.ReleaseAndGetAddressOf()); - samp.Filter = D3D11_FILTER_MIN_MAG_MIP_POINT; - s_->device->CreateSamplerState(&samp, s_->sampPoint.ReleaseAndGetAddressOf()); -``` -(The `s_->device->QueryInterface(__uuidof(IDXGIDevice1), (void**)&dxgiDev)` line keeps `dxgiDev` as a local raw pointer - unchanged. The `if (!s_->sampLinear || !s_->sampPoint)` test is unchanged.) - -- [ ] **Step 10: `recreateRtv`, `invalidateCapture`, `retarget` - release + create sites** - -In `State::recreateRtv`, replace: -```cpp - SafeRelease(rtv); -``` -with: -```cpp - rtv.Reset(); -``` -and replace: -```cpp - HRESULT hr = device->CreateRenderTargetView(back, nullptr, &rtv); -``` -with: -```cpp - HRESULT hr = device->CreateRenderTargetView(back, nullptr, rtv.ReleaseAndGetAddressOf()); -``` - -In `RenderEngine::invalidateCapture`, replace: -```cpp - SafeRelease(s_->dupl); -``` -with: -```cpp - s_->dupl.Reset(); -``` - -In `RenderEngine::retarget`, replace: -```cpp - SafeRelease(s_->rtv); // ResizeBuffers requires all back-buffer refs released -``` -with: -```cpp - s_->rtv.Reset(); // ResizeBuffers requires all back-buffer refs released -``` -and replace: -```cpp - SafeRelease(s_->dupl); - s_->haveDesktop = false; -``` -with: -```cpp - s_->dupl.Reset(); - s_->haveDesktop = false; -``` -(`s_->swap->ResizeBuffers`, `!s_->swap`, and the `recreateRtv()` calls are unchanged.) - -- [ ] **Step 11: `updateCursorTexture` - release + create sites** - -In `State::updateCursorTexture`, replace: -```cpp - SafeRelease(cursorSRV); - SafeRelease(cursorTex); -``` -with: -```cpp - cursorSRV.Reset(); - cursorTex.Reset(); -``` -and replace: -```cpp - if (FAILED(device->CreateTexture2D(&td, &srd, &cursorTex))) { cursorReady = false; return; } - if (FAILED(device->CreateShaderResourceView(cursorTex, nullptr, &cursorSRV))) { cursorReady = false; return; } -``` -with: -```cpp - if (FAILED(device->CreateTexture2D(&td, &srd, cursorTex.ReleaseAndGetAddressOf()))) { cursorReady = false; return; } - if (FAILED(device->CreateShaderResourceView(cursorTex.Get(), nullptr, cursorSRV.ReleaseAndGetAddressOf()))) { cursorReady = false; return; } -``` - -- [ ] **Step 12: `render` - the per-frame use sites (R2)** - -In `State::render`: - -Replace: -```cpp - ID3D11DeviceContext* c = ctx; -``` -with: -```cpp - ID3D11DeviceContext* c = ctx.Get(); -``` -Replace: -```cpp - c->OMSetRenderTargets(1, &rtv, nullptr); - const float clear[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; - c->ClearRenderTargetView(rtv, clear); -``` -with: -```cpp - c->OMSetRenderTargets(1, rtv.GetAddressOf(), nullptr); - const float clear[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; - c->ClearRenderTargetView(rtv.Get(), clear); -``` -Replace: -```cpp - c->UpdateSubresource(cb, 0, nullptr, &cbv, 0, 0); - c->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); - c->VSSetShader(vs, nullptr, 0); - c->VSSetConstantBuffers(0, 1, &cb); - c->PSSetShader(ps, nullptr, 0); - c->PSSetConstantBuffers(0, 1, &cb); // PS needs blurUV (motion blur) - c->PSSetShaderResources(0, 1, &desktopSRV); - ID3D11SamplerState* samp = p.bilinear ? sampLinear : sampPoint; - c->PSSetSamplers(0, 1, &samp); -``` -with: -```cpp - c->UpdateSubresource(cb.Get(), 0, nullptr, &cbv, 0, 0); - c->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); - c->VSSetShader(vs.Get(), nullptr, 0); - c->VSSetConstantBuffers(0, 1, cb.GetAddressOf()); - c->PSSetShader(ps.Get(), nullptr, 0); - c->PSSetConstantBuffers(0, 1, cb.GetAddressOf()); // PS needs blurUV (motion blur) - c->PSSetShaderResources(0, 1, desktopSRV.GetAddressOf()); - ID3D11SamplerState* samp = (p.bilinear ? sampLinear : sampPoint).Get(); - c->PSSetSamplers(0, 1, &samp); -``` -Replace: -```cpp - c->UpdateSubresource(ccb, 0, nullptr, ccbv, 0, 0); - c->OMSetBlendState(cursorInvert ? blendInvert : blend, nullptr, 0xFFFFFFFF); - c->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); - c->VSSetShader(cvs, nullptr, 0); - c->VSSetConstantBuffers(0, 1, &ccb); - c->PSSetShader(cps, nullptr, 0); - c->PSSetShaderResources(0, 1, &cursorSRV); - c->PSSetSamplers(0, 1, &sampLinear); -``` -with: -```cpp - c->UpdateSubresource(ccb.Get(), 0, nullptr, ccbv, 0, 0); - c->OMSetBlendState((cursorInvert ? blendInvert : blend).Get(), nullptr, 0xFFFFFFFF); - c->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); - c->VSSetShader(cvs.Get(), nullptr, 0); - c->VSSetConstantBuffers(0, 1, ccb.GetAddressOf()); - c->PSSetShader(cps.Get(), nullptr, 0); - c->PSSetShaderResources(0, 1, cursorSRV.GetAddressOf()); - c->PSSetSamplers(0, 1, sampLinear.GetAddressOf()); -``` - -- [ ] **Step 13: `dumpBackbufferPng` - pass device/ctx as raw (R2)** - -In `RenderEngine::dumpBackbufferPng`, replace: -```cpp - bool ok = SaveTextureToPng(s_->device, s_->ctx, back, path); -``` -with: -```cpp - bool ok = SaveTextureToPng(s_->device.Get(), s_->ctx.Get(), back, path); -``` -(`back` is a local raw pointer - its `GetBuffer` and `SafeRelease(back)` are unchanged.) - -- [ ] **Step 14: `shutdown` - delete the release list** - -In `RenderEngine::shutdown`, delete this entire block: -```cpp - SafeRelease(s_->cursorSRV); - SafeRelease(s_->cursorTex); - SafeRelease(s_->blendInvert); - SafeRelease(s_->blend); - SafeRelease(s_->ccb); - SafeRelease(s_->cps); - SafeRelease(s_->cvs); - SafeRelease(s_->sampPoint); - SafeRelease(s_->sampLinear); - SafeRelease(s_->cb); - SafeRelease(s_->ps); - SafeRelease(s_->vs); - SafeRelease(s_->desktopSRV); - SafeRelease(s_->dupl); - SafeRelease(s_->desktopCopy); - SafeRelease(s_->rtv); - SafeRelease(s_->swap); - SafeRelease(s_->ctx); - SafeRelease(s_->device); -``` -and replace it with a single comment: -```cpp - // COM objects are ComPtr members of State; they release automatically when State is destroyed - // (in ~RenderEngine, immediately after this returns). `device` is declared first so it releases - // last; the windowed BLT-model swapchain has no fullscreen/HWND-outlives-swapchain constraint, - // so releasing it after DestroyWindow below is safe. -``` -The surrounding `shutdown` code (the `magInited` cursor-restore block above, and the `if (s_->hwnd) { DestroyWindow(...); }` + `s_->ready = false;` below) stays exactly as is. - -- [ ] **Step 15: Build (the single compile gate for the whole conversion)** - -Run: `build.bat` -Expected: exit 0, `Wind.exe` emitted, no `/W4` warnings. If it fails to compile, the error points at a missed site - fix it per the three rules (a member passed where a `T*` is expected needs `.Get()`; where a `T**` is expected needs `.GetAddressOf()` for bind/`.ReleaseAndGetAddressOf()` for create; a leftover `SafeRelease(s_->member)` needs `.Reset()`). - -- [ ] **Step 16: Sanity-run the unit tests (should be unaffected)** - -Run: `build.bat test` -Expected: exit 0, 32 cases / 94 assertions pass (no pure logic changed). - -- [ ] **Step 17: Commit** - -```bash -git add src/render_engine.cpp -git commit -m "refactor: ComPtr RAII for render_engine State; drop manual shutdown release list (#36) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 2: Final gate + push + PR - -**Files:** none (git/GitHub only). The manual single-monitor verification (repeated-zoom re-creation paths + clean-shutdown cursor restore + `WIND_SELFTEST` PNG) is performed by the user before the PR is opened. - -- [ ] **Step 1: Final build + test gate** - -Run: `build.bat` and `build.bat test` -Expected: both exit 0; tests 32 / 94. - -- [ ] **Step 2: Confirm the release list is gone** - -Run: `grep -c "SafeRelease(s_->" src/render_engine.cpp` (expect `0` - no member SafeRelease remains; only local-temporary `SafeRelease(res)`/`SafeRelease(tex)`/`SafeRelease(back)`/`SafeRelease(output)`/etc. remain, which is correct). Also `grep -c "ComPtr<" src/render_engine.cpp` should be ~19 (the member declarations). - -- [ ] **Step 3: Push the branch** - -```bash -git push -u origin feat/comptr-raii -``` - -- [ ] **Step 4: Open the PR (stacked; base = feat/structure-refactor; references issue #36)** - -```bash -gh pr create --base feat/structure-refactor --head feat/comptr-raii \ - --title "ComPtr RAII for render_engine State (#36)" \ - --body "Convert RenderEngine::State's ~20 persistent COM members to Microsoft::WRL::ComPtr and delete the hand-maintained shutdown() release list. Behavior- and performance-identical; safety/hygiene only. Closes #36. Stacked on #35 (base feat/structure-refactor). - -- Members -> ComPtr (, header-only, no new lib). Three mechanical rules at each touch-site: .ReleaseAndGetAddressOf() (create), .Get()/.GetAddressOf() (use), .Reset() (release); -> calls unchanged. -- shutdown() drops its ~20 SafeRelease lines; members auto-release at ~State (device declared first -> released last; windowed BLT swapchain safe to release post-DestroyWindow). -- Local COM temporaries + png_dump's WIC locals + com_util.h SafeRelease are intentionally untouched. -- Not CI-testable (D3D): verified by clean /W4 build + build.bat test (32/94) + a manual single-monitor run (repeated zoom churn exercising recreateRtv/invalidateCapture/ensureDesktopCopy/updateCursorTexture, clean-shutdown cursor restore, WIND_SELFTEST PNG). - -Spec: docs/superpowers/specs/2026-05-26-comptr-raii-design.md -Plan: docs/superpowers/plans/2026-05-26-comptr-raii.md - -🤖 Generated with [Claude Code](https://claude.com/claude-code)" -``` - -- [ ] **Step 5: Report the PR URL to the user.** - ---- - -## Self-Review (completed during planning) - -**Spec coverage:** -- `` + `using ComPtr` -> Task 1 Step 1. ✓ -- ~20 member declarations -> ComPtr -> Steps 2-5 (device/ctx/swap/rtv; dupl/desktopCopy/desktopSRV; vs/ps/cb/sampLinear/sampPoint; cvs/cps/ccb/blend/blendInvert/cursorTex/cursorSRV = 19 members). ✓ -- Rule R1 (create -> ReleaseAndGetAddressOf): all create sites in `initialize` (Step 9), `ensureDesktopCopy` (7), `recreateDupl` (6), `recreateRtv` (10), `updateCursorTexture` (11). ✓ -- Rule R2 (use -> Get/GetAddressOf): `render` (12), `recreateDupl` device arg (6), `capture` CopyResource (8), `dumpBackbufferPng` (13). ✓ -- Rule R3 (release -> Reset): `recreateDupl` (6), `ensureDesktopCopy` (7), `capture` ACCESS_LOST (8), `recreateRtv`/`invalidateCapture`/`retarget` (10), `updateCursorTexture` (11). ✓ -- `shutdown()` release list deleted -> Step 14. ✓ -- Locals/`png_dump`/`com_util.h` untouched -> not edited (Step 8/13 explicitly keep local `SafeRelease`); File Structure lists the no-change functions. ✓ -- Build + test verification, stacked PR base, issue ref -> Task 2. ✓ - -**Placeholder scan:** none. Every step shows exact old/new code. Step 15 explains how to resolve a compile error via the three rules (not a placeholder - it is fallback guidance for a mechanical migration the compiler validates). - -**Type consistency:** the three rules (`.ReleaseAndGetAddressOf()`, `.Get()`, `.GetAddressOf()`, `.Reset()`) are applied consistently and match WRL ComPtr's API. `ComPtr` member names are unchanged from the originals, so every `s_->member` / `member` reference in unedited `->` calls and bool tests still resolves. The `(cond ? a : b).Get()` form relies on both ternary operands being same-type ComPtr lvalues (true for sampLinear/sampPoint and blendInvert/blend). diff --git a/docs/superpowers/plans/2026-05-26-configurable-zoom.md b/docs/superpowers/plans/2026-05-26-configurable-zoom.md deleted file mode 100644 index 3a6dcd36..00000000 --- a/docs/superpowers/plans/2026-05-26-configurable-zoom.md +++ /dev/null @@ -1,381 +0,0 @@ -# Configurable Zoom Experience Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add per-direction zoom-speed multipliers and an opt-in "smooth zoom" mode (zoom-in accelerates while held), all configurable, with defaults that reproduce today's linear zoom exactly. - -**Architecture:** All zoom logic stays in the pure, unit-tested `ZoomController`. A new `setProfile(...)` carries 5 config-driven parameters (in/out speed multipliers, smooth toggle, accel amount, ramp seconds) plus a `heldIn_` timer for the accel ramp. `RunTick` calls `setProfile` each frame from the live config (free hot-reload, no level reset). No render-engine changes. - -**Tech Stack:** C++17, MSVC. Pure logic in `src/zoom_controller.*` and `src/config.*` (no ``), compiled into the doctest unit-test binary. Build: `build.bat` (app), `build.bat test` (tests, exit 0 = pass). - -**Spec:** `docs/superpowers/specs/2026-05-26-configurable-zoom-design.md` - -**Branch:** `feat/zoom-config` (already created off `main`). - ---- - -## File structure - -- `src/config.h` - add 5 `Config` fields (the new knobs, with defaults). -- `src/config.cpp` - parse the 5 keys in `ParseConfig`; write them in `LoadConfig`'s default-ini text. -- `tools/uiaccess_setup.ps1` - add the 5 keys to the deployed default ini. -- `tests/test_config.cpp` - assert defaults + parsing of the 5 keys. -- `src/zoom_controller.h` - add `setProfile(...)`, the profile members, and `heldIn_`. -- `src/zoom_controller.cpp` - implement `setProfile`, the speed/accel math in `tick`, reset `heldIn_`. -- `tests/test_zoom_controller.cpp` - new tests for speeds, smooth ramp, reset, out-ignores-accel, guards. Existing tests are the regression guard. -- `src/main.cpp` - in `RunTick`, call `t.zoom.setProfile(...)` from `t.cfg` each frame. - ---- - -### Task 1: Config knobs - -**Files:** -- Modify: `src/config.h` (after `double fullRangeSeconds = 1.2;`) -- Modify: `src/config.cpp` (`ParseConfig` else-if chain; `LoadConfig` default text) -- Modify: `tools/uiaccess_setup.ps1` (default ini here-string) -- Test: `tests/test_config.cpp` - -- [ ] **Step 1: Write the failing tests** - -In `tests/test_config.cpp`, add to the existing `TEST_CASE("renderer knobs have sane defaults")` block (right after the `CHECK(c.multiMonitor == 1);` line): - -```cpp - CHECK(c.smoothZoom == 0); // linear (current) by default - CHECK(c.zoomInSpeed == doctest::Approx(1.0)); - CHECK(c.zoomOutSpeed == doctest::Approx(1.0)); - CHECK(c.smoothZoomAccel == doctest::Approx(3.0)); - CHECK(c.smoothZoomRamp == doctest::Approx(0.6)); -``` - -And add a new standalone test case at the end of the file: - -```cpp -TEST_CASE("zoom-speed and smooth-zoom knobs parse") { - Config c = ParseConfig( - "smoothZoom=1\nzoomInSpeed=2.0\nzoomOutSpeed=0.5\n" - "smoothZoomAccel=4.0\nsmoothZoomRamp=0.25\n"); - CHECK(c.smoothZoom == 1); - CHECK(c.zoomInSpeed == doctest::Approx(2.0)); - CHECK(c.zoomOutSpeed == doctest::Approx(0.5)); - CHECK(c.smoothZoomAccel == doctest::Approx(4.0)); - CHECK(c.smoothZoomRamp == doctest::Approx(0.25)); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `build.bat test` -Expected: compile error - `Config` has no member `smoothZoom` (etc.). That is the failing state. - -- [ ] **Step 3: Add the fields to `Config`** - -In `src/config.h`, immediately after the line `double fullRangeSeconds = 1.2;`, insert: - -```cpp - // --- Zoom experience (see docs/superpowers/specs/2026-05-26-configurable-zoom-design.md) --- - // Per-direction rate multipliers (1.0 = today's speed); apply in BOTH linear and smooth modes. - double zoomInSpeed = 1.0; // 0.25-4.0 - double zoomOutSpeed = 1.0; // 0.25-4.0 - // Smooth zoom: 0 = linear/constant (default); 1 = zoom-IN accelerates while held. - int smoothZoom = 0; - // Smooth-mode top zoom-in rate = zoomInSpeed * smoothZoomAccel (>=1; 1 = no accel). 1.0-8.0. - double smoothZoomAccel = 3.0; - // Seconds of continuous holding to reach the top zoom-in rate. 0.1-3.0. - double smoothZoomRamp = 0.6; -``` - -- [ ] **Step 4: Parse the keys in `ParseConfig`** - -In `src/config.cpp`, in the `else if` chain inside `ParseConfig`, after the `fullRangeSeconds` line -(`else if (key == "fullRangeSeconds") c.fullRangeSeconds = std::stod(val);`), add: - -```cpp - else if (key == "zoomInSpeed") c.zoomInSpeed = std::stod(val); - else if (key == "zoomOutSpeed") c.zoomOutSpeed = std::stod(val); - else if (key == "smoothZoom") c.smoothZoom = std::stoi(val); - else if (key == "smoothZoomAccel") c.smoothZoomAccel = std::stod(val); - else if (key == "smoothZoomRamp") c.smoothZoomRamp = std::stod(val); -``` - -- [ ] **Step 5: Write the keys in `LoadConfig`'s default ini** - -In `src/config.cpp`, in `LoadConfig`, find the default-text line `"maxLevel=8.0\nfullRangeSeconds=1.2\n"` -and replace it with: - -```cpp - "maxLevel=8.0\nfullRangeSeconds=1.2\n" - "; zoomInSpeed/zoomOutSpeed: zoom rate multipliers (1.0=default, 2.0=twice as fast, 0.5=half)\n" - "zoomInSpeed=1.0\nzoomOutSpeed=1.0\n" - "; smoothZoom: 0=linear constant speed (default); 1=zoom-IN accelerates the longer you hold\n" - "smoothZoom=0\n" - "; smoothZoomAccel: smooth-mode top zoom-in rate = zoomInSpeed * this (1=no accel)\n" - "smoothZoomAccel=3.0\n" - "; smoothZoomRamp: seconds of holding to reach the top zoom-in rate\n" - "smoothZoomRamp=0.6\n" -``` - -- [ ] **Step 6: Add the keys to the deployed default ini** - -In `tools/uiaccess_setup.ps1`, in the `$ini = @"..."@` here-string, after the -`fullRangeSeconds=1.2` line, add: - -``` -zoomInSpeed=1.0 -zoomOutSpeed=1.0 -smoothZoom=0 -smoothZoomAccel=3.0 -smoothZoomRamp=0.6 -``` - -- [ ] **Step 7: Run tests to verify they pass** - -Run: `build.bat test` -Expected: PASS - all cases pass, including the two new assertions blocks. - -- [ ] **Step 8: Commit** - -```bash -git add src/config.h src/config.cpp tools/uiaccess_setup.ps1 tests/test_config.cpp -git commit -m "feat: add zoom-speed + smooth-zoom config knobs (defaults = current behavior)" -``` - ---- - -### Task 2: ZoomController speed + smooth-accel model - -**Files:** -- Modify: `src/zoom_controller.h` -- Modify: `src/zoom_controller.cpp` -- Test: `tests/test_zoom_controller.cpp` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_zoom_controller.cpp` (note: `#include ` at the top of the file first, for `std::pow`): - -```cpp -TEST_CASE("zoomInSpeed multiplies the in rate") { - ZoomController z(1.0, 8.0, 1.2); - z.setProfile(2.0, 1.0, false, 3.0, 0.6); // 2x in-speed - z.setDirection(ZoomDir::In); - z.tick(0.6); // 2x for 0.6s == 1x for 1.2s == full range - CHECK(z.level() == doctest::Approx(8.0)); -} -TEST_CASE("zoomOutSpeed multiplies the out rate") { - ZoomController z(1.0, 8.0, 1.2); - z.setDirection(ZoomDir::In); z.tick(1.2); // at 8.0 (default profile) - z.setProfile(1.0, 2.0, false, 3.0, 0.6); // 2x out-speed - z.setDirection(ZoomDir::Out); z.tick(0.6); // 2x for 0.6s == full range back down - CHECK(z.level() == doctest::Approx(1.0)); -} -TEST_CASE("smooth zoom-in outpaces linear over the same hold") { - ZoomController lin(1.0, 1e9, 1.2); // huge max so neither clamps - ZoomController sm (1.0, 1e9, 1.2); - lin.setProfile(1.0, 1.0, false, 3.0, 0.2); - sm .setProfile(1.0, 1.0, true, 3.0, 0.2); - lin.setDirection(ZoomDir::In); sm.setDirection(ZoomDir::In); - for (int i = 0; i < 20; ++i) { lin.tick(0.05); sm.tick(0.05); } // 1.0s, well past the 0.2s ramp - CHECK(sm.level() > lin.level()); -} -TEST_CASE("smooth zoom plateaus at inSpeed*accel rate") { - ZoomController z(1.0, 8.0, 100.0); // slow base so it won't clamp - z.setProfile(1.0, 1.0, true, 2.0, 0.1); // accel 2x, ramp 0.1s - z.setDirection(ZoomDir::In); - for (int i = 0; i < 5; ++i) z.tick(0.05); // heldIn 0.25s, well past ramp -> plateau - double l1 = z.level(); - z.tick(0.1); - double l2 = z.level(); - // At plateau the per-tick factor is pow(R, dt*inSpeed*accel/T) with R=8, inSpeed=1, accel=2. - CHECK(l2 == doctest::Approx(l1 * std::pow(8.0, 0.1 * 2.0 / 100.0))); -} -TEST_CASE("releasing resets the smooth ramp so the next in starts slow") { - ZoomController z(1.0, 8.0, 100.0); - z.setProfile(1.0, 1.0, true, 4.0, 0.1); - z.setDirection(ZoomDir::In); - for (int i = 0; i < 5; ++i) z.tick(0.05); // warmed to plateau (fast) - double warmBefore = z.level(); - z.tick(0.02); double warmGain = z.level() - warmBefore; // a fast (plateau) increment - z.setDirection(ZoomDir::None); z.tick(0.1); // release -> heldIn resets to 0 - z.setDirection(ZoomDir::In); - double freshBefore = z.level(); - z.tick(0.02); double freshGain = z.level() - freshBefore; // should be a slow (near-base) increment - CHECK(freshGain < warmGain); -} -TEST_CASE("zoom-out ignores smooth acceleration") { - ZoomController z(1.0, 8.0, 1.2); - z.setDirection(ZoomDir::In); z.tick(1.2); // at 8.0 - z.setProfile(1.0, 1.0, true, 5.0, 0.1); // smooth on, big accel - z.setDirection(ZoomDir::Out); z.tick(0.6); // out should be plain 1x rate (no accel) - CHECK(z.level() == doctest::Approx(8.0 / std::pow(8.0, 0.5))); // == 2.8284, the linear out result -} -TEST_CASE("smooth-zoom guards: accel<1 and ramp=0 don't break the curve") { - ZoomController a(1.0, 8.0, 1.2); - a.setProfile(1.0, 1.0, true, 0.5, 0.0); // accel<1 and ramp=0 - a.setDirection(ZoomDir::In); a.tick(0.6); - CHECK(a.level() == doctest::Approx(2.8284).epsilon(0.001)); // accel<1 ignored -> linear midpoint - ZoomController b(1.0, 1e9, 1.2); - b.setProfile(1.0, 1.0, true, 2.0, 0.0); // ramp=0 -> instant top, must not divide by zero - b.setDirection(ZoomDir::In); b.tick(0.01); - CHECK(b.level() > 1.0); // finite, advanced (no NaN/inf) -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `build.bat test` -Expected: compile error - `ZoomController` has no member `setProfile`. That is the failing state. - -- [ ] **Step 3: Add `setProfile` + members to the header** - -Replace the body of `class ZoomController` in `src/zoom_controller.h` with: - -```cpp -class ZoomController { -public: - ZoomController(double minLevel, double maxLevel, double fullRangeSeconds); - void setDirection(ZoomDir d); - ZoomDir direction() const { return dir_; } - // Speed/acceleration profile (hot-reloadable; does NOT reset the level): - // inSpeed/outSpeed - per-direction rate multipliers (1.0 = base; both modes) - // smooth - accelerate zoom-IN while held - // accel - smooth-mode top in-rate = inSpeed * accel (clamped >=1; 1 = no accel) - // rampSeconds - seconds of continuous zoom-in to reach the top in-rate (<=0 = instant) - void setProfile(double inSpeed, double outSpeed, bool smooth, double accel, double rampSeconds); - void tick(double dtSeconds); // ramp level multiplicatively toward bound - double level() const { return level_; } - void reset(); // level=min, dir=None, held cleared -private: - double minLevel_, maxLevel_, fullRangeSeconds_; - double level_; - ZoomDir dir_ = ZoomDir::None; - double inSpeed_ = 1.0, outSpeed_ = 1.0; // defaults reproduce today's behavior - bool smooth_ = false; - double accel_ = 3.0, rampSeconds_ = 0.6; - double heldIn_ = 0.0; // continuous seconds zoom-in held (drives accel ramp) -}; -``` - -- [ ] **Step 4: Implement `setProfile` + the math in `tick` + reset** - -Replace `src/zoom_controller.cpp` with: - -```cpp -#include "zoom_controller.h" -#include -#include -namespace wind { -ZoomDir ResolveDirection(bool inHeld, bool outHeld) { - if (inHeld == outHeld) return ZoomDir::None; // neither, or both - return inHeld ? ZoomDir::In : ZoomDir::Out; -} -ZoomController::ZoomController(double minLevel, double maxLevel, double fullRangeSeconds) - : minLevel_(minLevel), maxLevel_(maxLevel), - fullRangeSeconds_(fullRangeSeconds), level_(minLevel) {} -void ZoomController::setDirection(ZoomDir d) { dir_ = d; } -void ZoomController::setProfile(double inSpeed, double outSpeed, bool smooth, - double accel, double rampSeconds) { - inSpeed_ = inSpeed; outSpeed_ = outSpeed; smooth_ = smooth; - accel_ = accel; rampSeconds_ = rampSeconds; -} -void ZoomController::tick(double dt) { - // Track continuous zoom-in hold time for the smooth-zoom accel ramp; any non-In direction - // (release or reverse) resets it, so each fresh zoom-in starts slow again. - if (dir_ == ZoomDir::In && dt > 0.0) heldIn_ += dt; - else if (dir_ != ZoomDir::In) heldIn_ = 0.0; - if (dir_ == ZoomDir::None || dt <= 0.0) return; - - double speed; - if (dir_ == ZoomDir::In) { - double accelMult = 1.0; - if (smooth_ && accel_ > 1.0) { // accel<=1 -> no acceleration - double t = (rampSeconds_ > 0.0 && heldIn_ < rampSeconds_) - ? heldIn_ / rampSeconds_ // 0..1 ramp (ramp<=0 -> instant top) - : 1.0; - accelMult = 1.0 + (accel_ - 1.0) * t; // 1..accel - } - speed = inSpeed_ * accelMult; - } else { - speed = outSpeed_; // out never accelerates - } - double f = std::pow(maxLevel_ / minLevel_, dt * speed / fullRangeSeconds_); - if (dir_ == ZoomDir::In) level_ *= f; - else level_ /= f; - level_ = std::min(maxLevel_, std::max(minLevel_, level_)); -} -void ZoomController::reset() { level_ = minLevel_; dir_ = ZoomDir::None; heldIn_ = 0.0; } -} -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `build.bat test` -Expected: PASS - the 7 new cases AND all pre-existing zoom tests (the regression guard: with default -profile, `tick` is byte-for-byte the old `pow` step). - -- [ ] **Step 6: Commit** - -```bash -git add src/zoom_controller.h src/zoom_controller.cpp tests/test_zoom_controller.cpp -git commit -m "feat: ZoomController speed multipliers + smooth zoom-in acceleration" -``` - ---- - -### Task 3: Wire the live config into RunTick - -**Files:** -- Modify: `src/main.cpp` (`RunTick`, at the zoom drive point) - -- [ ] **Step 1: Add the `setProfile` call** - -In `src/main.cpp`, in `RunTick`, find: - -```cpp - t.zoom.setDirection(ResolveDirection(inHeld, outHeld)); -``` - -and insert immediately ABOVE it: - -```cpp - // Apply the live zoom profile every frame (free hot-reload; setProfile does not reset level). - t.zoom.setProfile(t.cfg.zoomInSpeed, t.cfg.zoomOutSpeed, t.cfg.smoothZoom != 0, - t.cfg.smoothZoomAccel, t.cfg.smoothZoomRamp); -``` - -(The existing dt-clamp on `t.zoom.tick(...)` just below stays unchanged.) - -- [ ] **Step 2: Build the app** - -Run: `build.bat` -Expected: exit 0, `Wind.exe` produced. (Kill any running Wind first if the link fails with LNK1104.) - -- [ ] **Step 3: Smoke-test the render path is intact** - -Run (PowerShell): `Start-Process .\Wind.exe -Environment @{ WIND_SELFTEST = "1" } -Wait` -Expected: `wind_selftest.png` is written and shows a normal magnified frame (zoom config does not -touch the render path, so this only confirms nothing regressed in the build/integration). - -- [ ] **Step 4: Commit** - -```bash -git add src/main.cpp -git commit -m "feat: feed live zoom profile (speeds + smooth zoom) into the tick" -``` - ---- - -## Manual verification (after all tasks) - -Defaults must feel identical to today. Then, editing `magnifier.ini` (hot-reloads in ~1s): -- `zoomInSpeed=2.0` -> zoom-in reaches max in ~0.6s; `0.5` -> ~2.4s. `zoomOutSpeed` likewise for out. -- `smoothZoom=1` -> zoom-in starts at `zoomInSpeed` and accelerates to `zoomInSpeed*smoothZoomAccel` - over `smoothZoomRamp` seconds, then holds steady; releasing and re-holding starts slow again; - zoom-out stays constant. - -## Notes for the implementer - -- Pure-logic files (`config.cpp` parse half, `zoom_controller.cpp`) MUST NOT include ``; - the test build compiles only the pure `.cpp` files. ``/`` are fine. -- The default profile (`smoothZoom=0`, speeds `1.0`) makes `tick` mathematically identical to the - current implementation - the pre-existing `test_zoom_controller.cpp` cases are the regression guard - and must keep passing untouched. -- Do NOT change `maxLevel` / `fullRangeSeconds` semantics, and do NOT touch `render_engine`. diff --git a/docs/superpowers/plans/2026-05-26-multi-monitor-follow-cursor.md b/docs/superpowers/plans/2026-05-26-multi-monitor-follow-cursor.md deleted file mode 100644 index f3c02ff7..00000000 --- a/docs/superpowers/plans/2026-05-26-multi-monitor-follow-cursor.md +++ /dev/null @@ -1,936 +0,0 @@ -# Multi-Monitor Follow-Cursor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** On each zoom-in, magnify whichever monitor the cursor is on (instead of always the primary), while keeping the single-monitor path behaviorally identical. - -**Architecture:** Introduce a `(originX, originY)` monitor origin. Pure logic (`CursorMapper`, `transform`) stays in local monitor pixels; convert only at the `GetCursorPos`/`SetCursorPos` boundaries. The engine selects its DXGI output by device name and gains a `retarget()` that moves the overlay + resizes the swapchain + rebinds the duplication when the monitor changes on zoom-in. A `multiMonitor` config flag (default on) is the kill-switch back to legacy primary-only behavior. - -**Tech Stack:** C++17, MSVC `cl.exe`, DXGI Desktop Duplication + Direct3D 11, Win32 (`MonitorFromPoint`/`GetMonitorInfoW`), doctest. Spec: `docs/superpowers/specs/2026-05-26-multi-monitor-follow-cursor-design.md`. Branch `feat/multi-monitor`, issue #32. - -**Key commands:** -- App build: `build.bat` (emits `Wind.exe`; exit 0 = success) -- Build + run unit tests: `build.bat test` (exit 0 = pass) -- Compile-all check (no link): `build.bat check` - -**Conventions:** No em-dashes. Commit trailer on every commit: -``` -Co-Authored-By: Claude Opus 4.7 -``` - ---- - -## File Structure - -- `src/config.h` / `src/config.cpp` - add the `multiMonitor` flag (pure parse + default-ini line). -- `tests/test_config.cpp` - assert the `multiMonitor` default and parse (pure, TDD). -- `src/render_engine.h` - new `MonitorTarget` struct; new `initialize(const MonitorTarget&, ...)` and `retarget(const MonitorTarget&)` signatures. -- `src/render_engine.cpp` - overlay placed at the monitor origin; output-by-name selection (`selectOutput`); `retarget`; size-aware `ensureDesktopCopy`; `RLog` instrumentation; smoke-test call site. -- `src/main.cpp` - `MonitorUnderCursor`/`PrimaryMonitor`/`SameMonitor` helpers; `TickState` holds a `MonitorTarget`; zoom-in retarget + origin-corrected coordinates; selftest/pacingtest call sites. -- `tools/uiaccess_setup.ps1` - add `multiMonitor=1` to the deployed ini. -- `CLAUDE.md` - document multi-monitor follow-cursor + the multi-GPU limit. - ---- - -## Task 1: Config flag `multiMonitor` (pure, TDD) - -**Files:** -- Test: `tests/test_config.cpp` -- Modify: `src/config.h`, `src/config.cpp` - -- [ ] **Step 1: Write the failing tests** - -In `tests/test_config.cpp`, add the default assertion inside the existing `TEST_CASE("renderer knobs have sane defaults")` block (after the `c.tickHzCap == 0` line): - -```cpp - CHECK(c.multiMonitor == 1); // follow the cursor's monitor by default -``` - -Then add a new standalone test case at the end of the file: - -```cpp -TEST_CASE("multiMonitor can be set") { - CHECK(ParseConfig("multiMonitor=0\n").multiMonitor == 0); - CHECK(ParseConfig("multiMonitor=1\n").multiMonitor == 1); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `build.bat test` -Expected: compile error `'multiMonitor' is not a member of 'wind::Config'` (the field does not exist yet). - -- [ ] **Step 3: Add the field to the Config struct** - -In `src/config.h`, add this field directly after the `int hdrTonemap = 1;` field (before the closing `};` of `struct Config`): - -```cpp - // Multi-monitor: 1 (default) = on each zoom-in, magnify whichever monitor the cursor is - // on; 0 = legacy single-monitor behavior (primary monitor only). Hot-reloadable (applies - // on the next zoom-in). Kill-switch for the multi-monitor path. - int multiMonitor = 1; -``` - -- [ ] **Step 4: Parse the key** - -In `src/config.cpp`, in `ParseConfig`, add this line after the `else if (key == "hdrTonemap") ...` line: - -```cpp - else if (key == "multiMonitor") c.multiMonitor = std::stoi(val); -``` - -- [ ] **Step 5: Add it to the default-ini writer** - -In `src/config.cpp`, in `LoadConfig`, append to the default-ini text (right before the final `"hdrTonemap=1\n";` line, turning that line into a continuation). Replace: - -```cpp - "; hdrTonemap: 1=HDR10->SDR tonemap when Windows HDR is on (no-op on SDR); 0=off\n" - "hdrTonemap=1\n"; -``` - -with: - -```cpp - "; hdrTonemap: 1=HDR10->SDR tonemap when Windows HDR is on (no-op on SDR); 0=off\n" - "hdrTonemap=1\n" - "; multiMonitor: 1=magnify whichever monitor the cursor is on at zoom-in; 0=primary only\n" - "multiMonitor=1\n"; -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `build.bat test` -Expected: PASS (exit 0). The default block now asserts `multiMonitor == 1` and the new case checks both values. - -- [ ] **Step 7: Commit** - -```bash -git add src/config.h src/config.cpp tests/test_config.cpp -git commit -m "feat: multiMonitor config flag, default on (#32) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 2: `MonitorTarget` type + `initialize` takes a monitor (behavior identical) - -This introduces the monitor descriptor and threads it through `initialize`, but keeps behavior identical by passing the primary monitor (origin 0,0). No follow logic yet. - -**Files:** -- Modify: `src/render_engine.h`, `src/render_engine.cpp`, `src/main.cpp` - -- [ ] **Step 1: Add the `MonitorTarget` struct to the header** - -In `src/render_engine.h`, add this struct immediately after `namespace wind {` and before `struct RenderFrameParams {`: - -```cpp -// A target monitor for the magnifier overlay. All values are in physical pixels in the -// virtual-desktop coordinate space (the process is Per-Monitor-V2 DPI aware). `device` is the -// GDI/DXGI device name (\\.\DISPLAYn, 32 = CCHDEVICENAME) used to match the monitor to its DXGI -// output by name. An empty `device` means "first output" (the legacy single-monitor path). -struct MonitorTarget { - int x = 0, y = 0; // top-left in virtual-desktop pixels (monitor origin) - int w = 0, h = 0; // size in physical pixels - wchar_t device[32] = {}; -}; -``` - -- [ ] **Step 2: Change the `initialize` declaration** - -In `src/render_engine.h`, replace: - -```cpp - bool initialize(int screenW, int screenH, int zorderBand = 0, bool hdrTonemap = false); -``` - -with: - -```cpp - bool initialize(const MonitorTarget& monitor, int zorderBand = 0, bool hdrTonemap = false); -``` - -- [ ] **Step 3: Add origin + device fields to the engine State** - -In `src/render_engine.cpp`, in `struct RenderEngine::State`, add these fields right after the `int sw = 0, sh = 0;` line: - -```cpp - int originX = 0, originY = 0; // target monitor top-left in virtual-desktop pixels - wchar_t targetDevice[32] = {}; // DXGI output DeviceName to capture ("" = first output) -``` - -- [ ] **Step 4: Rewrite `initialize` to use the MonitorTarget** - -In `src/render_engine.cpp`, change the `initialize` definition. Replace the signature and the first lines: - -```cpp -bool RenderEngine::initialize(int screenW, int screenH, int zorderBand, bool hdrTonemap) { - s_->sw = screenW; - s_->sh = screenH; - s_->wantHdrTonemap = hdrTonemap; // read before recreateDupl decides the capture format - RLog("=== initialize sw=%d sh=%d band=%d hdrTonemap=%d ===", screenW, screenH, zorderBand, (int)hdrTonemap); -``` - -with: - -```cpp -bool RenderEngine::initialize(const MonitorTarget& monitor, int zorderBand, bool hdrTonemap) { - const int screenW = monitor.w, screenH = monitor.h; - s_->sw = screenW; - s_->sh = screenH; - s_->originX = monitor.x; - s_->originY = monitor.y; - lstrcpynW(s_->targetDevice, monitor.device, 32); // "" = first output (legacy path) - s_->wantHdrTonemap = hdrTonemap; // read before recreateDupl decides the capture format - RLog("=== initialize device=%ls origin=(%d,%d) size=%dx%d band=%d hdrTonemap=%d ===", - s_->targetDevice, monitor.x, monitor.y, screenW, screenH, zorderBand, (int)hdrTonemap); -``` - -- [ ] **Step 5: Place the overlay at the monitor origin (not 0,0)** - -In `src/render_engine.cpp`, in `initialize`, the overlay is currently created at `0, 0, screenW, screenH` in two places. Update both to use the monitor origin. - -Replace the `CreateWindowInBand` call: - -```cpp - s_->hwnd = pCWIB(exStyle, atom, L"Wind Magnifier", WS_POPUP, - 0, 0, screenW, screenH, nullptr, nullptr, wc.hInstance, nullptr, - static_cast(zorderBand)); -``` - -with: - -```cpp - s_->hwnd = pCWIB(exStyle, atom, L"Wind Magnifier", WS_POPUP, - monitor.x, monitor.y, screenW, screenH, nullptr, nullptr, - wc.hInstance, nullptr, static_cast(zorderBand)); -``` - -Replace the fallback `CreateWindowExW` call: - -```cpp - s_->hwnd = CreateWindowExW(exStyle, kClass, L"Wind Magnifier", WS_POPUP, - 0, 0, screenW, screenH, nullptr, nullptr, wc.hInstance, nullptr); -``` - -with: - -```cpp - s_->hwnd = CreateWindowExW(exStyle, kClass, L"Wind Magnifier", WS_POPUP, - monitor.x, monitor.y, screenW, screenH, nullptr, nullptr, - wc.hInstance, nullptr); -``` - -- [ ] **Step 6: Add the `PrimaryMonitor` helper to main.cpp** - -In `src/main.cpp`, add this helper just after the `DetectRefreshHz()` function (before the `// --- Per-tick state ---` comment): - -```cpp -// The primary monitor as a MonitorTarget (origin 0,0, primary size, empty device name = first -// DXGI output). This is the legacy single-monitor target and the universal fallback. -static MonitorTarget PrimaryMonitor() { - MonitorTarget t; - t.x = 0; t.y = 0; - t.w = GetSystemMetrics(SM_CXSCREEN); - t.h = GetSystemMetrics(SM_CYSCREEN); - t.device[0] = L'\0'; - return t; -} -``` - -- [ ] **Step 7: Update the `initialize` call site in main.cpp** - -In `src/main.cpp`, in `wWinMain`, replace: - -```cpp - int sw = GetSystemMetrics(SM_CXSCREEN); - int sh = GetSystemMetrics(SM_CYSCREEN); - - // --- Own GPU renderer (DXGI Desktop Duplication + D3D11) --- - RenderEngine renderEngine; - if (!renderEngine.initialize(sw, sh, cfg.zorderBand, cfg.hdrTonemap != 0)) { -``` - -with: - -```cpp - // Target monitor for this session. Task 6 swaps PrimaryMonitor() for the cursor's monitor - // when multiMonitor is on; for now both paths use the primary (behavior unchanged). - MonitorTarget startupMon = PrimaryMonitor(); - int sw = startupMon.w; - int sh = startupMon.h; - - // --- Own GPU renderer (DXGI Desktop Duplication + D3D11) --- - RenderEngine renderEngine; - if (!renderEngine.initialize(startupMon, cfg.zorderBand, cfg.hdrTonemap != 0)) { -``` - -(`sw`/`sh` remain for the existing `TickState ts(renderEngine, sw, sh, cfg)` call, which is unchanged in this task.) - -- [ ] **Step 8: Update the smoke-test call site** - -In `src/render_engine.cpp`, in the `#ifdef WIND_RENDER_SMOKE` block, replace: - -```cpp - int sw = GetSystemMetrics(SM_CXSCREEN), sh = GetSystemMetrics(SM_CYSCREEN); - wind::RenderEngine eng; - if (!eng.initialize(sw, sh)) { MessageBoxW(nullptr, L"init failed", L"smoke", 0); return 1; } -``` - -with: - -```cpp - int sw = GetSystemMetrics(SM_CXSCREEN), sh = GetSystemMetrics(SM_CYSCREEN); - wind::MonitorTarget mon; mon.x = 0; mon.y = 0; mon.w = sw; mon.h = sh; - wind::RenderEngine eng; - if (!eng.initialize(mon)) { MessageBoxW(nullptr, L"init failed", L"smoke", 0); return 1; } -``` - -- [ ] **Step 9: Build and verify it compiles** - -Run: `build.bat` -Expected: builds clean, emits `Wind.exe` (exit 0). No warnings from `/W4` on the changed lines. - -- [ ] **Step 10: Sanity-check single-monitor behavior is unchanged** - -Run: `Wind.exe`, hold the zoom key (PageUp), confirm the magnifier still zooms and pans normally, then quit (Ctrl+Alt+Q). The overlay is at origin (0,0) = primary, identical to before. - -- [ ] **Step 11: Commit** - -```bash -git add src/render_engine.h src/render_engine.cpp src/main.cpp -git commit -m "feat: MonitorTarget type; initialize takes a monitor (primary, no behavior change) (#32) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 3: Select the DXGI output by device name (`selectOutput`) - -Replaces the hardcoded `EnumOutputs(0)` with name-based selection on our device's adapter, falling back to the first output. With an empty `targetDevice` (the only case so far) it picks output 0 = identical behavior. - -**Files:** -- Modify: `src/render_engine.cpp` - -- [ ] **Step 1: Declare `selectOutput` on the State** - -In `src/render_engine.cpp`, in `struct RenderEngine::State`, add this declaration near `bool recreateDupl();`: - -```cpp - // Find the output on our D3D device's adapter whose DeviceName matches `device`. Returns an - // AddRef'd output, or (if fallbackToFirst) output 0, or nullptr. Used to capture a specific - // monitor and to validate a retarget before touching the window/swapchain. - IDXGIOutput* selectOutput(const wchar_t* device, bool fallbackToFirst); -``` - -- [ ] **Step 2: Implement `selectOutput`** - -In `src/render_engine.cpp`, add this definition immediately before `bool RenderEngine::State::recreateDupl() {`: - -```cpp -IDXGIOutput* RenderEngine::State::selectOutput(const wchar_t* device, bool fallbackToFirst) { - IDXGIDevice* dxgiDev = nullptr; - if (FAILED(this->device->QueryInterface(__uuidof(IDXGIDevice), (void**)&dxgiDev))) return nullptr; - IDXGIAdapter* adapter = nullptr; - dxgiDev->GetAdapter(&adapter); - SafeRelease(dxgiDev); - if (!adapter) return nullptr; - - IDXGIOutput* match = nullptr; // name match (preferred) - IDXGIOutput* first = nullptr; // output 0 (fallback) - for (UINT i = 0; ; ++i) { - IDXGIOutput* o = nullptr; - if (adapter->EnumOutputs(i, &o) == DXGI_ERROR_NOT_FOUND || !o) break; - DXGI_OUTPUT_DESC od{}; - if (device && device[0] && SUCCEEDED(o->GetDesc(&od)) && wcscmp(od.DeviceName, device) == 0) { - match = o; // keep this ref; stop searching - break; - } - if (i == 0) first = o; // keep output 0 for the fallback - else o->Release(); - } - SafeRelease(adapter); - if (match) { SafeRelease(first); return match; } - if (fallbackToFirst) return first; // may be nullptr if the adapter has no outputs - SafeRelease(first); - return nullptr; -} -``` - -- [ ] **Step 3: Add the `` include for `wcscmp`** - -In `src/render_engine.cpp`, in the include block near the top, add after `#include `: - -```cpp -#include -``` - -- [ ] **Step 4: Use `selectOutput` in `recreateDupl`** - -In `src/render_engine.cpp`, in `recreateDupl`, replace this block: - -```cpp - SafeRelease(dupl); - IDXGIDevice* dxgiDev = nullptr; - if (FAILED(device->QueryInterface(__uuidof(IDXGIDevice), (void**)&dxgiDev))) return false; - IDXGIAdapter* adapter = nullptr; - dxgiDev->GetAdapter(&adapter); - SafeRelease(dxgiDev); - if (!adapter) return false; - IDXGIOutput* output = nullptr; - adapter->EnumOutputs(0, &output); - SafeRelease(adapter); - if (!output) return false; -``` - -with: - -```cpp - SafeRelease(dupl); - // Capture the target monitor's output (matched by device name), falling back to the first - // output for the legacy single-monitor path (empty targetDevice) or any name mismatch. - IDXGIOutput* output = selectOutput(targetDevice, /*fallbackToFirst=*/true); - if (!output) return false; - RLog("recreateDupl: targetDevice=%ls", targetDevice[0] ? targetDevice : L"(first)"); -``` - -- [ ] **Step 5: Build and verify** - -Run: `build.bat` -Expected: builds clean, emits `Wind.exe` (exit 0). - -- [ ] **Step 6: Sanity-check single-monitor** - -Run: `Wind.exe`, zoom (PageUp), confirm normal magnify/pan, quit (Ctrl+Alt+Q). With an empty `targetDevice`, `selectOutput` returns output 0 = unchanged. - -- [ ] **Step 7: Commit** - -```bash -git add src/render_engine.cpp -git commit -m "feat: select DXGI output by device name, fallback to first (#32) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 4: Size-aware `ensureDesktopCopy` - -So a retarget to a different-resolution monitor recreates the copy texture at the new size (today it keys only on format). - -**Files:** -- Modify: `src/render_engine.cpp` - -- [ ] **Step 1: Track the copy texture size in State** - -In `src/render_engine.cpp`, in `struct RenderEngine::State`, find: - -```cpp - DXGI_FORMAT copyFormat = DXGI_FORMAT_B8G8R8A8_UNORM; // current desktopCopy format -``` - -and add directly below it: - -```cpp - int copyW = 0, copyH = 0; // current desktopCopy dimensions -``` - -- [ ] **Step 2: Make `ensureDesktopCopy` compare size as well as format** - -In `src/render_engine.cpp`, in `ensureDesktopCopy`, replace: - -```cpp -bool RenderEngine::State::ensureDesktopCopy(DXGI_FORMAT fmt) { - if (desktopCopy && copyFormat == fmt) return true; -``` - -with: - -```cpp -bool RenderEngine::State::ensureDesktopCopy(DXGI_FORMAT fmt) { - if (desktopCopy && copyFormat == fmt && copyW == sw && copyH == sh) return true; -``` - -- [ ] **Step 3: Record the new size after a successful (re)create** - -In `src/render_engine.cpp`, in `ensureDesktopCopy`, replace: - -```cpp - copyFormat = fmt; - RLog("ensureDesktopCopy: format=%u", (unsigned)fmt); - return true; -``` - -with: - -```cpp - copyFormat = fmt; - copyW = sw; copyH = sh; - RLog("ensureDesktopCopy: format=%u size=%dx%d", (unsigned)fmt, sw, sh); - return true; -``` - -- [ ] **Step 4: Build and verify** - -Run: `build.bat` -Expected: builds clean, emits `Wind.exe` (exit 0). Behavior unchanged at a fixed size (size matches on the fast path). - -- [ ] **Step 5: Commit** - -```bash -git add src/render_engine.cpp -git commit -m "feat: size-aware ensureDesktopCopy (recreate on monitor size change) (#32) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 5: `retarget()` engine method - -Moves the overlay, resizes the swapchain if needed, and rebinds the duplication to a new monitor. Validates the output is on our adapter first (multi-GPU safety). Not called yet. - -**Files:** -- Modify: `src/render_engine.h`, `src/render_engine.cpp` - -- [ ] **Step 1: Declare `retarget` in the header** - -In `src/render_engine.h`, add this declaration right after the `initialize(...)` line: - -```cpp - // Re-point the magnifier at a different monitor (call on zoom-in when the cursor's monitor - // changed; the overlay must still be hidden/alpha 0). Moves + resizes the overlay, resizes - // the swapchain if needed, and rebinds Desktop Duplication to the new output. Returns false - // and changes nothing if the monitor's output is not on our D3D device's adapter (multi-GPU) - // or a D3D step fails, so the caller can keep magnifying the current monitor. - bool retarget(const MonitorTarget& monitor); -``` - -- [ ] **Step 2: Implement `retarget`** - -In `src/render_engine.cpp`, add this definition immediately after the `void RenderEngine::invalidateCapture() { ... }` function: - -```cpp -bool RenderEngine::retarget(const MonitorTarget& m) { - if (!s_ || !s_->ready || !s_->hwnd || !s_->swap) return false; - - // Validate the target's output is on OUR adapter BEFORE touching the window/swapchain, so we - // never end up displaying one monitor's pixels on another monitor's overlay (multi-GPU). - if (m.device[0]) { - IDXGIOutput* probe = s_->selectOutput(m.device, /*fallbackToFirst=*/false); - if (!probe) { - RLog("retarget: device=%ls not on our adapter; keeping current monitor", m.device); - return false; - } - probe->Release(); - } - - const bool sizeChanged = (m.w != s_->sw || m.h != s_->sh); - - // Move/resize the overlay. During a zoom-in the window is still at alpha 0 (invisible), so - // this never flashes. Keep it topmost. - SetWindowPos(s_->hwnd, HWND_TOPMOST, m.x, m.y, m.w, m.h, SWP_NOACTIVATE); - - if (sizeChanged) { - // ResizeBuffers requires all back-buffer references released first (the RTV holds one). - SafeRelease(s_->rtv); - HRESULT hr = s_->swap->ResizeBuffers(1, m.w, m.h, DXGI_FORMAT_B8G8R8A8_UNORM, 0); - if (FAILED(hr)) { RLog("retarget: ResizeBuffers failed hr=0x%08lX", (unsigned long)hr); return false; } - ID3D11Texture2D* back = nullptr; - if (FAILED(s_->swap->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&back))) { - RLog("retarget: GetBuffer failed after ResizeBuffers"); return false; - } - hr = s_->device->CreateRenderTargetView(back, nullptr, &s_->rtv); - SafeRelease(back); - if (FAILED(hr)) { RLog("retarget: CreateRenderTargetView failed hr=0x%08lX", (unsigned long)hr); return false; } - } - - // Adopt the new geometry + device, then force a fresh capture on the new output (same flags - // as invalidateCapture). The next capture() recreates the duplication via selectOutput and - // recreates desktopCopy at the new size (ensureDesktopCopy is size-aware). - s_->originX = m.x; s_->originY = m.y; - s_->sw = m.w; s_->sh = m.h; - lstrcpynW(s_->targetDevice, m.device, 32); - SafeRelease(s_->dupl); - s_->haveDesktop = false; - s_->freshCapture = true; - s_->prevSrcValid = false; - s_->lastClickX = s_->lastClickY = INT_MIN; // don't skip the first SetCursorPos on the new monitor - RLog("retarget: device=%ls origin=(%d,%d) size=%dx%d sizeChanged=%d", - s_->targetDevice, m.x, m.y, m.w, m.h, (int)sizeChanged); - return true; -} -``` - -- [ ] **Step 3: Build and verify it compiles** - -Run: `build.bat` -Expected: builds clean, emits `Wind.exe` (exit 0). `retarget` is unused for now (no call site), which is fine. - -- [ ] **Step 4: Commit** - -```bash -git add src/render_engine.h src/render_engine.cpp -git commit -m "feat: RenderEngine::retarget (move overlay + rebind capture, multi-GPU safe) (#32) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 6: Wire up follow-cursor in main.cpp - -Detect the cursor's monitor, retarget on zoom-in, and correct all coordinates by the monitor origin. This activates the feature. - -**Files:** -- Modify: `src/main.cpp` - -- [ ] **Step 1: Add `MonitorUnderCursor` and `SameMonitor` helpers** - -In `src/main.cpp`, add these two helpers directly after the `PrimaryMonitor()` helper added in Task 2: - -```cpp -// The monitor the cursor is currently on, as a MonitorTarget. Falls back to the primary if the -// query fails. Used at startup and on each zoom-in (when multiMonitor is on). -static MonitorTarget MonitorUnderCursor() { - POINT pt; GetCursorPos(&pt); - HMONITOR mon = MonitorFromPoint(pt, MONITOR_DEFAULTTOPRIMARY); - MONITORINFOEXW mi{}; mi.cbSize = sizeof(mi); - if (mon && GetMonitorInfoW(mon, &mi)) { - MonitorTarget t; - t.x = mi.rcMonitor.left; - t.y = mi.rcMonitor.top; - t.w = mi.rcMonitor.right - mi.rcMonitor.left; - t.h = mi.rcMonitor.bottom - mi.rcMonitor.top; - lstrcpynW(t.device, mi.szDevice, 32); - return t; - } - return PrimaryMonitor(); -} - -// Whether two targets are the same monitor (origin + size + device name). -static bool SameMonitor(const MonitorTarget& a, const MonitorTarget& b) { - return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h && wcscmp(a.device, b.device) == 0; -} -``` - -- [ ] **Step 2: Make `TickState` hold a `MonitorTarget`** - -In `src/main.cpp`, in `struct TickState`, replace: - -```cpp - RenderEngine& renderEngine; - int sw, sh; - Config cfg; -``` - -with: - -```cpp - RenderEngine& renderEngine; - MonitorTarget mon; // current target monitor (origin + size + device name) - Config cfg; -``` - -and replace the constructor: - -```cpp - TickState(RenderEngine& re, int w, int h, const Config& c) - : renderEngine(re), sw(w), sh(h), cfg(c), - zoom(1.0, c.maxLevel, c.fullRangeSeconds), - mapper(w, h, c.cursorSensitivity, c.cursorSmoothing) {} -``` - -with: - -```cpp - TickState(RenderEngine& re, const MonitorTarget& m, const Config& c) - : renderEngine(re), mon(m), cfg(c), - zoom(1.0, c.maxLevel, c.fullRangeSeconds), - mapper(m.w, m.h, c.cursorSensitivity, c.cursorSmoothing) {} -``` - -- [ ] **Step 3: Update the config-reload mapper rebuild in `RunTick`** - -In `src/main.cpp`, in `RunTick`, in the hot-reload block, replace: - -```cpp - t.mapper = CursorMapper(t.sw, t.sh, nc.cursorSensitivity, nc.cursorSmoothing); -``` - -with: - -```cpp - t.mapper = CursorMapper(t.mon.w, t.mon.h, nc.cursorSensitivity, nc.cursorSmoothing); -``` - -- [ ] **Step 4: Retarget + origin-correct the zoom-in transition** - -In `src/main.cpp`, in `RunTick`, replace the zoom-in block: - -```cpp - bool zoomIn = (t.prevLvl <= 1.0); // zoom-in transition - if (zoomIn) { - POINT pt; GetCursorPos(&pt); - t.mapper.reset(pt.x, pt.y); - t.renderEngine.hideSystemCursor(true); - t.renderEngine.invalidateCapture(); // grab a live frame, not a stale cached one - } - if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x, pt.y); } -``` - -with: - -```cpp - bool zoomIn = (t.prevLvl <= 1.0); // zoom-in transition - if (zoomIn) { - // Follow the cursor's monitor (multiMonitor on). Only reconfigure when it actually - // changed; retarget() returns false on multi-GPU/failure, in which case we keep the - // current monitor. The overlay is still at alpha 0 here, so a move never flashes. - if (t.cfg.multiMonitor) { - MonitorTarget nt = MonitorUnderCursor(); - if (!SameMonitor(nt, t.mon) && t.renderEngine.retarget(nt)) { - t.mon = nt; - t.mapper = CursorMapper(nt.w, nt.h, t.cfg.cursorSensitivity, t.cfg.cursorSmoothing); - } - } - POINT pt; GetCursorPos(&pt); - t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); // virtual -> local monitor coords - t.renderEngine.hideSystemCursor(true); - t.renderEngine.invalidateCapture(); // grab a live frame, not a stale cached one - } - if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); } -``` - -- [ ] **Step 5: Add the origin offset to `clickDesktop` when filling params** - -In `src/main.cpp`, in `RunTick`, replace: - -```cpp - p.clickDesktopX = r.clickDesktopX; p.clickDesktopY = r.clickDesktopY; -``` - -with: - -```cpp - // clickDesktop is local monitor px; SetCursorPos needs virtual-desktop coords. - p.clickDesktopX = r.clickDesktopX + t.mon.x; p.clickDesktopY = r.clickDesktopY + t.mon.y; -``` - -- [ ] **Step 6: Pick the startup monitor based on the flag** - -In `src/main.cpp`, in `wWinMain`, replace the lines added in Task 2: - -```cpp - // Target monitor for this session. Task 6 swaps PrimaryMonitor() for the cursor's monitor - // when multiMonitor is on; for now both paths use the primary (behavior unchanged). - MonitorTarget startupMon = PrimaryMonitor(); - int sw = startupMon.w; - int sh = startupMon.h; -``` - -with: - -```cpp - // Target monitor for this session: the cursor's monitor when multiMonitor is on, else the - // primary. The first zoom-in re-checks and retargets if the cursor moved to another monitor. - MonitorTarget startupMon = (cfg.multiMonitor != 0) ? MonitorUnderCursor() : PrimaryMonitor(); -``` - -- [ ] **Step 7: Update the `TickState` construction** - -In `src/main.cpp`, in `wWinMain`, replace: - -```cpp - TickState ts(renderEngine, sw, sh, cfg); -``` - -with: - -```cpp - TickState ts(renderEngine, startupMon, cfg); -``` - -- [ ] **Step 8: Fix the WIND_SELFTEST block coordinates** - -In `src/main.cpp`, in the `WIND_SELFTEST` block, replace: - -```cpp - POINT pt; GetCursorPos(&pt); - ts.mapper.reset(pt.x, pt.y); - renderEngine.hideSystemCursor(true); - renderEngine.setVisible(true); - RenderFrameParams p{}; -``` - -with: - -```cpp - POINT pt; GetCursorPos(&pt); - ts.mapper.reset(pt.x - ts.mon.x, pt.y - ts.mon.y); - renderEngine.hideSystemCursor(true); - renderEngine.setVisible(true); - RenderFrameParams p{}; -``` - -and in the same block, replace: - -```cpp - p.clickDesktopX = r.clickDesktopX; p.clickDesktopY = r.clickDesktopY; -``` - -with: - -```cpp - p.clickDesktopX = r.clickDesktopX + ts.mon.x; p.clickDesktopY = r.clickDesktopY + ts.mon.y; -``` - -- [ ] **Step 9: Fix the WIND_PACINGTEST block coordinates** - -In `src/main.cpp`, in the `WIND_PACINGTEST` block, replace: - -```cpp - POINT pt; GetCursorPos(&pt); - ts.mapper.reset(pt.x, pt.y); - renderEngine.hideSystemCursor(true); -``` - -with: - -```cpp - POINT pt; GetCursorPos(&pt); - ts.mapper.reset(pt.x - ts.mon.x, pt.y - ts.mon.y); - renderEngine.hideSystemCursor(true); -``` - -and in the same block, replace: - -```cpp - p.clickDesktopX = r.clickDesktopX; p.clickDesktopY = r.clickDesktopY; -``` - -with: - -```cpp - p.clickDesktopX = r.clickDesktopX + ts.mon.x; p.clickDesktopY = r.clickDesktopY + ts.mon.y; -``` - -- [ ] **Step 10: Build and run unit tests** - -Run: `build.bat` then `build.bat test` -Expected: both exit 0. App builds; the pure tests still pass (no pure logic changed). - -- [ ] **Step 11: Single-monitor regression check** - -Run: `Wind.exe`. With one display, `MonitorUnderCursor()` returns that monitor at origin (0,0), so `SameMonitor` is true after startup and `retarget` is never invoked. Confirm: zoom in (PageUp), pan around, the cursor stays under the magnified pointer, clicks land correctly, recenter works (if bound), quit (Ctrl+Alt+Q). Behavior must be identical to before. - -- [ ] **Step 12: Inspect the render log** - -Open `%TEMP%\wind_render.log`. Confirm `initialize device=\\.\DISPLAY1 origin=(0,0) size=...` (or similar) and that no `retarget:` lines appear during a same-monitor session. This is the instrumentation the user will rely on if they try two monitors. - -- [ ] **Step 13: Commit** - -```bash -git add src/main.cpp -git commit -m "feat: follow the cursor's monitor on zoom-in (origin-corrected coords) (#32) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 7: Deploy ini + docs - -**Files:** -- Modify: `tools/uiaccess_setup.ps1`, `CLAUDE.md` - -- [ ] **Step 1: Add `multiMonitor` to the deployed ini** - -In `tools/uiaccess_setup.ps1`, in the `$ini = @"..."@` here-string, add these two lines right after the `dwmFlush=0` line (before the closing `"@`): - -``` -; multiMonitor: 1=magnify whichever monitor the cursor is on at zoom-in; 0=primary only -multiMonitor=1 -``` - -- [ ] **Step 2: Document the feature + multi-GPU limit in CLAUDE.md** - -In `CLAUDE.md`, in the `## IMPORTANT gotchas` section, add this bullet at the end of the list: - -```markdown -- MULTI-MONITOR: on each zoom-in we magnify the monitor the cursor is on (`multiMonitor=1` - default; `0` = primary only). The overlay is moved/resized and the DXGI output is re-selected - by device name (`render_engine` `retarget`/`selectOutput`); the pipeline works in LOCAL monitor - pixels with a `(originX,originY)` offset applied only at `GetCursorPos`/`SetCursorPos`. Limit: - if the cursor's monitor is on a DIFFERENT GPU than our D3D device, `retarget` returns false and - we keep the current monitor (no cross-adapter chase). While zoomed you stay on one monitor - (the OS cursor is pinned to it); switch by zooming out and back in on the other one. -``` - -- [ ] **Step 3: Commit** - -```bash -git add tools/uiaccess_setup.ps1 CLAUDE.md -git commit -m "docs: deploy ini multiMonitor=1 + CLAUDE.md multi-monitor notes (#32) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 8: Push + open PR - -**Files:** none (git/GitHub only). - -- [ ] **Step 1: Final build + test gate** - -Run: `build.bat` and `build.bat test` -Expected: both exit 0. - -- [ ] **Step 2: Push the branch** - -```bash -git push -u origin feat/multi-monitor -``` - -- [ ] **Step 3: Open the PR (references issue #32)** - -```bash -gh pr create --base feat/own-renderer --head feat/multi-monitor \ - --title "Multi-monitor follow-cursor (#32)" \ - --body "Magnify whichever monitor the cursor is on at zoom-in. Closes #32. - -- (originX,originY) monitor-origin model; pure CursorMapper/transform untouched (local px), conversion only at GetCursorPos/SetCursorPos. -- DXGI output selected by device name (selectOutput); RenderEngine::retarget moves the overlay + ResizeBuffers + rebinds duplication on monitor change, validated to be multi-GPU-safe (returns false rather than show monitor A on monitor B). -- Config kill-switch multiMonitor (default 1; 0 = legacy primary-only). -- Single-monitor path verified behaviorally identical (the only locally testable path); heavy RLog around detection/retarget for the user to verify the multi-monitor path on their hardware. - -Spec: docs/superpowers/specs/2026-05-26-multi-monitor-follow-cursor-design.md - -🤖 Generated with [Claude Code](https://claude.com/claude-code)" -``` - -- [ ] **Step 4: Report the PR URL to the user.** - ---- - -## Self-Review (completed during planning) - -**Spec coverage:** -- Coordinate model `(originX, originY)` -> Task 2 (State fields), Task 6 (boundary conversions). ✓ -- `MonitorTarget` type -> Task 2. ✓ -- `MonitorUnderCursor`/`PrimaryMonitor` -> Task 2 (Primary), Task 6 (UnderCursor). ✓ -- `initialize(const MonitorTarget&)` + overlay at origin -> Task 2. ✓ -- Output-by-name selection + first-output fallback -> Task 3. ✓ -- Size-aware `ensureDesktopCopy` -> Task 4. ✓ -- `retarget` with multi-GPU validation, ResizeBuffers, fresh-capture flags, lastClick reset -> Task 5. ✓ -- Zoom-in retarget + mapper rebuild + origin coords + clickDesktop offset -> Task 6. ✓ -- `multiMonitor` flag (config + default-ini + tests) -> Task 1; deploy ini + CLAUDE.md -> Task 7. ✓ -- Selftest/pacingtest origin correctness -> Task 6 (Steps 8-9). ✓ -- Single-monitor regression verification -> Task 2/3/6 sanity checks; RLog instrumentation -> Task 6 Step 12. ✓ -- Issue->branch->PR workflow -> Task 8. ✓ - -**Placeholder scan:** none (every code step shows full code; no TBD/TODO/"handle errors"). - -**Type consistency:** `MonitorTarget{x,y,w,h,device[32]}` used identically across Tasks 2/5/6. `selectOutput(const wchar_t*, bool)` declared Task 3 Step 1, defined Step 2, reused in Task 5. `retarget(const MonitorTarget&)` declared Task 5 Step 1, called Task 6 Step 4. `t.mon.{x,y,w,h}` consistent after Task 6 Step 2. `lstrcpynW(., ., 32)` used in render_engine.cpp and main.cpp consistently. ✓ diff --git a/docs/superpowers/plans/2026-05-26-structure-refactor.md b/docs/superpowers/plans/2026-05-26-structure-refactor.md deleted file mode 100644 index 6265150c..00000000 --- a/docs/superpowers/plans/2026-05-26-structure-refactor.md +++ /dev/null @@ -1,1007 +0,0 @@ -# Structure Refactor (PR-A) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Shrink `render_engine.cpp` (1046 lines) by extracting four self-contained concerns, de-dup `main.cpp`, and log silent `initialize` failures - all with zero behavior change. - -**Architecture:** Move verbatim code into focused units (`hdr_info`, `cursor_decode`, `render_shaders`, `png_dump`) behind clear interfaces, sharing `SafeRelease` via `com_util.h`. In `main.cpp`, extract a shared `FillRenderParams` and unify the duplicated button mapping into `InputRouter`. ComPtr RAII is a separate follow-up (PR-B), out of scope here. - -**Tech Stack:** C++17, MSVC `cl.exe`, Direct3D 11 / DXGI, WIC, Win32. Spec: `docs/superpowers/specs/2026-05-26-structure-refactor-design.md`. Branch `feat/structure-refactor`, issue #34. - -**Key commands:** -- App build: `build.bat` (globs `src\*.cpp`, emits `Wind.exe`; exit 0 = success). New `.cpp` files are compiled automatically. -- Build + run unit tests: `build.bat test` (exit 0 = pass; compiles only the pure files, so the new Win32 units are not in this build). - -**Conventions:** No em-dashes. Commit trailer on every commit: -``` -Co-Authored-By: Claude Opus 4.7 -``` - -**Global rule for the move tasks:** the extracted bodies must be **byte-for-byte the same code**, only relocated (drop the `static` keyword where noted, and for `png_dump` swap the `s_->` members for parameters as shown). No logic changes, no reformatting. - ---- - -## File Structure - -- **New** `src/com_util.h` - `SafeRelease` template (shared). -- **New** `src/render_shaders.h` - `kMagHLSL`, `kCursorHLSL`, `MagCB`. -- **New** `src/hdr_info.{h,cpp}` - `GetHdrEnabled`, `GetSDRWhiteNits`. -- **New** `src/cursor_decode.{h,cpp}` - `DecodeCursorBGRA`. -- **New** `src/png_dump.{h,cpp}` - `SaveTextureToPng`. -- **Modify** `src/render_engine.cpp` - remove the moved code, add includes, wrap `dumpBackbufferPng`, add HRESULT logging. -- **Modify** `src/main.cpp` - `FillRenderParams`, button mapping via `InputRouter`, remove `SetZoomButton` + statics. -- **Modify** `src/input_router.{h,cpp}` - button ids as members, `setButtonState`/`isZoomButton`. - ---- - -## Task 1: Shared `SafeRelease` (`com_util.h`) - -**Files:** Create `src/com_util.h`; Modify `src/render_engine.cpp`. - -- [ ] **Step 1: Create `src/com_util.h`** - -```cpp -#pragma once -namespace wind { -// Release a COM interface pointer and null it. Safe on null. Shared by the renderer and the -// PNG-dump helper. (Retired in the planned ComPtr migration.) -template inline void SafeRelease(T*& p) { if (p) { p->Release(); p = nullptr; } } -} -``` - -- [ ] **Step 2: Include it in `render_engine.cpp`** - -In `src/render_engine.cpp`, immediately after the line `#include "render_engine.h"`, add: -```cpp -#include "com_util.h" -``` - -- [ ] **Step 3: Remove the local `SafeRelease` definition** - -In `src/render_engine.cpp`, delete this line (currently just after `namespace wind {`): -```cpp -template static void SafeRelease(T*& p) { if (p) { p->Release(); p = nullptr; } } -``` - -- [ ] **Step 4: Build** - -Run: `build.bat` -Expected: exit 0, `Wind.exe` emitted, no `/W4` warnings. (All existing `SafeRelease(...)` calls now resolve to the header version in the same namespace.) - -- [ ] **Step 5: Commit** - -```bash -git add src/com_util.h src/render_engine.cpp -git commit -m "refactor: share SafeRelease via com_util.h (#34) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 2: Shaders header (`render_shaders.h`) - -**Files:** Create `src/render_shaders.h`; Modify `src/render_engine.cpp`. - -- [ ] **Step 1: Create `src/render_shaders.h` with the `MagCB` struct and both shader strings moved verbatim** - -Create `src/render_shaders.h`. Copy the `MagCB` struct, the `kMagHLSL` raw-string literal, and the `kCursorHLSL` raw-string literal **verbatim** out of `render_engine.cpp` (including their existing comments), wrapped as below. Change `static const char* kMagHLSL =` to `inline constexpr const char* kMagHLSL =` and likewise for `kCursorHLSL`. Do not alter the HLSL text. - -```cpp -#pragma once -namespace wind { - -// -struct MagCB { - float uvMinX, uvMinY, uvMaxX, uvMaxY; // reg 0 - float blurX, blurY, brightness, hdrMode; // reg 1 - float scRgbScale, pad0, pad1, pad2; // reg 2 -}; - -// -inline constexpr const char* kMagHLSL = R"( -... PASTE THE EXISTING kMagHLSL RAW STRING CONTENTS VERBATIM ... -)"; - -// -inline constexpr const char* kCursorHLSL = R"( -... PASTE THE EXISTING kCursorHLSL RAW STRING CONTENTS VERBATIM ... -)"; - -} -``` - -- [ ] **Step 2: Remove the moved definitions from `render_engine.cpp`** - -In `src/render_engine.cpp`, delete the `MagCB` struct definition (and its comment), the `static const char* kMagHLSL = R"(...)";` block (and its comment), and the `static const char* kCursorHLSL = R"(...)";` block (and its comment). **Leave** the `CompileShader` function in place (it stays in `render_engine.cpp`). - -- [ ] **Step 3: Include the header in `render_engine.cpp`** - -In `src/render_engine.cpp`, after the `#include "com_util.h"` line, add: -```cpp -#include "render_shaders.h" -``` - -- [ ] **Step 4: Build** - -Run: `build.bat` -Expected: exit 0. (`initialize` still references `kMagHLSL`/`kCursorHLSL` and `sizeof(MagCB)`; `render()` still references `MagCB` - all now via the header.) - -- [ ] **Step 5: Commit** - -```bash -git add src/render_shaders.h src/render_engine.cpp -git commit -m "refactor: move HLSL shaders + MagCB to render_shaders.h (#34) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 3: HDR queries (`hdr_info.{h,cpp}`) - -**Files:** Create `src/hdr_info.h`, `src/hdr_info.cpp`; Modify `src/render_engine.cpp`. - -- [ ] **Step 1: Create `src/hdr_info.h`** - -```cpp -#pragma once -namespace wind { -// Whether Windows HDR ("Use HDR") is actually ON right now (DisplayConfig -// ADVANCED_COLOR_INFO_2 activeColorMode). False if the API is unavailable (older Windows). -bool GetHdrEnabled(); -// SDR white level (nits) for the active HDR path, so HDR->SDR tonemapping matches the desktop. -// Returns a default if the query fails. -double GetSDRWhiteNits(); -} -``` - -- [ ] **Step 2: Create `src/hdr_info.cpp` and move both functions verbatim (drop `static`)** - -```cpp -#include "hdr_info.h" -#include -#include -namespace wind { - -// -bool GetHdrEnabled() { - ... PASTE THE EXISTING GetHdrEnabled BODY VERBATIM ... -} - -// -double GetSDRWhiteNits() { - ... PASTE THE EXISTING GetSDRWhiteNits BODY VERBATIM ... -} - -} -``` - -(The bodies move unchanged; only the leading `static` keyword on each function is dropped.) - -- [ ] **Step 3: Remove the two functions from `render_engine.cpp`** - -In `src/render_engine.cpp`, delete the `static bool GetHdrEnabled() { ... }` function (and its comment) and the `static double GetSDRWhiteNits() { ... }` function (and its comment). - -- [ ] **Step 4: Include the header in `render_engine.cpp`** - -In `src/render_engine.cpp`, after the `#include "render_shaders.h"` line, add: -```cpp -#include "hdr_info.h" -``` - -- [ ] **Step 5: Build** - -Run: `build.bat` -Expected: exit 0. (`recreateDupl` calls `GetHdrEnabled()`; `initialize` calls `GetSDRWhiteNits()` - both now via the header.) - -- [ ] **Step 6: Commit** - -```bash -git add src/hdr_info.h src/hdr_info.cpp src/render_engine.cpp -git commit -m "refactor: move HDR/colorspace queries to hdr_info (#34) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 4: Cursor decode (`cursor_decode.{h,cpp}`) - -**Files:** Create `src/cursor_decode.h`, `src/cursor_decode.cpp`; Modify `src/render_engine.cpp`. - -- [ ] **Step 1: Create `src/cursor_decode.h`** - -```cpp -#pragma once -#include -#include -#include -namespace wind { -// Decode an HCURSOR into top-down 32bpp BGRA (B8G8R8A8_UNORM order). Handles color cursors with -// per-pixel alpha and invert-style (no-alpha, e.g. I-beam) cursors: isInvert is set and `out` is -// white-on-black to be drawn with an invert blend. Returns size + hotspot. False on failure. -bool DecodeCursorBGRA(HCURSOR hc, std::vector& out, int& w, int& h, - int& hotX, int& hotY, bool& isInvert); -} -``` - -- [ ] **Step 2: Create `src/cursor_decode.cpp` and move the function verbatim (drop `static`)** - -```cpp -#include "cursor_decode.h" -namespace wind { - -// -bool DecodeCursorBGRA(HCURSOR hc, std::vector& out, - int& w, int& h, int& hotX, int& hotY, bool& isInvert) { - ... PASTE THE EXISTING DecodeCursorBGRA BODY VERBATIM ... -} - -} -``` - -(Body unchanged; only the leading `static` is dropped. ``, ``, `` come from the header.) - -- [ ] **Step 3: Remove the function from `render_engine.cpp`** - -In `src/render_engine.cpp`, delete the `static bool DecodeCursorBGRA(...) { ... }` function (and its comment block). - -- [ ] **Step 4: Include the header in `render_engine.cpp`** - -In `src/render_engine.cpp`, after the `#include "hdr_info.h"` line, add: -```cpp -#include "cursor_decode.h" -``` - -- [ ] **Step 5: Build** - -Run: `build.bat` -Expected: exit 0. (`State::updateCursorTexture` calls `DecodeCursorBGRA(...)` - now via the header.) - -- [ ] **Step 6: Commit** - -```bash -git add src/cursor_decode.h src/cursor_decode.cpp src/render_engine.cpp -git commit -m "refactor: move HCURSOR decode to cursor_decode (#34) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 5: PNG dump (`png_dump.{h,cpp}`) - -**Files:** Create `src/png_dump.h`, `src/png_dump.cpp`; Modify `src/render_engine.cpp`. - -- [ ] **Step 1: Create `src/png_dump.h`** - -```cpp -#pragma once -struct ID3D11Device; -struct ID3D11DeviceContext; -struct ID3D11Texture2D; -namespace wind { -// Copy a GPU texture to a staging texture and WIC-encode it as a 32bpp BGRA PNG at `path`. -// Verification-only (used by the render selftest). Returns false on any D3D/WIC failure. -bool SaveTextureToPng(ID3D11Device* dev, ID3D11DeviceContext* ctx, ID3D11Texture2D* tex, - const wchar_t* path); -} -``` - -- [ ] **Step 2: Create `src/png_dump.cpp`** - -```cpp -#include "png_dump.h" -#include "com_util.h" -#include -#include -#include -#pragma comment(lib, "windowscodecs.lib") -#pragma comment(lib, "ole32.lib") -namespace wind { -bool SaveTextureToPng(ID3D11Device* dev, ID3D11DeviceContext* ctx, ID3D11Texture2D* tex, - const wchar_t* path) { - if (!dev || !ctx || !tex) return false; - D3D11_TEXTURE2D_DESC td{}; - tex->GetDesc(&td); - D3D11_TEXTURE2D_DESC sd = td; - sd.Usage = D3D11_USAGE_STAGING; - sd.BindFlags = 0; - sd.CPUAccessFlags = D3D11_CPU_ACCESS_READ; - sd.MiscFlags = 0; - ID3D11Texture2D* stage = nullptr; - HRESULT hr = dev->CreateTexture2D(&sd, nullptr, &stage); - if (FAILED(hr)) return false; - ctx->CopyResource(stage, tex); - - D3D11_MAPPED_SUBRESOURCE map{}; - hr = ctx->Map(stage, 0, D3D11_MAP_READ, 0, &map); - if (FAILED(hr)) { SafeRelease(stage); return false; } - - bool ok = false; - CoInitializeEx(nullptr, COINIT_MULTITHREADED); - IWICImagingFactory* wic = nullptr; - if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, - __uuidof(IWICImagingFactory), (void**)&wic))) { - IWICBitmap* bmp = nullptr; - if (SUCCEEDED(wic->CreateBitmapFromMemory(td.Width, td.Height, - GUID_WICPixelFormat32bppBGRA, map.RowPitch, - map.RowPitch * td.Height, (BYTE*)map.pData, &bmp))) { - IWICStream* stream = nullptr; - wic->CreateStream(&stream); - if (stream && SUCCEEDED(stream->InitializeFromFilename(path, GENERIC_WRITE))) { - IWICBitmapEncoder* enc = nullptr; - wic->CreateEncoder(GUID_ContainerFormatPng, nullptr, &enc); - if (enc && SUCCEEDED(enc->Initialize(stream, WICBitmapEncoderNoCache))) { - IWICBitmapFrameEncode* frame = nullptr; - enc->CreateNewFrame(&frame, nullptr); - if (frame && SUCCEEDED(frame->Initialize(nullptr))) { - frame->SetSize(td.Width, td.Height); - WICPixelFormatGUID pf = GUID_WICPixelFormat32bppBGRA; - frame->SetPixelFormat(&pf); - if (SUCCEEDED(frame->WriteSource(bmp, nullptr)) && - SUCCEEDED(frame->Commit()) && SUCCEEDED(enc->Commit())) { - ok = true; - } - } - SafeRelease(frame); - } - SafeRelease(enc); - } - SafeRelease(stream); - SafeRelease(bmp); - } - SafeRelease(wic); - } - ctx->Unmap(stage, 0); - SafeRelease(stage); - return ok; -} -} -``` - -- [ ] **Step 3: Replace `RenderEngine::dumpBackbufferPng` with a thin wrapper** - -In `src/render_engine.cpp`, replace the entire `bool RenderEngine::dumpBackbufferPng(const wchar_t* path) { ... }` function (the ~60-line version starting at the `// Verification helper:` comment) with: -```cpp -// --------------------------------------------------------------------------- -// Verification helper: copy back-buffer 0 to a PNG (WIC encode lives in png_dump). -bool RenderEngine::dumpBackbufferPng(const wchar_t* path) { - if (!s_->ready) return false; - ID3D11Texture2D* back = nullptr; - if (FAILED(s_->swap->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&back))) return false; - bool ok = SaveTextureToPng(s_->device, s_->ctx, back, path); - SafeRelease(back); - return ok; -} -``` - -- [ ] **Step 4: Include the header and drop the now-unused ``** - -In `src/render_engine.cpp`, after the `#include "cursor_decode.h"` line, add: -```cpp -#include "png_dump.h" -``` -Then remove the line `#include ` from the system-include block (WIC is no longer used directly in `render_engine.cpp`; the `windowscodecs.lib`/`ole32.lib` pragmas may stay, they are harmless). - -- [ ] **Step 5: Build** - -Run: `build.bat` -Expected: exit 0. (`dumpFrame` still calls `dumpBackbufferPng`, which now delegates to `SaveTextureToPng`.) - -- [ ] **Step 6: Optional self-test verification** - -If convenient, run `set WIND_SELFTEST=1 && Wind.exe` (cmd) or `$env:WIND_SELFTEST=1; .\Wind.exe` (PowerShell) and confirm a `wind_selftest.png` is produced in the repo root, proving the extracted PNG path still works. Delete the PNG afterward. (Not required to pass; build is the gate.) - -- [ ] **Step 7: Commit** - -```bash -git add src/png_dump.h src/png_dump.cpp src/render_engine.cpp -git commit -m "refactor: move WIC PNG encode to png_dump; dumpBackbufferPng wraps it (#34) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 6: De-dup `RenderFrameParams` fill in `main.cpp` - -**Files:** Modify `src/main.cpp`. - -- [ ] **Step 1: Add the `FillRenderParams` helper** - -In `src/main.cpp`, add this function directly **after** the `CursorModeFromCfg` helper (search for `static int CursorModeFromCfg(const Config& c)` and place the new function after its closing brace): -```cpp -// Fill a RenderFrameParams from the mapper result + config for the given monitor and zoom level -// (the normal live-tick interpretation). The self-test harnesses call this, then override only -// the few fields they deliberately differ on (cursorMode, vsync, motion blur). -static void FillRenderParams(RenderFrameParams& p, const MapResult& r, const Config& cfg, - const MonitorTarget& mon, double level) { - p.level = level; - p.srcLeft = r.srcLeft; p.srcTop = r.srcTop; - p.cursorScreenX = r.cursorScreenX; p.cursorScreenY = r.cursorScreenY; - // clickDesktop is local monitor px; SetCursorPos needs virtual-desktop coords. - p.clickDesktopX = r.clickDesktopX + mon.x; p.clickDesktopY = r.clickDesktopY + mon.y; - p.cursorScaleWithZoom = (cfg.cursorScaleWithZoom != 0); - p.bilinear = (cfg.bilinear != 0); - p.motionBlur = (cfg.motionBlur != 0); - p.motionBlurStrength = cfg.motionBlurStrength; - p.brightness = cfg.brightness; - p.cursorMode = CursorModeFromCfg(cfg); - // In DwmFlush mode we present immediately (no vsync block) and let DwmFlush() pace. - p.vsync = (cfg.vsync != 0 && cfg.dwmFlush == 0); -} -``` -NOTE: if `FillRenderParams` is placed before `CursorModeFromCfg` is declared, it will not compile. Place it after `CursorModeFromCfg`. (Both are above `RunTick`, which uses them.) - -- [ ] **Step 2: Use it in `RunTick`** - -In `src/main.cpp`, in `RunTick`, replace this block: -```cpp - MapResult r = t.mapper.update(dx, dy, lvl); - RenderFrameParams p{}; - p.level = lvl; - p.srcLeft = r.srcLeft; p.srcTop = r.srcTop; - p.cursorScreenX = r.cursorScreenX; p.cursorScreenY = r.cursorScreenY; - // clickDesktop is local monitor px; SetCursorPos needs virtual-desktop coords. - p.clickDesktopX = r.clickDesktopX + t.mon.x; p.clickDesktopY = r.clickDesktopY + t.mon.y; - p.cursorScaleWithZoom = (t.cfg.cursorScaleWithZoom != 0); - p.bilinear = (t.cfg.bilinear != 0); - p.motionBlur = (t.cfg.motionBlur != 0); - p.motionBlurStrength = t.cfg.motionBlurStrength; - p.brightness = t.cfg.brightness; - p.cursorMode = CursorModeFromCfg(t.cfg); - // In DwmFlush mode we present immediately (no vsync block) and let DwmFlush() pace. - p.vsync = (t.cfg.vsync != 0 && t.cfg.dwmFlush == 0); - t.renderEngine.renderFrame(p); -``` -with: -```cpp - MapResult r = t.mapper.update(dx, dy, lvl); - RenderFrameParams p{}; - FillRenderParams(p, r, t.cfg, t.mon, lvl); - t.renderEngine.renderFrame(p); -``` - -- [ ] **Step 3: Use it in the `WIND_SELFTEST` block** - -In `src/main.cpp`, in the `WIND_SELFTEST` loop, replace: -```cpp - MapResult r = ts.mapper.update(0, 0, 4.0); - p.level = 4.0; p.srcLeft = r.srcLeft; p.srcTop = r.srcTop; - p.cursorScreenX = r.cursorScreenX; p.cursorScreenY = r.cursorScreenY; - p.clickDesktopX = r.clickDesktopX + ts.mon.x; p.clickDesktopY = r.clickDesktopY + ts.mon.y; - p.cursorScaleWithZoom = (cfg.cursorScaleWithZoom != 0); - p.bilinear = (cfg.bilinear != 0); - p.motionBlur = (cfg.motionBlur != 0); - p.motionBlurStrength = cfg.motionBlurStrength; - p.brightness = cfg.brightness; - p.cursorMode = 1; // always draw the cursor in the selftest dump - p.vsync = true; - renderEngine.renderFrame(p); -``` -with: -```cpp - MapResult r = ts.mapper.update(0, 0, 4.0); - FillRenderParams(p, r, cfg, ts.mon, 4.0); - p.cursorMode = 1; // always draw the cursor in the selftest dump - p.vsync = true; - renderEngine.renderFrame(p); -``` - -- [ ] **Step 4: Use it in the `WIND_PACINGTEST` block** - -In `src/main.cpp`, in the `WIND_PACINGTEST` loop, replace: -```cpp - MapResult r = ts.mapper.update(dxp, 0, 4.0); - RenderFrameParams p{}; - p.level = 4.0; p.srcLeft = r.srcLeft; p.srcTop = r.srcTop; - p.cursorScreenX = r.cursorScreenX; p.cursorScreenY = r.cursorScreenY; - p.clickDesktopX = r.clickDesktopX + ts.mon.x; p.clickDesktopY = r.clickDesktopY + ts.mon.y; - p.cursorScaleWithZoom = (cfg.cursorScaleWithZoom != 0); - p.bilinear = (cfg.bilinear != 0); p.motionBlur = false; p.motionBlurStrength = 1.0; - p.brightness = cfg.brightness; p.cursorMode = 1; p.vsync = (cfg.vsync != 0); -``` -with: -```cpp - MapResult r = ts.mapper.update(dxp, 0, 4.0); - RenderFrameParams p{}; - FillRenderParams(p, r, cfg, ts.mon, 4.0); - p.motionBlur = false; p.motionBlurStrength = 1.0; // pacing test: no blur - p.cursorMode = 1; p.vsync = (cfg.vsync != 0); -``` - -- [ ] **Step 5: Build and test** - -Run: `build.bat` then `build.bat test` -Expected: both exit 0. The three call sites now share one helper; behavior is identical (selftest still forces cursorMode=1/vsync=true; pacingtest still forces no-blur/cursorMode=1/vsync=cfg.vsync). - -- [ ] **Step 6: Commit** - -```bash -git add src/main.cpp -git commit -m "refactor: extract FillRenderParams shared by tick + selftests (#34) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 7: Unify the button-state mapping into `InputRouter` - -**Files:** Modify `src/input_router.h`, `src/input_router.cpp`, `src/main.cpp`. - -- [ ] **Step 1: Declare the new methods + members in `input_router.h`** - -In `src/input_router.h`, in `class InputRouter`, replace: -```cpp - bool start(int inButtonId, int outButtonId, bool swallow); - void stop(); - InputState& state() { return state_; } - // Atomically read and zero the accumulated raw deltas. - void drainRaw(int& dx, int& dy); -private: - InputState state_; -}; -``` -with: -```cpp - bool start(int inButtonId, int outButtonId, bool swallow); - void stop(); - InputState& state() { return state_; } - // Atomically read and zero the accumulated raw deltas. - void drainRaw(int& dx, int& dy); - // Map an XBUTTON id (1 = XBUTTON1, 2 = XBUTTON2) to the in/out held state, using the - // configured zoom buttons. Shared by the WH_MOUSE_LL hook and main's WM_INPUT path. - void setButtonState(int xbuttonId, bool down); - // Whether the id is one of the configured zoom buttons (used to decide swallowing). - bool isZoomButton(int xbuttonId) const; - // Whether the hook should swallow the configured zoom buttons (set in start()). - bool swallowEnabled() const { return swallow_; } -private: - InputState state_; - int inButtonId_ = 2; // 1 = XBUTTON1, 2 = XBUTTON2 (set in start()) - int outButtonId_ = 1; - bool swallow_ = true; -}; -``` - -- [ ] **Step 2: Rework `input_router.cpp` to use members + the shared mapping** - -In `src/input_router.cpp`, replace this block: -```cpp -namespace wind { -static InputRouter* g_router = nullptr; -static int g_inButtonId = 2, g_outButtonId = 1; -static bool g_swallow = true; -static HHOOK g_mouseHook = nullptr; - -static int xbuttonIdFromHook(WPARAM wParam, LPARAM lParam) { - auto* mi = reinterpret_cast(lParam); - if (wParam == WM_XBUTTONDOWN || wParam == WM_XBUTTONUP) { - WORD hi = HIWORD(mi->mouseData); // XBUTTON1 or XBUTTON2 - return (hi == XBUTTON1) ? 1 : (hi == XBUTTON2 ? 2 : 0); - } - return 0; -} - -static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { - if (code == HC_ACTION && g_router) { - int id = xbuttonIdFromHook(wParam, lParam); - bool down = (wParam == WM_XBUTTONDOWN); - bool up = (wParam == WM_XBUTTONUP); - if (id != 0 && (down || up)) { - if (id == g_inButtonId) g_router->state().inHeld.store(down); - if (id == g_outButtonId) g_router->state().outHeld.store(down); - if (g_swallow && (id == g_inButtonId || id == g_outButtonId)) - return 1; // swallow so browser back/forward don't fire - } - } - return CallNextHookEx(g_mouseHook, code, wParam, lParam); -} - -bool InputRouter::start(int inButtonId, int outButtonId, bool swallow) { - g_router = this; g_inButtonId = inButtonId; g_outButtonId = outButtonId; g_swallow = swallow; - g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, MouseProc, GetModuleHandleW(nullptr), 0); - return g_mouseHook != nullptr; - // Raw Input registration (RIDEV_INPUTSINK) + WM_INPUT decoding live in main.cpp's - // message-only window, which calls AccumulateRaw() with the decoded deltas. -} -``` -with this (the file-static `g_inButtonId`/`g_outButtonId`/`g_swallow` are gone - those values now live on the `InputRouter` instance; `g_router` and `g_mouseHook` stay because the C hook callback needs file-scope access to the active router): -```cpp -namespace wind { -static InputRouter* g_router = nullptr; -static HHOOK g_mouseHook = nullptr; - -static int xbuttonIdFromHook(WPARAM wParam, LPARAM lParam) { - auto* mi = reinterpret_cast(lParam); - if (wParam == WM_XBUTTONDOWN || wParam == WM_XBUTTONUP) { - WORD hi = HIWORD(mi->mouseData); // XBUTTON1 or XBUTTON2 - return (hi == XBUTTON1) ? 1 : (hi == XBUTTON2 ? 2 : 0); - } - return 0; -} - -// Shared by the WH_MOUSE_LL hook (below) and main's WM_INPUT path: map an XBUTTON id to held. -void InputRouter::setButtonState(int xbuttonId, bool down) { - if (xbuttonId == inButtonId_) state_.inHeld.store(down); - if (xbuttonId == outButtonId_) state_.outHeld.store(down); -} -bool InputRouter::isZoomButton(int xbuttonId) const { - return xbuttonId == inButtonId_ || xbuttonId == outButtonId_; -} - -static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { - if (code == HC_ACTION && g_router) { - int id = xbuttonIdFromHook(wParam, lParam); - bool down = (wParam == WM_XBUTTONDOWN); - bool up = (wParam == WM_XBUTTONUP); - if (id != 0 && (down || up)) { - g_router->setButtonState(id, down); - if (g_router->swallowEnabled() && g_router->isZoomButton(id)) - return 1; // swallow so browser back/forward don't fire - } - } - return CallNextHookEx(g_mouseHook, code, wParam, lParam); -} - -bool InputRouter::start(int inButtonId, int outButtonId, bool swallow) { - g_router = this; inButtonId_ = inButtonId; outButtonId_ = outButtonId; swallow_ = swallow; - g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, MouseProc, GetModuleHandleW(nullptr), 0); - return g_mouseHook != nullptr; - // Raw Input registration (RIDEV_INPUTSINK) + WM_INPUT decoding live in main.cpp's - // message-only window, which calls AccumulateRaw() with the decoded deltas. -} -``` - -- [ ] **Step 3: Route the `WM_INPUT` path through `setButtonState` in `main.cpp`** - -In `src/main.cpp`, in `WndProc`'s `WM_INPUT` handler, replace: -```cpp - USHORT bf = m.usButtonFlags; - if (bf & RI_MOUSE_BUTTON_4_DOWN) SetZoomButton(1, true); - if (bf & RI_MOUSE_BUTTON_4_UP) SetZoomButton(1, false); - if (bf & RI_MOUSE_BUTTON_5_DOWN) SetZoomButton(2, true); - if (bf & RI_MOUSE_BUTTON_5_UP) SetZoomButton(2, false); -``` -with: -```cpp - USHORT bf = m.usButtonFlags; - if (bf & RI_MOUSE_BUTTON_4_DOWN) g_input.setButtonState(1, true); - if (bf & RI_MOUSE_BUTTON_4_UP) g_input.setButtonState(1, false); - if (bf & RI_MOUSE_BUTTON_5_DOWN) g_input.setButtonState(2, true); - if (bf & RI_MOUSE_BUTTON_5_UP) g_input.setButtonState(2, false); -``` - -- [ ] **Step 4: Remove the now-redundant `SetZoomButton` and its statics in `main.cpp`** - -In `src/main.cpp`, delete: -```cpp -static int g_zoomInBtnId = 2; // XBUTTON id: 1 = XBUTTON1, 2 = XBUTTON2 (set from cfg) -static int g_zoomOutBtnId = 1; - -// Set side-button state from a Raw Input transition. Mirrors the hook's id mapping so -// the two state sources are interchangeable and idempotent. -static void SetZoomButton(int xbuttonId, bool down) { - if (xbuttonId == g_zoomInBtnId) g_input.state().inHeld.store(down); - if (xbuttonId == g_zoomOutBtnId) g_input.state().outHeld.store(down); -} -``` -Then, in `wWinMain`, delete the two now-unused assignments: -```cpp - g_zoomInBtnId = cfg.zoomInButton; - g_zoomOutBtnId = cfg.zoomOutButton; -``` -(The configured ids now live in `InputRouter`, set by the existing `g_input.start(cfg.zoomInButton, cfg.zoomOutButton, true)` call, which is unchanged.) - -- [ ] **Step 5: Build and test** - -Run: `build.bat` then `build.bat test` -Expected: both exit 0, no `/W4` warnings (no unused statics/functions left). - -- [ ] **Step 6: Manual behavior check (input is behavior-sensitive)** - -Run `Wind.exe`. Confirm: the configured side button still zooms (in/out), and while Wind is running the mouse back/forward buttons are still swallowed (don't navigate the browser). Keyboard PageUp/PageDown still zoom. Quit with Ctrl+Alt+Q. - -- [ ] **Step 7: Commit** - -```bash -git add src/input_router.h src/input_router.cpp src/main.cpp -git commit -m "refactor: unify button-state mapping into InputRouter (#34) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 8: Log HRESULT failures in `initialize` - -**Files:** Modify `src/render_engine.cpp`. - -Add an `RLog` before each currently-silent `return false` in `RenderEngine::initialize`. `RLog` is already defined in `render_engine.cpp`. Apply each replacement exactly (the multi-line anchors disambiguate the three identical `if (FAILED(hr)) return false;` lines). - -- [ ] **Step 1: Window creation** - -Replace: -```cpp - if (!s_->hwnd) return false; -``` -with: -```cpp - if (!s_->hwnd) { RLog("initialize: CreateWindow failed gle=%lu", (unsigned long)GetLastError()); return false; } -``` - -- [ ] **Step 2: D3D11CreateDevice** - -Replace: -```cpp - &s_->device, &got, &s_->ctx); - if (FAILED(hr)) return false; -``` -with: -```cpp - &s_->device, &got, &s_->ctx); - if (FAILED(hr)) { RLog("initialize: D3D11CreateDevice failed hr=0x%08lX", (unsigned long)hr); return false; } -``` - -- [ ] **Step 3: IDXGIDevice1 QueryInterface** - -Replace: -```cpp - if (FAILED(s_->device->QueryInterface(__uuidof(IDXGIDevice1), (void**)&dxgiDev))) return false; -``` -with: -```cpp - if (FAILED(s_->device->QueryInterface(__uuidof(IDXGIDevice1), (void**)&dxgiDev))) { RLog("initialize: QueryInterface(IDXGIDevice1) failed"); return false; } -``` - -- [ ] **Step 4: No factory** - -Replace: -```cpp - if (!factory) return false; -``` -with: -```cpp - if (!factory) { RLog("initialize: no IDXGIFactory from adapter"); return false; } -``` - -- [ ] **Step 5: CreateSwapChain** - -Replace: -```cpp - SafeRelease(factory); - if (FAILED(hr)) return false; -``` -with: -```cpp - SafeRelease(factory); - if (FAILED(hr)) { RLog("initialize: CreateSwapChain failed hr=0x%08lX", (unsigned long)hr); return false; } -``` - -- [ ] **Step 6: GetBuffer** - -Replace: -```cpp - if (FAILED(s_->swap->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&back))) return false; -``` -with: -```cpp - if (FAILED(s_->swap->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&back))) { RLog("initialize: swapchain GetBuffer(0) failed"); return false; } -``` - -- [ ] **Step 7: CreateRenderTargetView** - -Replace: -```cpp - hr = s_->device->CreateRenderTargetView(back, nullptr, &s_->rtv); - SafeRelease(back); - if (FAILED(hr)) return false; -``` -with: -```cpp - hr = s_->device->CreateRenderTargetView(back, nullptr, &s_->rtv); - SafeRelease(back); - if (FAILED(hr)) { RLog("initialize: CreateRenderTargetView failed hr=0x%08lX", (unsigned long)hr); return false; } -``` - -- [ ] **Step 8: ensureDesktopCopy** - -Replace: -```cpp - if (!s_->ensureDesktopCopy(DXGI_FORMAT_B8G8R8A8_UNORM)) return false; -``` -with: -```cpp - if (!s_->ensureDesktopCopy(DXGI_FORMAT_B8G8R8A8_UNORM)) { RLog("initialize: ensureDesktopCopy failed"); return false; } -``` - -- [ ] **Step 9: Magnify shader compile** - -Replace: -```cpp - if (!vsb || !psb) { SafeRelease(vsb); SafeRelease(psb); return false; } -``` -with: -```cpp - if (!vsb || !psb) { RLog("initialize: magnify shader compile failed"); SafeRelease(vsb); SafeRelease(psb); return false; } -``` - -- [ ] **Step 10: Magnify shader create** - -Replace: -```cpp - SafeRelease(vsb); SafeRelease(psb); - if (FAILED(hr) || FAILED(hr2)) return false; -``` -with: -```cpp - SafeRelease(vsb); SafeRelease(psb); - if (FAILED(hr) || FAILED(hr2)) { RLog("initialize: magnify shader create failed hr=0x%08lX hr2=0x%08lX", (unsigned long)hr, (unsigned long)hr2); return false; } -``` - -- [ ] **Step 11: Magnify constant buffer** - -Replace: -```cpp - if (FAILED(s_->device->CreateBuffer(&cbd, nullptr, &s_->cb))) return false; -``` -with: -```cpp - if (FAILED(s_->device->CreateBuffer(&cbd, nullptr, &s_->cb))) { RLog("initialize: CreateBuffer(magnify cb) failed"); return false; } -``` - -- [ ] **Step 12: Cursor shader compile** - -Replace: -```cpp - if (!cvsb || !cpsb) { SafeRelease(cvsb); SafeRelease(cpsb); return false; } -``` -with: -```cpp - if (!cvsb || !cpsb) { RLog("initialize: cursor shader compile failed"); SafeRelease(cvsb); SafeRelease(cpsb); return false; } -``` - -- [ ] **Step 13: Cursor shader create** - -Replace: -```cpp - SafeRelease(cvsb); SafeRelease(cpsb); - if (FAILED(hr3) || FAILED(hr4)) return false; -``` -with: -```cpp - SafeRelease(cvsb); SafeRelease(cpsb); - if (FAILED(hr3) || FAILED(hr4)) { RLog("initialize: cursor shader create failed hr3=0x%08lX hr4=0x%08lX", (unsigned long)hr3, (unsigned long)hr4); return false; } -``` - -- [ ] **Step 14: Cursor constant buffer** - -Replace: -```cpp - if (FAILED(s_->device->CreateBuffer(&ccbd, nullptr, &s_->ccb))) return false; -``` -with: -```cpp - if (FAILED(s_->device->CreateBuffer(&ccbd, nullptr, &s_->ccb))) { RLog("initialize: CreateBuffer(cursor cb) failed"); return false; } -``` - -- [ ] **Step 15: Alpha blend state** - -Replace: -```cpp - if (FAILED(s_->device->CreateBlendState(&bd, &s_->blend))) return false; -``` -with: -```cpp - if (FAILED(s_->device->CreateBlendState(&bd, &s_->blend))) { RLog("initialize: CreateBlendState(alpha) failed"); return false; } -``` - -- [ ] **Step 16: Invert blend state** - -Replace: -```cpp - if (FAILED(s_->device->CreateBlendState(&ib, &s_->blendInvert))) return false; -``` -with: -```cpp - if (FAILED(s_->device->CreateBlendState(&ib, &s_->blendInvert))) { RLog("initialize: CreateBlendState(invert) failed"); return false; } -``` - -- [ ] **Step 17: Samplers** - -Replace: -```cpp - if (!s_->sampLinear || !s_->sampPoint) return false; -``` -with: -```cpp - if (!s_->sampLinear || !s_->sampPoint) { RLog("initialize: CreateSamplerState failed"); return false; } -``` - -- [ ] **Step 18: recreateDupl** - -Replace: -```cpp - if (!s_->recreateDupl()) return false; -``` -with: -```cpp - if (!s_->recreateDupl()) { RLog("initialize: recreateDupl failed"); return false; } -``` - -- [ ] **Step 19: Build** - -Run: `build.bat` -Expected: exit 0, no `/W4` warnings. Control flow unchanged; only diagnostics added. - -- [ ] **Step 20: Commit** - -```bash -git add src/render_engine.cpp -git commit -m "refactor: log HRESULT at initialize failure points (#34) - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 9: Final gate + push + PR - -**Files:** none (git/GitHub only). - -- [ ] **Step 1: Final build + test gate** - -Run: `build.bat` and `build.bat test` -Expected: both exit 0; tests 32 cases / 94 assertions pass. - -- [ ] **Step 2: Confirm `render_engine.cpp` shrank** - -Run: `wc -l src/render_engine.cpp` (or PowerShell `(Get-Content src/render_engine.cpp).Count`). Expected: roughly ~770 lines (down from 1046). This is a sanity signal, not a hard gate. - -- [ ] **Step 3: Push the branch** - -```bash -git push -u origin feat/structure-refactor -``` - -- [ ] **Step 4: Open the PR (references issue #34)** - -```bash -gh pr create --base feat/own-renderer --head feat/structure-refactor \ - --title "Structure refactor PR-A: split render_engine.cpp, de-dup main.cpp, log init failures (#34)" \ - --body "Internal cleanup, zero behavior change. Closes #34 (PR-A of two; ComPtr RAII is the separate PR-B). - -- Extract hdr_info, cursor_decode, render_shaders, png_dump from render_engine.cpp (~1046 -> ~770 lines); share SafeRelease via com_util.h. -- main.cpp: FillRenderParams shared by the tick + both self-test blocks; button-state mapping unified into InputRouter (removed the duplicate SetZoomButton + its statics). -- Log HRESULT at the previously-silent initialize() failure points. -- All extracted code moved verbatim; build clean + unit tests pass (32/94); single-monitor zoom/pan/click/cursor/selftest verified unchanged; side-button + back/forward-swallow input behavior confirmed. - -Spec: docs/superpowers/specs/2026-05-26-structure-refactor-design.md - -🤖 Generated with [Claude Code](https://claude.com/claude-code)" -``` - -- [ ] **Step 5: Report the PR URL to the user.** - ---- - -## Self-Review (completed during planning) - -**Spec coverage:** -- `com_util.h` (shared SafeRelease) -> Task 1. ✓ -- `render_shaders.h` (kMagHLSL/kCursorHLSL/MagCB) -> Task 2. ✓ -- `hdr_info.{h,cpp}` -> Task 3. ✓ -- `cursor_decode.{h,cpp}` -> Task 4. ✓ -- `png_dump.{h,cpp}` + dumpBackbufferPng wrapper -> Task 5. ✓ -- FillRenderParams (tick + 2 selftests) -> Task 6. ✓ -- Button-state unify into InputRouter -> Task 7. ✓ -- HRESULT logging in initialize -> Task 8. ✓ -- Issue->branch->PR, build/test verification -> Task 9. ✓ -- `build.bat` not modified (app globs src\*.cpp; test build lists pure files only) -> respected; no task changes build.bat. ✓ - -**Placeholder scan:** none. Task 2/3/4 instruct verbatim moves with the function signatures as anchors and an explicit "drop static"; the existing literal/body content is copied, not re-typed, which is the correct discipline for a move (the `... PASTE ... VERBATIM ...` markers denote "copy the existing code here", not unfinished work). - -**Type consistency:** `MonitorTarget`, `MapResult`, `Config`, `RenderFrameParams` used consistently in `FillRenderParams` (Task 6) match `main.cpp`/`render_engine.h`. `setButtonState(int,bool)` / `isZoomButton(int) const` / `swallowEnabled() const` declared (Task 7 Step 1) and used (Step 2/3) consistently. `SaveTextureToPng(ID3D11Device*, ID3D11DeviceContext*, ID3D11Texture2D*, const wchar_t*)` declared (Task 5 Step 1), defined (Step 2), called (Step 3) identically. `SafeRelease` template signature matches the original. ✓ diff --git a/docs/superpowers/plans/2026-05-27-config-ui-mvp.md b/docs/superpowers/plans/2026-05-27-config-ui-mvp.md deleted file mode 100644 index 7e88d3ca..00000000 --- a/docs/superpowers/plans/2026-05-27-config-ui-mvp.md +++ /dev/null @@ -1,707 +0,0 @@ -# Wind Config UI (MVP) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** A working settings GUI: a separate `WindConfig.exe` (C++ + WebView2) hosting a Svelte UI that reads/writes `magnifier.ini`, opened from the tray, applying changes live - with onboarding and visual polish deferred. - -**Architecture:** `WindConfig.exe` is a standalone process that talks to the core only through `magnifier.ini` (the core already hot-reloads it), so the zoom path is untouched. A thin C++ WebView2 host loads the built Svelte app from disk via a virtual host mapping and exposes a tiny `getConfig`/`setConfig` JSON bridge; `setConfig` does a surgical, atomic ini write. The Svelte app renders controls from a schema and live-applies on change. - -**Tech Stack:** C++17/MSVC (host), WebView2 (Evergreen runtime + vendored SDK), Svelte + Vite + Node (UI), doctest (host unit tests), Playwright (UI E2E). - -**Spec:** `docs/superpowers/specs/2026-05-27-config-ui-design.md` -**Branch:** `feat/config-ui` (already created). - ---- - -## File structure - -- `third_party/webview2/` - vendored WebView2 SDK (`include/WebView2.h` etc. + `x64/WebView2LoaderStatic.lib`). -- `src/config_ui/ini_edit.h` / `ini_edit.cpp` - **pure** surgical ini read/update (no ``; unit-tested). -- `src/config_ui/main.cpp` - the Win32 + WebView2 host (`WindConfig.exe`): window, WebView2 init, virtual-host mapping, the JSON bridge, and the Win32 file read / atomic write. -- `tests/test_ini_edit.cpp` - doctest unit tests for `ini_edit`. -- `ui/` - Svelte + Vite app: `src/settings-schema.js`, `src/bridge.js`, `src/App.svelte`, `src/lib/*.svelte`, `index.html`, `package.json`, `vite.config.js`; builds to `ui/dist/`. -- `ui/tests/settings.spec.js` + `ui/playwright.config.js` - Playwright E2E with a mock bridge. -- `build.bat` - add a `config` target (build UI + compile the host) and add `ini_edit.cpp` to the `test` target. -- `src/tray.cpp` - add an "Open Settings" menu item that launches `WindConfig.exe`. - -The core's perf path (`render_engine`, the tick loop) is **not** touched. - ---- - -### Task 1: Vendor the WebView2 SDK - -**Files:** Create `third_party/webview2/` (headers + x64 static loader). - -- [ ] **Step 1: Download + extract the SDK** (PowerShell, from repo root) - -```powershell -$ver = "1.0.2792.45" -$nupkg = "$env:TEMP\webview2.zip" -Invoke-WebRequest "https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/$ver" -OutFile $nupkg -$dst = "$env:TEMP\wv2"; Remove-Item $dst -Recurse -Force -ErrorAction SilentlyContinue -Expand-Archive $nupkg -DestinationPath $dst -New-Item -ItemType Directory -Force third_party\webview2\include, third_party\webview2\x64 | Out-Null -Copy-Item "$dst\build\native\include\*" third_party\webview2\include\ -Recurse -Force -Copy-Item "$dst\build\native\x64\WebView2LoaderStatic.lib" third_party\webview2\x64\ -Force -``` - -- [ ] **Step 2: Verify the files exist** - -Run: `dir third_party\webview2\include\WebView2.h third_party\webview2\x64\WebView2LoaderStatic.lib` -Expected: both files listed. (If the NuGet layout differs by version, locate `WebView2.h` and `WebView2LoaderStatic.lib` under the extracted `$dst` and copy them to the same destinations.) - -- [ ] **Step 3: Record the version** - create `third_party/webview2/VERSION.txt` containing the line `Microsoft.Web.WebView2 1.0.2792.45`. - -- [ ] **Step 4: Commit** - -```bash -git add third_party/webview2 -git commit -m "chore: vendor WebView2 SDK (headers + x64 static loader)" -``` - ---- - -### Task 2: Pure ini_edit module + unit tests (TDD) - -**Files:** -- Create: `src/config_ui/ini_edit.h`, `src/config_ui/ini_edit.cpp` -- Test: `tests/test_ini_edit.cpp` -- Modify: `build.bat` (the `:test` compile line) - -- [ ] **Step 1: Write the failing tests** - create `tests/test_ini_edit.cpp`: - -```cpp -#include "doctest.h" -#include "../src/config_ui/ini_edit.h" -using namespace wind; - -TEST_CASE("ReadIniValues parses key=value, skipping comments and blanks") { - auto m = ReadIniValues("; c\nmaxLevel=8.0\n\nzoomInSpeed = 1.2\n# x\n"); - CHECK(m["maxLevel"] == "8.0"); - CHECK(m["zoomInSpeed"] == "1.2"); // trimmed - CHECK(m.count("c") == 0); -} -TEST_CASE("UpdateIniText replaces an existing key in place, preserving the rest") { - std::string t = "; speed knob\nzoomInSpeed=1.0\nmaxLevel=8.0\n"; - std::string r = UpdateIniText(t, "zoomInSpeed", "2.0"); - auto m = ReadIniValues(r); - CHECK(m["zoomInSpeed"] == "2.0"); - CHECK(m["maxLevel"] == "8.0"); // untouched - CHECK(r.find("; speed knob") != std::string::npos); // comment preserved -} -TEST_CASE("UpdateIniText appends a missing key") { - std::string r = UpdateIniText("maxLevel=8.0\n", "smoothZoom", "1"); - auto m = ReadIniValues(r); - CHECK(m["smoothZoom"] == "1"); - CHECK(m["maxLevel"] == "8.0"); -} -TEST_CASE("UpdateIniText leaves unknown keys and comment-only lines intact") { - std::string t = "; header\nfoo=bar\n; mid\nzoomInSpeed=1.0\n"; - std::string r = UpdateIniText(t, "zoomInSpeed", "3.0"); - CHECK(r.find("foo=bar") != std::string::npos); - CHECK(r.find("; mid") != std::string::npos); - CHECK(ReadIniValues(r)["zoomInSpeed"] == "3.0"); -} -TEST_CASE("read-modify-write round trip is stable") { - std::string r = UpdateIniText(UpdateIniText("a=1\nb=2\n", "a", "10"), "c", "3"); - auto m = ReadIniValues(r); - CHECK(m["a"] == "10"); CHECK(m["b"] == "2"); CHECK(m["c"] == "3"); -} -``` - -- [ ] **Step 2: Add `ini_edit.cpp` to the test build** - in `build.bat`, on the `:test` `cl` line, append `src\config_ui\ini_edit.cpp` to the source list (after `src\lock_detector.cpp`): - -``` - src\transform.cpp src\zoom_controller.cpp src\config.cpp src\cursor_mapper.cpp src\lock_detector.cpp src\config_ui\ini_edit.cpp ^ -``` - -- [ ] **Step 3: Run tests to verify they fail** - -Run: `build.bat test` -Expected: compile error - `ini_edit.h` not found / `ReadIniValues` undefined. That is the failing state. - -- [ ] **Step 4: Create `src/config_ui/ini_edit.h`** - -```cpp -#pragma once -#include -#include -namespace wind { -// Parse INI text into key->value, skipping ';'/'#' comments and blank lines; keys/values trimmed. -std::map ReadIniValues(const std::string& text); -// Return INI text with `key`'s value replaced IN PLACE, preserving every other line (comments, -// order, unknown keys). If `key` is absent, append "key=value". Pure (no I/O, no ). -std::string UpdateIniText(const std::string& text, const std::string& key, const std::string& value); -} -``` - -- [ ] **Step 5: Create `src/config_ui/ini_edit.cpp`** - -```cpp -#include "ini_edit.h" -#include -namespace wind { -static std::string trim(const std::string& s) { - size_t a = s.find_first_not_of(" \t\r\n"); - if (a == std::string::npos) return ""; - size_t b = s.find_last_not_of(" \t\r\n"); - return s.substr(a, b - a + 1); -} -std::map ReadIniValues(const std::string& text) { - std::map out; - std::istringstream in(text); - std::string line; - while (std::getline(in, line)) { - std::string t = trim(line); - if (t.empty() || t[0] == ';' || t[0] == '#') continue; - size_t eq = t.find('='); - if (eq == std::string::npos) continue; - std::string k = trim(t.substr(0, eq)); - if (!k.empty()) out[k] = trim(t.substr(eq + 1)); - } - return out; -} -std::string UpdateIniText(const std::string& text, const std::string& key, const std::string& value) { - std::istringstream in(text); - std::string line, out; - bool replaced = false; - const bool endsWithNewline = !text.empty() && text.back() == '\n'; - while (std::getline(in, line)) { - if (!replaced) { - std::string t = trim(line); - const bool comment = t.empty() || t[0] == ';' || t[0] == '#'; - size_t eq = t.find('='); - if (!comment && eq != std::string::npos && trim(t.substr(0, eq)) == key) { - out += key + "=" + value + "\n"; - replaced = true; - continue; - } - } - out += line + "\n"; - } - if (!replaced) out += key + "=" + value + "\n"; - if (!endsWithNewline && !out.empty() && out.back() == '\n') out.pop_back(); - return out; -} -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `build.bat test` -Expected: PASS - all cases, "Status: SUCCESS!". - -- [ ] **Step 7: Commit** - -```bash -git add src/config_ui/ini_edit.h src/config_ui/ini_edit.cpp tests/test_ini_edit.cpp build.bat -git commit -m "feat: pure surgical ini read/update module + unit tests" -``` - ---- - -### Task 3: WebView2 host shell (window + load a placeholder UI) - -**Files:** -- Create: `src/config_ui/main.cpp`, `ui/dist/index.html` (temporary placeholder, replaced in Task 5) -- Modify: `build.bat` (add `:config` target) - -This task stands up `WindConfig.exe` opening a window that displays local web content. The bridge comes in Task 4; the real Svelte build replaces the placeholder in Task 5. - -- [ ] **Step 1: Create a placeholder `ui/dist/index.html`** - -```html -Wind Settings -

Wind Settings

WebView2 host OK.

-``` - -- [ ] **Step 2: Create `src/config_ui/main.cpp`** (uses `Microsoft::WRL::Callback` for the async handlers; links the static loader) - -```cpp -#include -#include -#include // if unavailable, use Microsoft::WRL::ComPtr instead (see note) -#include "WebView2.h" -#include -#include -#pragma comment(lib, "shlwapi.lib") - -using namespace Microsoft::WRL; -static wil::com_ptr g_controller; -static wil::com_ptr g_webview; -// Stub for Task 3 (so the host links + runs with a placeholder UI); Task 4 replaces the body. -static void HandleWebMessage(ICoreWebView2* /*wv*/, const std::wstring& /*json*/) {} - -// Directory of this exe (the UI assets sit in \ui). -static std::wstring ExeDir() { - wchar_t p[MAX_PATH]; GetModuleFileNameW(nullptr, p, MAX_PATH); - PathRemoveFileSpecW(p); return p; -} - -static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { - if (m == WM_SIZE && g_controller) { RECT r; GetClientRect(h, &r); g_controller->put_Bounds(r); return 0; } - if (m == WM_DESTROY) { PostQuitMessage(0); return 0; } - return DefWindowProcW(h, m, w, l); -} - -int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { - WNDCLASSW wc{}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.lpszClassName = L"WindConfigWnd"; - RegisterClassW(&wc); - HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"Wind Settings", WS_OVERLAPPEDWINDOW, - CW_USEDEFAULT, CW_USEDEFAULT, 960, 680, nullptr, nullptr, hInst, nullptr); - ShowWindow(hwnd, SW_SHOW); - - std::wstring uiDir = ExeDir() + L"\\ui\\dist"; // the built Vite output (Task 5); placeholder for Task 3 - CreateCoreWebView2EnvironmentWithOptions(nullptr, nullptr, nullptr, - Callback( - [hwnd, uiDir](HRESULT, ICoreWebView2Environment* env) -> HRESULT { - env->CreateCoreWebView2Controller(hwnd, - Callback( - [hwnd, uiDir](HRESULT, ICoreWebView2Controller* controller) -> HRESULT { - g_controller = controller; - g_controller->get_CoreWebView2(&g_webview); - RECT r; GetClientRect(hwnd, &r); g_controller->put_Bounds(r); - g_webview->SetVirtualHostNameToFolderMapping( - L"wind.config", uiDir.c_str(), - COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); - EventRegistrationToken tok; - g_webview->add_WebMessageReceived( - Callback( - [](ICoreWebView2* wv, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT { - LPWSTR json = nullptr; - if (SUCCEEDED(args->get_WebMessageAsJson(&json)) && json) { - HandleWebMessage(wv, json); CoTaskMemFree(json); - } - return S_OK; - }).Get(), &tok); - g_webview->Navigate(L"https://wind.config/index.html"); - return S_OK; - }).Get()); - return S_OK; - }).Get()); - - MSG msg; while (GetMessageW(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessageW(&msg); } - return 0; -} -``` - -NOTE for the implementer: if `wil/com.h` is not vendored, replace `wil::com_ptr` with `Microsoft::WRL::ComPtr` (already used elsewhere in this repo) and use `.Get()`/`&` accordingly. `Callback<>` comes from ``. Iterate until it compiles and links against `third_party/webview2`. - -- [ ] **Step 3: Add the `config` target to `build.bat`** (after the `:uiaccess` block, before `:test`) - -``` -rem --- Config UI host (WindConfig.exe). Builds the Svelte UI first if Node is present. ---- -:config -if exist "%ROOT%ui\package.json" ( - pushd "%ROOT%ui" - if not exist node_modules ( call npm install || (popd & echo [build] npm install failed & exit /b 1) ) - call npm run build || (popd & echo [build] ui build failed & exit /b 1) - popd -) -cl /nologo /std:c++17 /EHsc /O2 /W4 /DUNICODE /D_UNICODE ^ - /I third_party\webview2\include ^ - src\config_ui\main.cpp src\config_ui\ini_edit.cpp ^ - /Fe:WindConfig.exe ^ - /link third_party\webview2\x64\WebView2LoaderStatic.lib ^ - user32.lib shell32.lib shlwapi.lib ole32.lib version.lib /SUBSYSTEM:WINDOWS -exit /b %errorlevel% -``` - -The host maps the virtual host directly to `\ui\dist`, so no asset staging/copy is needed - the Vite build writes `ui/dist` in place and the host reads it there. - -- [ ] **Step 4: Build and run** - -Run: `build.bat config` then `.\WindConfig.exe` -Expected: a 960x680 window opens showing "Wind Settings / WebView2 host OK." Close it. (If WebView2 runtime is missing, install the Evergreen runtime.) - -- [ ] **Step 5: Commit** - -```bash -git add src/config_ui/main.cpp ui/dist/index.html build.bat -git commit -m "feat: WindConfig.exe WebView2 host shell loading local UI" -``` - ---- - -### Task 4: The config bridge (getConfig / setConfig) - -**Files:** Modify `src/config_ui/main.cpp` (add `HandleWebMessage` + Win32 file read / atomic write). - -- [ ] **Step 1: Add file I/O + the message handler to `main.cpp`** (append; uses `ini_edit` + the core's `magnifier.ini` resolved next to the exe) - -```cpp -#include "ini_edit.h" -#include -#include - -static std::wstring IniPath() { return ExeDir() + L"\\magnifier.ini"; } - -static std::string ReadFileUtf8(const std::wstring& path) { - std::ifstream f(path, std::ios::binary); - if (!f) return ""; - std::stringstream ss; ss << f.rdbuf(); return ss.str(); -} -// Atomic: write a temp file in the same dir, then replace the target. -static void WriteFileAtomic(const std::wstring& path, const std::string& text) { - std::wstring tmp = path + L".tmp"; - { std::ofstream f(tmp, std::ios::binary | std::ios::trunc); f.write(text.data(), (std::streamsize)text.size()); } - MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); -} - -static std::wstring Widen(const std::string& s) { - int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); - std::wstring w(n, L'\0'); MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), w.data(), n); return w; -} -static std::string Narrow(const std::wstring& w) { - int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); - std::string s(n, '\0'); WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), s.data(), n, nullptr, nullptr); return s; -} -// Minimal JSON string-field extractor (the messages are tiny + we control them). -static std::string JsonField(const std::string& j, const std::string& key) { - size_t k = j.find("\"" + key + "\""); if (k == std::string::npos) return ""; - size_t c = j.find(':', k); if (c == std::string::npos) return ""; - size_t q1 = j.find('"', c + 1); if (q1 == std::string::npos) return ""; - size_t q2 = j.find('"', q1 + 1); if (q2 == std::string::npos) return ""; - return j.substr(q1 + 1, q2 - q1 - 1); -} -static std::string JsonEscape(const std::string& s) { - std::string o; for (char ch : s) { if (ch == '"' || ch == '\\') o += '\\'; o += ch; } return o; -} - -void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { - std::string j = Narrow(jsonW); - std::string type = JsonField(j, "type"); - if (type == "getConfig") { - auto vals = wind::ReadIniValues(ReadFileUtf8(IniPath())); - std::string out = "{\"type\":\"config\",\"values\":{"; - bool first = true; - for (auto& kv : vals) { if (!first) out += ","; first = false; - out += "\"" + JsonEscape(kv.first) + "\":\"" + JsonEscape(kv.second) + "\""; } - out += "}}"; - wv->PostWebMessageAsJson(Widen(out).c_str()); - } else if (type == "setConfig") { - std::string key = JsonField(j, "key"), value = JsonField(j, "value"); - if (!key.empty()) WriteFileAtomic(IniPath(), wind::UpdateIniText(ReadFileUtf8(IniPath()), key, value)); - } -} -``` - -NOTE: remove the `extern void HandleWebMessage(...)` forward declaration's `extern` if you define it in the same file; keep one definition. Values are passed as strings both ways; the Svelte schema knows each key's type. - -- [ ] **Step 2: Build** - -Run: `build.bat config` -Expected: exit 0, `WindConfig.exe` builds. (Behavior is exercised by the Svelte app in Task 5 + Playwright in Task 7.) - -- [ ] **Step 3: Commit** - -```bash -git add src/config_ui/main.cpp -git commit -m "feat: config bridge - getConfig + surgical atomic setConfig" -``` - ---- - -### Task 5: Svelte + Vite settings UI - -**Files:** Create the `ui/` project (`package.json`, `vite.config.js`, `index.html`, `src/main.js`, `src/App.svelte`, `src/settings-schema.js`, `src/bridge.js`, `src/lib/Row.svelte`). - -- [ ] **Step 1: Scaffold** (PowerShell, repo root) - -```powershell -New-Item -ItemType Directory -Force ui\src\lib | Out-Null -``` - -Create `ui/package.json`: - -```json -{ - "name": "wind-config-ui", - "private": true, - "type": "module", - "scripts": { "dev": "vite", "build": "vite build", "test": "playwright test" }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.1.0", - "svelte": "^4.2.0", - "vite": "^5.2.0", - "@playwright/test": "^1.44.0" - } -} -``` - -Create `ui/vite.config.js`: - -```js -import { defineConfig } from 'vite'; -import { svelte } from '@sveltejs/vite-plugin-svelte'; -export default defineConfig({ plugins: [svelte()], base: './', build: { outDir: 'dist' } }); -``` - -Create `ui/index.html`: - -```html -Wind Settings -
-``` - -Create `ui/src/main.js`: - -```js -import App from './App.svelte'; -export default new App({ target: document.getElementById('app') }); -``` - -- [ ] **Step 2: The bridge client** - create `ui/src/bridge.js` (real WebView2 bridge with a browser/Playwright mock fallback): - -```js -const wv = window.chrome && window.chrome.webview; -const listeners = new Set(); -if (wv) wv.addEventListener('message', e => listeners.forEach(fn => fn(e.data))); - -export function onMessage(fn) { listeners.add(fn); return () => listeners.delete(fn); } -export function post(msg) { - if (wv) wv.postMessage(msg); - else window.__windMock && window.__windMock(msg); // Playwright/browser mock -} -export function getConfig() { - return new Promise(resolve => { - const off = onMessage(m => { if (m && m.type === 'config') { off(); resolve(m.values || {}); } }); - post({ type: 'getConfig' }); - }); -} -export function setConfig(key, value) { post({ type: 'setConfig', key, value: String(value) }); } -``` - -- [ ] **Step 3: The settings schema** - create `ui/src/settings-schema.js` (matches the current config keys; `def` = default used when the ini lacks the key): - -```js -export const sections = [ - { id: 'zoom', label: 'Zoom', rows: [ - { key: 'zoomInSpeed', type: 'slider', label: 'Zoom-in speed', desc: 'Multiplier (1.0 = default).', min: 0.25, max: 4, step: 0.05, def: 1.0 }, - { key: 'zoomOutSpeed', type: 'slider', label: 'Zoom-out speed', desc: 'Multiplier (1.0 = default).', min: 0.25, max: 4, step: 0.05, def: 1.0 }, - { key: 'smoothZoom', type: 'toggle', label: 'Smooth zoom', desc: 'Zoom-in eases up to your speed.', def: 0 }, - { key: 'smoothZoomAccel', type: 'slider', label: 'Smooth ease-in depth', desc: 'Higher = slower start.', min: 1, max: 8, step: 0.5, def: 3.0 }, - { key: 'smoothZoomRamp', type: 'slider', label: 'Smooth ramp (s)', desc: 'Seconds to reach full speed.', min: 0.1, max: 3, step: 0.1, def: 0.6 }, - { key: 'maxLevel', type: 'slider', label: 'Max zoom', desc: 'How far you can zoom.', min: 2, max: 50, step: 1, def: 8.0 }, - ]}, - { id: 'cursor', label: 'Cursor', rows: [ - { key: 'cursorSensitivity', type: 'slider', label: 'Locked sensitivity', desc: 'Pan scale when a game locks the cursor.', min: 0.25, max: 4, step: 0.05, def: 1.0 }, - { key: 'cursorSmoothing', type: 'slider', label: 'Pan smoothing', desc: '0 = off, higher = smoother.', min: 0, max: 0.95, step: 0.05, def: 0.8 }, - { key: 'cursorScaleWithZoom', type: 'toggle', label: 'Scale cursor with zoom', def: 1 }, - { key: 'cursorVisibility', type: 'select', label: 'Cursor visibility', options: ['auto','always','never'], def: 'auto' }, - ]}, - { id: 'display', label: 'Display', rows: [ - { key: 'bilinear', type: 'toggle', label: 'Smooth scaling', desc: 'Bilinear vs crisp pixels.', def: 1 }, - { key: 'brightness', type: 'slider', label: 'Brightness', min: 0.5, max: 1.5, step: 0.05, def: 1.0 }, - { key: 'hdrTonemap', type: 'toggle', label: 'HDR tonemap', desc: 'HDR10 -> SDR when HDR is on.', def: 1 }, - { key: 'multiMonitor',type: 'toggle', label: 'Follow cursor monitor', def: 1 }, - ]}, - { id: 'advanced', label: 'Advanced', rows: [ - { key: 'vsync', type: 'toggle', label: 'VSync', def: 1 }, - { key: 'dwmFlush', type: 'toggle', label: 'DWM-flush pacing', def: 0 }, - { key: 'cropCapture', type: 'toggle', label: 'Crop capture on full repaints', def: 1 }, - { key: 'diagnostics', type: 'toggle', label: 'Frametime logging', def: 0 }, - ]}, -]; -``` - -- [ ] **Step 4: The Row component** - create `ui/src/lib/Row.svelte`: - -```svelte - -
-
{row.label}
{#if row.desc}
{row.desc}
{/if}
-
- {#if row.type === 'toggle'} - onChange(e.target.checked ? 1 : 0)} /> - {:else if row.type === 'slider'} - onChange(e.target.value)} /> - {value} - {:else if row.type === 'select'} - - {/if} -
-
- -``` - -- [ ] **Step 5: The App** - create `ui/src/App.svelte`: - -```svelte - -
- -
-

{section.label}

- {#each section.rows as r} change(r.key, v)} />{/each} -
-
- -``` - -- [ ] **Step 6: Build the UI** - -Run: `cd ui && npm install && npm run build && cd ..` -Expected: `ui/dist/` produced (index.html + assets). Delete the Task 3 placeholder `ui/dist/index.html` first if it conflicts (the Vite build overwrites `dist`). - -- [ ] **Step 7: Build the host + run end to end** - -Run: `build.bat config` then `.\WindConfig.exe` -Expected: the window shows the sidebar + Zoom settings; moving a slider / toggling writes the matching key to `magnifier.ini` (verify by opening the ini). If `Wind.exe` is running, the change applies live (zoom to see). - -- [ ] **Step 8: Commit** - -```bash -git add ui/package.json ui/vite.config.js ui/index.html ui/src -git commit -m "feat: Svelte settings UI - schema-driven, live-apply via the bridge" -``` - ---- - -### Task 6: Tray "Open Settings" - -**Files:** Modify `src/tray.cpp`. - -- [ ] **Step 1: Add the menu item + handler** - in `src/tray.cpp`: - - Add an id near the others: `static const UINT ID_SETTINGS = 1003;` - - In the popup menu, insert before "Edit config": - `AppendMenuW(m, MF_STRING, ID_SETTINGS, L"Open Settings");` - - In the command handling, add: - -```cpp - if (cmd == ID_SETTINGS) - ShellExecuteW(nullptr, L"open", L"WindConfig.exe", nullptr, nullptr, SW_SHOW); - else if (cmd == ID_EDIT) - ShellExecuteW(nullptr, L"open", L"notepad.exe", L"magnifier.ini", nullptr, SW_SHOW); -``` - -(Keep the existing `ID_EDIT`/`ID_QUIT` behavior; just add the `ID_SETTINGS` branch.) - -- [ ] **Step 2: Build the core** - -Run: `build.bat` -Expected: exit 0, `Wind.exe` builds. - -- [ ] **Step 3: Manual check** - -Run `.\Wind.exe`, right-click the tray icon -> "Open Settings" -> `WindConfig.exe` opens. (Both binaries must be in the same folder.) - -- [ ] **Step 4: Commit** - -```bash -git add src/tray.cpp -git commit -m "feat: tray 'Open Settings' launches WindConfig.exe" -``` - ---- - -### Task 7: Playwright E2E with a mock bridge - -**Files:** Create `ui/playwright.config.js`, `ui/tests/settings.spec.js`. - -- [ ] **Step 1: Playwright config** - create `ui/playwright.config.js`: - -```js -import { defineConfig } from '@playwright/test'; -export default defineConfig({ - testDir: './tests', - webServer: { command: 'npm run dev', port: 5173, reuseExistingServer: true }, - use: { baseURL: 'http://localhost:5173' }, -}); -``` - -- [ ] **Step 2: The spec** - create `ui/tests/settings.spec.js` (injects a mock bridge before the app loads, asserting render + that a toggle emits the right setConfig): - -```js -import { test, expect } from '@playwright/test'; - -test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.__sets = []; - // Mock the WebView2 bridge: capture posts, reply to getConfig synchronously. - const listeners = new Set(); - window.chrome = { webview: { - addEventListener: (_e, fn) => listeners.add(fn), - postMessage: (msg) => { - if (msg.type === 'getConfig') - listeners.forEach(fn => fn({ data: { type: 'config', values: { zoomInSpeed: '1.2', smoothZoom: '0' } } })); - if (msg.type === 'setConfig') window.__sets.push(msg); - }, - }}; - }); -}); - -test('renders settings and reflects ini values', async ({ page }) => { - await page.goto('/'); - await expect(page.getByText('Zoom')).toBeVisible(); - await expect(page.getByText('Zoom-in speed')).toBeVisible(); -}); - -test('toggling a setting emits setConfig with the right key', async ({ page }) => { - await page.goto('/'); - await page.getByText('Smooth zoom').locator('xpath=../..').getByRole('checkbox').click(); - const sets = await page.evaluate(() => window.__sets); - expect(sets.some(s => s.key === 'smoothZoom' && s.value === '1')).toBeTruthy(); -}); -``` - -- [ ] **Step 3: Install browsers + run** - -Run: `cd ui && npx playwright install chromium && npx playwright test && cd ..` -Expected: both tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add ui/playwright.config.js ui/tests -git commit -m "test: Playwright E2E for the settings UI (mock bridge)" -``` - ---- - -## Manual verification (after all tasks) - -1. `build.bat` (core) + `build.bat config` (UI host) both succeed; `Wind.exe`, `WindConfig.exe`, and `ui/` assets sit together. -2. Run `Wind.exe`; tray -> "Open Settings" opens the UI. -3. Change zoom-in speed / toggle smooth zoom; confirm `magnifier.ini` updates (comments preserved) and, with the magnifier zoomed, the change applies live. -4. Switch the OS theme light<->dark; reopen the UI; it adapts. - -## Notes for the implementer - -- The host file I/O is intentionally in `main.cpp` (Win32) so `ini_edit.cpp` stays pure and unit-testable (it is compiled into BOTH `WindConfig.exe` and the `build.bat test` binary). -- Keep `WindConfig.exe` a separate process; do not link it into `Wind.exe` and do not touch `render_engine`/the tick loop. -- WebView2 specifics (handler signatures, `wil` vs `WRL::ComPtr`) may need small iteration against the vendored `WebView2.h`; the success criterion is "WindConfig.exe builds, opens, shows the UI, and read/writes the ini." Use `Microsoft::WRL::ComPtr` (already in this repo) if `wil` is not vendored. -- Deferred (later plans, do NOT build now): onboarding flow + `--onboard`, first-launch auto-spawn + `onboarded` key, full Tabby-style theming/polish. diff --git a/docs/superpowers/plans/2026-05-27-config-ui-polish.md b/docs/superpowers/plans/2026-05-27-config-ui-polish.md deleted file mode 100644 index 87e8b216..00000000 --- a/docs/superpowers/plans/2026-05-27-config-ui-polish.md +++ /dev/null @@ -1,1042 +0,0 @@ -# Config UI Polish + Onboarding Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Restyle the Wind config UI into the locked scroll-spy two-tone-rail design with a custom integrated title bar, and add a 3-step first-launch onboarding flow. - -**Architecture:** A separate `WindConfig.exe` WebView2 host loads a Svelte SPA that switches between a Settings view and an Onboarding view by launch mode. The host owns a frameless window; the web UI draws the chrome. The core magnifier is untouched except for an `onboarded` flag and a first-launch spawn. All UI-to-core communication stays through `magnifier.ini` (surgical, atomic writes). - -**Tech Stack:** C++17/MSVC (host + core), WebView2 (vendored SDK 1.0.2792.45), Svelte 4 + Vite 5, Playwright, doctest. - ---- - -## Conventions for every task - -- **No em-dashes anywhere** (project CLAUDE.md rule). Use commas, en-dashes, or rephrase. This includes code comments, UI copy, and commit messages. -- **Kill running binaries before any C++ build** (avoids LNK1104 file lock): - `powershell -Command "Get-Process Wind,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force"` -- **The committed HTML mockups are the exact visual + motion reference.** Port markup, CSS, and the SVG animations from them rather than inventing: - - Settings look: `mockups/config-ui-onepage.html` - - Onboarding look + wind animation: `mockups/config-ui-onboarding.html` -- Build commands: `build.bat` (Wind.exe), `build.bat test` (doctest, exit 0 = pass), `build.bat config` (npm build of `ui/` then WindConfig.exe). -- Playwright: from `ui/`, `npx playwright test`. - -## File structure - -**Core (C++):** -- `src/config.h` - add `int onboarded = 0;`. -- `src/config.cpp` - parse `onboarded`; add it to the default-ini writer text. -- `src/main.cpp` - first-launch spawn of `WindConfig.exe --onboard` when `onboarded == 0`. -- `tests/test_config.cpp` - `onboarded` default + parse case. - -**Host (C++):** -- `src/config_ui/main.cpp` - frameless custom title bar, `--onboard` mode, `window` + `openIni` bridge messages, a global HWND. - -**UI (Svelte), all under `ui/src/`:** -- `bridge.js` - add `getMode`, `windowControl`, `openIni`. -- `theme.css` (new) - dark/light design tokens. `theme.js` (new) - apply/persist `uiTheme`. -- `App.svelte` - becomes a thin router (mode -> Onboarding or Settings). -- `Settings.svelte` (new) - single scrolling page, rail, sections, staged Apply footer. -- `Onboarding.svelte` (new) - 3-step guide, dots, Skip, wind animation, apply-on-advance. -- `lib/Rail.svelte` (new) - icon rail + scroll-spy + theme toggle + cog + avatar. -- `lib/Section.svelte` (new) - sticky section header + rows. -- `lib/KeybindCapture.svelte` (new) - keydown/side-button capture control. -- `lib/scrollspy.js` (new) - Svelte action: click-to-scroll + active-on-scroll. -- `lib/Row.svelte` - add `keybind` and `button` control types. -- `lib/icons.js` (new) - shared inline-SVG strings (ported from the mockups). -- `settings-schema.js` - sections incl. About; keybind + button rows. - -**Tests (UI):** -- `ui/tests/settings.spec.js` - extend (render-all, scroll-spy active, theme toggle writes `uiTheme`, staged Apply, keybind capture). -- `ui/tests/onboarding.spec.js` (new) - 3-step flow, apply-on-advance, `onboarded=1`. - ---- - -## Task 1: Core `onboarded` key + first-launch spawn - -**Files:** -- Modify: `src/config.h` (Config struct, after `cropCapture`) -- Modify: `src/config.cpp` (parser + default-ini writer) -- Modify: `src/main.cpp` (wWinMain, before the main loop) -- Test: `tests/test_config.cpp` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_config.cpp` (after the `cropCapture can be set` case): - -```cpp -TEST_CASE("onboarded defaults to 0 and parses") { - CHECK(ParseConfig("").onboarded == 0); - CHECK(ParseConfig("onboarded=1\n").onboarded == 1); -} -``` - -- [ ] **Step 2: Run it to verify it fails** - -Run: `build.bat test` -Expected: FAIL to compile (`onboarded` is not a member of `Config`). - -- [ ] **Step 3: Add the field** - -In `src/config.h`, immediately after the `cropCapture` member (the last field, line ~70): - -```cpp - int cropCapture = 1; - // First-launch onboarding: 0 = not yet onboarded (also true of a freshly created ini), so the - // core spawns WindConfig.exe --onboard once; the onboarding flow sets this to 1 on completion. - int onboarded = 0; -``` - -- [ ] **Step 4: Parse it** - -In `src/config.cpp`, in `ParseConfig`'s if/else chain, after the `cropCapture` line: - -```cpp - else if (key == "cropCapture") c.cropCapture = std::stoi(val); - else if (key == "onboarded") c.onboarded = std::stoi(val); -``` - -- [ ] **Step 5: Add to the default-ini writer** - -In `src/config.cpp` `LoadConfig`, in the default-ini `out << ...` block, append after the `cropCapture=1\n` line (keep the closing `;`): - -```cpp - "cropCapture=1\n" - "; onboarded: 0 = run the first-launch setup once; set to 1 once finished\n" - "onboarded=0\n"; -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `build.bat test` -Expected: PASS, `[doctest] Status: SUCCESS!` (all cases, including the new one). - -- [ ] **Step 7: Add the first-launch spawn** - -In `src/main.cpp` `wWinMain`, after the two self-test early-return blocks and immediately before `QueryPerformanceFrequency(&ts.freq);` (line ~451), add: - -```cpp - // First launch: open the guided setup once (off the hot path). onboarded==0 also covers a - // freshly created ini. Non-blocking: spawn WindConfig.exe --onboard and continue to the tray. - if (cfg.onboarded == 0) { - wchar_t cmd[] = L"WindConfig.exe --onboard"; - STARTUPINFOW si{}; si.cb = sizeof(si); - PROCESS_INFORMATION pi{}; - if (CreateProcessW(L"WindConfig.exe", cmd, nullptr, nullptr, FALSE, - 0, nullptr, nullptr, &si, &pi)) { - CloseHandle(pi.hThread); - CloseHandle(pi.hProcess); - } - } -``` - -- [ ] **Step 8: Build the app to verify it compiles** - -Run (kill first): `powershell -Command "Get-Process Wind,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force"` then `build.bat` -Expected: `Wind.exe` produced, exit 0. - -- [ ] **Step 9: Commit** - -```bash -git add src/config.h src/config.cpp src/main.cpp tests/test_config.cpp -git commit -m "feat: onboarded config key + first-launch WindConfig --onboard spawn (#57)" -``` - ---- - -## Task 2: Bridge additions (mode, window control, openIni) - -**Files:** -- Modify: `ui/src/bridge.js` -- Modify: `ui/tests/settings.spec.js` (mock must accept the new message types so existing tests stay green) - -- [ ] **Step 1: Add the bridge functions** - -Append to `ui/src/bridge.js` (after `setConfig`): - -```js -// Launch mode: WindConfig.exe navigates to ...?mode=onboard for first-launch setup. -export function getMode() { - return new URLSearchParams(location.search).get('mode') === 'onboard' ? 'onboard' : 'settings'; -} -// Custom title bar buttons -> host runs ShowWindow(SW_MINIMIZE) / WM_CLOSE. -export function windowControl(action) { post({ type: 'window', action }); } -// "Edit config file" -> host opens magnifier.ini in the default editor. -export function openIni() { post({ type: 'openIni' }); } -``` - -- [ ] **Step 2: Make the Playwright mock tolerate the new messages** - -In `ui/tests/settings.spec.js`, in the `postMessage` mock, the existing handler ignores unknown types already (it only branches on `getConfig`/`setConfig`), so `window`/`openIni` are no-ops. No change needed here yet; the onboarding spec (Task 8) will assert on them. Verify the existing tests still pass. - -- [ ] **Step 3: Build the UI and run Playwright** - -Run: `cd ui && npm run build && npx playwright test` -Expected: existing 2 tests PASS. - -- [ ] **Step 4: Commit** - -```bash -git add ui/src/bridge.js -git commit -m "feat: config bridge getMode/windowControl/openIni (#57)" -``` - ---- - -## Task 3: Host - window-control + openIni messages + --onboard mode - -**Files:** -- Modify: `src/config_ui/main.cpp` - -- [ ] **Step 1: Add a global HWND and shellapi include** - -In `src/config_ui/main.cpp`, after the existing includes add `#include `, and after the `g_webview` global (line ~14) add: - -```cpp -static HWND g_hwnd = nullptr; -``` - -- [ ] **Step 2: Handle the new message types** - -In `HandleWebMessage`, extend the if/else chain after the `setConfig` branch: - -```cpp - } else if (type == "window") { - std::string action = JsonField(j, "action"); - if (action == "minimize") ShowWindow(g_hwnd, SW_MINIMIZE); - else if (action == "close") PostMessageW(g_hwnd, WM_CLOSE, 0, 0); - } else if (type == "openIni") { - ShellExecuteW(nullptr, L"open", L"notepad.exe", IniPath().c_str(), nullptr, SW_SHOWNORMAL); - } -``` - -- [ ] **Step 3: Read `--onboard` and navigate accordingly** - -Change the `wWinMain` signature to capture the command line and set `g_hwnd`, and choose the navigate URL. Replace the signature line and add the mode parse near the top of `wWinMain`: - -```cpp -int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { - bool onboard = lpCmdLine && wcsstr(lpCmdLine, L"--onboard") != nullptr; -``` - -After `CreateWindowExW(...)` assigns `hwnd`, set the global: - -```cpp - g_hwnd = hwnd; -``` - -In the controller-created callback, capture `onboard` and pick the URL. Change the lambda capture from `[hwnd, uiDir]` to `[hwnd, uiDir, onboard]` (both the environment and controller lambdas), and replace the `Navigate` call: - -```cpp - g_webview->Navigate(onboard - ? L"https://wind.config/index.html?mode=onboard" - : L"https://wind.config/index.html"); -``` - -- [ ] **Step 4: Build the host** - -Run (kill first): `powershell -Command "Get-Process Wind,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force"` then `build.bat config` -Expected: `WindConfig.exe` produced, exit 0. - -- [ ] **Step 5: Manual smoke check** - -Run: `WindConfig.exe` (opens settings) and `WindConfig.exe --onboard` (will show the same UI for now; mode wiring lands in Task 8). Confirm the window opens and is not crashing. - -- [ ] **Step 6: Commit** - -```bash -git add src/config_ui/main.cpp -git commit -m "feat: config host window/openIni messages + --onboard launch mode (#57)" -``` - ---- - -## Task 4: Host - frameless custom title bar - -**Files:** -- Modify: `src/config_ui/main.cpp` - -This is the highest-risk task. Primary approach: a frameless window (remove the OS caption via `WM_NCCALCSIZE`) with WebView2 non-client region support so CSS `app-region: drag` handles dragging. `WM_NCHITTEST` handles resize borders. If the installed WebView2 runtime lacks non-client region support, the fallback returns `HTCAPTION` for the left part of the title band (the window-control buttons sit top-right, so leaving the right strip as `HTCLIENT` keeps them clickable). - -- [ ] **Step 1: Create the window without a caption** - -Replace the `CreateWindowExW` call's style `WS_OVERLAPPEDWINDOW` with the frameless-but-resizable set: - -```cpp - HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"Wind Settings", - WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_CLIPCHILDREN, - CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, hInst, nullptr); -``` - -- [ ] **Step 2: Strip the non-client frame and hit-test borders in WndProc** - -In `WndProc`, before the `WM_SIZE` handler, add `WM_NCCALCSIZE` and `WM_NCHITTEST`: - -```cpp - if (m == WM_NCCALCSIZE && w == TRUE) { - // Remove the standard window frame so the client area spans the whole window (we draw our - // own title bar in the web UI). When maximized, inset by the frame so content is not clipped - // off-screen and the taskbar stays reachable. - if (IsZoomed(h)) { - UINT dpi = GetDpiForWindow(h); if (!dpi) dpi = 96; - int fx = GetSystemMetricsForDpi(SM_CXFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi); - int fy = GetSystemMetricsForDpi(SM_CYFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi); - auto* p = reinterpret_cast(l); - p->rgrc[0].left += fx; p->rgrc[0].right -= fx; - p->rgrc[0].top += fy; p->rgrc[0].bottom -= fy; - } - return 0; - } - if (m == WM_NCHITTEST) { - // Resize borders (8px DPI-scaled). Drag is handled by WebView2 non-client regions - // (CSS app-region: drag); fall back to HTCAPTION on the left of the title band if needed. - UINT dpi = GetDpiForWindow(h); if (!dpi) dpi = 96; - const int border = MulDiv(8, dpi, 96); - const int titleH = MulDiv(44, dpi, 96); - POINT pt{ GET_X_LPARAM(l), GET_Y_LPARAM(l) }; ScreenToClient(h, &pt); - RECT rc; GetClientRect(h, &rc); - bool left = pt.x < border, right = pt.x >= rc.right - border; - bool top = pt.y < border, bottom = pt.y >= rc.bottom - border; - if (top && left) return HTTOPLEFT; if (top && right) return HTTOPRIGHT; - if (bottom && left) return HTBOTTOMLEFT; if (bottom && right) return HTBOTTOMRIGHT; - if (left) return HTLEFT; if (right) return HTRIGHT; - if (top) return HTTOP; if (bottom) return HTBOTTOM; - // Fallback drag region: left ~70% of the title band (buttons are top-right). With non-client - // region support enabled this is overridden by the web app-region; harmless either way. - if (pt.y < titleH && pt.x < rc.right - MulDiv(120, dpi, 96)) return HTCAPTION; - return HTCLIENT; - } -``` - -Add `#include ` (for `GET_X_LPARAM`/`GET_Y_LPARAM`) to the includes. - -- [ ] **Step 3: Enable WebView2 non-client region support** - -In the controller-created callback, after `g_controller->get_CoreWebView2(&g_webview);` and before the virtual-host mapping, add: - -```cpp - { ComPtr s0; - if (SUCCEEDED(g_webview->get_Settings(&s0))) { - ComPtr s9; - if (SUCCEEDED(s0.As(&s9)) && s9) - s9->put_IsNonClientRegionSupportEnabled(TRUE); - } } -``` - -If `ICoreWebView2Settings9` is not declared in the vendored header, the `.As(&s9)` simply fails at runtime (`s9` stays null) and the `WM_NCHITTEST` HTCAPTION fallback covers dragging. Do not block on it. - -- [ ] **Step 4: Build the host** - -Run (kill first): `powershell -Command "Get-Process Wind,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force"` then `build.bat config` -Expected: `WindConfig.exe` produced, exit 0. - -- [ ] **Step 5: Manual check** - -Run `WindConfig.exe`. Confirm: no OS title bar; the window can be resized from its edges; it can be moved by dragging the top area (works fully after Task 6 adds the `app-region: drag` title bar, or via the HTCAPTION fallback now). Min size still enforced. - -- [ ] **Step 6: Commit** - -```bash -git add src/config_ui/main.cpp -git commit -m "feat: frameless custom title bar for the config window (#57)" -``` - ---- - -## Task 5: UI theme tokens + uiTheme persistence - -**Files:** -- Create: `ui/src/theme.css` -- Create: `ui/src/theme.js` -- Modify: `ui/src/main.js` (import theme.css) - -- [ ] **Step 1: Create the token stylesheet** - -Create `ui/src/theme.css` with the tokens from the mockups (these exact values; light + dark): - -```css -:root { - --bg:#ffffff; --rail:#e9e9ee; --panel-line:rgba(0,0,0,.08); - --text:#1c1c22; --muted:#6c6c77; --line:#ededf1; --hover:rgba(0,0,0,.05); - --accent:#5b5bd6; --accent-icon:#5b5bd6; --accent-soft:rgba(91,91,214,.13); - --track:rgba(0,0,0,.14); --chip:rgba(0,0,0,.04); -} -@media (prefers-color-scheme: dark) { - :root:not(.force-light) { - --bg:#0e0e12; --rail:#17171b; --panel-line:rgba(255,255,255,.06); - --text:#f3f3f6; --muted:#85858f; --line:#1f1f25; --hover:rgba(255,255,255,.07); - --accent:#5b5bd6; --accent-icon:#9090f2; --accent-soft:rgba(91,91,214,.22); - --track:rgba(255,255,255,.16); --chip:rgba(255,255,255,.06); - } -} -:root.force-dark { - --bg:#0e0e12; --rail:#17171b; --panel-line:rgba(255,255,255,.06); - --text:#f3f3f6; --muted:#85858f; --line:#1f1f25; --hover:rgba(255,255,255,.07); - --accent:#5b5bd6; --accent-icon:#9090f2; --accent-soft:rgba(91,91,214,.22); - --track:rgba(255,255,255,.16); --chip:rgba(255,255,255,.06); -} -:root.force-light { - --bg:#ffffff; --rail:#e9e9ee; --panel-line:rgba(0,0,0,.08); - --text:#1c1c22; --muted:#6c6c77; --line:#ededf1; --hover:rgba(0,0,0,.05); - --accent:#5b5bd6; --accent-icon:#5b5bd6; --accent-soft:rgba(91,91,214,.13); - --track:rgba(0,0,0,.14); --chip:rgba(0,0,0,.04); -} -html,body{margin:0;height:100%;background:var(--bg);color:var(--text); - font-family:"Segoe UI Variable","Segoe UI",system-ui,sans-serif;} -:global(input[type=checkbox]),:global(input[type=range]){accent-color:var(--accent);} -``` - -- [ ] **Step 2: Create the theme helper** - -Create `ui/src/theme.js`: - -```js -import { setConfig } from './bridge.js'; - -// uiTheme = 'auto' | 'dark' | 'light'. auto follows prefers-color-scheme; dark/light force a class -// that overrides the media query. Persisted in magnifier.ini (UI-only key; the core ignores it). -export function applyTheme(mode) { - const c = document.documentElement.classList; - c.remove('force-dark', 'force-light'); - if (mode === 'dark') c.add('force-dark'); - else if (mode === 'light') c.add('force-light'); -} -export function currentTheme(values) { - return values && values.uiTheme ? values.uiTheme : 'auto'; -} -// The sun/moon toggle cycles auto -> dark -> light -> auto and persists. -export function nextTheme(mode) { - return mode === 'auto' ? 'dark' : mode === 'dark' ? 'light' : 'auto'; -} -export function setTheme(mode) { applyTheme(mode); setConfig('uiTheme', mode); } -``` - -- [ ] **Step 3: Import the stylesheet at the entry** - -In `ui/src/main.js`, add at the top: - -```js -import './theme.css'; -``` - -- [ ] **Step 4: Build the UI to verify it compiles** - -Run: `cd ui && npm run build` -Expected: build succeeds, `ui/dist` produced. - -- [ ] **Step 5: Commit** - -```bash -git add ui/src/theme.css ui/src/theme.js ui/src/main.js -git commit -m "feat: config UI theme tokens + uiTheme persistence (#57)" -``` - ---- - -## Task 6: Settings view (rail + sections + scroll-spy + staged Apply) - -**Files:** -- Create: `ui/src/lib/icons.js`, `ui/src/lib/scrollspy.js`, `ui/src/lib/Rail.svelte`, `ui/src/lib/Section.svelte`, `ui/src/Settings.svelte` -- Modify: `ui/src/settings-schema.js`, `ui/src/App.svelte` -- Test: `ui/tests/settings.spec.js` - -Port all visual CSS/markup/SVG from `mockups/config-ui-onepage.html` (the rail, two-tone, sticky 24px headers, control styles, footer). The code below provides the structure and logic; match the mockup for styling. - -- [ ] **Step 1: Shared icons** - -Create `ui/src/lib/icons.js`, exporting the inline-SVG strings used by the rail and steps. Copy the exact `ic` map from `mockups/config-ui-onepage.html` and `mockups/config-ui-onboarding.html` (keys: `zoom, cursor, display, adv, about, glyph, cog, person, sun, moon, min, close`). Example shape: - -```js -export const ic = { - zoom: '', - // ...copy the rest verbatim from mockups/config-ui-onepage.html... -}; -``` - -- [ ] **Step 2: Scroll-spy action** - -Create `ui/src/lib/scrollspy.js`: - -```js -// Svelte action on the scroll container. opts: { sectionIds, onActive }. -// Click-to-scroll is done by the rail (it calls scrollToSection); this watches scroll and reports -// the active section (the last one whose top has passed the 70px band), matching the mockup. -export function scrollspy(node, opts) { - let { sectionIds, onActive } = opts; - function onScroll() { - let cur = sectionIds[0]; - for (const id of sectionIds) { - const el = node.querySelector('#sec-' + id); - if (el && el.offsetTop - node.scrollTop <= 70) cur = id; - } - onActive(cur); - } - node.addEventListener('scroll', onScroll); - onScroll(); - return { - update(o) { sectionIds = o.sectionIds; onActive = o.onActive; onScroll(); }, - destroy() { node.removeEventListener('scroll', onScroll); }, - }; -} -export function scrollToSection(node, id) { - const el = node.querySelector('#sec-' + id); - if (el) node.scrollTo({ top: el.offsetTop - 4, behavior: 'smooth' }); -} -``` - -- [ ] **Step 3: Rail component** - -Create `ui/src/lib/Rail.svelte`. Props: `sections` (array of `{id,label,icon}`), `active`, `onSelect(id)`, `theme`, `onToggleTheme`, `onOpenIni`. Markup/CSS ported from the mockup rail (64px, app glyph top, per-section icon buttons with `.active` accent bar + tint + `title` tooltip, bottom theme toggle + cog + avatar). Logic: - -```svelte - - - -``` - -- [ ] **Step 4: Section component** - -Create `ui/src/lib/Section.svelte` (sticky 24px header + a `` for rows): - -```svelte - -
-

{label}

{#if desc}

{desc}

{/if}
- -
- -``` - -- [ ] **Step 5: Rewrite the schema with sections + descriptions** - -Replace `ui/src/settings-schema.js` so each section has `id`, `label`, `icon`, `desc`, and `rows` (keep all existing keys; add the About section; the keybind rows are added in Task 7). Use the mockup section descriptions: - -```js -export const sections = [ - { id:'zoom', label:'Zoom', icon:'zoom', desc:'How magnification grows while you hold the zoom button.', rows: [ - { key:'zoomInSpeed', type:'slider', label:'Zoom-in speed', desc:'Multiplier (1.0 = default).', min:0.25, max:4, step:0.05, def:1.0 }, - { key:'zoomOutSpeed', type:'slider', label:'Zoom-out speed', desc:'Multiplier (1.0 = default).', min:0.25, max:4, step:0.05, def:1.0 }, - { key:'smoothZoom', type:'toggle', label:'Smooth zoom', desc:'Zoom-in eases up to your speed.', def:0 }, - { key:'smoothZoomAccel', type:'slider', label:'Smooth ease-in depth', desc:'Higher = slower start.', min:1, max:8, step:0.5, def:3.0, dependsOn:'smoothZoom' }, - { key:'smoothZoomRamp', type:'slider', label:'Smooth ramp (s)', desc:'Seconds to reach full speed.', min:0.1, max:3, step:0.1, def:0.6, dependsOn:'smoothZoom' }, - { key:'maxLevel', type:'slider', label:'Max zoom', desc:'How far you can zoom.', min:2, max:50, step:1, def:8.0 }, - ]}, - { id:'cursor', label:'Cursor', icon:'cursor', desc:'Pointer movement and visibility while zoomed.', rows: [ - { key:'cursorSensitivity', type:'slider', label:'Cursor speed', desc:'Pan speed multiplier (1.0 = match your mouse).', min:0.25, max:4, step:0.05, def:1.0 }, - { key:'cursorSmoothing', type:'slider', label:'Pan smoothing', desc:'0 = off, higher = smoother.', min:0, max:0.95, step:0.05, def:0.8 }, - { key:'cursorScaleWithZoom', type:'toggle', label:'Scale cursor with zoom', def:1 }, - { key:'cursorVisibility', type:'select', label:'Cursor visibility', options:['auto','always','never'], def:'auto' }, - ]}, - { id:'display', label:'Display', icon:'display', desc:'Image quality of the magnified view.', rows: [ - { key:'bilinear', type:'toggle', label:'Smooth scaling', desc:'Bilinear vs crisp pixels.', def:1 }, - { key:'sharpness', type:'slider', label:'Sharpness', desc:'Crisps the magnified image (0 = off).', min:0, max:1, step:0.05, def:0.0 }, - { key:'brightness', type:'slider', label:'Brightness', min:0.5, max:1.5, step:0.05, def:1.0 }, - { key:'hdrTonemap', type:'toggle', label:'HDR tonemap', desc:'HDR10 to SDR when HDR is on.', def:1 }, - { key:'multiMonitor',type:'toggle', label:'Follow cursor monitor', def:1 }, - ]}, - { id:'adv', label:'Advanced', icon:'adv', desc:'Pacing and diagnostics. Defaults are usually best.', rows: [ - { key:'vsync', type:'toggle', label:'VSync', def:1 }, - { key:'dwmFlush', type:'toggle', label:'DWM-flush pacing', def:0 }, - { key:'cropCapture', type:'toggle', label:'Crop capture on full repaints', def:1 }, - { key:'diagnostics', type:'toggle', label:'Frametime logging', def:0 }, - { key:'__openIni', type:'button', label:'Config file', desc:'Open magnifier.ini in your editor.', action:'openIni', btn:'Edit config file' }, - ]}, - { id:'about', label:'About', icon:'about', desc:'', rows: [ - { key:'__about', type:'about' }, - ]}, -]; -``` - -- [ ] **Step 6: Settings view (single page + rail + staged Apply)** - -Create `ui/src/Settings.svelte`. Carry the staged-apply logic from the current `App.svelte` (values/saved/change/apply/discard/dirty), render every section stacked inside the scroll container with the scrollspy action, render the rail, and wire theme + openIni: - -```svelte - -
- scrollToSection(scroller, id)} - {theme} onToggleTheme={toggleTheme} onOpenIni={openIni} /> -
-
- Wind Settings -
- -
-
-
active = id }}> - {#each sections as s} -
- {#each s.rows as r} - change(r.key, val)} /> - {/each} -
- {/each} -
-
- {dirty ? 'Unsaved changes' : 'All changes saved'} - - -
-
-
- -``` - -- [ ] **Step 7: Point App.svelte at Settings for now** - -Replace `ui/src/App.svelte` body with a temporary direct render (the router lands in Task 8): - -```svelte - - -``` - -- [ ] **Step 8: Update the existing Playwright tests for the new layout** - -Rewrite `ui/tests/settings.spec.js` to match the single-page rail layout. The mock must now also return `uiTheme`: - -```js -import { test, expect } from '@playwright/test'; - -test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.__sets = []; - const listeners = new Set(); - window.chrome = { webview: { - addEventListener: (_e, fn) => listeners.add(fn), - postMessage: (msg) => { - if (msg.type === 'getConfig') - listeners.forEach(fn => fn({ data: { type: 'config', values: { zoomInSpeed: '1.2', smoothZoom: '0', uiTheme: 'auto' } } })); - if (msg.type === 'setConfig') window.__sets.push(msg); - }, - }}; - }); -}); - -test('renders all sections on one page', async ({ page }) => { - await page.goto('/'); - await expect(page.getByText('Zoom-in speed')).toBeVisible(); - await expect(page.getByText('Cursor speed')).toBeVisible(); - await expect(page.getByText('Sharpness')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'About' })).toBeVisible(); -}); - -test('rail click scrolls and marks the section active', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: 'Display' }).click(); - await expect(page.getByRole('button', { name: 'Display' })).toHaveClass(/active/); -}); - -test('theme toggle writes uiTheme', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: 'Toggle theme' }).click(); - const sets = await page.evaluate(() => window.__sets); - expect(sets.some(s => s.key === 'uiTheme')).toBeTruthy(); -}); - -test('changes stage until Apply, then setConfig fires', async ({ page }) => { - await page.goto('/'); - await page.getByText('Smooth zoom', { exact: true }).locator('xpath=../..').getByRole('checkbox').click(); - expect(await page.evaluate(() => window.__sets.filter(s => s.key === 'smoothZoom').length)).toBe(0); - await page.getByRole('button', { name: 'Apply' }).click(); - const sets = await page.evaluate(() => window.__sets); - expect(sets.some(s => s.key === 'smoothZoom' && s.value === '1')).toBeTruthy(); -}); -``` - -(The rail buttons use the section `label` as their accessible name via the `title` attribute; if Playwright does not match `getByRole('button',{name})` on `title`, add `aria-label={s.label}` to the `.ritem` button in Rail.svelte. Include that aria-label so these tests are stable.) - -- [ ] **Step 9: Build + test** - -Run: `cd ui && npm run build && npx playwright test` -Expected: all 4 tests PASS. - -- [ ] **Step 10: Commit** - -```bash -git add ui/src/lib/icons.js ui/src/lib/scrollspy.js ui/src/lib/Rail.svelte ui/src/lib/Section.svelte ui/src/Settings.svelte ui/src/App.svelte ui/src/settings-schema.js ui/tests/settings.spec.js -git commit -m "feat: settings view - scroll-spy two-tone rail, single page, staged Apply (#57)" -``` - ---- - -## Task 7: Keybind capture + button/about row types - -**Files:** -- Create: `ui/src/lib/KeybindCapture.svelte` -- Modify: `ui/src/lib/Row.svelte`, `ui/src/settings-schema.js` -- Test: `ui/tests/settings.spec.js` (add a keybind case) - -The core OR-combines mouse side-buttons and VK keys (see `src/main.cpp`), so a keybind can be either. The capture writes `zoomInButton`/`zoomInVk` (or the out variants). Mouse `button===3` is XBUTTON1 (back) -> our `zoomInButton`/`zoomOutButton` value 1; `button===4` is XBUTTON2 (forward) -> value 2. Key presses write the VK code to `zoomInVk`/`zoomOutVk`. - -- [ ] **Step 1: KeybindCapture component** - -Create `ui/src/lib/KeybindCapture.svelte`: - -```svelte - - - - -``` - -- [ ] **Step 2: Wire keybind + button + about into Row.svelte** - -Extend `ui/src/lib/Row.svelte`. Add `export let values = {};` (keybind needs sibling keys) and branches: - -```svelte -{:else if row.type === 'keybind'} - -{:else if row.type === 'button'} - -{:else if row.type === 'about'} -
Wind, a fast magnifier. GitHub
-``` - -Import `KeybindCapture` in Row's script. For the `button` type, `onChange('__action', row.action)` lets the parent route actions (Settings maps `openIni` -> `openIni()`). - -- [ ] **Step 3: Surface keybinds in the Zoom section + pass values + handle actions** - -In `ui/src/settings-schema.js`, prepend two keybind rows to the Zoom section's `rows`: - -```js - { key:'__zoomIn', type:'keybind', label:'Zoom in', desc:'Hold to magnify', buttonKey:'zoomInButton', vkKey:'zoomInVk' }, - { key:'__zoomOut', type:'keybind', label:'Zoom out', desc:'Hold to zoom back', buttonKey:'zoomOutButton', vkKey:'zoomOutVk' }, -``` - -In `ui/src/Settings.svelte`: (a) load the keybind sibling keys into `values` (extend the onMount loop to also pull `zoomInButton/zoomInVk/zoomOutButton/zoomOutVk` defaults `2/33/1/34`); (b) pass `values` to `Row`; (c) in `change`, intercept the `__action` key: - -```js - function change(key, val) { - if (key === '__action') { if (val === 'openIni') openIni(); return; } - values = { ...values, [key]: val }; - } -``` - -Keybind writes go through `change(buttonKey/vkKey, ...)` so they stage and are written on Apply like everything else. - -- [ ] **Step 4: Add a keybind Playwright case** - -Append to `ui/tests/settings.spec.js`: - -```js -test('keybind capture writes a VK on keydown', async ({ page }) => { - await page.goto('/'); - await page.getByText('Zoom in', { exact: true }).locator('xpath=../..').getByRole('button').click(); - await page.keyboard.press('F2'); // keyCode 113 - await page.getByRole('button', { name: 'Apply' }).click(); - const sets = await page.evaluate(() => window.__sets); - expect(sets.some(s => s.key === 'zoomInVk' && s.value === '113')).toBeTruthy(); -}); -``` - -(The mock's `getConfig` should also return `zoomInButton:'2', zoomInVk:'33', zoomOutButton:'1', zoomOutVk:'34'` so the control renders; add those keys to the mock's values object.) - -- [ ] **Step 5: Build + test** - -Run: `cd ui && npm run build && npx playwright test` -Expected: all tests PASS (5 now). - -- [ ] **Step 6: Commit** - -```bash -git add ui/src/lib/KeybindCapture.svelte ui/src/lib/Row.svelte ui/src/settings-schema.js ui/src/Settings.svelte ui/tests/settings.spec.js -git commit -m "feat: keybind capture + button/about rows in settings (#57)" -``` - ---- - -## Task 8: App router + Onboarding view - -**Files:** -- Modify: `ui/src/App.svelte` -- Create: `ui/src/Onboarding.svelte` -- Test: `ui/tests/onboarding.spec.js` - -Port the 3-step markup, dots, Skip, the custom title bar buttons, and the welcome wind-trails-into-logo animation (SVG + keyframes) verbatim from `mockups/config-ui-onboarding.html`. The router and apply-on-advance logic are below. - -- [ ] **Step 1: Router** - -Replace `ui/src/App.svelte`: - -```svelte - -{#if mode === 'onboard'} - -{:else} - -{/if} -``` - -- [ ] **Step 2: Onboarding component (apply-on-advance)** - -Create `ui/src/Onboarding.svelte`. Three steps: Welcome (wind animation), Set keys (two `KeybindCapture` rows, staged locally), You're all set (animated check ring). Apply-on-advance: pressing Next on the keys step writes the staged binding keys via `setConfig`; the final "Open Settings" writes `onboarded=1`, calls `onDone`. `windowControl` for min/close. - -```svelte - -
-
-
- - -
-
-
- -
- -

Welcome to Wind

-

A fast magnifier that lives in your tray. Let's set up the essentials.

-
- -
-

Set your zoom keys

-

Pick the buttons you'll hold to zoom. Mouse side-buttons work great, or choose keyboard keys.

-
Zoom in
Hold to magnify
-
-
Zoom out
Hold to zoom back
-
-
- -
- -

You're all set

-
-
-
{#each Array(N) as _, i}{/each}
-
- {#if cur < N - 1}{/if} - {#if cur > 0}{/if} - -
-
- -``` - -- [ ] **Step 3: Onboarding Playwright test** - -Create `ui/tests/onboarding.spec.js`: - -```js -import { test, expect } from '@playwright/test'; - -test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.__sets = []; - const listeners = new Set(); - window.chrome = { webview: { - addEventListener: (_e, fn) => listeners.add(fn), - postMessage: (msg) => { - if (msg.type === 'getConfig') - listeners.forEach(fn => fn({ data: { type: 'config', values: { uiTheme: 'auto' } } })); - if (msg.type === 'setConfig') window.__sets.push(msg); - }, - }}; - }); -}); - -test('onboarding walks 3 steps, applies keys on advance, sets onboarded', async ({ page }) => { - await page.goto('/?mode=onboard'); - await expect(page.getByRole('heading', { name: 'Welcome to Wind' })).toBeVisible(); - await page.getByRole('button', { name: 'Get started' }).click(); - await expect(page.getByRole('heading', { name: 'Set your zoom keys' })).toBeVisible(); - await page.getByRole('button', { name: 'Next' }).click(); // applies keys - expect(await page.evaluate(() => window.__sets.some(s => s.key === 'zoomInButton'))).toBeTruthy(); - await expect(page.getByRole('heading', { name: "You're all set" })).toBeVisible(); - await page.getByRole('button', { name: 'Open Settings' }).click(); - expect(await page.evaluate(() => window.__sets.some(s => s.key === 'onboarded' && s.value === '1'))).toBeTruthy(); -}); - -test('settings mode does not show onboarding', async ({ page }) => { - await page.goto('/'); - await expect(page.getByText('Zoom-in speed')).toBeVisible(); -}); -``` - -- [ ] **Step 4: Build + test** - -Run: `cd ui && npm run build && npx playwright test` -Expected: all specs PASS (settings + onboarding). - -- [ ] **Step 5: Commit** - -```bash -git add ui/src/App.svelte ui/src/Onboarding.svelte ui/tests/onboarding.spec.js -git commit -m "feat: app router + 3-step onboarding (wind intro, apply-on-advance) (#57)" -``` - ---- - -## Task 9: Full build, integration verification, manual checklist - -**Files:** none (verification only) - -- [ ] **Step 1: Kill running binaries, full build** - -Run: `powershell -Command "Get-Process Wind,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force"` -Run: `build.bat test` (expect SUCCESS), `build.bat` (expect `Wind.exe`), `build.bat config` (expect ui build + `WindConfig.exe`). - -- [ ] **Step 2: Playwright full run** - -Run: `cd ui && npx playwright test` -Expected: all specs PASS. - -- [ ] **Step 3: Manual checklist** (run the built binaries) - -- `WindConfig.exe`: frameless window, draggable by the title strip, minimize + close work, resizable, min size enforced; rail scroll-spy (click + scroll), sticky 24px headers, staged Apply writes on Apply only; theme toggle flips dark/light and persists (reopen to confirm `uiTheme` stuck); keybind capture rebinds zoom in/out; "Edit config file" opens the ini. -- `WindConfig.exe --onboard`: 3-step flow; welcome wind-into-logo animation; Next on the keys step applies them (verify by holding the key in another app while onboarding is open, since the core hot-reloads); "Open Settings" switches to the settings view and the window keeps running. -- First-launch: set `onboarded=0` in `magnifier.ini`, launch `Wind.exe`; confirm `WindConfig.exe --onboard` auto-opens and the magnifier still runs in the tray. Finish onboarding, confirm `onboarded=1` is written and it does not reopen on the next `Wind.exe` launch. -- Confirm zoom still works and feels unchanged (the core path is untouched). - -- [ ] **Step 4: Final review + branch finish** - -Dispatch a final code review across the branch, then use superpowers:finishing-a-development-branch to open the PR against `main` referencing #57. - ---- - -## Self-review (against the spec) - -**Spec coverage:** -- Custom title bar -> Task 4 (frameless) + Task 6/8 (web app-region drag + window buttons). Covered. -- Single scrolling page + scroll-spy rail -> Task 6. Covered. -- Staged Apply kept -> Task 6 (ported from current App.svelte). Covered. -- Theme tokens + uiTheme persist (auto/dark/light) -> Task 5 + Task 6 toggle. Covered. -- Keybind capture (VK + side-button) -> Task 7. Covered. -- About + account placeholder -> Task 6 schema (`about` row) + Rail avatar. Covered. -- Onboarding 3-step, apply-on-advance, no auto-apply/Apply button, Open Settings -> Task 8. Covered. -- Welcome wind-into-logo animation -> Task 8 (ported from mockup). Covered. -- onboarded key + first-launch spawn -> Task 1. Covered. -- `window`/`openIni` bridge + mode -> Tasks 2/3. Covered. -- Tests: Playwright (settings scroll-spy/theme/staged Apply/keybind, onboarding flow) + test_config onboarded -> Tasks 1, 6, 7, 8. Covered. - -**Placeholder scan:** Visual CSS/SVG is delegated to the committed mockups by explicit file reference (not a TODO); all logic, signatures, and tests are spelled out. No "TBD/handle edge cases" left in code steps. - -**Type/name consistency:** `getMode/windowControl/openIni` (bridge) used consistently; `uiTheme` key consistent across theme.js, Settings, tests; keybind row props `buttonKey/vkKey` consistent across schema, Row, KeybindCapture, Onboarding; `__action`/`openIni` routing consistent; section objects use `{id,label,icon,desc,rows}` consistently in schema, Rail, Settings, Section. - -**Risk note:** Task 4 (frameless title bar) is the main risk; the WebView2 non-client region path has a `WM_NCHITTEST` HTCAPTION fallback so dragging works even without runtime support. diff --git a/docs/superpowers/plans/2026-05-31-logging-observability.md b/docs/superpowers/plans/2026-05-31-logging-observability.md deleted file mode 100644 index e5be172f..00000000 --- a/docs/superpowers/plans/2026-05-31-logging-observability.md +++ /dev/null @@ -1,1248 +0,0 @@ -# Logging / Observability Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship a unified, low-overhead logging subsystem that captures enough about a customer's machine and any crash to diagnose bugs without local reproduction, written locally now and structured so a future server-upload is a drop-in delivery sink. - -**Architecture:** One `src/logging` module (pure formatting/rotation/snapshot helpers + a Win32 backend) replaces the always-on `RLog`/`SiLog`. It writes a rolling per-process log to `%LOCALAPPDATA%\Wind\logs\`, emits a system snapshot at startup, installs a crash handler that writes a minidump + text summary, and offers an "Export diagnostics" zip (vendored miniz) from both binaries. Logging is event-driven only, so the per-frame path pays nothing. - -**Tech Stack:** C++17, MSVC `cl.exe`. Win32 (`DbgHelp`/`MiniDumpWriteDump`, DXGI, GDI/`EnumDisplayMonitors`, `GetVersionEx`-equivalent via RtlGetVersion). Vendored `third_party/miniz` for zip. Tests: vendored `third_party/doctest.h`. - -Spec: `docs/superpowers/specs/2026-05-31-logging-observability-design.md`. Issue #81. Branch `feat/logging-observability`. - ---- - -## File Structure - -- **Create `src/version.h`** - single source of truth for the version string + numeric tuple. Included by the snapshot, the logger, and `src/wind.rc` (VERSIONINFO). -- **Create `src/logging.h`** - public API: `LogLevel`, `LogInit/Log/LogShutdown`, and the pure helpers `FormatLogLine`, `LogLevelName`, `ShouldRotate`, `BuildSnapshot` (+ `SystemInfo`/`MonitorInfo` structs). -- **Create `src/logging.cpp`** - pure helpers (compiled into `WIND_TESTS`); Win32 backend (file handle, rotation, snapshot queries, minidump, export-zip) under `#ifndef WIND_TESTS`. -- **Create `tests/test_logging.cpp`** - doctest unit tests for the pure helpers. -- **Create `third_party/miniz.h` + `third_party/miniz.c`** - vendored single-file zip library (public domain). -- **Modify `src/config_path.h`** - add `ResolveLogDir()` (parallel to `ResolveIniPath`). -- **Modify `src/render_engine.cpp`** - route `RLog` through `wind::Log`; extend `CursorRestoreFilter` to also write minidump + summary. -- **Modify `src/main.cpp`** - route `SiLog` through `wind::Log`; call `LogInit` + snapshot at startup, `LogShutdown` at exit; add "Export diagnostics" handling. -- **Modify `src/tray.cpp`** - add an "Export diagnostics" tray menu item. -- **Modify `src/config_ui/main.cpp`** - `LogInit("config")` at startup; add an `exportDiagnostics` bridge action. -- **Modify `ui/src/...`** - add an "Export diagnostics" button that posts `exportDiagnostics`. -- **Modify `src/wind.rc`** - add `VERSIONINFO`. -- **Modify `build.bat`** - compile `logging.cpp` + `miniz.c`, link `Dbghelp.lib`, enable PDB generation (`/Zi` + `/DEBUG`), add `src/logging.cpp` to the `:test` file list. - ---- - -## Task 1: Version single-source-of-truth - -**Files:** -- Create: `src/version.h` - -- [ ] **Step 1: Create the version header** - -```cpp -// src/version.h - single source of truth for the Wind version. -// Used by the system snapshot, every log line's session header, and src/wind.rc (VERSIONINFO). -#pragma once - -#define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 1 -#define WIND_VER_PATCH 0 - -// String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.1.0" -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/version.h -git commit -m "feat(logging): add version single-source-of-truth header" -``` - ---- - -## Task 2: Pure log-line formatting - -**Files:** -- Create: `src/logging.h` -- Create: `src/logging.cpp` -- Test: `tests/test_logging.cpp` - -- [ ] **Step 1: Write the header with the pure formatting API** - -```cpp -// src/logging.h -#pragma once -#include -#include - -namespace wind { - -enum class LogLevel { Info, Warn, Error }; - -// --- Pure helpers (no ; compiled into the WIND_TESTS build) --- - -// "INFO" / "WARN" / "ERROR" (4-5 chars, used verbatim in the line). -const char* LogLevelName(LogLevel lvl); - -// One formatted log line WITHOUT a trailing newline. -// "2026-05-31T08:14:22.137Z WARN render " -// tsMsUtc = milliseconds since the Unix epoch (UTC). category is a short tag. -std::string FormatLogLine(unsigned long long tsMsUtc, LogLevel lvl, - const char* category, const std::string& msg); - -} // namespace wind -``` - -- [ ] **Step 2: Write the failing test** - -```cpp -// tests/test_logging.cpp -#include "doctest.h" -#include "../src/logging.h" -using namespace wind; - -TEST_CASE("LogLevelName maps levels") { - CHECK(std::string(LogLevelName(LogLevel::Info)) == "INFO"); - CHECK(std::string(LogLevelName(LogLevel::Warn)) == "WARN"); - CHECK(std::string(LogLevelName(LogLevel::Error)) == "ERROR"); -} - -TEST_CASE("FormatLogLine renders ISO-8601 UTC ms + level + category + msg") { - // 2026-05-31T08:14:22.137Z == 1780214062137 ms since epoch. - std::string line = FormatLogLine(1780214062137ULL, LogLevel::Warn, "render", "device lost"); - CHECK(line == "2026-05-31T08:14:22.137Z WARN render device lost"); -} - -TEST_CASE("FormatLogLine has no trailing newline") { - std::string line = FormatLogLine(0ULL, LogLevel::Info, "startup", "hi"); - CHECK(line.back() != '\n'); -} -``` - -- [ ] **Step 3: Run the test to verify it fails to compile/link** - -Run: `build.bat test` -Expected: FAIL - unresolved `LogLevelName` / `FormatLogLine` (logging.cpp not written/added yet). - -- [ ] **Step 4: Implement the pure helpers in logging.cpp** - -```cpp -// src/logging.cpp -#include "logging.h" -#include -#include - -namespace wind { - -const char* LogLevelName(LogLevel lvl) { - switch (lvl) { - case LogLevel::Info: return "INFO"; - case LogLevel::Warn: return "WARN"; - case LogLevel::Error: return "ERROR"; - } - return "INFO"; -} - -std::string FormatLogLine(unsigned long long tsMsUtc, LogLevel lvl, - const char* category, const std::string& msg) { - const time_t secs = (time_t)(tsMsUtc / 1000ULL); - const unsigned ms = (unsigned)(tsMsUtc % 1000ULL); - struct tm g{}; -#if defined(_WIN32) - gmtime_s(&g, &secs); -#else - gmtime_r(&secs, &g); -#endif - char ts[40]; - std::snprintf(ts, sizeof(ts), "%04d-%02d-%02dT%02d:%02d:%02d.%03uZ", - g.tm_year + 1900, g.tm_mon + 1, g.tm_mday, - g.tm_hour, g.tm_min, g.tm_sec, ms); - std::string out = ts; - out += " "; - out += LogLevelName(lvl); - out += " "; - out += (category ? category : ""); - out += " "; - out += msg; - return out; -} - -} // namespace wind -``` - -- [ ] **Step 5: Add logging.cpp to the test build** - -Modify `build.bat` `:test` target file list (the line listing pure `.cpp` sources) to append `src\logging.cpp`: - -```bat - tests\*.cpp ^ - src\transform.cpp src\zoom_controller.cpp src\config.cpp src\cursor_mapper.cpp src\lock_detector.cpp src\config_ui\ini_edit.cpp src\logging.cpp ^ - /Fe:wind_tests.exe -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `build.bat test` -Expected: PASS - all assertions green. - -- [ ] **Step 7: Commit** - -```bash -git add src/logging.h src/logging.cpp tests/test_logging.cpp build.bat -git commit -m "feat(logging): pure log-line formatting + tests" -``` - ---- - -## Task 3: Pure rotation policy - -**Files:** -- Modify: `src/logging.h` -- Modify: `src/logging.cpp` -- Test: `tests/test_logging.cpp` - -- [ ] **Step 1: Add the rotation API to the header** - -Add inside `namespace wind` in `src/logging.h`, after `FormatLogLine`: - -```cpp -// Rotation policy. Returns true if a file of `currentSizeBytes` should be rotated before the -// next write. maxBytes is the per-file cap (the backend uses 1 MiB). -bool ShouldRotate(unsigned long long currentSizeBytes, unsigned long long maxBytes); - -// The shipped limits (kept here so the backend and tests agree on one source). -constexpr unsigned long long kLogMaxBytes = 1024ULL * 1024ULL; // 1 MiB per file -constexpr int kLogGenerations = 3; // wind-core.log + .1 + .2 -constexpr int kCrashKeep = 3; // most-recent crash pairs kept -``` - -- [ ] **Step 2: Write the failing test** - -Append to `tests/test_logging.cpp`: - -```cpp -TEST_CASE("ShouldRotate triggers only at/over the cap") { - CHECK(ShouldRotate(0, kLogMaxBytes) == false); - CHECK(ShouldRotate(kLogMaxBytes - 1, kLogMaxBytes) == false); - CHECK(ShouldRotate(kLogMaxBytes, kLogMaxBytes) == true); - CHECK(ShouldRotate(kLogMaxBytes + 1, kLogMaxBytes) == true); -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `build.bat test` -Expected: FAIL - unresolved `ShouldRotate`. - -- [ ] **Step 4: Implement ShouldRotate in logging.cpp** - -Add to `src/logging.cpp` inside `namespace wind`: - -```cpp -bool ShouldRotate(unsigned long long currentSizeBytes, unsigned long long maxBytes) { - return currentSizeBytes >= maxBytes; -} -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `build.bat test` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/logging.h src/logging.cpp tests/test_logging.cpp -git commit -m "feat(logging): rotation policy helper + limits constants + tests" -``` - ---- - -## Task 4: Pure system-snapshot assembly - -**Files:** -- Modify: `src/logging.h` -- Modify: `src/logging.cpp` -- Test: `tests/test_logging.cpp` - -- [ ] **Step 1: Add the snapshot structs + builder to the header** - -Add inside `namespace wind` in `src/logging.h`: - -```cpp -struct MonitorInfo { - std::string name; // e.g. "\\.\DISPLAY1" - int w = 0, h = 0; // resolution - int refreshHz = 0; - int dpiPercent = 100; - int rotationDeg = 0; // 0/90/180/270 - bool hdr = false; - std::string vrr; // "on" / "off" / "unknown" -}; - -struct SystemInfo { - std::string windVersion; // WIND_VERSION_STR - std::string buildFlavor; // "normal" / "uiaccess" - std::string osBuild; // "Windows 10.0.26200" - std::string cpu; // brand string - int logicalCores = 0; - unsigned long long ramBytes = 0; - std::string gpu; // adapter description - std::string driverVersion; - std::vector monitors; - std::string configDump; // already-rendered "key=value" lines, newline-separated -}; - -// Render the snapshot as a labelled multi-line block (each line ready to be logged). -std::string BuildSnapshot(const SystemInfo& si); -``` - -- [ ] **Step 2: Write the failing test** - -Append to `tests/test_logging.cpp`: - -```cpp -TEST_CASE("BuildSnapshot includes version, OS, GPU and each monitor") { - SystemInfo si; - si.windVersion = "0.1.0"; si.buildFlavor = "uiaccess"; - si.osBuild = "Windows 10.0.26200"; si.cpu = "TestCPU"; si.logicalCores = 8; - si.ramBytes = 17179869184ULL; si.gpu = "TestGPU"; si.driverVersion = "31.0.15.4601"; - MonitorInfo m; m.name = "\\\\.\\DISPLAY1"; m.w = 3840; m.h = 2160; m.refreshHz = 143; - m.dpiPercent = 150; m.rotationDeg = 0; m.hdr = true; m.vrr = "on"; - si.monitors.push_back(m); - si.configDump = "maxLevel=20\ncropCapture=0"; - - std::string s = BuildSnapshot(si); - CHECK(s.find("Wind 0.1.0 (uiaccess)") != std::string::npos); - CHECK(s.find("Windows 10.0.26200") != std::string::npos); - CHECK(s.find("TestGPU") != std::string::npos); - CHECK(s.find("31.0.15.4601") != std::string::npos); - CHECK(s.find("3840x2160@143") != std::string::npos); - CHECK(s.find("hdr=1") != std::string::npos); - CHECK(s.find("vrr=on") != std::string::npos); - CHECK(s.find("maxLevel=20") != std::string::npos); -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `build.bat test` -Expected: FAIL - unresolved `BuildSnapshot`. - -- [ ] **Step 4: Implement BuildSnapshot in logging.cpp** - -Add `#include ` to the top of `src/logging.cpp`, then add inside `namespace wind`: - -```cpp -std::string BuildSnapshot(const SystemInfo& si) { - std::ostringstream o; - o << "==== system snapshot ====\n"; - o << "Wind " << si.windVersion << " (" << si.buildFlavor << ")\n"; - o << "OS: " << si.osBuild << "\n"; - o << "CPU: " << si.cpu << " (" << si.logicalCores << " logical cores)\n"; - o << "RAM: " << (si.ramBytes / (1024ULL * 1024ULL)) << " MiB\n"; - o << "GPU: " << si.gpu << " driver " << si.driverVersion << "\n"; - o << "Monitors: " << si.monitors.size() << "\n"; - for (const auto& m : si.monitors) { - o << " " << m.name << " " << m.w << "x" << m.h << "@" << m.refreshHz - << " dpi=" << m.dpiPercent << "% rot=" << m.rotationDeg - << " hdr=" << (m.hdr ? 1 : 0) << " vrr=" << m.vrr << "\n"; - } - o << "---- config ----\n" << si.configDump << "\n"; - o << "========================="; - return o.str(); -} -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `build.bat test` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/logging.h src/logging.cpp tests/test_logging.cpp -git commit -m "feat(logging): system-snapshot assembly + tests" -``` - ---- - -## Task 5: Log directory resolution - -**Files:** -- Modify: `src/config_path.h` - -- [ ] **Step 1: Add ResolveLogDir next to ResolveIniPath** - -Add inside `namespace wind` in `src/config_path.h`, after `ResolveIniPath`: - -```cpp -// Directory for logs + crash dumps. Mirrors ResolveIniPath: exe dir if writable (dev/portable), -// else %LOCALAPPDATA%\Wind\logs (the read-only Program Files deploy). Creates the directory. -// Returns a path WITHOUT a trailing backslash. -inline std::wstring ResolveLogDir() { - wchar_t exePathBuf[MAX_PATH]; - GetModuleFileNameW(nullptr, exePathBuf, MAX_PATH); - wchar_t* slash = wcsrchr(exePathBuf, L'\\'); - if (slash) *slash = L'\0'; - std::wstring exeDir(exePathBuf); - - std::wstring sentinel = exeDir + L"\\.windwritetest"; - HANDLE h = CreateFileW(sentinel.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_DELETE_ON_CLOSE, nullptr); - std::wstring base; - if (h != INVALID_HANDLE_VALUE) { CloseHandle(h); base = exeDir; } - else { - wchar_t buf[MAX_PATH]; - DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", buf, MAX_PATH); - if (n == 0 || n >= MAX_PATH) { GetTempPathW(MAX_PATH, buf); } - base = std::wstring(buf) + L"\\Wind"; - CreateDirectoryW(base.c_str(), nullptr); - } - std::wstring logs = base + L"\\logs"; - CreateDirectoryW(logs.c_str(), nullptr); - return logs; -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `build.bat check` -Expected: PASS - all `src\*.cpp` compile (config_path.h is included where used; no new callers yet, so this just confirms the header is well-formed by virtue of being included in main.cpp/config_ui later; run `build.bat` to be sure). - -Run: `build.bat` -Expected: PASS - `Wind.exe` builds. - -- [ ] **Step 3: Commit** - -```bash -git add src/config_path.h -git commit -m "feat(logging): ResolveLogDir (per-user-writable logs folder)" -``` - ---- - -## Task 6: Win32 logger backend (LogInit / Log / LogShutdown) - -**Files:** -- Modify: `src/logging.h` -- Modify: `src/logging.cpp` - -- [ ] **Step 1: Add the runtime API to the header** - -Add inside `namespace wind` in `src/logging.h`: - -```cpp -// --- Win32 runtime backend (excluded from WIND_TESTS) --- -// processTag is a short, filename-safe tag: "core" -> wind-core.log, "config" -> wind-config.log. -// Resolves the log dir, rotates if the existing file is at/over kLogMaxBytes, opens for append. -void LogInit(const wchar_t* processTag); -// Append one event line. Thread-safe. Flushes on Warn/Error. NEVER call from the per-frame path. -void Log(LogLevel lvl, const char* category, const char* fmt, ...); -void LogShutdown(); // flush + close -``` - -- [ ] **Step 2: Implement the backend** - -Add to `src/logging.cpp`, at the very bottom of the file, the Win32 section (the file's pure part above stays compiled into tests; this part is excluded): - -```cpp -#ifndef WIND_TESTS -#include -#include -#include -#include "config_path.h" - -namespace wind { -namespace { - HANDLE g_logFile = INVALID_HANDLE_VALUE; - std::mutex g_logMutex; - std::wstring g_logPath; - - unsigned long long NowMsUtc() { - FILETIME ft; GetSystemTimeAsFileTime(&ft); // 100ns ticks since 1601 - ULARGE_INTEGER u; u.LowPart = ft.dwLowDateTime; u.HighPart = ft.dwHighDateTime; - // 1601->1970 offset in 100ns units = 116444736000000000. - return (u.QuadPart - 116444736000000000ULL) / 10000ULL; - } - - // wind-.log -> wind-.1.log -> wind-.2.log; oldest dropped. - void RotateIfNeeded(const std::wstring& dir, const std::wstring& stem) { - std::wstring base = dir + L"\\" + stem + L".log"; - WIN32_FILE_ATTRIBUTE_DATA d{}; - if (!GetFileAttributesExW(base.c_str(), GetFileExInfoStandard, &d)) return; - ULARGE_INTEGER sz; sz.LowPart = d.nFileSizeLow; sz.HighPart = d.nFileSizeHigh; - if (!ShouldRotate(sz.QuadPart, kLogMaxBytes)) return; - // Drop the oldest, shift the rest up by one generation. - std::wstring oldest = dir + L"\\" + stem + L"." + std::to_wstring(kLogGenerations - 1) + L".log"; - DeleteFileW(oldest.c_str()); - for (int i = kLogGenerations - 2; i >= 1; --i) { - std::wstring from = dir + L"\\" + stem + L"." + std::to_wstring(i) + L".log"; - std::wstring to = dir + L"\\" + stem + L"." + std::to_wstring(i + 1) + L".log"; - MoveFileExW(from.c_str(), to.c_str(), MOVEFILE_REPLACE_EXISTING); - } - std::wstring to1 = dir + L"\\" + stem + L".1.log"; - MoveFileExW(base.c_str(), to1.c_str(), MOVEFILE_REPLACE_EXISTING); - } -} // namespace - -void LogInit(const wchar_t* processTag) { - std::lock_guard lk(g_logMutex); - std::wstring dir = ResolveLogDir(); - std::wstring stem = std::wstring(L"wind-") + processTag; - RotateIfNeeded(dir, stem); - g_logPath = dir + L"\\" + stem + L".log"; - g_logFile = CreateFileW(g_logPath.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ, - nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); -} - -void Log(LogLevel lvl, const char* category, const char* fmt, ...) { - char msg[1024]; - va_list ap; va_start(ap, fmt); - _vsnprintf_s(msg, sizeof(msg), _TRUNCATE, fmt, ap); - va_end(ap); - std::string line = FormatLogLine(NowMsUtc(), lvl, category, msg); - line += "\r\n"; - std::lock_guard lk(g_logMutex); - if (g_logFile == INVALID_HANDLE_VALUE) return; - DWORD wrote = 0; - WriteFile(g_logFile, line.data(), (DWORD)line.size(), &wrote, nullptr); - if (lvl != LogLevel::Info) FlushFileBuffers(g_logFile); -} - -void LogShutdown() { - std::lock_guard lk(g_logMutex); - if (g_logFile != INVALID_HANDLE_VALUE) { - FlushFileBuffers(g_logFile); - CloseHandle(g_logFile); - g_logFile = INVALID_HANDLE_VALUE; - } -} - -} // namespace wind -#endif // WIND_TESTS -``` - -- [ ] **Step 3: Add logging.cpp to the app + uiaccess builds** - -In `build.bat`, the `:test` target already lists `src\logging.cpp` (Task 2). The normal/uiaccess/config builds compile `src\*.cpp`, which already includes `src\logging.cpp` automatically (no change needed for those). The config build compiles only `src\config_ui\*.cpp`, so add `src\logging.cpp` to the `:config` `cl` source list: - -```bat - src\config_ui\main.cpp src\config_ui\ini_edit.cpp src\logging.cpp src\wind.res ^ -``` - -- [ ] **Step 4: Verify it builds** - -Run: `build.bat` -Expected: PASS - `Wind.exe` builds (logging.cpp compiles into it). - -Run: `build.bat test` -Expected: PASS - pure tests still green (the `#ifndef WIND_TESTS` backend is excluded). - -- [ ] **Step 5: Commit** - -```bash -git add src/logging.h src/logging.cpp build.bat -git commit -m "feat(logging): Win32 logger backend (rolling file, rotation, thread-safe)" -``` - ---- - -## Task 7: Route RLog + SiLog through the unified logger - -**Files:** -- Modify: `src/render_engine.cpp:35-46` (RLog) -- Modify: `src/main.cpp:437-442` (SiLog) - -- [ ] **Step 1: Replace RLog's body with a Log() call** - -In `src/render_engine.cpp`, add `#include "logging.h"` near the other includes, then replace the `RLog` function (currently opening `%TEMP%\wind_render.log` per call) with: - -```cpp -// Render-engine events route through the unified logger (category "render"). -static void RLog(const char* fmt, ...) { - char msg[1024]; - va_list ap; va_start(ap, fmt); - _vsnprintf_s(msg, sizeof(msg), _TRUNCATE, fmt, ap); - va_end(ap); - wind::Log(wind::LogLevel::Info, "render", "%s", msg); -} -``` - -Keep the existing `#include ` (already present for the varargs). Remove the now-unused `GetTempPathA`/`fopen` lines that were inside the old RLog. - -- [ ] **Step 2: Replace SiLog's body with a Log() call** - -In `src/main.cpp`, add `#include "logging.h"` near the other includes, then replace `SiLog` (currently `%TEMP%\wind_si.log`) with: - -```cpp -// Single-instance startup events route through the unified logger (category "startup"). -static void SiLog(const char* msg, unsigned long val) { - wind::Log(wind::LogLevel::Info, "startup", "%s %lu", msg, val); -} -``` - -- [ ] **Step 3: Verify it builds** - -Run: `build.bat` -Expected: PASS. (Note: `Log` is a no-op until `LogInit` runs - wired in Task 8. Calls before init are safely dropped because `g_logFile` is `INVALID_HANDLE_VALUE`.) - -- [ ] **Step 4: Commit** - -```bash -git add src/render_engine.cpp src/main.cpp -git commit -m "feat(logging): route RLog + SiLog through the unified logger" -``` - ---- - -## Task 8: Initialize logging + emit the snapshot at startup - -**Files:** -- Modify: `src/logging.h` -- Modify: `src/logging.cpp` -- Modify: `src/main.cpp` (wWinMain, ~line 494; exit path ~line 736) -- Modify: `src/config_ui/main.cpp` (wWinMain, ~line 216) - -- [ ] **Step 1: Add a snapshot-gathering entry point to the header** - -Add inside `namespace wind` in `src/logging.h`: - -```cpp -// Gather the machine/display/config snapshot and write it to the log. buildFlavor is "normal" or -// "uiaccess"; configDump is the live config rendered as key=value lines (may be empty for the -// config host). Safe to call once right after LogInit. -void LogSystemSnapshot(const char* buildFlavor, const std::string& configDump); -``` - -- [ ] **Step 2: Implement LogSystemSnapshot (Win32 queries) in logging.cpp** - -Add to the `#ifndef WIND_TESTS` section of `src/logging.cpp` (add `#include "version.h"`, `#include `, `#include `, `#pragma comment(lib, "dxgi.lib")` near that section's other includes): - -```cpp -// RtlGetVersion gives the true build number (GetVersionEx lies without a manifest entry). -typedef LONG (WINAPI *RtlGetVersionFn)(OSVERSIONINFOEXW*); - -static std::string OsBuildString() { - OSVERSIONINFOEXW v{}; v.dwOSVersionInfoSize = sizeof(v); - HMODULE nt = GetModuleHandleW(L"ntdll.dll"); - auto fn = nt ? (RtlGetVersionFn)GetProcAddress(nt, "RtlGetVersion") : nullptr; - if (fn && fn(&v) == 0) { - char b[64]; - std::snprintf(b, sizeof(b), "Windows %lu.%lu.%lu", - v.dwMajorVersion, v.dwMinorVersion, v.dwBuildNumber); - return b; - } - return "Windows (unknown build)"; -} - -static std::string CpuBrandString() { - int regs[4] = {0}; - char brand[0x40] = {0}; - __cpuid(regs, 0x80000000); - if ((unsigned)regs[0] >= 0x80000004) { - for (unsigned f = 0x80000002, off = 0; f <= 0x80000004; ++f, off += 16) { - __cpuid(regs, (int)f); - memcpy(brand + off, regs, 16); - } - // Trim leading spaces some CPUs pad with. - std::string s(brand); - size_t a = s.find_first_not_of(' '); - return a == std::string::npos ? s : s.substr(a); - } - return "unknown CPU"; -} - -static void QueryGpu(std::string& gpuOut, std::string& driverOut) { - gpuOut = "unknown"; driverOut = "unknown"; - IDXGIFactory* factory = nullptr; - if (FAILED(CreateDXGIFactory(__uuidof(IDXGIFactory), (void**)&factory)) || !factory) return; - IDXGIAdapter* adapter = nullptr; - if (factory->EnumAdapters(0, &adapter) == S_OK && adapter) { - DXGI_ADAPTER_DESC desc{}; - if (SUCCEEDED(adapter->GetDesc(&desc))) { - char nm[256]; std::snprintf(nm, sizeof(nm), "%ls", desc.Description); - gpuOut = nm; - } - LARGE_INTEGER umd{}; - if (SUCCEEDED(adapter->CheckInterfaceSupport(__uuidof(IDXGIDevice), &umd))) { - char dv[64]; - std::snprintf(dv, sizeof(dv), "%u.%u.%u.%u", - HIWORD(umd.HighPart), LOWORD(umd.HighPart), HIWORD(umd.LowPart), LOWORD(umd.LowPart)); - driverOut = dv; - } - adapter->Release(); - } - factory->Release(); -} - -struct MonEnumCtx { std::vector* out; }; -static BOOL CALLBACK MonEnumProc(HMONITOR hMon, HDC, LPRECT, LPARAM lp) { - auto* ctx = reinterpret_cast(lp); - MONITORINFOEXW mi{}; mi.cbSize = sizeof(mi); - if (!GetMonitorInfoW(hMon, &mi)) return TRUE; - MonitorInfo m; - char nm[64]; std::snprintf(nm, sizeof(nm), "%ls", mi.szDevice); m.name = nm; - DEVMODEW dm{}; dm.dmSize = sizeof(dm); - if (EnumDisplaySettingsW(mi.szDevice, ENUM_CURRENT_SETTINGS, &dm)) { - m.w = (int)dm.dmPelsWidth; m.h = (int)dm.dmPelsHeight; - m.refreshHz = (int)dm.dmDisplayFrequency; - switch (dm.dmDisplayOrientation) { - case DMDO_90: m.rotationDeg = 90; break; - case DMDO_180: m.rotationDeg = 180; break; - case DMDO_270: m.rotationDeg = 270; break; - default: m.rotationDeg = 0; break; - } - } - UINT dx = 96, dy = 96; - if (SUCCEEDED(GetDpiForMonitor(hMon, MDT_EFFECTIVE_DPI, &dx, &dy))) - m.dpiPercent = (int)((dx * 100 + 48) / 96); - m.hdr = false; // HDR/VRR are not uniformly queryable via this path; record conservatively. - m.vrr = "unknown"; - ctx->out->push_back(m); - return TRUE; -} - -void LogSystemSnapshot(const char* buildFlavor, const std::string& configDump) { - SystemInfo si; - si.windVersion = WIND_VERSION_STR; - si.buildFlavor = buildFlavor ? buildFlavor : "normal"; - si.osBuild = OsBuildString(); - si.cpu = CpuBrandString(); - SYSTEM_INFO sinf{}; GetSystemInfo(&sinf); si.logicalCores = (int)sinf.dwNumberOfProcessors; - MEMORYSTATUSEX mem{}; mem.dwLength = sizeof(mem); - if (GlobalMemoryStatusEx(&mem)) si.ramBytes = mem.ullTotalPhys; - QueryGpu(si.gpu, si.driverVersion); - MonEnumCtx ctx{ &si.monitors }; - EnumDisplayMonitors(nullptr, nullptr, MonEnumProc, (LPARAM)&ctx); - si.configDump = configDump; - - std::string block = BuildSnapshot(si); - // Emit each line as its own event so the multi-line block is never truncated by Log's - // fixed 1024-char buffer. Lines containing '%' (e.g. "dpi=150%") are safe: they are passed - // as the %s ARGUMENT, never as the format string. - size_t start = 0; - while (true) { - size_t nl = block.find('\n', start); - std::string ln = block.substr(start, nl == std::string::npos ? std::string::npos : nl - start); - Log(LogLevel::Info, "snapshot", "%s", ln.c_str()); - if (nl == std::string::npos) break; - start = nl + 1; - } -} -``` - -Add `#include ` (for `__cpuid`), `#include ` and `#pragma comment(lib, "shcore.lib")` (for `GetDpiForMonitor`/`MDT_EFFECTIVE_DPI`) to the Win32 section includes. - -- [ ] **Step 3: Wire LogInit + snapshot into Wind.exe** - -In `src/main.cpp` `wWinMain`, immediately after `SiLog("=== launch ===", 0);` (so startup events are captured), but note `LogInit` must precede the first `SiLog`. Replace the start of `wWinMain` so the order is: - -```cpp - wind::LogInit(L"core"); - SiLog("=== launch ===", 0); - RestoreInputState(); -``` - -After the config is loaded later in `wWinMain` (where the `Config cfg` is available), emit the snapshot. Find the line that loads the config (search `LoadConfig`) and right after it add: - -```cpp - // Render the live config as key=value lines for the snapshot. - { - std::ostringstream cd; - cd << "maxLevel=" << cfg.maxLevel << "\nzoomInSpeed=" << cfg.zoomInSpeed - << "\nzoomOutSpeed=" << cfg.zoomOutSpeed << "\nmultiMonitor=" << cfg.multiMonitor - << "\ncropCapture=" << cfg.cropCapture << "\nvsync=" << cfg.vsync - << "\ndwmFlush=" << cfg.dwmFlush << "\nzorderBand=" << cfg.zorderBand - << "\ncursorVisibility=" << cfg.cursorVisibility << "\nhdrTonemap=" << cfg.hdrTonemap; - #ifdef WIND_UIACCESS - wind::LogSystemSnapshot("uiaccess", cd.str()); - #else - wind::LogSystemSnapshot("normal", cd.str()); - #endif - } -``` - -Add `#include ` to `src/main.cpp` if not present. - -At the exit path (near the existing teardown around `src/main.cpp:736`, after `UnregisterHotKey`), add: - -```cpp - wind::LogShutdown(); -``` - -- [ ] **Step 4: Define WIND_UIACCESS for the uiaccess build** - -In `build.bat`, the `:uiaccess` `cl` line: add `/DWIND_UIACCESS` to its flags so the snapshot reports the right flavour: - -```bat -cl /nologo /std:c++17 /EHsc /O2 /W4 /DUNICODE /D_UNICODE /DWIND_UIACCESS ^ -``` - -- [ ] **Step 5: Wire LogInit into WindConfig.exe** - -In `src/config_ui/main.cpp`, add `#include "../logging.h"` near the top, then as the first statements of `wWinMain` add: - -```cpp - wind::LogInit(L"config"); - wind::LogSystemSnapshot("config", ""); -``` - -And before the function returns (end of `wWinMain`), add `wind::LogShutdown();`. - -- [ ] **Step 6: Verify it builds (all targets)** - -Run: `build.bat` then `build.bat config` then `build.bat test` -Expected: all PASS. - -- [ ] **Step 7: Manual verification** - -Run `Wind.exe`, then open `%LOCALAPPDATA%\Wind\logs\wind-core.log`. Confirm it contains the `=== launch ===` line and a `==== system snapshot ====` block with this machine's real resolution (3840x2160@143), GPU, driver, and config. Open WindConfig and confirm `wind-config.log` appears. - -- [ ] **Step 8: Commit** - -```bash -git add src/logging.h src/logging.cpp src/main.cpp src/config_ui/main.cpp build.bat -git commit -m "feat(logging): init logging + write system snapshot at startup (both binaries)" -``` - ---- - -## Task 9: Crash handler (minidump + text summary) - -**Files:** -- Modify: `src/logging.h` -- Modify: `src/logging.cpp` -- Modify: `src/render_engine.cpp:903-914` (extend CursorRestoreFilter) -- Modify: `build.bat` (link `Dbghelp.lib`) - -- [ ] **Step 1: Add the crash-writer API to the header** - -Add inside `namespace wind` in `src/logging.h`: - -```cpp -// Write a minidump + text summary into the log dir for an unhandled exception. Safe to call from a -// SetUnhandledExceptionFilter (does minimal, allocation-light work). `ep` is the EXCEPTION_POINTERS -// passed to the filter (typed as void* so the header stays -free). -void WriteCrashReport(void* exceptionPointers); -``` - -- [ ] **Step 2: Implement WriteCrashReport in logging.cpp** - -Add to the `#ifndef WIND_TESTS` section (add `#include ` and `#pragma comment(lib, "dbghelp.lib")`): - -```cpp -void WriteCrashReport(void* exceptionPointers) { - auto* ep = reinterpret_cast(exceptionPointers); - std::wstring dir = ResolveLogDir(); - - // Timestamped, sortable name. Reuse NowMsUtc for uniqueness. - unsigned long long ts = NowMsUtc(); - std::wstring stamp = std::to_wstring(ts); - std::wstring dmpPath = dir + L"\\wind-crash-" + stamp + L".dmp"; - std::wstring txtPath = dir + L"\\wind-crash-" + stamp + L".txt"; - - // Minidump. - HANDLE f = CreateFileW(dmpPath.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (f != INVALID_HANDLE_VALUE) { - MINIDUMP_EXCEPTION_INFORMATION mei{}; - mei.ThreadId = GetCurrentThreadId(); - mei.ExceptionPointers = ep; - mei.ClientPointers = FALSE; - MINIDUMP_TYPE type = (MINIDUMP_TYPE)(MiniDumpWithThreadInfo | MiniDumpWithHandleData); - MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), f, type, - ep ? &mei : nullptr, nullptr, nullptr); - CloseHandle(f); - } - - // Text summary. - HANDLE t = CreateFileW(txtPath.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (t != INVALID_HANDLE_VALUE) { - char buf[512]; - DWORD code = ep && ep->ExceptionRecord ? ep->ExceptionRecord->ExceptionCode : 0; - void* addr = ep && ep->ExceptionRecord ? ep->ExceptionRecord->ExceptionAddress : nullptr; - // Faulting module name from the exception address. - wchar_t modName[MAX_PATH] = L"(unknown)"; - HMODULE mod = nullptr; - if (addr && GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | - GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, - (LPCWSTR)addr, &mod) && mod) { - GetModuleFileNameW(mod, modName, MAX_PATH); - } - int n = std::snprintf(buf, sizeof(buf), - "Wind crash\r\nversion=%s\r\nexceptionCode=0x%08lX\r\naddress=%p\r\nmodule=%ls\r\n", - WIND_VERSION_STR, code, addr, modName); - DWORD wrote = 0; if (n > 0) WriteFile(t, buf, (DWORD)n, &wrote, nullptr); - CloseHandle(t); - } - - // Mirror a one-line marker into the main log so the timeline shows the crash. - Log(LogLevel::Error, "crash", "unhandled exception -> %ls", dmpPath.c_str()); - LogShutdown(); -} - -// Delete all but the kCrashKeep most-recent crash dump+summary pairs. Called from LogInit. -static void PruneOldCrashes(const std::wstring& dir) { - std::vector dmps; - WIN32_FIND_DATAW fd{}; - std::wstring pat = dir + L"\\wind-crash-*.dmp"; - HANDLE h = FindFirstFileW(pat.c_str(), &fd); - if (h != INVALID_HANDLE_VALUE) { - do { dmps.push_back(fd.cFileName); } while (FindNextFileW(h, &fd)); - FindClose(h); - } - if ((int)dmps.size() <= kCrashKeep) return; - std::sort(dmps.begin(), dmps.end()); // timestamped names sort chronologically - for (size_t i = 0; i + kCrashKeep < dmps.size(); ++i) { - std::wstring stem = dmps[i].substr(0, dmps[i].size() - 4); // strip ".dmp" - DeleteFileW((dir + L"\\" + stem + L".dmp").c_str()); - DeleteFileW((dir + L"\\" + stem + L".txt").c_str()); - } -} -``` - -Add `#include ` to the Win32 section. Call `PruneOldCrashes(dir)` inside `LogInit` (right after `ResolveLogDir()`), so old crash artifacts self-prune: - -```cpp -void LogInit(const wchar_t* processTag) { - std::lock_guard lk(g_logMutex); - std::wstring dir = ResolveLogDir(); - PruneOldCrashes(dir); - std::wstring stem = std::wstring(L"wind-") + processTag; - ... -``` - -- [ ] **Step 3: Extend the existing crash filter to write the report** - -In `src/render_engine.cpp`, add `#include "logging.h"` (if not already from Task 7), and change `CursorRestoreFilter`: - -```cpp -static LONG WINAPI CursorRestoreFilter(EXCEPTION_POINTERS* ep) { - MagShowSystemCursor(TRUE); - SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, SPIF_SENDCHANGE); - wind::WriteCrashReport(ep); // minidump + text summary into the log dir - return EXCEPTION_CONTINUE_SEARCH; // let the default handler still report the crash -} -``` - -- [ ] **Step 4: Link Dbghelp.lib in the normal + uiaccess builds** - -In `build.bat`, add `Dbghelp.lib` to the `/link` library list of both the normal app build and the `:uiaccess` build: - -```bat - /link Magnification.lib Dwmapi.lib user32.lib shell32.lib gdi32.lib Dbghelp.lib ^ -``` - -- [ ] **Step 5: Verify it builds** - -Run: `build.bat` -Expected: PASS. - -- [ ] **Step 6: Manual verification (force a crash)** - -Temporarily add, behind an env gate near the top of `wWinMain` in `main.cpp`: - -```cpp - if (GetEnvironmentVariableW(L"WIND_FORCECRASH", nullptr, 0) > 0) { volatile int* p = nullptr; *p = 1; } -``` - -Run `WIND_FORCECRASH=1 Wind.exe` (PowerShell: `$env:WIND_FORCECRASH=1; .\Wind.exe`). Confirm `wind-crash-.dmp` + `.txt` appear in the log dir, the `.txt` shows `exceptionCode=0xC0000005`, and the `.dmp` opens in Visual Studio with a stack. Then REMOVE the WIND_FORCECRASH line before committing (or keep it - decide; the plan removes it to avoid shipping a crash trigger). - -- [ ] **Step 7: Commit** - -```bash -git add src/logging.h src/logging.cpp src/render_engine.cpp build.bat -git commit -m "feat(logging): crash handler writes minidump + text summary; prune old dumps" -``` - ---- - -## Task 10: Vendor miniz + zip helper - -> **Implemented differently (recorded deviation):** the shipped code does NOT vendor miniz. It zips -> via Windows' built-in PowerShell `Compress-Archive`, stage-copying the logs to a temp dir first -> (so it works while both binaries hold their logs open). See the design doc's "Export + the server -> seam" section. The miniz steps below are the original plan, kept for history. - -**Files:** -- Create: `third_party/miniz.h` -- Create: `third_party/miniz.c` -- Modify: `src/logging.h` -- Modify: `src/logging.cpp` -- Modify: `build.bat` - -- [ ] **Step 1: Vendor miniz** - -Download the single-file `miniz` amalgamation (public domain, https://github.com/richgel999/miniz, release `miniz.c` + `miniz.h`) into `third_party/miniz.c` and `third_party/miniz.h`. No edits. (If offline, the engineer obtains the two release files; they are a self-contained zlib/zip implementation with no further dependencies.) - -- [ ] **Step 2: Add the export API to the header** - -Add inside `namespace wind` in `src/logging.h`: - -```cpp -// Zip every file in the log dir into destZipPath. Returns true on success. (Delivery is the -// caller's choice: write to Desktop now, POST to a server later - the bundle is the stable seam.) -bool ZipLogDir(const wchar_t* destZipPath); - -// Convenience: build "\Wind-diagnostics-.zip", zip the log dir into it, and return the -// path written (empty on failure). The tray item / config button call this, then reveal it. -std::wstring ExportDiagnosticsToDesktop(); -``` - -- [ ] **Step 3: Implement ZipLogDir + ExportDiagnosticsToDesktop in logging.cpp** - -Add to the `#ifndef WIND_TESTS` section (add `#include "../third_party/miniz.h"`, `#include `, `#pragma comment(lib, "shell32.lib")`): - -```cpp -bool ZipLogDir(const wchar_t* destZipPath) { - std::wstring dir = ResolveLogDir(); - // miniz works with narrow paths; convert via the system code page (log dir is ASCII in practice). - char destA[MAX_PATH * 2]; - WideCharToMultiByte(CP_UTF8, 0, destZipPath, -1, destA, sizeof(destA), nullptr, nullptr); - DeleteFileW(destZipPath); // overwrite any prior export - - mz_zip_archive zip{}; - if (!mz_zip_writer_init_file(&zip, destA, 0)) return false; - - bool ok = true; - WIN32_FIND_DATAW fd{}; - std::wstring pat = dir + L"\\*"; - HANDLE h = FindFirstFileW(pat.c_str(), &fd); - if (h != INVALID_HANDLE_VALUE) { - do { - if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; - std::wstring full = dir + L"\\" + fd.cFileName; - char fullA[MAX_PATH * 2], nameA[MAX_PATH]; - WideCharToMultiByte(CP_UTF8, 0, full.c_str(), -1, fullA, sizeof(fullA), nullptr, nullptr); - WideCharToMultiByte(CP_UTF8, 0, fd.cFileName, -1, nameA, sizeof(nameA), nullptr, nullptr); - if (!mz_zip_writer_add_file(&zip, nameA, fullA, nullptr, 0, MZ_BEST_SPEED)) ok = false; - } while (FindNextFileW(h, &fd)); - FindClose(h); - } - if (!mz_zip_writer_finalize_archive(&zip)) ok = false; - mz_zip_writer_end(&zip); - return ok; -} - -std::wstring ExportDiagnosticsToDesktop() { - wchar_t desktop[MAX_PATH]; - if (FAILED(SHGetFolderPathW(nullptr, CSIDL_DESKTOPDIRECTORY, nullptr, 0, desktop))) - return L""; - std::wstring dest = std::wstring(desktop) + L"\\Wind-diagnostics-" + std::to_wstring(NowMsUtc()) + L".zip"; - // Flush the live log so the export captures the latest lines. - { std::lock_guard lk(g_logMutex); if (g_logFile != INVALID_HANDLE_VALUE) FlushFileBuffers(g_logFile); } - if (!ZipLogDir(dest.c_str())) return L""; - return dest; -} -``` - -- [ ] **Step 4: Compile miniz.c in every build that links logging** - -In `build.bat`, add `third_party\miniz.c` to the source list of the normal app build, the `:uiaccess` build, and the `:config` build (it is needed by `ExportDiagnosticsToDesktop`). Example for the normal build: - -```bat - src\*.cpp third_party\miniz.c src\wind.res ^ -``` - -(`src\*.cpp` already includes `logging.cpp`. For `:config`, add both `src\logging.cpp` and `third_party\miniz.c`.) The `:test` build does NOT use miniz (the zip code is in the `#ifndef WIND_TESTS` section), so leave `:test` unchanged. - -- [ ] **Step 5: Verify it builds** - -Run: `build.bat` then `build.bat config` then `build.bat test` -Expected: all PASS. - -- [ ] **Step 6: Commit** - -```bash -git add third_party/miniz.h third_party/miniz.c src/logging.h src/logging.cpp build.bat -git commit -m "feat(logging): vendor miniz + export-diagnostics zip helper" -``` - ---- - -## Task 11: Export-diagnostics entry points (tray + WindConfig button) - -**Files:** -- Modify: `src/tray.cpp` (menu + command handling) -- Modify: `src/main.cpp` (handle the new tray command) -- Modify: `src/config_ui/main.cpp` (bridge action) -- Modify: `ui/src/bridge.js` + `ui/src/Settings.svelte` (button) - -- [ ] **Step 1: Add the tray menu item** - -In `src/tray.cpp`, add a command id and menu entry. After `static const UINT ID_SETTINGS = 1003, ID_QUIT = 1002;` add: - -```cpp -static const UINT ID_EXPORTDIAG = 1004; -``` - -In `HandleMessage`, where the menu is built, add an item between Settings and Quit: - -```cpp - AppendMenuW(m, MF_STRING, ID_SETTINGS, L"Open Settings"); - AppendMenuW(m, MF_STRING, ID_EXPORTDIAG, L"Export diagnostics"); - AppendMenuW(m, MF_STRING, ID_QUIT, L"Quit"); -``` - -And after the existing `if (cmd == ID_SETTINGS) ...` handling, add: - -```cpp - else if (cmd == ID_EXPORTDIAG) { - std::wstring zip = wind::ExportDiagnosticsToDesktop(); - if (!zip.empty()) { - // Reveal the zip in Explorer. - std::wstring args = L"/select,\"" + zip + L"\""; - ShellExecuteW(nullptr, L"open", L"explorer.exe", args.c_str(), nullptr, SW_SHOWNORMAL); - Notify(L"Wind", L"Diagnostics exported to your Desktop."); - } else { - Notify(L"Wind", L"Could not export diagnostics."); - } - } -``` - -Add `#include "logging.h"` and `#include ` to `src/tray.cpp` if not present. - -- [ ] **Step 2: Verify the tray path builds** - -Run: `build.bat` -Expected: PASS. - -- [ ] **Step 3: Add the WindConfig bridge action** - -In `src/config_ui/main.cpp` `HandleWebMessage`, add a branch alongside the existing `openIni` handling: - -```cpp - } else if (type == "exportDiagnostics") { - std::wstring zip = wind::ExportDiagnosticsToDesktop(); - if (!zip.empty()) { - std::wstring args = L"/select,\"" + zip + L"\""; - ShellExecuteW(nullptr, L"open", L"explorer.exe", args.c_str(), nullptr, SW_SHOWNORMAL); - } - } -``` - -(`logging.h` is already included from Task 8.) - -- [ ] **Step 4: Add the bridge function + button in the UI** - -In `ui/src/bridge.js`, add after `openIni`: - -```js -// "Export diagnostics" -> host zips %LOCALAPPDATA%\Wind\logs to the Desktop and reveals it. -export function exportDiagnostics() { post({ type: 'exportDiagnostics' }); } -``` - -In `ui/src/Settings.svelte`, import it and add a button in the footer/about area (near the existing "Edit config file" action). Find the `openIni` import and usage and mirror it: - -```svelte - import { getConfig, setConfig, openIni, exportDiagnostics } from './bridge.js'; -``` - -Add a button (place it beside the existing openIni button): - -```svelte - -``` - -- [ ] **Step 5: Build the config UI + host** - -Run: `build.bat config` -Expected: PASS - Svelte builds, `WindConfig.exe` compiles. - -- [ ] **Step 6: Manual verification** - -Run `Wind.exe`; right-click the tray icon -> "Export diagnostics". Confirm a `Wind-diagnostics-.zip` appears on the Desktop, Explorer selects it, and it contains `wind-core.log` (with the snapshot) + any crash files. Open WindConfig -> click "Export diagnostics" -> confirm the same. - -- [ ] **Step 7: Commit** - -```bash -git add src/tray.cpp src/config_ui/main.cpp ui/src/bridge.js ui/src/Settings.svelte -git commit -m "feat(logging): export-diagnostics from tray + WindConfig settings" -``` - ---- - -## Task 12: VERSIONINFO + PDB generation - -**Files:** -- Modify: `src/wind.rc` -- Modify: `build.bat` - -- [ ] **Step 1: Add VERSIONINFO to the resource script** - -In `src/wind.rc`, after the existing `IDI_WIND ICON ...` line, add (include the version header at the top of the .rc): - -```rc -#include "version.h" - -VS_VERSION_INFO VERSIONINFO - FILEVERSION WIND_VER_MAJOR,WIND_VER_MINOR,WIND_VER_PATCH,0 - PRODUCTVERSION WIND_VER_MAJOR,WIND_VER_MINOR,WIND_VER_PATCH,0 - FILEOS 0x40004L - FILETYPE 0x1L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904b0" - BEGIN - VALUE "CompanyName", "Wind" - VALUE "FileDescription", "Wind fullscreen magnifier" - VALUE "FileVersion", WIND_VERSION_STR - VALUE "ProductName", "Wind" - VALUE "ProductVersion", WIND_VERSION_STR - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1200 - END -END -``` - -- [ ] **Step 2: Enable PDB generation for the shipped builds** - -In `build.bat`, add `/Zi` to the `cl` flags and `/DEBUG /OPT:REF /OPT:ICF` to the `/link` flags of the normal app build and the `:uiaccess` build, so a matching PDB is produced (release codegen preserved by `/OPT:REF,ICF`). Example: - -```bat -cl /nologo /std:c++17 /EHsc /O2 /Zi /W4 /DUNICODE /D_UNICODE ^ - ... - /link ... Dbghelp.lib ^ - /DEBUG /OPT:REF /OPT:ICF ^ - /MANIFEST:EMBED ... -``` - -- [ ] **Step 3: Ignore the build PDBs in the repo (already covered)** - -`.gitignore` already lists `*.pdb`. Confirm. The author archives the PDB per shipped release OUTSIDE the repo (the deploy script `tools\uiaccess_setup.ps1` can copy `Wind.pdb` alongside the install for the author's own symbol store - note this as a follow-up; not required for this task). - -- [ ] **Step 4: Verify it builds and the version shows** - -Run: `build.bat` -Expected: PASS, and `Wind.pdb` is produced next to `Wind.exe`. - -PowerShell check: -```powershell -(Get-Item .\Wind.exe).VersionInfo.FileVersion -``` -Expected: `0.1.0`. - -- [ ] **Step 5: Commit** - -```bash -git add src/wind.rc build.bat -git commit -m "feat(logging): VERSIONINFO + PDB generation for minidump symbolication" -``` - ---- - -## Final verification - -- [ ] **All builds green:** `build.bat`, `build.bat uiaccess`, `build.bat config`, `build.bat test` (test count = prior + new logging cases). -- [ ] **Snapshot present:** fresh run writes `%LOCALAPPDATA%\Wind\logs\wind-core.log` with the snapshot block. -- [ ] **Crash path:** the force-crash check (Task 9 step 6) produced a readable dump (then the trigger was removed). -- [ ] **Export:** tray + WindConfig both produce a Desktop zip containing the logs. -- [ ] **No per-frame logging:** `grep -n "wind::Log\|RLog\|Log(" src/main.cpp src/render_engine.cpp` shows no calls inside the per-frame tick / `renderFrame` body. -- [ ] **No em-dashes** anywhere in the diff. -- [ ] Dispatch a final code review, then use superpowers:finishing-a-development-branch to land via PR (references #81). diff --git a/docs/superpowers/plans/2026-06-03-quick-zoom.md b/docs/superpowers/plans/2026-06-03-quick-zoom.md deleted file mode 100644 index 61143298..00000000 --- a/docs/superpowers/plans/2026-06-03-quick-zoom.md +++ /dev/null @@ -1,510 +0,0 @@ -# Quick Zoom (double-tap toggle) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Double-tapping either zoom key instantly toggles the magnifier between 1.0x ("0%") and a remembered zoom level (or a configurable default), remembering the level being left only when it is above 200%. - -**Architecture:** All decision logic is pure and unit-tested in `src/zoom_controller.{h,cpp}` (already compiled into both the app and test builds): a `QuickZoomDetector` that fires on two quick taps of the same channel, a `ZoomController::setLevel` instant snap, and an `ApplyQuickZoom` free function holding the store/restore arithmetic. `RunTick` in `src/main.cpp` rising-edge-detects the existing `inHeld`/`outHeld` flags, feeds the detector a QPC timestamp, and applies the result by snapping the level so the existing same-tick zoom-in/zoom-out transitions handle all rendering. Three hot-reloadable config knobs gate and tune it. - -**Tech Stack:** C++17, MSVC `cl.exe`, doctest (`third_party/doctest.h`), Svelte/Vite config UI. - -Spec: `docs/superpowers/specs/2026-06-03-quick-zoom-design.md`. - ---- - -## File Structure - -- `src/zoom_controller.h` (modify): add `ZoomController::setLevel`, the `QuickZoomDetector` class, the `QuickZoomResult` struct, and the `ApplyQuickZoom` free function. -- `src/zoom_controller.cpp` (modify): implementations of the above. -- `tests/test_quick_zoom.cpp` (create): unit tests for `QuickZoomDetector` and `ApplyQuickZoom` (auto-included by `tests\*.cpp`). -- `tests/test_zoom_controller.cpp` (modify): add `setLevel` clamp tests. -- `src/config.h` (modify): add `quickZoom`, `quickZoomWindowMs`, `quickZoomDefault`. -- `src/config.cpp` (modify): parse + clamp the three keys, add them to the default-ini template. -- `tests/test_config.cpp` (modify): parse + clamp tests for the three keys. -- `src/main.cpp` (modify): `TickState` fields + the edge-detect/apply block in `RunTick`. -- `ui/src/settings-schema.js` (modify): three rows in the Zoom section (no bridge/host change; `UpdateIniText` appends unknown keys, `setConfig` writes any key). - ---- - -## Task 1: `ZoomController::setLevel` (instant snap) - -**Files:** -- Modify: `src/zoom_controller.h` (inside `class ZoomController`, near `reset()`) -- Modify: `src/zoom_controller.cpp` -- Test: `tests/test_zoom_controller.cpp` - -- [ ] **Step 1: Write the failing test** - -Append to `tests/test_zoom_controller.cpp`: - -```cpp -TEST_CASE("setLevel snaps and clamps to bounds") { - ZoomController z(1.0, 8.0); - z.setLevel(4.0); - CHECK(z.level() == doctest::Approx(4.0)); - z.setLevel(100.0); // above max -> clamps to 8.0 - CHECK(z.level() == doctest::Approx(8.0)); - z.setLevel(0.5); // below min -> clamps to 1.0 - CHECK(z.level() == doctest::Approx(1.0)); -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `build.bat test` -Expected: compile error - `setLevel` is not a member of `ZoomController`. - -- [ ] **Step 3: Implement** - -In `src/zoom_controller.h`, add inside `class ZoomController` right after the `reset();` declaration: - -```cpp - void setLevel(double l); // instant snap to a level (clamped to [min,max]); dir_ untouched -``` - -In `src/zoom_controller.cpp`, add after `ZoomController::reset()`: - -```cpp -void ZoomController::setLevel(double l) { - level_ = std::min(maxLevel_, std::max(minLevel_, l)); -} -``` - -(`` is already included in this file.) - -- [ ] **Step 4: Run to verify it passes** - -Run: `build.bat test` -Expected: all tests PASS (exit 0). - -- [ ] **Step 5: Commit** - -```bash -git add src/zoom_controller.h src/zoom_controller.cpp tests/test_zoom_controller.cpp -git commit -m "feat(zoom): ZoomController::setLevel instant snap" -``` - ---- - -## Task 2: `QuickZoomDetector` (double-tap detection) - -**Files:** -- Modify: `src/zoom_controller.h` (after the `ZoomController` class) -- Modify: `src/zoom_controller.cpp` -- Test: `tests/test_quick_zoom.cpp` (create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/test_quick_zoom.cpp`: - -```cpp -#include "doctest.h" -#include "../src/zoom_controller.h" -using namespace wind; - -TEST_CASE("double-tap inside the window fires once") { - QuickZoomDetector d; - d.setWindow(0.3); - CHECK(d.update(true, false, 0.00) == false); // first in-tap - CHECK(d.update(true, false, 0.20) == true); // second in-tap within 0.3s - CHECK(d.update(false, false, 0.21) == false); // no edge -> nothing -} -TEST_CASE("two taps outside the window do not fire") { - QuickZoomDetector d; - d.setWindow(0.3); - CHECK(d.update(true, false, 0.00) == false); - CHECK(d.update(true, false, 0.50) == false); // 0.5s gap > window; just rearms - CHECK(d.update(true, false, 0.60) == true); // now within window of the 0.50 tap -} -TEST_CASE("channels are independent - in then out does not fire") { - QuickZoomDetector d; - d.setWindow(0.3); - CHECK(d.update(true, false, 0.00) == false); // in tap - CHECK(d.update(false, true, 0.10) == false); // out tap (different channel) -} -TEST_CASE("either channel double-tapped fires") { - QuickZoomDetector d; - d.setWindow(0.3); - CHECK(d.update(false, true, 0.00) == false); - CHECK(d.update(false, true, 0.15) == true); // out double-tap -} -TEST_CASE("triple-tap = one fire then a fresh sequence") { - QuickZoomDetector d; - d.setWindow(0.3); - CHECK(d.update(true, false, 0.00) == false); - CHECK(d.update(true, false, 0.10) == true); // tap 2 fires + consumes - CHECK(d.update(true, false, 0.20) == false); // tap 3 starts a new sequence - CHECK(d.update(true, false, 0.30) == true); // tap 4 fires -} -TEST_CASE("a changed window is respected") { - QuickZoomDetector d; - d.setWindow(0.1); - CHECK(d.update(true, false, 0.00) == false); - CHECK(d.update(true, false, 0.20) == false); // 0.2s > 0.1s window -> rearm, no fire -} -TEST_CASE("reset clears pending taps") { - QuickZoomDetector d; - d.setWindow(0.3); - CHECK(d.update(true, false, 0.00) == false); - d.reset(); - CHECK(d.update(true, false, 0.10) == false); // first tap was cleared -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `build.bat test` -Expected: compile error - `QuickZoomDetector` is undeclared. - -- [ ] **Step 3: Implement** - -In `src/zoom_controller.h`, add after the closing `};` of `class ZoomController` (still inside `namespace wind`): - -```cpp -// Pure double-tap detector for quick zoom. Two independent channels (in, out): each remembers the -// time of its last down-edge; two down-edges of the SAME channel within the window fire once. -// A fire of either channel drives the same quick-zoom toggle (see ApplyQuickZoom). Fed rising edges -// from the tick loop with a monotonic timestamp (QPC seconds in the app; arbitrary in tests). -class QuickZoomDetector { -public: - void setWindow(double seconds) { window_ = seconds; } - // inEdge/outEdge: that channel went down THIS tick. nowSeconds: a monotonic clock. Returns true - // exactly once when a double-tap completes (and consumes it, so a triple-tap restarts). - bool update(bool inEdge, bool outEdge, double nowSeconds); - void reset() { lastInDown_ = lastOutDown_ = kNever; } -private: - static constexpr double kNever = -1e9; - double window_ = 0.3; - double lastInDown_ = kNever; - double lastOutDown_ = kNever; -}; -``` - -In `src/zoom_controller.cpp`, add after `ZoomController::setLevel`: - -```cpp -bool QuickZoomDetector::update(bool inEdge, bool outEdge, double nowSeconds) { - bool fire = false; - if (inEdge) { - if (nowSeconds - lastInDown_ <= window_) { fire = true; lastInDown_ = kNever; } - else lastInDown_ = nowSeconds; - } - if (outEdge) { - if (nowSeconds - lastOutDown_ <= window_) { fire = true; lastOutDown_ = kNever; } - else lastOutDown_ = nowSeconds; - } - return fire; -} -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `build.bat test` -Expected: all tests PASS (exit 0). - -- [ ] **Step 5: Commit** - -```bash -git add src/zoom_controller.h src/zoom_controller.cpp tests/test_quick_zoom.cpp -git commit -m "feat(zoom): QuickZoomDetector double-tap detection" -``` - ---- - -## Task 3: `ApplyQuickZoom` (store/restore arithmetic) - -**Files:** -- Modify: `src/zoom_controller.h` (after `QuickZoomDetector`) -- Modify: `src/zoom_controller.cpp` -- Test: `tests/test_quick_zoom.cpp` - -- [ ] **Step 1: Write the failing test** - -Append to `tests/test_quick_zoom.cpp`: - -```cpp -TEST_CASE("ApplyQuickZoom: zoomed above 200% snaps out and remembers") { - QuickZoomResult r = ApplyQuickZoom(/*cur*/5.0, /*stored*/0.0, /*def*/4.0, /*max*/12.0); - CHECK(r.newLevel == doctest::Approx(1.0)); - CHECK(r.newStored == doctest::Approx(5.0)); -} -TEST_CASE("ApplyQuickZoom: shallow zoom (<=200%) snaps out but is NOT remembered") { - QuickZoomResult r = ApplyQuickZoom(/*cur*/1.5, /*stored*/5.0, /*def*/4.0, /*max*/12.0); - CHECK(r.newLevel == doctest::Approx(1.0)); - CHECK(r.newStored == doctest::Approx(5.0)); // prior memory preserved -} -TEST_CASE("ApplyQuickZoom: at 0% with a stored level snaps in to it (clamped to max)") { - QuickZoomResult in = ApplyQuickZoom(/*cur*/1.0, /*stored*/5.0, /*def*/4.0, /*max*/12.0); - CHECK(in.newLevel == doctest::Approx(5.0)); - QuickZoomResult clamped = ApplyQuickZoom(/*cur*/1.0, /*stored*/20.0, /*def*/4.0, /*max*/12.0); - CHECK(clamped.newLevel == doctest::Approx(12.0)); -} -TEST_CASE("ApplyQuickZoom: at 0% with nothing stored uses the default") { - QuickZoomResult r = ApplyQuickZoom(/*cur*/1.0, /*stored*/0.0, /*def*/4.0, /*max*/12.0); - CHECK(r.newLevel == doctest::Approx(4.0)); - CHECK(r.newStored == doctest::Approx(0.0)); // memory unchanged on snap-in -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `build.bat test` -Expected: compile error - `ApplyQuickZoom` / `QuickZoomResult` undeclared. - -- [ ] **Step 3: Implement** - -In `src/zoom_controller.h`, add after the `QuickZoomDetector` class (inside `namespace wind`): - -```cpp -// Result of one quick-zoom toggle: the level to snap to, and the (possibly updated) remembered level. -struct QuickZoomResult { double newLevel; double newStored; }; -// Pure toggle arithmetic. cur = current level, stored = remembered level (0 = none yet), def = the -// configured default, maxLevel = ceiling. If zoomed (cur > 1.0): snap out to 1.0, remembering cur -// only when it is above 200% (cur > 2.0). If at 1.0: snap in to stored (or def if none), clamped to -// [1.0, maxLevel]. -QuickZoomResult ApplyQuickZoom(double cur, double stored, double def, double maxLevel); -``` - -In `src/zoom_controller.cpp`, add after `QuickZoomDetector::update`: - -```cpp -QuickZoomResult ApplyQuickZoom(double cur, double stored, double def, double maxLevel) { - constexpr double kEps = 1e-6; - constexpr double kStoreThreshold = 2.0; // remember the level being left only if > 200% - QuickZoomResult r{cur, stored}; - if (cur > 1.0 + kEps) { // zoomed -> snap out to 0% - if (cur > kStoreThreshold) r.newStored = cur; - r.newLevel = 1.0; - } else { // at 0% -> snap in - double target = (stored > 0.0) ? stored : def; - if (target > maxLevel) target = maxLevel; - if (target < 1.0) target = 1.0; - r.newLevel = target; - } - return r; -} -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `build.bat test` -Expected: all tests PASS (exit 0). - -- [ ] **Step 5: Commit** - -```bash -git add src/zoom_controller.h src/zoom_controller.cpp tests/test_quick_zoom.cpp -git commit -m "feat(zoom): ApplyQuickZoom store/restore arithmetic" -``` - ---- - -## Task 4: Config knobs (`quickZoom`, `quickZoomWindowMs`, `quickZoomDefault`) - -**Files:** -- Modify: `src/config.h` -- Modify: `src/config.cpp` (parse, clamp, default-ini template) -- Test: `tests/test_config.cpp` - -- [ ] **Step 1: Write the failing test** - -Append to `tests/test_config.cpp` (uses `ParseConfig`, already exercised in that file): - -```cpp -TEST_CASE("quick-zoom config parses and clamps") { - Config def = ParseConfig(""); - CHECK(def.quickZoom == 1); - CHECK(def.quickZoomWindowMs == 300); - CHECK(def.quickZoomDefault == doctest::Approx(4.0)); - - Config c = ParseConfig("quickZoom=0\nquickZoomWindowMs=250\nquickZoomDefault=6.0\n"); - CHECK(c.quickZoom == 0); - CHECK(c.quickZoomWindowMs == 250); - CHECK(c.quickZoomDefault == doctest::Approx(6.0)); - - Config hi = ParseConfig("quickZoomWindowMs=99999\nquickZoomDefault=99\n"); - CHECK(hi.quickZoomWindowMs == 2000); // clamped to max - CHECK(hi.quickZoomDefault == doctest::Approx(50.0)); // clamped to max - Config lo = ParseConfig("quickZoomWindowMs=1\nquickZoomDefault=0.1\n"); - CHECK(lo.quickZoomWindowMs == 50); // clamped to min - CHECK(lo.quickZoomDefault == doctest::Approx(1.0)); // clamped to min -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `build.bat test` -Expected: compile error - `quickZoom` / `quickZoomWindowMs` / `quickZoomDefault` are not members of `Config`. - -- [ ] **Step 3: Implement** - -In `src/config.h`, add inside `struct Config` right after the `onboarded` field (line ~100): - -```cpp - // Quick zoom: double-tap either zoom key to toggle between 1.0x ("0%") and a remembered level. - int quickZoom = 1; // 1 = enabled (default on), 0 = off - int quickZoomWindowMs = 300; // max ms between the two taps to count as a double-tap - double quickZoomDefault = 4.0; // level to snap to when nothing has been remembered yet -``` - -In `src/config.cpp`, add to the `ParseConfig` else-if chain after the `onboarded` line (line 61): - -```cpp - else if (key == "quickZoom") c.quickZoom = std::stoi(val); - else if (key == "quickZoomWindowMs") c.quickZoomWindowMs = std::stoi(val); - else if (key == "quickZoomDefault") c.quickZoomDefault = std::stod(val); -``` - -In `src/config.cpp`, add to the clamp block (after the `c.brightness` clamp, line ~76, before `return c;`): - -```cpp - c.quickZoomWindowMs = (int)clampd(c.quickZoomWindowMs, 50.0, 2000.0); - c.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 50.0); -``` - -In `src/config.cpp`, in `LoadConfig`'s default-ini template, add after the `smoothZoomRamp=0.6\n` line (line ~125): - -```cpp - "; quickZoom: double-tap either zoom key to toggle between 0% and your last level\n" - "; (above 200%); 1=on, 0=off\n" - "quickZoom=1\n" - "; quickZoomWindowMs: max milliseconds between the two taps (50-2000)\n" - "quickZoomWindowMs=300\n" - "; quickZoomDefault: level to jump to before you've set one (e.g. 4.0 = 400%)\n" - "quickZoomDefault=4.0\n" -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `build.bat test` -Expected: all tests PASS (exit 0). - -- [ ] **Step 5: Commit** - -```bash -git add src/config.h src/config.cpp tests/test_config.cpp -git commit -m "feat(config): quickZoom, quickZoomWindowMs, quickZoomDefault knobs" -``` - ---- - -## Task 5: Wire quick zoom into `RunTick` - -**Files:** -- Modify: `src/main.cpp` (`TickState` struct ~line 89-115, and `RunTick` ~line 250-258) - -No unit test (this is the Win32 tick glue; the decision logic is already covered by Tasks 1-4). Verification is an app build plus a manual smoke test. - -- [ ] **Step 1: Add `TickState` fields** - -In `src/main.cpp`, in the `TickState` struct, add these members (next to `recenterKeyWasDown`, ~line 106): - -```cpp - double quickZoomStored = 0.0; // remembered quick-zoom level (0 = none yet); in-memory - bool prevInHeld = false; // for rising-edge detection of the zoom-in channel - bool prevOutHeld = false; - QuickZoomDetector quickZoom; // pure double-tap detector -``` - -- [ ] **Step 2: Add the detect/apply block in `RunTick`** - -In `src/main.cpp`, insert this block immediately BEFORE the line `double lvl = t.zoom.level();` (~line 258), after the recenter-key handling: - -```cpp - // Quick zoom: a double-tap of EITHER zoom channel toggles between 1.0x and a remembered level. - // Rising-edge-detect the already-computed held flags, feed the pure detector a QPC timestamp, - // and apply the toggle by snapping the level so the SAME-tick zoom-in/out transitions below - // (which key off lvl vs prevLvl) handle all the overlay/cursor work. Window is applied live - // (hot-reload), mirroring setProfile. The two-tap ramp is harmless: the snap overrides it. - bool inEdge = inHeld && !t.prevInHeld; - bool outEdge = outHeld && !t.prevOutHeld; - t.prevInHeld = inHeld; t.prevOutHeld = outHeld; - if (t.cfg.quickZoom) { - t.quickZoom.setWindow(t.cfg.quickZoomWindowMs / 1000.0); - double nowSec = double(now.QuadPart) / double(t.freq.QuadPart); - if (t.quickZoom.update(inEdge, outEdge, nowSec)) { - QuickZoomResult qr = ApplyQuickZoom(t.zoom.level(), t.quickZoomStored, - t.cfg.quickZoomDefault, t.cfg.maxLevel); - t.zoom.setLevel(qr.newLevel); - t.quickZoomStored = qr.newStored; - } - } -``` - -(`now` and `t.freq` are the QPC values already read at the top of `RunTick`. `zoom_controller.h` is already included by `main.cpp`.) - -- [ ] **Step 3: Build the app** - -Run: `build.bat` -Expected: compiles and links, emits `Wind.exe`, exit 0. No new warnings. - -- [ ] **Step 4: Run the full test suite (guard against regressions)** - -Run: `build.bat test` -Expected: all tests PASS (exit 0). - -- [ ] **Step 5: Manual smoke test** - -Launch `Wind.exe` (a zoom key must be bound; bind one via the tray/config if needed). Verify: -- Manually hold-zoom in past 200%, then double-tap a zoom key -> snaps instantly to 0% (overlay hides, cursor restored). -- Double-tap again at 0% -> snaps back to the level you left. -- From a fresh launch at 0%, double-tap -> snaps to 400% (the default). -- Hold-zoom to under 200%, double-tap -> snaps to 0%; double-tap again -> returns to the earlier remembered level (the shallow zoom was not remembered). -- Normal hold-to-zoom still works unchanged. - -Quit cleanly via the tray or Ctrl+Alt+Q (confirm the cursor is restored). - -- [ ] **Step 6: Commit** - -```bash -git add src/main.cpp -git commit -m "feat(zoom): wire quick-zoom double-tap toggle into the tick loop" -``` - ---- - -## Task 6: Config UI rows - -**Files:** -- Modify: `ui/src/settings-schema.js` (the `zoom` section) - -No bridge or host change: `getConfig`/`setConfig` round-trip arbitrary keys, and `UpdateIniText` appends keys the ini does not yet contain. - -- [ ] **Step 1: Add the rows** - -In `ui/src/settings-schema.js`, inside the `zoom` section `rows` array, add after the `smoothZoomRamp` row (line ~15): - -```javascript - { key:'quickZoom', type:'toggle', label:'Quick zoom (double-tap)', desc:'Double-tap a zoom key to toggle between 0% and your last level (above 200%).', def:1 }, - { key:'quickZoomWindowMs', type:'slider', label:'Double-tap window (ms)', desc:'Max time between the two taps.', min:150, max:600, step:25, def:300, dependsOn:'quickZoom' }, - { key:'quickZoomDefault', type:'slider', label:'Quick-zoom default', desc:'Level used before you set one (4 = 400%).', min:2, max:50, step:0.5, def:4.0, dependsOn:'quickZoom' }, -``` - -- [ ] **Step 2: Build the config UI + host** - -Run: `build.bat config` -Expected: npm build of `ui/` succeeds, `WindConfig.exe` compiles, exit 0. - -- [ ] **Step 3: Manual verification** - -Launch `WindConfig.exe`. In the Zoom section, confirm: -- "Quick zoom (double-tap)" toggle appears (on by default). -- The window and default sliders appear and grey out when the toggle is off (`dependsOn`). -- Changing a value and Applying writes it to `magnifier.ini` (open the ini to confirm the key), and the running `Wind.exe` picks it up within ~1s (hot-reload). - -- [ ] **Step 4: Commit** - -```bash -git add ui/src/settings-schema.js ui/dist -git commit -m "feat(ui): quick-zoom settings rows in the Zoom section" -``` - ---- - -## Final verification - -- [ ] `build.bat test` -> all pure tests pass (exit 0). -- [ ] `build.bat` -> app builds clean. -- [ ] `build.bat config` -> config UI + host build clean. -- [ ] Manual smoke tests in Task 5 Step 5 and Task 6 Step 3 all pass. -- [ ] Open a GitHub issue for the feature, push the branch, open a PR referencing the issue (per project workflow; `quick-zoom` branch is already checked out). diff --git a/docs/superpowers/plans/2026-06-07-outline-lowzoom-idle.md b/docs/superpowers/plans/2026-06-07-outline-lowzoom-idle.md deleted file mode 100644 index 466099e9..00000000 --- a/docs/superpowers/plans/2026-06-07-outline-lowzoom-idle.md +++ /dev/null @@ -1,530 +0,0 @@ -# Outline Low-Zoom-Only + Idle-Hide Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add two opt-in refinements to the edge outline: show it only at/below a configurable zoom cutoff, and fade it out after the cursor is idle for a configurable number of seconds (instant return on movement). - -**Architecture:** Two pure helpers (`OutlineVisibleAtLevel`, `OutlineIdleAlpha`) carry the testable logic. `FillRenderParams` uses the first to gate `p.outline`; `RunTick` owns an idle-seconds accumulator and uses the second to set a new `p.outlineAlpha`. The render border pass switches from opaque to alpha blending and writes that alpha. Four new hot-reloadable config keys drive it, exposed in the WindConfig Display section. - -**Tech Stack:** C++17 / MSVC, Direct3D 11 + HLSL, doctest (vendored), Svelte + Vite (config UI). - -**Spec:** `docs/superpowers/specs/2026-06-07-outline-lowzoom-idle-design.md` - ---- - -## File Structure - -- `src/config.h` - declare two pure helpers; add four `Config` fields. -- `src/config.cpp` - implement the helpers (pure section); parse + clamp the four keys; ini template. -- `tests/test_config.cpp` - unit tests for the helpers and the new config keys. -- `src/render_engine.h` - add `float outlineAlpha` to `RenderFrameParams`. -- `src/render_engine.cpp` - border pass: alpha-blend + alpha gate. -- `src/main.cpp` - `FillRenderParams` gating; `TickState` idle accumulator; `RunTick` fade logic. -- `ui/src/settings-schema.js` - four Display-section rows (no new row type needed). - -The test build (`build.bat test`) compiles `src/config.cpp` with `WIND_TESTS`, so both helpers MUST live in the pure section of `config.cpp` (above the `#ifndef WIND_TESTS` block) and be declared in `config.h`. The render/tick code is Win32/D3D and is verified by build + the in-app self-test (low-zoom gating) and a manual check (idle fade). - ---- - -## Task 1: Pure helpers `OutlineVisibleAtLevel` + `OutlineIdleAlpha` (TDD) - -**Files:** -- Modify: `src/config.h` (declarations near `ParseHexColor`) -- Modify: `src/config.cpp` (implementations in the pure section) -- Test: `tests/test_config.cpp` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_config.cpp`: - -```cpp -TEST_CASE("OutlineVisibleAtLevel honors master toggle and low-zoom cutoff") { - Config c; // defaults: outline=0, outlineLowZoomOnly=0, outlineLowZoomMax=2.0 - CHECK(OutlineVisibleAtLevel(c, 1.5) == false); // master off - c.outline = 1; - CHECK(OutlineVisibleAtLevel(c, 1.5) == true); // on, no cutoff - CHECK(OutlineVisibleAtLevel(c, 9.0) == true); // on, cutoff disabled -> any level - c.outlineLowZoomOnly = 1; // cutoff at 2.0 - CHECK(OutlineVisibleAtLevel(c, 1.5) == true); // below cutoff - CHECK(OutlineVisibleAtLevel(c, 2.0) == true); // exactly at cutoff (inclusive) - CHECK(OutlineVisibleAtLevel(c, 2.5) == false); // above cutoff -} -TEST_CASE("OutlineIdleAlpha ramps from 1 to 0 across the fade window") { - CHECK(OutlineIdleAlpha(0.0, 7.0, 0.3) == doctest::Approx(1.0)); // not idle yet - CHECK(OutlineIdleAlpha(7.0, 7.0, 0.3) == doctest::Approx(1.0)); // at threshold, fade starts - CHECK(OutlineIdleAlpha(7.15, 7.0, 0.3) == doctest::Approx(0.5)); // half-way through the fade - CHECK(OutlineIdleAlpha(7.3, 7.0, 0.3) == doctest::Approx(0.0)); // fully faded - CHECK(OutlineIdleAlpha(99.0, 7.0, 0.3) == doctest::Approx(0.0)); // stays faded - // Degenerate fadeDuration <= 0 -> hard step. - CHECK(OutlineIdleAlpha(6.9, 7.0, 0.0) == doctest::Approx(1.0)); - CHECK(OutlineIdleAlpha(7.0, 7.0, 0.0) == doctest::Approx(0.0)); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cmd /c "build.bat test"` -Expected: FAIL to compile/link (both helpers undeclared). - -- [ ] **Step 3: Declare in `src/config.h`** - -Add directly below the existing `bool ParseHexColor(...);` declaration: - -```cpp -// Pure: whether the edge outline should show at this zoom level, given the master `outline` -// toggle and the optional low-zoom cutoff. (The "are we zoomed" level > 1.0 gate stays in the -// render pass.) -bool OutlineVisibleAtLevel(const Config& c, double level); - -// Pure: edge-outline idle-fade alpha. Returns 1.0 until `idleSeconds` reaches `threshold`, then -// ramps linearly to 0.0 over `fadeDuration` seconds (clamped to [0,1]). fadeDuration <= 0 gives a -// hard 1.0/0.0 step at the threshold. Deterministic so the fade ramp is unit-testable. -double OutlineIdleAlpha(double idleSeconds, double threshold, double fadeDuration); -``` - -- [ ] **Step 4: Implement in `src/config.cpp`** - -Add to the pure section (e.g. just after `ParseHexColor`, before `ParseConfig`): - -```cpp -bool OutlineVisibleAtLevel(const Config& c, double level) { - if (c.outline == 0) return false; - if (c.outlineLowZoomOnly != 0 && level > c.outlineLowZoomMax) return false; - return true; -} - -double OutlineIdleAlpha(double idleSeconds, double threshold, double fadeDuration) { - if (fadeDuration <= 0.0) return idleSeconds >= threshold ? 0.0 : 1.0; - double over = (idleSeconds - threshold) / fadeDuration; - if (over <= 0.0) return 1.0; - if (over >= 1.0) return 0.0; - return 1.0 - over; -} -``` - -Note: these reference the new `Config` fields (`outlineLowZoomOnly`, `outlineLowZoomMax`), added in Task 2. To keep this task compiling on its own, add the fields in Task 2 BEFORE running the build here - OR, simpler: do Task 1 Step 3-4 and Task 2 Step 3 together so the struct fields exist. Since the test in Step 1 constructs `Config c;` and reads `c.outline`/`c.outlineLowZoomOnly`/`c.outlineLowZoomMax`, the fields must exist for this task to build. Therefore: add the four `Config` fields from Task 2 Step 3 now (they are pure data with defaults and harmless), then implement the helpers. Task 2 then adds only the parse/clamp/tests for those fields. - -- [ ] **Step 5: Add the four `Config` fields to `src/config.h`** (needed for this task to compile) - -At the end of the `Config` struct (just before the closing `};`, after the existing `outlineColor` field): - -```cpp - // Low-zoom-only: show the outline only while level <= outlineLowZoomMax (when enabled). - int outlineLowZoomOnly = 0; // 1 = enable the cutoff - double outlineLowZoomMax = 2.0; // zoom cutoff (clamped [1.0, 50.0]) - // Idle-hide: fade the outline out after outlineIdleSeconds of no cursor motion (when enabled). - int outlineIdleHide = 0; // 1 = enable idle fade - double outlineIdleSeconds = 7.0; // idle timeout before fade (clamped [0.5, 60.0]) -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cmd /c "build.bat test"` -Expected: PASS (exit 0). - -- [ ] **Step 7: Commit** - -```bash -git add src/config.h src/config.cpp tests/test_config.cpp -git commit -m "feat(config): add OutlineVisibleAtLevel + OutlineIdleAlpha helpers and fields (#94)" -``` - ---- - -## Task 2: Parse, clamp, and ini template for the four keys (TDD) - -**Files:** -- Modify: `src/config.cpp` (`ParseConfig` branches + clamp block + `LoadConfig` template) -- Test: `tests/test_config.cpp` - -(The `Config` fields themselves were added in Task 1 Step 5.) - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_config.cpp`: - -```cpp -TEST_CASE("outline low-zoom + idle keys default and parse with clamps") { - Config d = ParseConfig(""); - CHECK(d.outlineLowZoomOnly == 0); - CHECK(d.outlineLowZoomMax == doctest::Approx(2.0)); - CHECK(d.outlineIdleHide == 0); - CHECK(d.outlineIdleSeconds == doctest::Approx(7.0)); - - Config c = ParseConfig( - "outlineLowZoomOnly=1\noutlineLowZoomMax=3.5\noutlineIdleHide=1\noutlineIdleSeconds=10\n"); - CHECK(c.outlineLowZoomOnly == 1); - CHECK(c.outlineLowZoomMax == doctest::Approx(3.5)); - CHECK(c.outlineIdleHide == 1); - CHECK(c.outlineIdleSeconds == doctest::Approx(10.0)); - - // Clamps: outlineLowZoomMax [1.0,50.0]; outlineIdleSeconds [0.5,60.0]. - CHECK(ParseConfig("outlineLowZoomMax=0.2\n").outlineLowZoomMax == doctest::Approx(1.0)); - CHECK(ParseConfig("outlineLowZoomMax=99\n").outlineLowZoomMax == doctest::Approx(50.0)); - CHECK(ParseConfig("outlineIdleSeconds=0\n").outlineIdleSeconds == doctest::Approx(0.5)); - CHECK(ParseConfig("outlineIdleSeconds=120\n").outlineIdleSeconds == doctest::Approx(60.0)); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cmd /c "build.bat test"` -Expected: FAIL (keys not parsed yet -> defaults returned for the parse cases, clamp cases wrong). - -- [ ] **Step 3: Add parse branches in `src/config.cpp`** - -In `ParseConfig`, after the existing `outlineColor` branch: - -```cpp - else if (key == "outlineLowZoomOnly") c.outlineLowZoomOnly = std::stoi(val); - else if (key == "outlineLowZoomMax") c.outlineLowZoomMax = std::stod(val); - else if (key == "outlineIdleHide") c.outlineIdleHide = std::stoi(val); - else if (key == "outlineIdleSeconds") c.outlineIdleSeconds = std::stod(val); -``` - -- [ ] **Step 4: Add clamps in `src/config.cpp`** - -In the clamp block (near the existing `outlineThickness` clamp, before `return c;`): - -```cpp - c.outlineLowZoomMax = clampd(c.outlineLowZoomMax, 1.0, 50.0); - c.outlineIdleSeconds = clampd(c.outlineIdleSeconds, 0.5, 60.0); -``` - -- [ ] **Step 5: Add documented keys to the `LoadConfig` template** - -In `LoadConfig`, in the `out << "...";` defaults string, after the existing `"outlineColor=#5b5bd6\n"` line: - -```cpp - "; outlineLowZoomOnly: 1 = show the outline only at/below outlineLowZoomMax; 0 = always\n" - "outlineLowZoomOnly=0\n" - "; outlineLowZoomMax: zoom cutoff for the above (2.0 = 200%); range 1.0-50.0\n" - "outlineLowZoomMax=2.0\n" - "; outlineIdleHide: 1 = fade the outline out after the mouse is still; 0 = stay shown\n" - "outlineIdleHide=0\n" - "; outlineIdleSeconds: seconds of no cursor movement before the fade; range 0.5-60.0\n" - "outlineIdleSeconds=7.0\n" -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cmd /c "build.bat test"` -Expected: PASS (exit 0). - -- [ ] **Step 7: Commit** - -```bash -git add src/config.cpp tests/test_config.cpp -git commit -m "feat(config): parse/clamp/template the outline low-zoom + idle keys (#94)" -``` - ---- - -## Task 3: `RenderFrameParams.outlineAlpha` + alpha-blended border pass - -**Files:** -- Modify: `src/render_engine.h` (`RenderFrameParams`) -- Modify: `src/render_engine.cpp` (`State::render` border block) - -No unit test (D3D); verified by build now and the self-test in Task 5. - -- [ ] **Step 1: Add the field to `RenderFrameParams` in `src/render_engine.h`** - -After the existing `float outlineR, outlineG, outlineB;` line (and its multi-line comment): - -```cpp - float outlineAlpha; // 0..1 fade for the outline (1 = solid); <= 0 skips the draw -``` - -- [ ] **Step 2: Update the border draw pass in `src/render_engine.cpp`** - -In `State::render`, the edge-outline block currently begins: - -```cpp - if (p.outline && p.level > 1.0 && haveDesktop) { -``` - -Change that line to also gate on alpha: - -```cpp - if (p.outline && p.level > 1.0 && haveDesktop && p.outlineAlpha > 0.0f) { -``` - -Inside that block, change the blend-state line from opaque to the existing alpha-blend state. Replace: - -```cpp - c->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF); // opaque -``` - -with: - -```cpp - c->OMSetBlendState(blend.Get(), nullptr, 0xFFFFFFFF); // alpha blend (supports the idle fade) -``` - -And change the per-edge constant buffer's alpha component from `1.0f` to `p.outlineAlpha`. Replace: - -```cpp - const float bcbv[8] = { posClipX, posClipY, sizeClipX, sizeClipY, r, g, b, 1.0f }; -``` - -with: - -```cpp - const float bcbv[8] = { posClipX, posClipY, sizeClipX, sizeClipY, r, g, b, p.outlineAlpha }; -``` - -(`blend` is the same SrcAlpha/InvSrcAlpha state the cursor pass uses; at alpha 1.0 the result is the -solid color, so a fully-shown outline stays crisp.) - -- [ ] **Step 3: Build to verify it compiles** - -Run: `cmd /c "build.bat"` -Expected: `Wind.exe` builds with no errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/render_engine.h src/render_engine.cpp -git commit -m "feat(render): alpha-blend the outline + add outlineAlpha param (#94)" -``` - ---- - -## Task 4: Wire gating + idle fade in `main.cpp` - -**Files:** -- Modify: `src/main.cpp` (`FillRenderParams`, `TickState`, `RunTick`) - -No unit test (Win32 tick); the pure pieces it calls are tested in Task 1. Verified by build + self-test + manual. - -- [ ] **Step 1: Update `FillRenderParams` in `src/main.cpp`** - -Find the outline lines added by the previous feature: - -```cpp - p.outline = (cfg.outline != 0); - p.outlineThicknessPx = cfg.outlineThickness; - float orr = 0.357f, og = 0.357f, ob = 0.839f; // #5b5bd6 fallback (accent) - ParseHexColor(cfg.outlineColor, orr, og, ob); - p.outlineR = orr; p.outlineG = og; p.outlineB = ob; -``` - -Replace the first line and append the alpha default, so the block becomes: - -```cpp - p.outline = OutlineVisibleAtLevel(cfg, level); - p.outlineThicknessPx = cfg.outlineThickness; - float orr = 0.357f, og = 0.357f, ob = 0.839f; // #5b5bd6 fallback (accent) - ParseHexColor(cfg.outlineColor, orr, og, ob); - p.outlineR = orr; p.outlineG = og; p.outlineB = ob; - p.outlineAlpha = 1.0f; // RunTick lowers this when idle-hide is active -``` - -- [ ] **Step 2: Add the idle accumulator to `TickState` in `src/main.cpp`** - -In `struct TickState`, after `bool cursorHidden = false;` (or any member; keep it grouped logically): - -```cpp - double outlineIdleSec = 0.0; // seconds the cursor has been still (drives the outline idle fade) -``` - -- [ ] **Step 3: Reset the idle timer on zoom-in in `RunTick`** - -In `RunTick`, the zoomed branch handles the zoom-in rising edge in an `if (zoomIn) { ... }` block (the one that calls `primeReveal()` / `setVisible(true)`). At the top of that `if (zoomIn) {` block, add: - -```cpp - t.outlineIdleSec = 0.0; // each zoom-in starts with the outline fully shown -``` - -- [ ] **Step 4: Compute the fade right after `FillRenderParams` in `RunTick`** - -In the zoomed branch, immediately AFTER the line `FillRenderParams(p, r, t.cfg, t.mon, lvl);` and BEFORE `if (t.cursorHidden) p.cursorMode = 2;`, insert: - -```cpp - // Idle-hide fade: when enabled and the outline is visible, accumulate idle time (reset on - // any hand motion - free OS-cursor delta or raw mickeys), then map it to the fade alpha. - // dt is the per-tick elapsed time computed at the top of RunTick. Fade duration is 0.3s. - const bool outlineMoved = (std::abs(curDx) + std::abs(curDy) + std::abs(rawDx) + std::abs(rawDy)) > 0; - if (t.cfg.outlineIdleHide && p.outline) { - t.outlineIdleSec = outlineMoved ? 0.0 : (t.outlineIdleSec + dt); - p.outlineAlpha = (float)OutlineIdleAlpha(t.outlineIdleSec, t.cfg.outlineIdleSeconds, 0.3); - } else { - t.outlineIdleSec = 0.0; // keep ready for when idle-hide is toggled on mid-session - } -``` - -Note: `curDx`, `curDy`, `rawDx`, `rawDy`, and `dt` are all already in scope at this point in `RunTick` (raw deltas drained earlier in the tick; `curDx/curDy` computed from the GetCursorPos delta; `dt` at the top). `OutlineIdleAlpha` is declared in `config.h`, already included by `main.cpp`. - -- [ ] **Step 5: Build to verify it compiles** - -Run: `cmd /c "build.bat"` -Expected: `Wind.exe` builds with no errors. - -- [ ] **Step 6: Commit** - -```bash -git add src/main.cpp -git commit -m "feat(render): gate outline by low-zoom and drive the idle fade (#94)" -``` - ---- - -## Task 5: Visual verification of low-zoom gating - -**Files:** none (verification only) - -The self-test renders the real path at 4.0x and dumps `wind_selftest.png` using the current -`magnifier.ini`. This confirms the low-zoom gating end to end (the time-based idle fade is checked -manually in Task 7's notes / by the user). - -- [ ] **Step 1: Build** - -Run: `cmd /c "build.bat"` -Expected: `Wind.exe` present. - -- [ ] **Step 2: Confirm the outline is HIDDEN above the cutoff** - -Back up the dev ini, then enable the outline with a low cutoff so 4.0x is above it (PowerShell): - -```powershell -Copy-Item magnifier.ini magnifier.ini.bak -Force -Add-Content magnifier.ini "outline=1" -Add-Content magnifier.ini "outlineThickness=30" -Add-Content magnifier.ini "outlineColor=#00ff00" -Add-Content magnifier.ini "outlineLowZoomOnly=1" -Add-Content magnifier.ini "outlineLowZoomMax=2.0" -Remove-Item wind_selftest.png -ErrorAction SilentlyContinue -$env:WIND_SELFTEST=1; & .\Wind.exe | Out-Null; Remove-Item Env:\WIND_SELFTEST -``` - -Open `wind_selftest.png`. Expected: NO green frame (the self-test runs at 4.0x, which is above the -2.0 cutoff). If a Wind instance is mid-exit and no PNG is written, wait ~1s and rerun the launch line. - -- [ ] **Step 3: Confirm the outline SHOWS below the cutoff** - -Raise the cutoff above 4.0x and rerun: - -```powershell -(Get-Content magnifier.ini) -replace '^outlineLowZoomMax=.*','outlineLowZoomMax=5.0' | Set-Content magnifier.ini -Remove-Item wind_selftest.png -ErrorAction SilentlyContinue -$env:WIND_SELFTEST=1; & .\Wind.exe | Out-Null; Remove-Item Env:\WIND_SELFTEST -``` - -Open `wind_selftest.png`. Expected: a solid green frame on all four edges (4.0x is now below the 5.0 -cutoff). - -- [ ] **Step 4: Restore the dev ini and clean artifacts** - -```powershell -Move-Item magnifier.ini.bak magnifier.ini -Force -Remove-Item wind_selftest.png,wind_hdr_diag.txt -ErrorAction SilentlyContinue -``` - -Confirm `git status` shows no changes from this task (it is verification only). - -- [ ] **Step 5: No commit** (verification only; nothing to commit). - ---- - -## Task 6: Config UI rows - -**Files:** -- Modify: `ui/src/settings-schema.js` (Display section) - -No new row type is needed (reuses `toggle` + `slider`); the generic WebView2 bridge round-trips the -keys. No `Row.svelte` or host changes. - -- [ ] **Step 1: Add four rows to the Display section in `ui/src/settings-schema.js`** - -In the `{ id:'display', ... rows: [ ... ] }` array, after the existing `outlineColor` row: - -```js - { key:'outlineLowZoomOnly', type:'toggle', label:'Only at low zoom', - desc:'Hide the outline once you zoom past the cutoff.', def:0, dependsOn:'outline' }, - { key:'outlineLowZoomMax', type:'slider', label:'Low-zoom cutoff', - desc:'Show only at or below this zoom (2 = 200%).', min:1.25, max:8, step:0.25, def:2, - dependsOn:'outlineLowZoomOnly' }, - { key:'outlineIdleHide', type:'toggle', label:'Hide when idle', - desc:'Fade the outline out when the mouse is still.', def:0, dependsOn:'outline' }, - { key:'outlineIdleSeconds', type:'slider', label:'Idle timeout (s)', - desc:'Seconds of no movement before it fades.', min:1, max:30, step:1, def:7, - dependsOn:'outlineIdleHide' }, -``` - -- [ ] **Step 2: Build the config UI + host** - -Run: `cmd /c "build.bat config"` -Expected: Vite builds `ui/dist/` and `WindConfig.exe` compiles, no errors. - -- [ ] **Step 3: Confirm no dist artifacts staged** - -Run: `git status --short` -Expected: only `ui/src/settings-schema.js` modified (ui/dist is untracked/gitignored). If ui/dist -appears tracked, do NOT stage it. - -- [ ] **Step 4: Commit** - -```bash -git add ui/src/settings-schema.js -git commit -m "feat(ui): add low-zoom + idle-hide rows for the outline (#94)" -``` - ---- - -## Task 7: Final verification + PR - -**Files:** none (verification + integration) - -- [ ] **Step 1: Full unit-test suite** - -Run: `cmd /c "build.bat test"` -Expected: PASS (exit 0). (Run `wind_tests.exe` directly if you want the doctest summary line.) - -- [ ] **Step 2: Build all binaries** - -Run: `cmd /c "build.bat"` then `cmd /c "build.bat config"` -Expected: `Wind.exe` and `WindConfig.exe` build clean. - -- [ ] **Step 3: Manual idle-fade check (recommended)** - -Launch the dev build with idle-hide on (PowerShell), zoom in, and hold the mouse still: - -```powershell -Copy-Item magnifier.ini magnifier.ini.bak -Force -Add-Content magnifier.ini "outline=1" -Add-Content magnifier.ini "outlineIdleHide=1" -Add-Content magnifier.ini "outlineIdleSeconds=3" -Start-Process .\Wind.exe -``` - -Zoom in, leave the mouse still ~3s: the outline should fade out over ~0.3s, then snap back the -instant you move the mouse. When done: exit Wind (tray), then -`Move-Item magnifier.ini.bak magnifier.ini -Force`. Confirm `git status` is clean. - -- [ ] **Step 4: Push and open the PR** - -```bash -git push -u origin feat/outline-lowzoom-idle -gh pr create --title "Outline: only-at-low-zoom and hide-when-idle options" --body "Closes #94. - -Two opt-in refinements to the edge outline (#92), both off by default: -- Only at low zoom: the outline shows only while zoomed at/below a configurable cutoff (default 2x). -- Hide when idle: the outline fades out (~0.3s) after the cursor is still for a configurable timeout (default 7s) and returns instantly on movement. - -Pure helpers OutlineVisibleAtLevel + OutlineIdleAlpha carry the logic (unit-tested); the border pass now alpha-blends so it can fade. Exposed in magnifier.ini and the WindConfig Display section. - -Spec: docs/superpowers/specs/2026-06-07-outline-lowzoom-idle-design.md -Plan: docs/superpowers/plans/2026-06-07-outline-lowzoom-idle.md -Verified: build.bat test green; WIND_SELFTEST confirms low-zoom gating; manual idle-fade check. - -🤖 Generated with [Claude Code](https://claude.com/claude-code)" -``` - ---- - -## Self-Review notes - -- **Spec coverage:** config keys + clamp + template (Tasks 1/2); pure helpers (Task 1); `outlineAlpha` + alpha-blend border (Task 3); `FillRenderParams` gating + `TickState`/`RunTick` idle timer + zoom-in reset (Task 4); low-zoom visual verification (Task 5); idle-fade manual check (Task 7); config UI rows (Task 6); unit tests (Tasks 1/2). All spec sections covered. -- **Type/name consistency:** `OutlineVisibleAtLevel(const Config&, double)` and `OutlineIdleAlpha(double,double,double)` are declared (Task 1 Step 3), implemented (Step 4), tested (Step 1), and called in `FillRenderParams`/`RunTick` (Task 4) with the same signatures. `Config` fields `outlineLowZoomOnly`/`outlineLowZoomMax`/`outlineIdleHide`/`outlineIdleSeconds` are consistent across Tasks 1/2/4 and the schema keys in Task 6. `RenderFrameParams.outlineAlpha` defined in Task 3, set in Task 4, consumed in Task 3's border pass. `TickState.outlineIdleSec` defined and used in Task 4. The 0.3s fade duration is identical in Task 4 and the spec. -- **Ordering note:** Task 1 deliberately adds the four `Config` fields (Step 5) so the helpers and their tests compile within Task 1; Task 2 then adds only parse/clamp/template + tests for those fields. This avoids a non-compiling intermediate state. diff --git a/docs/superpowers/plans/2026-06-07-zoom-edge-outline.md b/docs/superpowers/plans/2026-06-07-zoom-edge-outline.md deleted file mode 100644 index bddb07b1..00000000 --- a/docs/superpowers/plans/2026-06-07-zoom-edge-outline.md +++ /dev/null @@ -1,558 +0,0 @@ -# Zoom Edge Outline Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add an optional solid outline around the screen edges that appears while the magnifier is zoomed, as an at-a-glance indicator that zoom is active (most useful at low zoom). - -**Architecture:** A new solid-color quad shader draws four thin edge quads into the existing capture-excluded D3D11 overlay, after the magnify pass, gated on `level > 1.0`. Three new hot-reloadable config keys (`outline`, `outlineThickness`, `outlineColor`) drive it; a pure `ParseHexColor` helper converts the hex string to float RGB. The config UI exposes them in the Display section via a new native color-picker row type. The generic WebView2 bridge needs no changes. - -**Tech Stack:** C++17 / MSVC, Direct3D 11 + HLSL, doctest (vendored), Svelte + Vite (config UI). - -**Spec:** `docs/superpowers/specs/2026-06-07-zoom-edge-outline-design.md` - ---- - -## File Structure - -- `src/config.h` - declare `ParseHexColor`; add `outline`, `outlineThickness`, `outlineColor` to `Config`. -- `src/config.cpp` - implement `ParseHexColor` (pure section); parse + clamp the new keys; add them to the default-ini template. -- `tests/test_config.cpp` - unit tests for `ParseHexColor` and the new config keys. -- `src/render_shaders.h` - add `kBorderHLSL` solid-color shader. -- `src/render_engine.h` - add the new `RenderFrameParams` fields. -- `src/render_engine.cpp` - border device resources (build + recovery) and the draw pass. -- `src/main.cpp` - wire config -> params in `FillRenderParams`. -- `ui/src/lib/Row.svelte` - new `color` row type. -- `ui/src/settings-schema.js` - the three Display rows. - -The test build (`build.bat test`) compiles `src/config.cpp` with `WIND_TESTS`, so `ParseHexColor` MUST live in the pure section of `config.cpp` (above the `#ifndef WIND_TESTS` I/O block) and be declared in `config.h`. The render-engine code is Win32/D3D and is verified by build + the in-app self-test, not unit tests. - ---- - -## Task 1: `ParseHexColor` pure helper (TDD) - -**Files:** -- Modify: `src/config.h` (declaration near `ParseConfig`) -- Modify: `src/config.cpp` (implementation in the pure section, before `#ifndef WIND_TESTS`) -- Test: `tests/test_config.cpp` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_config.cpp`: - -```cpp -TEST_CASE("ParseHexColor parses 6-digit hex with and without leading #") { - float r = -1, g = -1, b = -1; - CHECK(ParseHexColor("#5b5bd6", r, g, b) == true); - CHECK(r == doctest::Approx(91.0f / 255.0f)); // 0x5b - CHECK(g == doctest::Approx(91.0f / 255.0f)); // 0x5b - CHECK(b == doctest::Approx(214.0f / 255.0f)); // 0xd6 - - float r2, g2, b2; - CHECK(ParseHexColor("ffffff", r2, g2, b2) == true); - CHECK(r2 == doctest::Approx(1.0f)); - CHECK(g2 == doctest::Approx(1.0f)); - CHECK(b2 == doctest::Approx(1.0f)); - - float r3, g3, b3; - CHECK(ParseHexColor("FF0000", r3, g3, b3) == true); // uppercase - CHECK(r3 == doctest::Approx(1.0f)); - CHECK(g3 == doctest::Approx(0.0f)); - CHECK(b3 == doctest::Approx(0.0f)); -} -TEST_CASE("ParseHexColor rejects malformed input and leaves outputs untouched") { - float r = 0.5f, g = 0.5f, b = 0.5f; - CHECK(ParseHexColor("", r, g, b) == false); - CHECK(ParseHexColor("#", r, g, b) == false); - CHECK(ParseHexColor("12345", r, g, b) == false); // too short - CHECK(ParseHexColor("1234567", r, g, b) == false); // too long - CHECK(ParseHexColor("gggggg", r, g, b) == false); // non-hex - CHECK(r == doctest::Approx(0.5f)); // unchanged on failure - CHECK(g == doctest::Approx(0.5f)); - CHECK(b == doctest::Approx(0.5f)); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `build.bat test` -Expected: FAIL to compile/link with `ParseHexColor` undeclared/unresolved. - -- [ ] **Step 3: Declare in `src/config.h`** - -Add directly below the `Config ParseConfig(const std::string& text);` declaration: - -```cpp -// Pure: parse "#rrggbb" or "rrggbb" (case-insensitive) into r,g,b floats in [0,1]. Returns -// false on any malformed input (wrong length, non-hex), leaving the outputs untouched so the -// caller keeps its fallback default. -bool ParseHexColor(const std::string& s, float& r, float& g, float& b); -``` - -- [ ] **Step 4: Implement in `src/config.cpp`** - -Add inside `namespace wind {`, in the pure section (e.g. just after the `trim` helper, before `ParseConfig`): - -```cpp -bool ParseHexColor(const std::string& s, float& r, float& g, float& b) { - size_t i = (!s.empty() && s[0] == '#') ? 1 : 0; - if (s.size() - i != 6) return false; - auto hexv = [](char ch, int& out) -> bool { - if (ch >= '0' && ch <= '9') { out = ch - '0'; return true; } - if (ch >= 'a' && ch <= 'f') { out = ch - 'a' + 10; return true; } - if (ch >= 'A' && ch <= 'F') { out = ch - 'A' + 10; return true; } - return false; - }; - int v[6]; - for (int k = 0; k < 6; ++k) if (!hexv(s[i + k], v[k])) return false; - r = (v[0] * 16 + v[1]) / 255.0f; - g = (v[2] * 16 + v[3]) / 255.0f; - b = (v[4] * 16 + v[5]) / 255.0f; - return true; -} -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `build.bat test` -Expected: PASS (exit 0), all `ParseHexColor` cases green. - -- [ ] **Step 6: Commit** - -```bash -git add src/config.h src/config.cpp tests/test_config.cpp -git commit -m "feat(config): add ParseHexColor pure helper (#92)" -``` - ---- - -## Task 2: Config fields, parse, and clamp (TDD) - -**Files:** -- Modify: `src/config.h` (`Config` struct) -- Modify: `src/config.cpp` (`ParseConfig` key handling + clamp block) -- Test: `tests/test_config.cpp` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_config.cpp`: - -```cpp -TEST_CASE("outline keys default off with accent color") { - Config c = ParseConfig(""); - CHECK(c.outline == 0); // off by default - CHECK(c.outlineThickness == 4); - CHECK(c.outlineColor == "#5b5bd6"); // Wind accent -} -TEST_CASE("outline keys parse and thickness clamps to [1,40]") { - Config c = ParseConfig("outline=1\noutlineThickness=8\noutlineColor=#ff0000\n"); - CHECK(c.outline == 1); - CHECK(c.outlineThickness == 8); - CHECK(c.outlineColor == "#ff0000"); - CHECK(ParseConfig("outlineThickness=0\n").outlineThickness == 1); // clamp low - CHECK(ParseConfig("outlineThickness=999\n").outlineThickness == 40); // clamp high -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `build.bat test` -Expected: FAIL to compile (`outline`, `outlineThickness`, `outlineColor` are not members of `Config`). - -- [ ] **Step 3: Add fields to `src/config.h`** - -Add at the end of the `Config` struct (just before the closing `};`): - -```cpp - // --- Edge outline (zoom indicator) ------------------------------------- - // 1 = draw a solid outline around the screen edges while zoomed (an at-a-glance "you are - // zoomed" indicator, handy at low zoom); 0 = off (default). Hot-reloadable. - int outline = 0; - // Outline width in physical pixels (clamped 1-40). - int outlineThickness = 4; - // Outline color as hex RGB ("#rrggbb"; leading '#' optional). Default = Wind accent. - std::string outlineColor = "#5b5bd6"; -``` - -- [ ] **Step 4: Parse + clamp in `src/config.cpp`** - -In `ParseConfig`, add to the key/value chain (e.g. after the `quickZoomMods` line): - -```cpp - else if (key == "outline") c.outline = std::stoi(val); - else if (key == "outlineThickness") c.outlineThickness = std::stoi(val); - else if (key == "outlineColor") c.outlineColor = val; -``` - -In the clamp block (just before `return c;`), add: - -```cpp - if (c.outlineThickness < 1) c.outlineThickness = 1; - if (c.outlineThickness > 40) c.outlineThickness = 40; -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `build.bat test` -Expected: PASS (exit 0). - -- [ ] **Step 6: Commit** - -```bash -git add src/config.h src/config.cpp tests/test_config.cpp -git commit -m "feat(config): add outline/outlineThickness/outlineColor keys (#92)" -``` - ---- - -## Task 3: Default-ini template keys - -**Files:** -- Modify: `src/config.cpp` (`LoadConfig`, the written-defaults string) - -No unit test (this is the I/O block excluded from the test build). Verified by build + by reading the generated ini. - -- [ ] **Step 1: Add documented keys to the template** - -In `LoadConfig`, inside the `out << ...` defaults string, append before the final `"onboarded: ..."` lines (or anywhere in the block; put it right after the `cropCapture=0\n` line): - -```cpp - "; outline: 1 = draw a solid outline around the screen edges while zoomed (an\n" - "; at-a-glance 'you are zoomed' indicator, handy at low zoom); 0 = off (default)\n" - "outline=0\n" - "; outlineThickness: outline width in pixels (1-40)\n" - "outlineThickness=4\n" - "; outlineColor: outline color as hex RGB (e.g. #5b5bd6 = Wind accent)\n" - "outlineColor=#5b5bd6\n" -``` - -- [ ] **Step 2: Build to verify it compiles** - -Run: `build.bat` -Expected: `Wind.exe` builds with no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/config.cpp -git commit -m "feat(config): document outline keys in the default ini template (#92)" -``` - ---- - -## Task 4: Border shader + RenderFrameParams fields - -**Files:** -- Modify: `src/render_shaders.h` (add `kBorderHLSL`) -- Modify: `src/render_engine.h` (`RenderFrameParams`) - -No unit test (HLSL + struct); verified by the build in Task 6 and the self-test in Task 7. - -- [ ] **Step 1: Add the solid-color shader to `src/render_shaders.h`** - -Add just before the closing `}` of `namespace wind {` (after `kCursorHLSL`): - -```cpp -// Solid-color quad shader for the zoom edge outline. Same quad expansion as the cursor shader, -// but the PS outputs a constant color (no texture). cb: posClip + sizeClip (clip-space placement) -// + an rgba color. Drawn as a 4-vertex triangle strip, once per screen edge. -inline constexpr const char* kBorderHLSL = R"( -cbuffer CB : register(b0) { float2 posClip; float2 sizeClip; float4 color; }; -struct VSOut { float4 pos : SV_POSITION; }; -VSOut VSMain(uint id : SV_VertexID) { - float2 q = float2(id & 1, (id >> 1) & 1); // (0,0),(1,0),(0,1),(1,1) - VSOut o; - o.pos = float4(posClip + q * sizeClip, 0, 1); - return o; -} -float4 PSMain(VSOut i) : SV_TARGET { return color; } -)"; -``` - -- [ ] **Step 2: Add the fields to `RenderFrameParams` in `src/render_engine.h`** - -Add to the `struct RenderFrameParams` (after `cropCapture`): - -```cpp - bool outline; // draw the edge outline while zoomed (level > 1.0) - int outlineThicknessPx; // outline width in physical px (clamped in render()) - float outlineR, outlineG, outlineB; // outline color 0..1, written straight to the backbuffer -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/render_shaders.h src/render_engine.h -git commit -m "feat(render): add border shader and outline RenderFrameParams fields (#92)" -``` - ---- - -## Task 5: Border device resources (build + device-lost recovery) - -**Files:** -- Modify: `src/render_engine.cpp` (`State` members, `buildDeviceResources`, `recoverDeviceLost`) - -- [ ] **Step 1: Add the resource members to `RenderEngine::State`** - -In the `// Cursor pass ...` member group of `struct RenderEngine::State`, add after the cursor pipeline members (e.g. after `ComPtr ccb;`): - -```cpp - // Border (edge-outline) pass: a solid-color quad pipeline reused for all four edges. - ComPtr bvs; - ComPtr bps; - ComPtr bcb; // posClip/sizeClip + rgba for the outline quad -``` - -- [ ] **Step 2: Build the resources in `buildDeviceResources`** - -In `RenderEngine::State::buildDeviceResources()`, after the cursor constant buffer is created (after the `CreateBuffer(&ccbd, ...)` block) and before the blend-state setup, add: - -```cpp - // --- Border (edge-outline) shader pipeline --- - ID3DBlob* bvsb = CompileShader(kBorderHLSL, "VSMain", "vs_5_0"); - ID3DBlob* bpsb = CompileShader(kBorderHLSL, "PSMain", "ps_5_0"); - if (!bvsb || !bpsb) { RLog("buildDeviceResources: border shader compile failed"); SafeRelease(bvsb); SafeRelease(bpsb); return false; } - HRESULT hr5 = device->CreateVertexShader(bvsb->GetBufferPointer(), bvsb->GetBufferSize(), nullptr, bvs.ReleaseAndGetAddressOf()); - HRESULT hr6 = device->CreatePixelShader(bpsb->GetBufferPointer(), bpsb->GetBufferSize(), nullptr, bps.ReleaseAndGetAddressOf()); - SafeRelease(bvsb); SafeRelease(bpsb); - if (FAILED(hr5) || FAILED(hr6)) { RLog("buildDeviceResources: border shader create failed hr5=0x%08lX hr6=0x%08lX", (unsigned long)hr5, (unsigned long)hr6); return false; } - - D3D11_BUFFER_DESC bcbd{}; - bcbd.ByteWidth = 32; // float2 posClip + float2 sizeClip + float4 color - bcbd.Usage = D3D11_USAGE_DEFAULT; - bcbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER; - if (FAILED(device->CreateBuffer(&bcbd, nullptr, bcb.ReleaseAndGetAddressOf()))) { RLog("buildDeviceResources: CreateBuffer(border cb) failed"); return false; } -``` - -- [ ] **Step 3: Release the resources in `recoverDeviceLost`** - -In `RenderEngine::recoverDeviceLost()`, alongside the other shader resets, add (e.g. right after the `s_->cb.Reset(); s_->ccb.Reset();` line): - -```cpp - s_->bvs.Reset(); s_->bps.Reset(); s_->bcb.Reset(); -``` - -(They are rebuilt by the `buildDeviceResources()` call already present later in `recoverDeviceLost`.) - -- [ ] **Step 4: Build to verify it compiles** - -Run: `build.bat` -Expected: `Wind.exe` builds with no errors. - -- [ ] **Step 5: Commit** - -```bash -git add src/render_engine.cpp -git commit -m "feat(render): create + recover border shader device resources (#92)" -``` - ---- - -## Task 6: Border draw pass - -**Files:** -- Modify: `src/render_engine.cpp` (`State::render`) - -- [ ] **Step 1: Add the draw pass between the magnify pass and the cursor pass** - -In `RenderEngine::State::render(const RenderFrameParams& p)`, insert this block immediately after the `if (haveDesktop) { ... }` magnify-pass block closes and before the `// Cursor pass:` comment: - -```cpp - // Edge-outline pass: four thin solid-color quads at the screen borders, drawn while zoomed. - // Into the capture-excluded overlay, so it never feeds back into Desktop Duplication. Opaque - // (no blend) for crisp edges. Gated on level > 1.0 (the overlay is only revealed while zoomed, - // so this is belt-and-braces). Four trivial draws, only when enabled. - if (p.outline && p.level > 1.0 && haveDesktop) { - int t = p.outlineThicknessPx; - if (t < 1) t = 1; - const int maxT = (sw < sh ? sw : sh) / 2; // never let opposite edges overlap/invert - if (t > maxT) t = maxT; - if (t > 0) { - const float r = p.outlineR, g = p.outlineG, b = p.outlineB; - c->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF); // opaque - c->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); - c->IASetInputLayout(nullptr); - c->VSSetShader(bvs.Get(), nullptr, 0); - c->VSSetConstantBuffers(0, 1, bcb.GetAddressOf()); - c->PSSetShader(bps.Get(), nullptr, 0); - c->PSSetConstantBuffers(0, 1, bcb.GetAddressOf()); - const int edges[4][4] = { // {x, y, w, h} in physical px - { 0, 0, sw, t }, // top - { 0, sh - t, sw, t }, // bottom - { 0, t, t, sh - 2 * t }, // left - { sw - t, t, t, sh - 2 * t }, // right - }; - for (int e = 0; e < 4; ++e) { - const int x = edges[e][0], y = edges[e][1], w = edges[e][2], h = edges[e][3]; - if (w <= 0 || h <= 0) continue; - const float posClipX = (float)(x / (double)sw * 2.0 - 1.0); - const float posClipY = (float)(1.0 - y / (double)sh * 2.0); - const float sizeClipX = (float)(w / (double)sw * 2.0); - const float sizeClipY = (float)(-(h / (double)sh * 2.0)); // clip-y up vs screen-y down - const float bcbv[8] = { posClipX, posClipY, sizeClipX, sizeClipY, r, g, b, 1.0f }; - c->UpdateSubresource(bcb.Get(), 0, nullptr, bcbv, 0, 0); - c->Draw(4, 0); - } - } - } -``` - -- [ ] **Step 2: Build to verify it compiles** - -Run: `build.bat` -Expected: `Wind.exe` builds with no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/render_engine.cpp -git commit -m "feat(render): draw the zoom edge outline pass (#92)" -``` - ---- - -## Task 7: Wire config to params + visual verification - -**Files:** -- Modify: `src/main.cpp` (`FillRenderParams`) - -- [ ] **Step 1: Populate the new params in `FillRenderParams`** - -In `FillRenderParams`, after the `p.cropCapture = (cfg.cropCapture != 0);` line, add: - -```cpp - p.outline = (cfg.outline != 0); - p.outlineThicknessPx = cfg.outlineThickness; - float orr = 0.357f, og = 0.357f, ob = 0.839f; // #5b5bd6 fallback (accent) - ParseHexColor(cfg.outlineColor, orr, og, ob); - p.outlineR = orr; p.outlineG = og; p.outlineB = ob; -``` - -(`config.h` is already included by `main.cpp`, so `ParseHexColor` is in scope.) - -- [ ] **Step 2: Build** - -Run: `build.bat` -Expected: `Wind.exe` builds with no errors. - -- [ ] **Step 3: Visual self-test with the outline enabled** - -The overlay is capture-excluded, so only the in-app dump can see it. Enable the outline in the dev ini, then run the self-test (it renders the real path at 4.0x and dumps a PNG): - -```bash -echo outline=1>> magnifier.ini -echo outlineColor=#5b5bd6>> magnifier.ini -set WIND_SELFTEST=1 -Wind.exe -``` - -Open `wind_selftest.png`. Expected: a solid 4px indigo (`#5b5bd6`) frame hugging all four screen edges, over the magnified view. Then change `outlineColor` to `#ff0000`, rerun, and confirm a red frame; set `outline=0`, rerun, and confirm no frame. - -- [ ] **Step 4: Commit** - -```bash -git add src/main.cpp -git commit -m "feat(render): drive the edge outline from config (#92)" -``` - ---- - -## Task 8: Config UI - color row type + Display rows - -**Files:** -- Modify: `ui/src/lib/Row.svelte` (new `color` row type) -- Modify: `ui/src/settings-schema.js` (three Display rows) - -The WebView2 bridge is generic (`getConfig` enumerates every ini key; `setConfig` writes any key), so no host/`ini_edit` changes are needed. `` emits `#rrggbb`, matching the ini default `#5b5bd6`. - -- [ ] **Step 1: Add the `color` branch to `ui/src/lib/Row.svelte`** - -In the control `{#if ...}` chain (e.g. after the `segmented` branch, before the closing `{/if}`), add: - -```svelte - {:else if row.type === 'color'} - onChange(e.target.value)} /> -``` - -And add to the ` -``` - -- [ ] **Step 3: Wire into Settings.svelte** - -Script changes in `ui/src/Settings.svelte`: - -1. Extend the bridge import with `listProfiles, switchProfile, createProfile, renameProfile, duplicateProfile, deleteProfile` and add `import ProfileMenu from './lib/ProfileMenu.svelte';`. -2. Refactor the config-loading body of `onMount` into a reusable `async function loadValues()` (the `getConfig()` -> `values`/`saved`/`kbDefaults` part, NOT the MPO part), and call `loadValues()` from `onMount`. Add profile state + a dirty-guarded action dispatcher: - -```js - let profiles = { names: [], active: '' }; - let profileError = ''; - onMount(async () => { profiles = await listProfiles(); }); // separate onMount is fine in Svelte - - // Switching/creating replaces the staged settings wholesale, so route every profile action - // through the unsaved-changes guard (same UX as closing, issue #164). - let profilePrompt = null; // pending {kind, payload} while the guard is up - function profileAction(kind, payload) { - const mutates = kind === 'switch' || kind === 'create' || kind === 'delete'; - if (mutates && dirty) { profilePrompt = { kind, payload }; return; } - runProfileAction(kind, payload); - } - function discardAndRunProfile() { const p = profilePrompt; profilePrompt = null; discard(); runProfileAction(p.kind, p.payload); } - async function runProfileAction(kind, payload) { - profileError = ''; - let r; - if (kind === 'switch') r = await switchProfile(payload.name); - else if (kind === 'create') r = await createProfile(payload.name); - else if (kind === 'rename') r = await renameProfile(payload.from, payload.to); - else if (kind === 'duplicate') r = await duplicateProfile(payload.name); - else if (kind === 'delete') r = await deleteProfile(payload.name); - profiles = { names: r.names, active: r.active }; - if (!r.ok) { profileError = r.error || 'Profile operation failed'; return; } - if (kind === 'switch' || kind === 'create' || kind === 'delete') await loadValues(); - // A fresh factory-defaults profile has no zoom keys bound (keybinds are per-profile): - // put the user right where fixing that starts. - if (kind === 'create') scrollToSection(scroller, 'keybinds'); - } -``` - -3. Caption markup - replace the current `.ctitle` span: - -```svelte - Wind Settings - -
-``` - -(The caption is `justify-content: space-between`; adding the flex spacer keeps the window buttons pinned right. Adjust the existing `.caption` rule to `justify-content: flex-start` if needed.) - -4. Guard modal - add next to the existing `closePrompt` modal markup: - -```svelte - {#if profilePrompt} -
- -
- {/if} -``` - -- [ ] **Step 4: Build + verify** - -Run: `cd ui && npm run build` (exit 0). - -- [ ] **Step 5: Commit** - -```bash -git add ui/src/bridge.js ui/src/lib/ProfileMenu.svelte ui/src/Settings.svelte -git commit -m "feat(profiles): settings titlebar profile dropdown with manage actions (#N)" -``` - ---- - -### Task 6: Playwright coverage for the profile UI - -**Files:** -- Modify: `ui/tests/settings.spec.js` (mock at lines 3-40 + new tests at the end) - -**Interfaces:** -- Consumes: Task 5's UI and bridge protocol. The mock mirrors Task 4's reply shape exactly. - -- [ ] **Step 1: Extend the mock** - -Inside the `addInitScript` callback in `ui/tests/settings.spec.js`, add profile state + handlers (after the `pickExe` handler): - -```js - // Profiles: an in-page stand-in for the host's file ops, same reply shape as the C++ host. - window.__profiles = window.__profiles || { names: ['Default', 'Gaming'], active: 'Default' }; - const reply = (ok = true, error = '') => listeners.forEach(fn => fn({ data: { - type: 'profiles', names: [...window.__profiles.names], - active: window.__profiles.active, ok, error } })); - if (msg.type === 'listProfiles') reply(); - if (msg.type === 'switchProfile') { window.__profiles.active = msg.name; reply(); } - if (msg.type === 'createProfile') { - window.__profiles.names.push(msg.name); window.__profiles.active = msg.name; reply(); - } - if (msg.type === 'renameProfile') { - window.__profiles.names = window.__profiles.names.map(n => n === msg.from ? msg.to : n); - if (window.__profiles.active === msg.from) window.__profiles.active = msg.to; - reply(); - } - if (msg.type === 'duplicateProfile') { window.__profiles.names.push(msg.name + ' copy'); reply(); } - if (msg.type === 'deleteProfile') { - window.__profiles.names = window.__profiles.names.filter(n => n !== msg.name); - if (window.__profiles.active === msg.name) window.__profiles.active = window.__profiles.names[0]; - reply(); - } -``` - -Also record profile messages for assertions: extend the existing `window.__sets.push(msg)` pattern by adding at the top of `postMessage`: - -```js - if (String(msg.type || '').match(/Profile$|^listProfiles$/)) window.__sets.push(msg); -``` - -- [ ] **Step 2: Add the tests** - -Append to `ui/tests/settings.spec.js`: - -```js -test('titlebar shows the active profile and lists all profiles on click', async ({ page }) => { - await page.goto('/'); - const trigger = page.getByRole('button', { name: /Default/ }); - await expect(trigger).toBeVisible(); - await trigger.click(); - await expect(page.getByRole('menuitem', { name: /Gaming/ })).toBeVisible(); - await expect(page.getByRole('button', { name: /Create new profile/ })).toBeVisible(); -}); - -test('clicking another profile switches and reloads', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Default/ }).click(); - await page.getByRole('menuitem', { name: /Gaming/ }).click(); - await expect(page.getByRole('button', { name: /Gaming/ })).toBeVisible(); - const sets = await page.evaluate(() => window.__sets); - expect(sets.some(s => s.type === 'switchProfile' && s.name === 'Gaming')).toBeTruthy(); -}); - -test('create validates the name inline and sends createProfile when valid', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Default/ }).click(); - await page.getByRole('button', { name: /Create new profile/ }).click(); - const input = page.getByPlaceholder('New profile name'); - await input.fill('Gaming'); // duplicate - await page.getByRole('button', { name: 'Create', exact: true }).click(); - await expect(page.getByText(/already exists/)).toBeVisible(); - await input.fill('Movies'); - await page.getByRole('button', { name: 'Create', exact: true }).click(); - const sets = await page.evaluate(() => window.__sets); - expect(sets.some(s => s.type === 'createProfile' && s.name === 'Movies')).toBeTruthy(); -}); - -test('right-click opens rename/duplicate/delete; rename round-trips', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Default/ }).click(); - await page.getByRole('menuitem', { name: /Gaming/ }).click({ button: 'right' }); - await page.getByRole('button', { name: 'Rename' }).click(); - await page.getByPlaceholder('New name').fill('Games'); - await page.getByRole('button', { name: 'Rename', exact: true }).click(); - const sets = await page.evaluate(() => window.__sets); - expect(sets.some(s => s.type === 'renameProfile' && s.from === 'Gaming' && s.to === 'Games')).toBeTruthy(); -}); - -test('delete asks for confirmation and is disabled on the last profile', async ({ page }) => { - await page.addInitScript(() => { window.__profiles = { names: ['Solo'], active: 'Solo' }; }); - await page.goto('/'); - await page.getByRole('button', { name: /Solo/ }).click(); - await page.getByRole('menuitem', { name: /Solo/ }).click({ button: 'right' }); - await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled(); -}); - -test('switching with staged changes raises the unsaved-changes guard', async ({ page }) => { - await page.goto('/'); - await page.getByText('Smooth zoom', { exact: true }).locator('xpath=../..').getByRole('checkbox').click(); - await page.getByRole('button', { name: /Default/ }).click(); - await page.getByRole('menuitem', { name: /Gaming/ }).click(); - await expect(page.getByText('Unsaved changes')).toBeVisible(); - await page.getByRole('button', { name: 'Discard and continue' }).click(); - await expect(page.getByRole('button', { name: /Gaming/ })).toBeVisible(); -}); -``` - -- [ ] **Step 3: Run the UI suite** - -Run: `cd ui && npx playwright test` -Expected: all tests PASS (pre-existing 10 + the 6 new ones). Fix selectors if a role/name drifted from the component as written - do not weaken assertions. - -- [ ] **Step 4: Commit** - -```bash -git add ui/tests/settings.spec.js -git commit -m "test(profiles): Playwright coverage for the titlebar profile menu (#N)" -``` - ---- - -### Task 7: Apply-mirror into the active profile - -**Files:** -- Modify: `src/config_ui/main.cpp` (the `setConfig` handler at `src/config_ui/main.cpp:169-171`) - -**Interfaces:** -- Consumes: Task 1 `MakeProfileText`, Task 2 I/O helpers, Task 4 `ProfilePath`. -- Produces: every `setConfig` write also refreshes the active profile file, keeping the live-bound contract ("Apply saves into the profile") with zero UI changes - keybind live-writes and staged Apply both route through `setConfig`. - -- [ ] **Step 1: Implement the mirror** - -Replace the `setConfig` branch with: - -```cpp - } else if (type == "setConfig") { - std::string key = JsonField(j, "key"), value = JsonField(j, "value"); - if (!key.empty()) { - WriteFileAtomic(IniPath(), wind::UpdateIniText(ReadFileUtf8(IniPath()), key, value)); - // Live-bound profiles: the active profile IS the settings, so every ini write is - // mirrored (as the full profile-scoped snapshot) into its file. Global keys never - // land there (MakeProfileText strips them). Missing profile/dir = pre-migration - // state; skip silently, the core seeds it on next launch. - const std::string live = ReadFileUtf8(IniPath()); - auto vals = wind::ReadIniValues(live); - const std::string active = vals.count("profile") ? vals["profile"] : ""; - if (!active.empty() && - GetFileAttributesW(ProfilePath(active).c_str()) != INVALID_FILE_ATTRIBUTES) - wind::WriteTextFileAtomic(ProfilePath(active), wind::MakeProfileText(live)); - } - } -``` - -- [ ] **Step 2: Build + manual verify** - -Run: `build.bat config` (exit 0). Manual: open WindConfig, change a setting, Apply; confirm the active `profiles\.ini` now contains the new value and no global keys. - -- [ ] **Step 3: Commit** - -```bash -git add src/config_ui/main.cpp -git commit -m "feat(profiles): mirror every setConfig into the active profile file (#N)" -``` - ---- - -### Task 8: Full verification, docs, deploy, PR - -**Files:** -- Modify: `CLAUDE.md` (one short paragraph in Architecture: profile files under `profiles\`, `profile=` key, global-key set, live-bound mirror, tray + titlebar switch, model-change restart) -- Modify: `README.md` only if it documents settings surfaces (add one line about profiles) - -- [ ] **Step 1: Full builds + full test suites** - -Run, expecting exit 0 / all green: -``` -build.bat -build.bat test -build.bat config -cd ui && npx playwright test -``` - -- [ ] **Step 2: End-to-end manual pass (dev build)** - -Fresh-migration check: rename the dev `profiles` dir away, start `Wind.exe`, confirm Default is seeded. Then: tray switch between two profiles (create the second via the UI); create a profile in the UI and confirm factory defaults + the keybind-section scroll; set keys in it; switch back and forth confirming keybinds travel with the profile; rename the active profile (tray shows the new name, `profile=` updated); duplicate; delete the active one (lands on first remaining); confirm the last profile's Delete is disabled; stage a change and switch (guard appears); switch to a profile whose `model` differs and confirm Wind restarts onto it. - -- [ ] **Step 3: Update CLAUDE.md, commit docs** - -```bash -git add CLAUDE.md README.md -git commit -m "docs(profiles): document the profiles model (#N)" -``` - -- [ ] **Step 4: Deploy the signed build for Max to test (STANDING RULE)** - -```powershell -Start-Process powershell -Verb RunAs -Wait -PassThru -WorkingDirectory '' -ArgumentList '-ExecutionPolicy','Bypass','-File','\tools\uiaccess_setup.ps1' -``` -Read `tools\uiaccess_setup.log`; verify `status=Valid` + `DONE`. Launch from a NORMAL shell: `Start-Process "C:\Program Files\Wind\Wind.exe"`. Tell Max it is live and what to check (tray Profiles submenu; titlebar dropdown: switch/create/rename/duplicate/delete; keybinds travel; model-change restart). - -- [ ] **Step 5: PR** - -```bash -git push -u origin feat/N-profiles -gh pr create --title "Profiles: named settings profiles (tray + settings UI)" --body "Closes #N. - -Named settings profiles per docs/superpowers/specs/2026-08-12-profiles-design.md: profile files under profiles/ next to magnifier.ini, live-bound active profile, tray Profiles submenu, titlebar dropdown with create/rename/duplicate/delete, per-profile keybinds, model-change restart, Default seeding migration. - -🤖 Generated with [Claude Code](https://claude.com/claude-code)" -``` - ---- - -## Self-review notes (resolved inline) - -- Spec coverage: tray submenu (Task 3), titlebar dropdown + manage verbs (Task 5), bridge (Task 4), live mirror (Task 7), migration (Task 2), pure logic + doctests (Task 1), Playwright (Task 6), error surfacing (Tasks 3/4/5), model-restart on switch (Tasks 3/4). -- The `renameProfile` case-only rename uses `MOVEFILE_REPLACE_EXISTING` so `Gaming` -> `gaming` works on the case-insensitive filesystem. -- `DoSwitchProfile` verifies the write by comparing parsed key/value maps, not raw text, so comment differences never false-fail. -- Task 7 runs after the UI tasks so the mirror lands once `setConfig` traffic is final; it has no UI dependency and could equally run after Task 4. diff --git a/docs/superpowers/plans/2026-08-20-wind-installer.md b/docs/superpowers/plans/2026-08-20-wind-installer.md deleted file mode 100644 index 884e62c1..00000000 --- a/docs/superpowers/plans/2026-08-20-wind-installer.md +++ /dev/null @@ -1,1670 +0,0 @@ -# Wind Installer Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship `Wind-Setup-x64-.exe` for GitHub Releases: a per-machine elevated installer that puts Wind in `C:\Program Files\Wind`, presents itself as a custom-drawn video screen like Prism's setup, and uninstalls cleanly. - -**Architecture:** NSIS 3.x driven by a hand-written script. Functionality lands first behind stock NSIS UI and is verified end to end; the custom presentation layer (ported from Prism's `kit.nsh` / `video.nsh`, with an overlay renderer swapped from Electron to Playwright) is then layered on top and replaces the stock pages. Pure decision logic (version comparison, WebView2 probe) lives in headers under `src/` and is doctested with the rest of the suite. - -**Tech Stack:** NSIS 3.x (`winget install NSIS.NSIS`), PowerShell 7 for the release script, Node + Playwright (already in `ui/package.json`) for overlay rendering, ImageMagick and ffmpeg for the frame pipeline, MSVC/doctest for the pure-logic tests. - -**Spec:** `docs/superpowers/specs/2026-08-20-installer-design.md` - -## Global Constraints - -- **No em-dashes (U+2014) anywhere** - code, comments, docs, commit messages, installer copy. Use en-dashes, commas, or rephrase. No `—` in `over.html`. -- **Pure-logic files must not include ``.** `src/installer_state.h` and `src/webview2_probe.h` are pure headers; the test build compiles `tests\*.cpp` with `/DWIND_TESTS` and no Windows headers. -- **Install root is `C:\Program Files\Wind`, fixed and not user-editable.** UIAccess requires a secure location; a browsable path would silently disable it. -- **Never hardcode `L"magnifier.ini"`.** The app owns its ini via `wind::ResolveIniPath()`; the installer writes no ini at all. -- **The installer writes nothing to `%LOCALAPPDATA%\Wind`.** The app seeds and owns that directory. -- **Autostart goes in `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run`, never HKCU.** An elevated installer's HKCU is the wrong hive. -- **Wind is launched de-elevated** via `ShellExecAsUser`, never plain `Exec`. -- **Quit a running Wind by setting the named event `Local\Wind_QuitRequest`**, waiting up to 5000 ms, and only then killing. Clean exit restores the OS cursor, releases `ClipCursor`, and restores the user's native-Magnifier registry backup. -- **Version comes from `src/version.h`** (`WIND_VER_MAJOR` / `WIND_VER_MINOR` / `WIND_VER_PATCH`) via `!searchparse`. Nothing else declares a version. -- **Signing is environment-driven** (`WIND_SIGN_THUMBPRINT`, or `WIND_SIGN_PFX` + `WIND_SIGN_PASSWORD`). No certificate, thumbprint or password enters the repository. -- **Overlay rectangles in `over.nsh` are in 640x480 units.** `kit.nsh` scales them by `$Dpi / 96`. -- **`installer/media/` is generated.** Never hand-edit it; regenerate with `make-over.mjs` / `make-loop.mjs`. -- **Feature work is issue -> branch -> PR** against `github.com/Maxaubert/Wind`. - ---- - -### Task 1: Pure decision logic for install state and WebView2 - -**Files:** -- Create: `src/installer_state.h` -- Create: `src/webview2_probe.h` -- Test: `tests/test_installer_state.cpp` -- Test: `tests/test_webview2_probe.cpp` - -**Interfaces:** -- Consumes: nothing. -- Produces: - - `enum class wind::InstallState { Fresh, Upgrade, Reinstall, Downgrade };` - - `wind::Version wind::ParseVersion(const std::string& s);` where `struct Version { int major=0, minor=0, patch=0; bool valid=false; };` - - `int wind::CompareVersion(const Version& a, const Version& b);` returning -1, 0 or 1. - - `wind::InstallState wind::ClassifyInstall(const std::string& found, const std::string& ours);` - - `bool wind::WebView2Present(const std::string& pv);` - -These are consumed by `installer/app.nsh` (Task 3) as documented behaviour, not as linked code: NSIS reimplements the same rules in six lines, and these tests are what pin the rules down. - -- [ ] **Step 1: Write the failing tests** - -`tests/test_installer_state.cpp`: - -```cpp -#include "doctest.h" -#include "../src/installer_state.h" - -using namespace wind; - -TEST_CASE("ParseVersion reads a three-part version") { - Version v = ParseVersion("1.2.3"); - CHECK(v.valid); - CHECK(v.major == 1); - CHECK(v.minor == 2); - CHECK(v.patch == 3); -} - -TEST_CASE("ParseVersion tolerates a four-part version and ignores the build field") { - Version v = ParseVersion("0.1.0.0"); - CHECK(v.valid); - CHECK(v.major == 0); - CHECK(v.minor == 1); - CHECK(v.patch == 0); -} - -TEST_CASE("ParseVersion rejects junk") { - CHECK_FALSE(ParseVersion("").valid); - CHECK_FALSE(ParseVersion("not-a-version").valid); - CHECK_FALSE(ParseVersion("1.2").valid); -} - -TEST_CASE("CompareVersion orders by major then minor then patch") { - CHECK(CompareVersion(ParseVersion("1.0.0"), ParseVersion("0.9.9")) == 1); - CHECK(CompareVersion(ParseVersion("0.1.0"), ParseVersion("0.1.0")) == 0); - CHECK(CompareVersion(ParseVersion("0.1.2"), ParseVersion("0.1.10")) == -1); -} - -TEST_CASE("ClassifyInstall calls an absent previous version a fresh install") { - CHECK(ClassifyInstall("", "0.2.0") == InstallState::Fresh); - CHECK(ClassifyInstall("garbage", "0.2.0") == InstallState::Fresh); -} - -TEST_CASE("ClassifyInstall separates upgrade, reinstall and downgrade") { - CHECK(ClassifyInstall("0.1.0", "0.2.0") == InstallState::Upgrade); - CHECK(ClassifyInstall("0.2.0", "0.2.0") == InstallState::Reinstall); - CHECK(ClassifyInstall("0.3.0", "0.2.0") == InstallState::Downgrade); -} -``` - -`tests/test_webview2_probe.cpp`: - -```cpp -#include "doctest.h" -#include "../src/webview2_probe.h" - -using namespace wind; - -TEST_CASE("WebView2Present accepts a real Evergreen version string") { - CHECK(WebView2Present("120.0.2210.91")); - CHECK(WebView2Present("109.0.1518.78")); -} - -TEST_CASE("WebView2Present treats absent, empty and the zero sentinel as missing") { - CHECK_FALSE(WebView2Present("")); - CHECK_FALSE(WebView2Present("0.0.0.0")); - CHECK_FALSE(WebView2Present("0.0.0")); -} - -TEST_CASE("WebView2Present rejects a value that is not a version at all") { - CHECK_FALSE(WebView2Present("unknown")); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `build.bat test` -Expected: FAIL at compile with `cannot open include file: '../src/installer_state.h'`. - -- [ ] **Step 3: Write the implementations** - -`src/installer_state.h`: - -```cpp -#pragma once -// Pure decision logic shared between the installer's NSIS script and the test suite. -// NSIS reimplements these rules in its own dialect; these definitions are what the rules -// are checked against, so a change here is a change the installer must follow. -// NO : this header is compiled into the desktop-free test binary. -#include - -namespace wind { - -struct Version { - int major = 0; - int minor = 0; - int patch = 0; - bool valid = false; -}; - -enum class InstallState { Fresh, Upgrade, Reinstall, Downgrade }; - -// "1.2.3" or "1.2.3.4" (the build field is read and discarded: ARP writes three parts, -// VERSIONINFO writes four, and they must compare equal). Anything else is invalid. -inline Version ParseVersion(const std::string& s) { - Version v; - int part[4] = {0, 0, 0, 0}; - int n = 0; // parts filled - bool digits = false; // saw at least one digit in the current part - for (size_t i = 0; i <= s.size(); ++i) { - const char c = (i < s.size()) ? s[i] : '.'; - if (c >= '0' && c <= '9') { - if (n >= 4) return Version{}; // more parts than a version has - part[n] = part[n] * 10 + (c - '0'); - digits = true; - } else if (c == '.') { - if (!digits) return Version{}; // ".." or a leading/trailing dot - ++n; - digits = false; - if (i == s.size()) break; - } else { - return Version{}; // any other character - } - } - if (n < 3) return Version{}; // "1.2" is not a version here - v.major = part[0]; - v.minor = part[1]; - v.patch = part[2]; - v.valid = true; - return v; -} - -inline int CompareVersion(const Version& a, const Version& b) { - if (a.major != b.major) return a.major < b.major ? -1 : 1; - if (a.minor != b.minor) return a.minor < b.minor ? -1 : 1; - if (a.patch != b.patch) return a.patch < b.patch ? -1 : 1; - return 0; -} - -// `found` is whatever DisplayVersion the ARP key held, which is "" on a clean machine and -// can be junk left by a half-removed install. Either way there is nothing to upgrade from. -inline InstallState ClassifyInstall(const std::string& found, const std::string& ours) { - const Version f = ParseVersion(found); - const Version o = ParseVersion(ours); - if (!f.valid || !o.valid) return InstallState::Fresh; - const int c = CompareVersion(f, o); - if (c < 0) return InstallState::Upgrade; - if (c == 0) return InstallState::Reinstall; - return InstallState::Downgrade; -} - -} // namespace wind -``` - -`src/webview2_probe.h`: - -```cpp -#pragma once -// Is the WebView2 Evergreen runtime installed? The answer is a registry string, and the -// only subtlety is that Microsoft's uninstaller leaves the value behind set to "0.0.0.0" -// rather than deleting it, so a non-empty value is not proof of presence. -// WindConfig.exe paints an empty shell without the runtime, which is why this is checked. -// NO : pure, so the rule is testable. -#include -#include "installer_state.h" - -namespace wind { - -inline bool WebView2Present(const std::string& pv) { - const Version v = ParseVersion(pv); - if (!v.valid) return false; - return !(v.major == 0 && v.minor == 0 && v.patch == 0); -} - -} // namespace wind -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `build.bat test` -Expected: PASS, and the summary's assertion count is higher than before this task. - -- [ ] **Step 5: Commit** - -```bash -git add src/installer_state.h src/webview2_probe.h tests/test_installer_state.cpp tests/test_webview2_probe.cpp -git commit -m "feat(installer): pure install-state and WebView2 probe logic" -``` - ---- - -### Task 2: A working installer behind stock NSIS UI - -Functionality first. At the end of this task there is a real, ugly, working `Wind-Setup-x64-0.1.0.exe`. The picture goes on in Tasks 6 to 9. - -**Files:** -- Create: `LICENSE` (MIT, 2026, Max Aubert) -- Create: `installer/wind.nsi` -- Modify: `build.bat` (add the `installer` target and NSIS discovery) -- Modify: `.gitignore` (ignore `installer/media/`, `dist/`) - -**Interfaces:** -- Consumes: nothing. -- Produces: - - `installer/wind.nsi` defining `${WIND_VERSION}`, `${WIND_VER_MAJOR/MINOR/PATCH}`, `${INSTALL_DIR}`, `${ARP_KEY}`, `${RUN_KEY}`, `${RUN_VALUE}` for Tasks 3 and 9. - - `dist/Wind-Setup-x64-.exe`. - - `build.bat installer`. - -- [ ] **Step 1: Install NSIS and confirm it is on PATH** - -Run: `winget install --id NSIS.NSIS --silent --accept-package-agreements --accept-source-agreements` -Then: `& "C:\Program Files (x86)\NSIS\makensis.exe" /VERSION` -Expected: a version string of `v3.` or higher. NSIS does not add itself to PATH, which is why `build.bat` discovers it by path in Step 4. - -- [ ] **Step 2: Add the LICENCE** - -SignPath Foundation will not issue a free certificate to a repository without an OSS licence, and Wind has none. Create `LICENSE` with the standard MIT text, `Copyright (c) 2026 Max Aubert`. - -- [ ] **Step 3: Write `installer/wind.nsi`** - -```nsis -; -; Wind setup. -; -; Per-machine, elevated, into Program Files, because UIAccess is only granted to a signed -; binary in a secure location. The path is therefore not the user's to change: installing -; to D:\Apps\Wind would silently disable the features this install mode exists to enable. -; -; This file is the whole installer while the picture is being built. installer\pages.nsh -; replaces the stock pages later; nothing in the sections changes when it does. -; - -Unicode true -ManifestDPIAware true -SetCompressor /SOLID lzma - -; ---- version: src\version.h is the only place a version is declared ---------- -!searchparse /file "..\src\version.h" "#define WIND_VER_MAJOR " WIND_VER_MAJOR -!searchparse /file "..\src\version.h" "#define WIND_VER_MINOR " WIND_VER_MINOR -!searchparse /file "..\src\version.h" "#define WIND_VER_PATCH " WIND_VER_PATCH -!define WIND_VERSION "${WIND_VER_MAJOR}.${WIND_VER_MINOR}.${WIND_VER_PATCH}" - -!define PRODUCT "Wind" -!define PUBLISHER "Max Aubert" -!define INSTALL_DIR "$PROGRAMFILES64\Wind" -!define ARP_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wind" -!define RUN_KEY "Software\Microsoft\Windows\CurrentVersion\Run" -!define RUN_VALUE "Wind" - -Name "${PRODUCT}" -OutFile "..\dist\Wind-Setup-x64-${WIND_VERSION}.exe" -InstallDir "${INSTALL_DIR}" -RequestExecutionLevel admin -ShowInstDetails hide -ShowUninstDetails hide - -VIProductVersion "${WIND_VER_MAJOR}.${WIND_VER_MINOR}.${WIND_VER_PATCH}.0" -VIAddVersionKey "ProductName" "${PRODUCT}" -VIAddVersionKey "FileDescription" "Wind setup" -VIAddVersionKey "FileVersion" "${WIND_VERSION}" -VIAddVersionKey "ProductVersion" "${WIND_VERSION}" -VIAddVersionKey "CompanyName" "${PUBLISHER}" -VIAddVersionKey "LegalCopyright" "Copyright (c) 2026 ${PUBLISHER}" - -!include "MUI2.nsh" -!include "LogicLib.nsh" -!include "x64.nsh" - -!define MUI_ICON "..\assets\wind.ico" -!define MUI_UNICON "..\assets\wind.ico" - -; Stock pages for now. pages.nsh replaces this block in Task 9. -!insertmacro MUI_PAGE_WELCOME -!insertmacro MUI_PAGE_INSTFILES -!insertmacro MUI_PAGE_FINISH -!insertmacro MUI_UNPAGE_CONFIRM -!insertmacro MUI_UNPAGE_INSTFILES -!insertmacro MUI_LANGUAGE "English" - -; Setup writes nothing to %LOCALAPPDATA%\Wind: the app seeds and owns that directory, -; including magnifier.ini, which it resolves through wind::ResolveIniPath(). -Section "Wind" SEC_WIND - SetOutPath "$INSTDIR" - File "..\Wind.exe" - File "..\WindConfig.exe" - SetOutPath "$INSTDIR\ui\dist" - File /r "..\ui\dist\*.*" - SetOutPath "$INSTDIR" - - WriteUninstaller "$INSTDIR\Uninstall.exe" - - ; Add or remove programs - WriteRegStr HKLM "${ARP_KEY}" "DisplayName" "${PRODUCT}" - WriteRegStr HKLM "${ARP_KEY}" "DisplayVersion" "${WIND_VERSION}" - WriteRegStr HKLM "${ARP_KEY}" "Publisher" "${PUBLISHER}" - WriteRegStr HKLM "${ARP_KEY}" "DisplayIcon" "$INSTDIR\Wind.exe" - WriteRegStr HKLM "${ARP_KEY}" "InstallLocation" "$INSTDIR" - WriteRegStr HKLM "${ARP_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\"" - WriteRegDWORD HKLM "${ARP_KEY}" "NoModify" 1 - WriteRegDWORD HKLM "${ARP_KEY}" "NoRepair" 1 - ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 - IntFmt $0 "0x%08X" $0 - WriteRegDWORD HKLM "${ARP_KEY}" "EstimatedSize" "$0" - - ; Start menu. SetShellVarContext all, because this is a per-machine install. - SetShellVarContext all - CreateShortcut "$SMPROGRAMS\Wind.lnk" "$INSTDIR\Wind.exe" -SectionEnd - -Section "Uninstall" - SetShellVarContext all - Delete "$SMPROGRAMS\Wind.lnk" - Delete "$DESKTOP\Wind.lnk" - DeleteRegValue HKLM "${RUN_KEY}" "${RUN_VALUE}" - DeleteRegKey HKLM "${ARP_KEY}" - - Delete "$INSTDIR\Wind.exe" - Delete "$INSTDIR\WindConfig.exe" - Delete "$INSTDIR\Uninstall.exe" - RMDir /r "$INSTDIR\ui" - ; Not RMDir /r on $INSTDIR itself: it is a user-chosen-looking path and a stray - ; recursive delete of the wrong directory is the one unrecoverable installer bug. - RMDir "$INSTDIR" -SectionEnd - -Function .onInit - ${IfNot} ${RunningX64} - MessageBox MB_ICONSTOP "Wind requires 64-bit Windows." - Abort - ${EndIf} - SetRegView 64 -FunctionEnd - -Function un.onInit - SetRegView 64 -FunctionEnd -``` - -- [ ] **Step 4: Add the `installer` target to `build.bat`** - -Append a target after `:check`, and add `installer` to the dispatch at the top of the file alongside `test`, `uiaccess` and `config`: - -```bat -rem --- Installer (needs NSIS; winget install NSIS.NSIS) --------------------- -:installer -set "MAKENSIS=%ProgramFiles(x86)%\NSIS\makensis.exe" -if not exist "%MAKENSIS%" set "MAKENSIS=%ProgramFiles%\NSIS\makensis.exe" -if not exist "%MAKENSIS%" ( - echo [build] NSIS not found. Install it with: winget install NSIS.NSIS - exit /b 1 -) -if not exist "%ROOT%dist" mkdir "%ROOT%dist" -rem /WX so a warning is a build failure: NSIS warns rather than errors on a missing -rem File source, which would otherwise ship an installer with nothing in it. -"%MAKENSIS%" /WX /V2 "%ROOT%installer\wind.nsi" -exit /b %errorlevel% -``` - -- [ ] **Step 5: Ignore the generated output** - -Add to `.gitignore`: - -``` -dist/ -installer/media/ -``` - -- [ ] **Step 6: Build the payload and the installer** - -Run: `build.bat` then `build.bat config` then `build.bat installer` -Expected: `dist\Wind-Setup-x64-0.1.0.exe` exists and `makensis` reported no warnings. - -- [ ] **Step 7: Verify install and uninstall by hand** - -Run the installer, accept the UAC prompt, click through the stock pages. Then check: - -```powershell -Test-Path 'C:\Program Files\Wind\Wind.exe' -Test-Path 'C:\Program Files\Wind\ui\dist\index.html' -Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Wind' | - Select-Object DisplayName, DisplayVersion, Publisher, EstimatedSize -``` - -Expected: all present, `DisplayVersion` is `0.1.0`, and Wind appears in Settings > Apps. -Then uninstall from Settings and re-run the checks: everything gone. - -- [ ] **Step 8: Commit** - -```bash -git add LICENSE installer/wind.nsi build.bat .gitignore -git commit -m "feat(installer): per-machine NSIS installer, ARP entry and uninstaller" -``` - ---- - -### Task 3: Wind-specific install behaviour - -**Files:** -- Create: `installer/app.nsh` -- Modify: `installer/wind.nsi` (include `app.nsh`, call its macros from the sections) - -**Interfaces:** -- Consumes: `${ARP_KEY}`, `${RUN_KEY}`, `${RUN_VALUE}`, `${WIND_VERSION}` from `installer/wind.nsi`. -- Produces, for `installer/pages.nsh` (Task 9): - - `Var WantAutostart` - 1 by default, read by `WIND_APPLY_AUTOSTART` - - `Var WantDesktop` - 0 by default - - `Var RunAfter` - 1 by default - - `!insertmacro WIND_QUIT_RUNNING` - stop a live Wind and WindConfig - - `!insertmacro WIND_ENSURE_WEBVIEW2` - bootstrap the runtime if absent - - `!insertmacro WIND_APPLY_AUTOSTART` - write or remove the HKLM Run value - - `!insertmacro WIND_LAUNCH_DEELEVATED` - start Wind as the signed-in user - -- [ ] **Step 1: Add the plugin dependency** - -`ShellExecAsUser` ships with NSIS 3 as `Plugins\x86-unicode\ShellExecAsUser.dll`. Confirm it: - -Run: `Test-Path "C:\Program Files (x86)\NSIS\Plugins\x86-unicode\ShellExecAsUser.dll"` -Expected: `True`. If `False`, download the plugin from the NSIS wiki into that folder; the script cannot compile without it. - -- [ ] **Step 2: Write `installer/app.nsh`** - -```nsis -; -; Wind setup, the parts that are about Wind rather than about installing. -; -; Four things the generic script does not know: how to make a running Wind let go of its -; own exe, whether WindConfig has a browser engine to run in, where autostart lives when -; the installer is elevated, and how to hand the app back to the user who asked for it. -; - -!include "LogicLib.nsh" -!include "FileFunc.nsh" - -!define WV2_CLIENT "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" - -Var WantAutostart -Var WantDesktop -Var RunAfter - -; ---- stop a running Wind ----------------------------------------------------- -; An upgrade always runs over a live tray app holding its own exe open. Wind exposes an -; auto-reset named event for exactly this (src\main.cpp:2199); setting it makes Wind exit -; CLEANLY, which restores the OS cursor, releases any ClipCursor, releases the shared -; Magnification runtime, and restores the user's native-Magnifier registry backup if the -; magnify model ever modified it. Killing the process skips all of that and can leave the -; pointer hidden or pinned to one pixel. So: ask, wait, and only then kill. -; -; Local\ is session-scoped and UAC elevation stays inside the session, so the elevated -; installer and the user's Wind see the same object. -!macro WIND_QUIT_RUNNING - DetailPrint "Closing Wind..." - ; WindConfig first: it holds no OS state, and closing it stops it relaunching Wind. - nsExec::Exec 'taskkill /IM WindConfig.exe /F' - Pop $0 - - ; EVENT_MODIFY_STATE = 0x0002 - System::Call 'kernel32::OpenEventW(i 0x0002, i 0, w "Local\Wind_QuitRequest") p .r0' - ${If} $0 <> 0 - System::Call 'kernel32::SetEvent(p $0)' - System::Call 'kernel32::CloseHandle(p $0)' - ; Up to 5 s in 250 ms steps, so a quick exit costs a quarter second, not five. - StrCpy $1 0 - ${Do} - Sleep 250 - IntOp $1 $1 + 1 - nsExec::Exec 'cmd /c tasklist /FI "IMAGENAME eq Wind.exe" /NH | find /I "Wind.exe"' - Pop $2 - ${If} $2 != 0 - ${Break} ; find returned non-zero: no such process, it is gone - ${EndIf} - ${LoopUntil} $1 >= 20 - ${EndIf} - - ; Whatever is left after the polite request gets terminated. The installer is elevated, - ; so this succeeds even against the signed UIAccess build's integrity level. - nsExec::Exec 'taskkill /IM Wind.exe /F' - Pop $0 - Sleep 300 -!macroend - -; ---- WebView2 ---------------------------------------------------------------- -; WindConfig.exe paints an empty shell without the Evergreen runtime. Microsoft's -; uninstaller leaves `pv` behind set to "0.0.0.0" rather than deleting it, so a value -; being present is not proof (src\webview2_probe.h pins that rule and tests it). -!macro WIND_ENSURE_WEBVIEW2 - StrCpy $0 "" - ReadRegStr $0 HKLM "${WV2_CLIENT}" "pv" - ${If} $0 == "" - ReadRegStr $0 HKCU "${WV2_CLIENT}" "pv" - ${EndIf} - ${If} $0 == "" - ${OrIf} $0 == "0.0.0.0" - DetailPrint "Installing the WebView2 runtime..." - ; ~2 MB stub that pulls the runtime itself, rather than bundling ~150 MB we would - ; ship to every user to serve the small minority that lack it. - File "/oname=$PLUGINSDIR\MicrosoftEdgeWebview2Setup.exe" "MicrosoftEdgeWebview2Setup.exe" - nsExec::Exec '"$PLUGINSDIR\MicrosoftEdgeWebview2Setup.exe" /silent /install' - Pop $0 - ${If} $0 != 0 - ; Not fatal. Wind.exe itself needs no browser engine; only Settings does, and it - ; can be repaired later. Failing the whole install over it would be worse. - DetailPrint "WebView2 install returned $0; Settings may not open until it is installed." - ${EndIf} - ${EndIf} -!macroend - -; ---- autostart --------------------------------------------------------------- -; HKLM, not HKCU. This installer is elevated, and an HKCU write from an elevated process -; lands in whichever hive the elevated token owns, which is the administrator's rather -; than the user's whenever a standard user elevated with a different account. HKLM Run is -; also what a per-machine install should use, and Task Manager's Startup tab lists it. -!macro WIND_APPLY_AUTOSTART - ${If} $WantAutostart == 1 - WriteRegStr HKLM "${RUN_KEY}" "${RUN_VALUE}" '"$INSTDIR\Wind.exe"' - ${Else} - DeleteRegValue HKLM "${RUN_KEY}" "${RUN_VALUE}" - ${EndIf} -!macroend - -; ---- launching the app ------------------------------------------------------- -; Plain Exec would hand Wind the installer's admin token. wind::ResolveIniPath() would -; then resolve %LOCALAPPDATA% to the ADMINISTRATOR's profile, so magnifier.ini, the -; profiles and the logs would land where the user never finds them, and a tray magnifier -; would run elevated forever for no reason. ShellExecAsUser re-parents the launch to the -; shell, which gives it the signed-in user's token. -!macro WIND_LAUNCH_DEELEVATED - ShellExecAsUser::ShellExecAsUser "open" "$INSTDIR\Wind.exe" "" "" -!macroend -``` - -- [ ] **Step 3: Fetch the WebView2 bootstrapper into the installer folder** - -```powershell -Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' ` - -OutFile 'installer\MicrosoftEdgeWebview2Setup.exe' -(Get-Item 'installer\MicrosoftEdgeWebview2Setup.exe').Length -``` - -Expected: roughly 1.5 to 2.5 MB. This file IS committed (it is a redistributable stub, not generated output), so add an exception in `.gitignore` if a pattern would catch it. - -- [ ] **Step 4: Wire the macros into `installer/wind.nsi`** - -Add after the other includes: - -```nsis -!include "app.nsh" -``` - -Inside `Section "Wind"`, before `SetOutPath "$INSTDIR"`: - -```nsis - !insertmacro WIND_QUIT_RUNNING -``` - -Inside `Section "Wind"`, after the shortcut block: - -```nsis - !insertmacro WIND_ENSURE_WEBVIEW2 - !insertmacro WIND_APPLY_AUTOSTART - ${If} $WantDesktop == 1 - CreateShortcut "$DESKTOP\Wind.lnk" "$INSTDIR\Wind.exe" - ${EndIf} -``` - -Inside `Section "Uninstall"`, as the first line after `SetShellVarContext all`: - -```nsis - !insertmacro WIND_QUIT_RUNNING -``` - -In `.onInit`, after `SetRegView 64`, seed the defaults the pages will later edit: - -```nsis - StrCpy $WantAutostart 1 - StrCpy $WantDesktop 0 - StrCpy $RunAfter 1 -``` - -And give the stock finish page something to do until Task 9 replaces it, before the `MUI_PAGE_FINISH` line: - -```nsis -!define MUI_FINISHPAGE_RUN -!define MUI_FINISHPAGE_RUN_FUNCTION WindLaunch -``` - -with the function defined after the language block: - -```nsis -Function WindLaunch - !insertmacro WIND_LAUNCH_DEELEVATED -FunctionEnd -``` - -- [ ] **Step 5: Add the uninstaller's data question** - -In `Section "Uninstall"`, after `RMDir "$INSTDIR"`: - -```nsis - ; The app's settings, profiles and logs. Default is to keep them, so reinstalling does - ; not silently discard a user's keybinds and profiles. SetShellVarContext current here - ; on purpose: this is the running user's data, not the machine's. - SetShellVarContext current - ${If} ${FileExists} "$LOCALAPPDATA\Wind\*.*" - MessageBox MB_YESNO|MB_ICONQUESTION \ - "Remove Wind's settings, profiles and logs as well?$\n$\n$LOCALAPPDATA\Wind" \ - /SD IDNO IDNO keepData - RMDir /r "$LOCALAPPDATA\Wind" - keepData: - ${EndIf} -``` - -`/SD IDNO` is what makes a silent uninstall keep the data rather than hang on the prompt. - -- [ ] **Step 6: Rebuild and verify the new behaviour** - -Run: `build.bat installer` -Expected: no warnings. - -Then, with Wind running from a previous install: - -```powershell -Start-Process 'C:\Program Files\Wind\Wind.exe' -Start-Sleep 2 -Get-Process Wind | Select-Object Id -``` - -Run `dist\Wind-Setup-x64-0.1.0.exe`. Expected: the install completes without a "file in use" error, the previously running Wind is gone by the time files are copied, and after the finish page a new Wind is in the tray. - -Then confirm the three new effects: - -```powershell -Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' | Select-Object Wind -(Get-Process Wind).Path -Get-CimInstance Win32_Process -Filter "Name='Wind.exe'" | - ForEach-Object { $_.GetOwner().User } -``` - -Expected: the Run value points at `C:\Program Files\Wind\Wind.exe`; the process path matches; and the owner is your own user account, not an administrator account. That last line is the one that proves `ShellExecAsUser` worked. - -- [ ] **Step 7: Commit** - -```bash -git add installer/app.nsh installer/wind.nsi installer/MicrosoftEdgeWebview2Setup.exe .gitignore -git commit -m "feat(installer): clean shutdown handshake, WebView2 bootstrap, autostart, de-elevated launch" -``` - ---- - -### Task 4: The release script - -**Files:** -- Create: `tools/release.ps1` -- Modify: `README.md` (a Releases section describing the artifact and the signing switch) - -**Interfaces:** -- Consumes: `build.bat`, `build.bat config`, `build.bat uiaccess`, `build.bat installer`. -- Produces: `dist/Wind-Setup-x64-.exe`, signed when a certificate is configured. - -- [ ] **Step 1: Write `tools/release.ps1`** - -```powershell -<# - Builds the release artifact: dist\Wind-Setup-x64-.exe - - Signing is environment-driven, so no certificate detail ever enters the repository: - $env:WIND_SIGN_THUMBPRINT a cert in Cert:\CurrentUser\My or Cert:\LocalMachine\My - or - $env:WIND_SIGN_PFX path to a .pfx - $env:WIND_SIGN_PASSWORD its password - - With a certificate this builds the uiAccess=true variant and signs it. Without one it - builds the ordinary variant, because shipping a manifest that asks for a privilege - Windows will refuse is noise in a public artifact. The app already degrades correctly: - transform_model.cpp probes TokenUIAccess and disables the desktop transform pick. -#> -[CmdletBinding()] -param([switch]$SkipBuild) - -$ErrorActionPreference = 'Stop' -$root = Split-Path -Parent $PSScriptRoot - -function Get-WindVersion { - $h = Get-Content "$root\src\version.h" -Raw - $maj = [regex]::Match($h, '#define\s+WIND_VER_MAJOR\s+(\d+)').Groups[1].Value - $min = [regex]::Match($h, '#define\s+WIND_VER_MINOR\s+(\d+)').Groups[1].Value - $pat = [regex]::Match($h, '#define\s+WIND_VER_PATCH\s+(\d+)').Groups[1].Value - if (-not $maj) { throw "could not read WIND_VER_MAJOR from src\version.h" } - "$maj.$min.$pat" -} - -function Get-SigningCert { - if ($env:WIND_SIGN_THUMBPRINT) { - foreach ($store in 'Cert:\CurrentUser\My', 'Cert:\LocalMachine\My') { - $c = Get-ChildItem $store -ErrorAction SilentlyContinue | - Where-Object Thumbprint -eq $env:WIND_SIGN_THUMBPRINT - if ($c) { return $c } - } - throw "WIND_SIGN_THUMBPRINT is set but no such certificate was found." - } - if ($env:WIND_SIGN_PFX) { - if (-not (Test-Path $env:WIND_SIGN_PFX)) { throw "WIND_SIGN_PFX does not exist: $($env:WIND_SIGN_PFX)" } - $pw = ConvertTo-SecureString $env:WIND_SIGN_PASSWORD -AsPlainText -Force - return Get-PfxCertificate -FilePath $env:WIND_SIGN_PFX -Password $pw - } - $null -} - -function Invoke-Sign { - param([string]$Path, $Cert) - # A timestamp is what keeps the signature valid after the certificate expires. - $s = Set-AuthenticodeSignature -FilePath $Path -Certificate $Cert -HashAlgorithm SHA256 ` - -TimestampServer 'http://timestamp.digicert.com' - if ($s.Status -ne 'Valid') { throw "signing $Path failed: $($s.Status) $($s.StatusMessage)" } - Write-Host "signed $(Split-Path -Leaf $Path): $($s.Status)" -} - -$version = Get-WindVersion -$cert = Get-SigningCert -$variant = if ($cert) { 'uiaccess' } else { '' } - -Write-Host "Wind $version" -if ($cert) { - Write-Host "signing with: $($cert.Subject)" - Write-Host "variant: uiAccess=true" -} else { - Write-Warning "no certificate configured (WIND_SIGN_THUMBPRINT / WIND_SIGN_PFX)." - Write-Warning "unsigned build: UIAccess features disabled (elevated-window keybinds, desktopTransform)." -} - -if (-not $SkipBuild) { - Write-Host "=== building Wind.exe ($(if ($variant) { $variant } else { 'standard' })) ===" - & cmd /c "`"$root\build.bat`" $variant" - if ($LASTEXITCODE -ne 0) { throw "build.bat $variant failed" } - - Write-Host "=== building WindConfig.exe + ui\dist ===" - & cmd /c "`"$root\build.bat`" config" - if ($LASTEXITCODE -ne 0) { throw "build.bat config failed" } -} - -foreach ($f in 'Wind.exe', 'WindConfig.exe') { - if (-not (Test-Path "$root\$f")) { throw "missing build output: $f" } -} -if (-not (Test-Path "$root\ui\dist\index.html")) { throw "missing ui\dist (build.bat config)" } - -# The payload is signed BEFORE makensis packs it: signing the installer does not sign -# what is inside it, and UIAccess is granted on Wind.exe's own signature. -if ($cert) { - Invoke-Sign "$root\Wind.exe" $cert - Invoke-Sign "$root\WindConfig.exe" $cert -} - -Write-Host "=== compiling the installer ===" -& cmd /c "`"$root\build.bat`" installer" -if ($LASTEXITCODE -ne 0) { throw "build.bat installer failed" } - -$out = "$root\dist\Wind-Setup-x64-$version.exe" -if (-not (Test-Path $out)) { throw "installer not produced: $out" } -if ($cert) { Invoke-Sign $out $cert } - -$mb = [math]::Round((Get-Item $out).Length / 1MB, 1) -Write-Host "" -Write-Host "DONE $out ($mb MB)" -if (-not $cert) { Write-Host " unsigned - see tools\release.ps1 for the signing switch" } -``` - -- [ ] **Step 2: Run it unsigned and confirm the warning and the artifact** - -Run: `pwsh -File tools\release.ps1` -Expected: the two "unsigned build" warnings, then `DONE ...\dist\Wind-Setup-x64-0.1.0.exe` with a size in MB. - -- [ ] **Step 3: Run it signed, using the existing dev certificate, to prove the signing path** - -The dev certificate `CN=Wind Dev Test Cert` already exists on this machine from `tools\uiaccess_setup.ps1`. It is not a real cert, but it exercises the whole signed branch: - -```powershell -$t = (Get-ChildItem Cert:\LocalMachine\My | Where-Object Subject -eq 'CN=Wind Dev Test Cert' | - Sort-Object NotAfter -Descending | Select-Object -First 1).Thumbprint -$env:WIND_SIGN_THUMBPRINT = $t -pwsh -File tools\release.ps1 -``` - -Expected: `variant: uiAccess=true`, three `signed ...: Valid` lines, and a `DONE` artifact. The timestamp server call needs the network; if it is unavailable the script fails loudly, which is correct. - -Then clear it so later tasks build unsigned: `Remove-Item Env:\WIND_SIGN_THUMBPRINT` - -- [ ] **Step 4: Document it in `README.md`** - -Add a `## Releases` section stating: the artifact name, that it installs per-machine to `C:\Program Files\Wind` and needs administrator rights, that autostart is offered during setup, and the two environment variables that enable signing. State plainly that unsigned builds lose elevated-window keybinds and `desktopTransform`, and that a free OV certificate is being sought from SignPath Foundation. - -- [ ] **Step 5: Commit** - -```bash -git add tools/release.ps1 README.md -git commit -m "feat(installer): release script with environment-driven signing" -``` - ---- - -### Task 5: The build gate - -**Files:** -- Create: `tools/installer_check.ps1` -- Modify: `build.bat` (run the check from the `installer` target) - -**Interfaces:** -- Consumes: `installer/wind.nsi`, `installer/over.nsh` (absent until Task 7; the rectangle check skips itself when the file does not exist), `dist/Wind-Setup-x64-.exe`. -- Produces: a non-zero exit code on any failure. - -- [ ] **Step 1: Write `tools/installer_check.ps1`** - -```powershell -<# - Build gate for the installer. Three things makensis cannot tell you: - - 1. whether every media file the script packs actually exists (NSIS warns rather - than errors on a missing File source, so a typo ships an empty installer), - 2. whether every rectangle the pages read is present in the generated over.nsh - (an edit to over.html that renames a control compiles fine and then hit-tests - against a rectangle of zero size), - 3. whether a silent install actually lands and a silent uninstall actually leaves. - - Note the limit: /S exercises the section, not the drawn UI, and the drawn UI is most - of the code. docs\VERIFICATION.md carries the manual matrix that covers the rest. -#> -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -$root = Split-Path -Parent $PSScriptRoot -$fail = @() - -function Check { param([string]$What, [scriptblock]$Test) - try { - if (& $Test) { Write-Host " ok $What" } - else { Write-Host " FAIL $What"; $script:fail += $What } - } catch { Write-Host " FAIL $What ($_)"; $script:fail += $What } -} - -Write-Host "installer check" - -# --- 1. every File source exists --------------------------------------------- -$nsh = Get-ChildItem "$root\installer" -Filter *.nsh -ErrorAction SilentlyContinue -$sources = @("$root\installer\wind.nsi") + $nsh.FullName -foreach ($f in $sources) { - foreach ($line in Get-Content $f) { - # File "..\Wind.exe" / File /r "..\ui\dist\*.*" / File "/oname=..." "x.exe" - $m = [regex]::Match($line, '^\s*File\s+(?:/r\s+)?(?:"/oname=[^"]*"\s+)?"([^"]+)"') - if (-not $m.Success) { continue } - $rel = $m.Groups[1].Value - if ($rel -match '^\$') { continue } # a runtime path, not a build-time one - $path = Join-Path "$root\installer" $rel - Check "File source exists: $rel" { Test-Path $path } - } -} - -# --- 2. every rectangle the pages read is generated --------------------------- -$over = "$root\installer\over.nsh" -if (Test-Path $over) { - $defined = @{} - foreach ($line in Get-Content $over) { - $m = [regex]::Match($line, '^\s*!define\s+(O_[A-Z0-9_]+)\s') - if ($m.Success) { $defined[$m.Groups[1].Value] = $true } - } - # Pages and the compositor refer to rectangles through the OAT / HITS macros, which - # take the bare NAME and build O__X and friends from it. - $used = New-Object System.Collections.Generic.HashSet[string] - foreach ($f in $nsh.FullName) { - foreach ($line in Get-Content $f) { - foreach ($m in [regex]::Matches($line, '!insertmacro\s+(?:OAT|HITS)\s+([A-Z0-9_]+)')) { - [void]$used.Add($m.Groups[1].Value) - } - } - } - foreach ($name in $used) { - foreach ($axis in 'X', 'Y', 'W', 'H') { - Check "rectangle defined: O_${name}_$axis" { $defined.ContainsKey("O_${name}_$axis") } - } - } -} else { - Write-Host " skip rectangle check (over.nsh not generated yet)" -} - -# --- 3. silent install and uninstall ------------------------------------------ -$h = Get-Content "$root\src\version.h" -Raw -$version = '{0}.{1}.{2}' -f - [regex]::Match($h, 'WIND_VER_MAJOR\s+(\d+)').Groups[1].Value, - [regex]::Match($h, 'WIND_VER_MINOR\s+(\d+)').Groups[1].Value, - [regex]::Match($h, 'WIND_VER_PATCH\s+(\d+)').Groups[1].Value -$setup = "$root\dist\Wind-Setup-x64-$version.exe" - -if (Test-Path $setup) { - $scratch = Join-Path $env:TEMP "wind-installer-check" - if (Test-Path $scratch) { Remove-Item $scratch -Recurse -Force } - - # /D must be the last argument and unquoted, which is an NSIS rule, not a typo. - Start-Process $setup -ArgumentList "/S", "/D=$scratch" -Wait - Check "silent install placed Wind.exe" { Test-Path "$scratch\Wind.exe" } - Check "silent install placed WindConfig.exe" { Test-Path "$scratch\WindConfig.exe" } - Check "silent install placed ui\dist" { Test-Path "$scratch\ui\dist\index.html" } - Check "ARP key written" { - (Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Wind' ` - -ErrorAction SilentlyContinue).DisplayVersion -eq $version - } - Check "Run value written" { - (Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' ` - -ErrorAction SilentlyContinue).Wind -match 'Wind\.exe' - } - - if (Test-Path "$scratch\Uninstall.exe") { - # _?= keeps the uninstaller in place so Wait actually waits for it, which is - # another NSIS rule: without it the uninstaller copies itself to TEMP and returns. - Start-Process "$scratch\Uninstall.exe" -ArgumentList "/S", "_?=$scratch" -Wait - Start-Sleep -Milliseconds 800 - Check "uninstall removed Wind.exe" { -not (Test-Path "$scratch\Wind.exe") } - Check "uninstall removed the ARP key" { - $null -eq (Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Wind' ` - -ErrorAction SilentlyContinue) - } - Check "uninstall removed the Run value" { - $null -eq (Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' ` - -ErrorAction SilentlyContinue).Wind - } - } - if (Test-Path $scratch) { Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue } -} else { - Write-Host " skip install smoke (no $setup)" -} - -Write-Host "" -if ($fail.Count) { Write-Host "$($fail.Count) check(s) failed"; exit 1 } -Write-Host "all checks passed"; exit 0 -``` - -- [ ] **Step 2: Run the gate against the Task 4 artifact** - -Run: `pwsh -File tools\installer_check.ps1` -Expected: `ok` for every File source, `skip` for the rectangle check, `ok` for all six install/uninstall lines, and `all checks passed`. The silent install needs elevation, so run it from an elevated shell. - -- [ ] **Step 3: Prove the gate catches a real fault** - -Temporarily change `File "..\Wind.exe"` in `installer/wind.nsi` to `File "..\Wnid.exe"`, re-run the gate. -Expected: `FAIL File source exists: ..\Wnid.exe` and exit code 1. Revert the typo. - -- [ ] **Step 4: Chain it from `build.bat installer`** - -Replace the last two lines of the `:installer` target so the compile is followed by the gate: - -```bat -"%MAKENSIS%" /WX /V2 "%ROOT%installer\wind.nsi" -if errorlevel 1 exit /b 1 -powershell -NoProfile -ExecutionPolicy Bypass -File "%ROOT%tools\installer_check.ps1" -exit /b %errorlevel% -``` - -- [ ] **Step 5: Commit** - -```bash -git add tools/installer_check.ps1 build.bat -git commit -m "test(installer): build gate for media, rectangles and silent install" -``` - ---- - -### Task 6: The frame pipeline and the placeholder loop - -**Files:** -- Create: `installer/make-loop.mjs` -- Create: `installer/media/800/v/*.jpg` (generated: Prism's frames, copied in) -- Create: `installer/README.md` - -**Interfaces:** -- Consumes: nothing. -- Produces: `installer/media/800/v/000.jpg ...`, and the frame count that Task 8 puts in `video.nsh` as `${FRAMES}`. - -- [ ] **Step 1: Copy Prism's frames in as the placeholder** - -```powershell -$src = 'C:\Users\Admin\Documents\Claude\Github\Prism\build\installer\media\800\v' -$dst = 'C:\Users\Admin\Documents\Claude\Github\Wind\installer\media\800\v' -New-Item -ItemType Directory -Force $dst | Out-Null -Copy-Item "$src\*.jpg" $dst -(Get-ChildItem $dst -Filter *.jpg).Count -``` - -Expected: 379. That number is `${FRAMES}` in Task 8. - -- [ ] **Step 2: Write `installer/make-loop.mjs`** - -A port of Prism's `make-loop.cjs` to ESM, with the hardcoded clip window turned into arguments, because Wind's clip is not yet shot and its length is unknown. - -```js -/** - * Builds the video loop setup plays, from a source clip. - * - * node installer/make-loop.mjs "C:\path\to\clip.mp4" [--start 0] [--len 16] [--fps 24] - * - * Out: installer/media/800/v/000.jpg ... - * - * Two things worth knowing: - * - * - The clip does not loop, so we make it loop. The last K frames are crossfaded onto - * the first K, which leaves the last frame running into frame 0 with a smaller step - * than an ordinary frame-to-frame one. - * - * - The install screen cannot animate: NSIS runs the section on the script thread, so - * nothing can call back into script while files are being written. That screen draws - * one frame and lets the progress bar carry the motion. - */ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const HERE = path.dirname(fileURLToPath(import.meta.url)) -const MEDIA = path.join(HERE, 'media') - -const argv = process.argv.slice(2) -const SRC = argv[0] -const opt = (name, dflt) => { - const i = argv.indexOf(`--${name}`) - return i >= 0 ? Number(argv[i + 1]) : dflt -} -const START = opt('start', 0) -const LEN = opt('len', 16) -const FPS = opt('fps', 24) -const K = opt('fade', 36) // crossfaded frames, a second and a half at 24 fps -// One size for every display. The overlay stays per DPI so type is always sharp, but the -// footage is defocused motion: an 800 to 1440 upscale is invisible on it, and halving the -// payload is what buys a long loop at a sane download size. -const SIZES = [{ w: 800, h: 600, q: 62 }] - -const run = (bin, args) => execFileSync(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }) - -if (!SRC || !fs.existsSync(SRC)) { - console.error('usage: node installer/make-loop.mjs [--start s] [--len s] [--fps n] [--fade n]') - process.exit(1) -} - -for (const { w, h, q } of SIZES) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wind-loop-')) - const raw = path.join(tmp, 'raw') - fs.mkdirSync(raw) - - run('ffmpeg', ['-y', '-v', 'error', '-ss', String(START), '-t', String(LEN), '-i', SRC, - '-vf', [`fps=${FPS}`, `scale=${w}:${h}:force_original_aspect_ratio=increase`, `crop=${w}:${h}`].join(','), - path.join(raw, '%03d.png')]) - - // However many frames the source really yielded: asking for one more than exists is the - // difference between a loop and a crash. - const have = fs.readdirSync(raw).length - const n = have - K - if (n < K * 2) throw new Error(`only ${have} frames available, need at least ${K * 3}`) - - const f = (i) => path.join(raw, `${String(i + 1).padStart(3, '0')}.png`) - const vdir = path.join(MEDIA, String(w), 'v') - fs.rmSync(vdir, { recursive: true, force: true }) - fs.mkdirSync(vdir, { recursive: true }) - - for (let i = 0; i < n; i++) { - const dst = path.join(vdir, `${String(i).padStart(3, '0')}.jpg`) - if (i < K) { - // out[i] = frame[n+i] fading out under frame[i] fading in, so the wrap from the last - // frame back to the first is already in progress by the time it happens - const pct = Math.round((100 * i) / K) - const blend = path.join(tmp, 'b.png') - run('magick', [f(n + i), f(i), '-define', `compose:args=${pct}`, '-compose', 'blend', '-composite', blend]) - run('magick', [blend, '-quality', String(q), '-sampling-factor', '4:2:0', '-strip', dst]) - } else { - run('magick', [f(i), '-quality', String(q), '-sampling-factor', '4:2:0', '-strip', dst]) - } - } - - fs.rmSync(tmp, { recursive: true, force: true }) - const bytes = fs.readdirSync(vdir).reduce((a, x) => a + fs.statSync(path.join(vdir, x)).size, 0) - console.log(`${w}x${h}: ${n} frames of ${have} available, ${(bytes / 1e6).toFixed(1)} MB`) - console.log(` set FRAMES to ${n} and TICK to ${Math.round(1000 / FPS)} in installer/video.nsh`) -} -``` - -- [ ] **Step 3: Verify the script runs against a clip** - -Re-encode a few seconds of the placeholder frames into a test clip and round-trip it: - -```powershell -$v = 'installer\media\800\v' -ffmpeg -y -v error -framerate 24 -i "$v\%03d.jpg" -c:v libx264 -pix_fmt yuv420p "$env:TEMP\wind-loop-test.mp4" -node installer/make-loop.mjs "$env:TEMP\wind-loop-test.mp4" --len 15 --fps 24 -``` - -Expected: a line reporting the frame count, the megabytes, and the `FRAMES` / `TICK` values. - -Then restore the placeholder frames from Prism (Step 1), because the round trip re-encoded them. - -- [ ] **Step 4: Write `installer/README.md`** - -Cover, in the style of Prism's: what each file is, how to change the words (edit `over.html`, regenerate both overlay sets), how to change the clip (run `make-loop.mjs`, put the reported numbers in `video.nsh`), what it costs in megabytes, and the standing rule that `installer/media/` is generated and never hand-edited. Note that the current footage is Prism's, held as a placeholder until Wind's own clip exists. - -- [ ] **Step 5: Commit** - -The frames are `.gitignore`d as generated output, so this commit is the scripts and the docs. - -```bash -git add installer/make-loop.mjs installer/README.md -git commit -m "feat(installer): frame-loop pipeline and installer README" -``` - ---- - -### Task 7: Wind's overlays - -**Files:** -- Create: `installer/over.html` -- Create: `installer/make-over.mjs` -- Create: `installer/over.nsh` (generated) -- Create: `installer/media/960/o/*.png`, `installer/media/1440/o/*.png` (generated) - -**Interfaces:** -- Consumes: nothing. -- Produces, for Tasks 8 and 9: - - Overlay PNGs named `.png` and `-hot-.png`, where screen is one of `welcome`, `setup`, `copy`, `done`. - - `box-on.png` and `box-off.png`, the two checkbox states stamped at runtime. - - `installer/over.nsh` defining `O___{X,Y,W,H}` in 640x480 units. - - Control names per screen: `CAP_MIN`, `CAP_X` on all four; `NEXT` on welcome, setup and done; `BACK` on setup and done-less screens as listed below; `PATH` on setup; `TRACK` on copy; `OPT_AUTOSTART` / `BOX_AUTOSTART` on setup; `OPT_RUN` / `BOX_RUN` and `OPT_DESK` / `BOX_DESK` on done. - -- [ ] **Step 1: Design the four screens** - -This is UI work, so it goes through the design skills rather than being eyeballed: invoke `frontend-design` for the visual direction and `impeccable` for the states and hierarchy pass. Constraints that are not negotiable: - -- No panel. Type sits on the picture, held up by weight and a shadow, so every pixel needs alpha. That is why `make-over.mjs` solves for alpha instead of capturing it. -- Anything whose value changes at runtime is left OUT of the art: the install path text, the progress fill, and every checkbox box. They leave a gap that NSIS paints into. -- Palette comes from Wind, not Prism. Take the mark from `assets/wind-badge.svg`. -- The copy, verbatim: - - welcome: "Install Wind" / "A fullscreen magnifier that keeps tracking the mouse, even when games hide it." Button: "Install Wind". - - setup: "Where Wind goes" / "Wind installs to Program Files so Windows will grant it the access its keyboard shortcuts and desktop zoom need." Path field (read-only look, no Browse button). Checkbox: "Start Wind when I sign in". Buttons: "Back", "Install". - - copy: "Installing" / "This takes a few seconds." Progress trough. - - done: "Wind installed" / no subtitle. Checkboxes: "Open Wind now", "Create desktop shortcut". Button: "Finish". -- No em-dashes and no `—`. - -- [ ] **Step 2: Write `installer/over.html`** - -Structure it exactly as Prism's does, because `make-over.mjs` depends on the contract: two same-size panes side by side, `#k` on black and `#w` on white; a `window.render(screen, hot, boxes)` that fills both; a `window.rects()` that returns every `[data-a]` element's bounding rectangle. The pane is 640x480 and the body is 1280x480. - -The `data-a` attribute names the rectangle, and the name is what ends up in `over.nsh` as `O___X`. Renaming one here without regenerating is the exact failure the Task 5 rectangle check catches. - -- [ ] **Step 3: Write `installer/make-over.mjs`** - -A port of `make-over.cjs` with Electron replaced by Playwright, which is already a devDependency in `ui/package.json`. The alpha solve is unchanged and is the reason for the two panes: - -```js -/** - * Renders installer/over.html into the alpha overlays the installer composites over its - * video loop, plus the rectangles NSIS needs to know about. - * - * node installer/make-over.mjs 1440 - * node installer/make-over.mjs 960 - * - * Alpha is not captured, it is solved for. Each screen is rendered twice, once on black - * and once on white; for a pixel of colour C at coverage a those give A = C*a and - * B = C*a + (1-a), so a = 1 - (B - A) and C = A / a. That is exact for antialiased type, - * soft shadows and the glow under a button, none of which a screenshot of a transparent - * window reliably brings back on Windows. - * - * Out: installer/media//o/[-hot-].png - * installer/over.nsh rectangles, in 640x480 units - */ -import { chromium } from 'playwright' -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const HERE = path.dirname(fileURLToPath(import.meta.url)) -const OUT = path.join(HERE, 'media') -const WIDTH = Number(process.argv[2]) || 1440 -const SCALE = WIDTH / 640 -const DIR = String(WIDTH) -const SCREENS = ['welcome', 'setup', 'copy', 'done'] - -// which control is drawn hot on which screen, and so which crops we need twice -const HOT = { - welcome: ['next', 'close', 'min'], - setup: ['next', 'back', 'close', 'min'], - copy: ['close', 'min'], - done: ['next', 'close', 'min'] -} - -const magick = (args) => execFileSync('magick', args, { stdio: ['ignore', 'pipe', 'pipe'] }) - -/** A = over black, B = over white -> straight RGBA */ -function solveAlpha(onBlack, onWhite, out) { - // alpha = 1 - (white - black), per channel, taken on the green channel which carries - // the most luma; colour = black / alpha, which magick does as a divide by the alpha. - const tmp = path.join(path.dirname(out), '_a.png') - magick([onWhite, onBlack, '-compose', 'minus', '-composite', '-negate', - '-colorspace', 'gray', tmp]) - magick([onBlack, tmp, '-compose', 'copy-opacity', '-composite', - '-channel', 'RGB', '-evaluate', 'multiply', '1', '+channel', out]) - fs.rmSync(tmp, { force: true }) -} - -const browser = await chromium.launch() -const page = await browser.newPage({ - viewport: { width: 1280 * SCALE, height: 480 * SCALE }, - deviceScaleFactor: 1 -}) -// The page is authored in 640x480 units; the scale is applied once, here, so every -// rectangle it reports is already in those units and needs no conversion. -await page.goto('file://' + path.join(HERE, 'over.html').replace(/\\/g, '/')) -await page.addStyleTag({ content: `html { zoom: ${SCALE} }` }) - -const odir = path.join(OUT, DIR, 'o') -fs.rmSync(odir, { recursive: true, force: true }) -fs.mkdirSync(odir, { recursive: true }) -const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wind-over-')) - -const w = Math.round(640 * SCALE) -const h = Math.round(480 * SCALE) -const lines = [ - '; Generated by make-over.mjs from over.html. Do not edit: run', - '; node installer/make-over.mjs 1440', - '; node installer/make-over.mjs 960', - '; Rectangles are in 640x480 units; the installer scales them to its window.', - '' -] - -async function shoot(name, screen, hot, boxes) { - await page.evaluate(([s, ht, bx]) => window.render(s, ht, bx), [screen, hot, boxes]) - await page.waitForTimeout(60) - const shot = path.join(tmp, 'shot.png') - await page.screenshot({ path: shot, clip: { x: 0, y: 0, width: w * 2, height: h } }) - const a = path.join(tmp, 'k.png') - const b = path.join(tmp, 'w.png') - magick([shot, '-crop', `${w}x${h}+0+0`, '+repage', a]) - magick([shot, '-crop', `${w}x${h}+${w}+0`, '+repage', b]) - solveAlpha(a, b, path.join(odir, `${name}.png`)) -} - -for (let s = 0; s < SCREENS.length; s++) { - const screen = SCREENS[s] - await shoot(screen, s, null, 'none') - for (const hot of HOT[screen]) await shoot(`${screen}-hot-${hot}`, s, hot, 'none') - - const rects = await page.evaluate(() => window.rects()) - for (const [name, r] of Object.entries(rects)) { - const u = (n) => Math.round(n / SCALE) - lines.push(`!define O_${screen.toUpperCase()}_${name}_X ${u(r.x)}`) - lines.push(`!define O_${screen.toUpperCase()}_${name}_Y ${u(r.y)}`) - lines.push(`!define O_${screen.toUpperCase()}_${name}_W ${u(r.width)}`) - lines.push(`!define O_${screen.toUpperCase()}_${name}_H ${u(r.height)}`) - } -} - -// The two checkbox states, cut out once and stamped every frame. Three checkboxes across -// two screens have eight states between them; baking those would be dozens of overlays. -for (const [state, file] of [['on', 'box-on.png'], ['off', 'box-off.png']]) { - await page.evaluate(([bx]) => window.render(3, null, bx), [state]) - await page.waitForTimeout(60) - const rects = await page.evaluate(() => window.rects()) - const r = rects.BOX_RUN - const shot = path.join(tmp, 'boxshot.png') - await page.screenshot({ path: shot, clip: { x: 0, y: 0, width: w * 2, height: h } }) - const a = path.join(tmp, 'bk.png') - const b = path.join(tmp, 'bw.png') - magick([shot, '-crop', `${Math.round(r.width)}x${Math.round(r.height)}+${Math.round(r.x)}+${Math.round(r.y)}`, '+repage', a]) - magick([shot, '-crop', `${Math.round(r.width)}x${Math.round(r.height)}+${Math.round(r.x) + w}+${Math.round(r.y)}`, '+repage', b]) - solveAlpha(a, b, path.join(odir, file)) -} - -fs.writeFileSync(path.join(HERE, 'over.nsh'), lines.join('\n') + '\n') -fs.rmSync(tmp, { recursive: true, force: true }) -await browser.close() - -const bytes = fs.readdirSync(odir).reduce((n, x) => n + fs.statSync(path.join(odir, x)).size, 0) -console.log(`${DIR}: ${fs.readdirSync(odir).length} overlays, ${(bytes / 1e6).toFixed(1)} MB`) -``` - -- [ ] **Step 4: Generate both overlay sets** - -```powershell -cd ui; npx playwright install chromium; cd .. -node installer/make-over.mjs 1440 -node installer/make-over.mjs 960 -``` - -Expected: two lines reporting the overlay count and size, `installer/over.nsh` written, and `installer/media/1440/o` plus `installer/media/960/o` populated. - -- [ ] **Step 5: Verify the alpha is real, not opaque** - -The single most likely failure here is a solve that returns fully opaque overlays, which look right in isolation and hide the whole video at runtime. - -```powershell -magick identify -format "%[opaque]\n" installer\media\1440\o\welcome.png -magick convert installer\media\1440\o\welcome.png -alpha extract -format "%[fx:mean]\n" info: -``` - -Expected: `false` from the first, and a mean well below 1.0 from the second (most of the frame is transparent). An answer of `true` and `1` means the solve failed and the two panes captured identically. - -- [ ] **Step 6: Commit** - -```bash -git add installer/over.html installer/make-over.mjs installer/over.nsh -git commit -m "feat(installer): Wind's overlay art and the Playwright overlay renderer" -``` - -`installer/media/` stays ignored; `over.nsh` is committed because the NSIS build needs it and it is small. - ---- - -### Task 8: The compositor - -**Files:** -- Create: `installer/kit.nsh` -- Create: `installer/video.nsh` - -**Interfaces:** -- Consumes: `installer/over.nsh` (Task 7), `installer/media/` (Tasks 6 and 7). -- Produces, for `installer/pages.nsh` (Task 9): - - `Var Dpi`, `Var Dialog`, `Var ArtDir`, `Var MediaSize`, `Var Font` - - `Var Screen`, `Var Frame`, `Var Hot`, `Var Leaving`, `Var Canvas` - - `!insertmacro HIDE_WIZARD_BUTTONS`, `!insertmacro UNPACK_MEDIA` - - `!insertmacro OAT ` (sets `$R0..$R3` to a scaled rectangle), `!insertmacro HITS ` - - `Function WindCanvas` (takes the dialog on the stack), `WindCanvasFree`, `WindPickOverlay`, `WindDraw`, `WindTick`, `WindClick` - - `!define ART_W 640`, `!define ART_H 480`, `!define FRAMES 379`, `!define TICK 42` - -- [ ] **Step 1: Port `kit.nsh`** - -Copy `C:\Users\Admin\Documents\Claude\Github\Prism\build\installer\kit.nsh` to `installer/kit.nsh` and make exactly these changes: - -- rename `prismGuiInit` to `windGuiInit` and the `MUI_CUSTOMFUNCTION_GUIINIT` define to match, -- change `${BUILD_RESOURCES_DIR}\installer\media\...` to `media\...`, since there is no electron-builder resource root and `wind.nsi` sits one level up from nothing (paths in NSIS are relative to the script being compiled), -- keep `ART_W 640` / `ART_H 480`, the DPI probe, the `timeBeginPeriod(1)` call, the GDI+ startup, the frame removal, the rounded region and the font unchanged. Each carries a comment explaining a bug it fixes; keep the comments. - -The DPI branch picks `1440` at 180 or above and `960` below, which on Max's 225% display means 1440. The footage stays one size (`media\800\v`) regardless. - -- [ ] **Step 2: Port `video.nsh`** - -Copy Prism's `video.nsh` to `installer/video.nsh` and make exactly these changes: - -- rename every `Prism*` function to `Wind*` (`PrismCanvas` to `WindCanvas`, and the same for `CanvasFree`, `PickOverlay`, `Draw`, `Tick`, `Click`), -- change the include to `!include "over.nsh"`, -- set `!define FRAMES` to the count Task 6 Step 1 reported (379 for the placeholder) and `!define TICK 42`, -- change the screen names in `WindPickOverlay` from `welcome/where/copy/done` to `welcome/setup/copy/done`, -- change the runtime-painted text on screen 1 from Prism's editable `$INSTDIR` to Wind's fixed path: still drawn with GDI into the `PATH` rectangle, but the source is the constant `${INSTALL_DIR}` expanded, and there is no `BROWSE` hit target, -- change the checkbox variables from `$WantMenu` / `$WantDesk` to `$WantAutostart` (screen 1), `$RunAfter` and `$WantDesktop` (screen 3), which Task 3 already declares, -- keep the two-DIB front/back buffer arrangement, the `WS_CLIPCHILDREN` set, the `SS_BITMAP` choice and their comments verbatim. Each documents a specific flash or flicker it prevents. - -- [ ] **Step 3: Confirm it compiles before any page uses it** - -Add `!include "kit.nsh"` and `!include "video.nsh"` to `installer/wind.nsi` (guarded, so the uninstaller pass does not resize a window it never draws): - -```nsis -!ifndef BUILD_UNINSTALLER - !include "kit.nsh" - !include "video.nsh" -!endif -``` - -Run: `build.bat installer` -Expected: compiles with no warnings. The stock pages are still in use, so nothing looks different yet; this step only proves the compositor parses, links its System calls and finds every media file and rectangle. - -- [ ] **Step 4: Commit** - -```bash -git add installer/kit.nsh installer/video.nsh installer/wind.nsi -git commit -m "feat(installer): frameless window and the frame compositor" -``` - ---- - -### Task 9: The four screens - -**Files:** -- Create: `installer/pages.nsh` -- Modify: `installer/wind.nsi` (replace the MUI page block with the custom pages) - -**Interfaces:** -- Consumes: everything Tasks 3, 7 and 8 produce. -- Produces: the finished installer UI. - -- [ ] **Step 1: Write `installer/pages.nsh`** - -Ported from Prism's, with the "where" page replaced by "setup" and no Browse. The shared start and leave functions are unchanged: - -```nsis -; -; Wind setup, the four screens. -; -; Each page is the same thing: an empty dialog, the canvas from video.nsh, and a timer. -; What differs is $Screen, which picks the overlay and decides what the clicks mean. -; There is not a single button control in this file. -; -; Prism has a "where it goes" page with a Browse button. Wind does not, and the omission -; is deliberate: UIAccess is only granted to a signed binary in a secure location, so an -; install to D:\Apps\Wind would silently disable the elevated-window keybinds and the -; desktop transform that the per-machine install exists to enable. The path is shown and -; the reason is given, which is more honest than a chooser whose wrong answers are quiet. -; - -Function windPageStart - ; A silent install still calls a custom page's creator, and nsDialogs::Show with nothing - ; to show never returns: /S would hang here forever. Abort in a creator means "skip this - ; page", which is exactly right. - ${If} ${Silent} - Abort - ${EndIf} - !insertmacro HIDE_WIZARD_BUTTONS - nsDialogs::Create 1018 - Pop $Dialog - ${If} $Dialog == error - Abort - ${EndIf} - Push $Dialog - Call WindCanvas - Call WindPickOverlay - Call WindDraw - ${NSD_CreateTimer} WindTick ${TICK} -FunctionEnd - -Function windPageLeave - ${NSD_KillTimer} WindTick - Call WindCanvasFree -FunctionEnd - -; ---- 1. welcome -------------------------------------------------------------- -Function windWelcomeCreate - ${If} ${Silent} - Abort - ${EndIf} - InitPluginsDir - !insertmacro UNPACK_MEDIA - StrCpy $Screen 0 - Call windPageStart - nsDialogs::Show -FunctionEnd - -; ---- 2. setup ---------------------------------------------------------------- -Function windSetupCreate - StrCpy $Screen 1 - Call windPageStart - nsDialogs::Show -FunctionEnd - -; ---- 3. copying -------------------------------------------------------------- -; MUI owns this page, and the section runs on the script thread, so nothing can call back -; into script while files are being written. This screen draws one frame and hands the -; motion to the progress bar, which Windows paints for us. -Function windCopyShow - ${If} ${Silent} - Return - ${EndIf} - FindWindow $R4 "#32770" "" $HWNDPARENT - GetDlgItem $R5 $R4 1004 ; progress bar - GetDlgItem $0 $R4 1006 ; status line - ShowWindow $0 ${SW_HIDE} - GetDlgItem $0 $R4 1016 ; the log - ShowWindow $0 ${SW_HIDE} - GetDlgItem $0 $R4 1027 ; "show details" - ShowWindow $0 ${SW_HIDE} - !insertmacro HIDE_WIZARD_BUTTONS - - ; MUI sizes this dialog from its own template, which is smaller than our window - IntOp $R2 ${ART_W} * $Dpi - IntOp $R2 $R2 / 96 - IntOp $R3 ${ART_H} * $Dpi - IntOp $R3 $R3 / 96 - System::Call 'user32::SetWindowPos(p $R4, p 0, i 0, i 0, i $R2, i $R3, i 0x14)' - - StrCpy $Screen 2 - Push $R4 - Call WindCanvas - StrCpy $Frame 30 - Call WindPickOverlay - Call WindDraw - - ; theme off, smooth on, Wind's palette, sat on the drawn trough and raised above canvas - System::Call 'uxtheme::SetWindowTheme(p $R5, w "", w "")' - System::Call 'user32::GetWindowLong(p $R5, i -16) i .r0' - IntOp $0 $0 | 0x01 ; PBS_SMOOTH - System::Call 'user32::SetWindowLong(p $R5, i -16, i $0)' - SendMessage $R5 ${PBM_SETBKCOLOR} 0 ${TRACK_BG} - SendMessage $R5 ${PBM_SETBARCOLOR} 0 ${TRACK_FG} - !insertmacro OAT COPY_TRACK - System::Call 'user32::SetWindowPos(p $R5, p 0, i $R0, i $R1, i $R2, i $R3, i 0x10)' -FunctionEnd - -; ---- 4. done ----------------------------------------------------------------- -Function windDoneCreate - StrCpy $Screen 3 - Call windPageStart - nsDialogs::Show -FunctionEnd - -Function windDoneLeave - Call windPageLeave - ${If} $WantDesktop == 1 - SetShellVarContext all - CreateShortcut "$DESKTOP\Wind.lnk" "$INSTDIR\Wind.exe" - ${EndIf} - ${If} $RunAfter == 1 - !insertmacro WIND_LAUNCH_DEELEVATED - ${EndIf} -FunctionEnd -``` - -`${TRACK_BG}` and `${TRACK_FG}` are two BGR colour constants defined in `wind.nsi` next to the other product defines, taken from the palette Task 7 settled on. NSIS wants them in `0xBBGGRR` order, which is the reverse of a CSS hex triplet, and getting that backwards is a silent wrong-colour bug rather than an error. - -- [ ] **Step 2: Replace the page block in `installer/wind.nsi`** - -Swap the three `MUI_PAGE_*` inserts and the `MUI_FINISHPAGE_RUN` defines for: - -```nsis -!include "pages.nsh" - -Page custom windWelcomeCreate windPageLeave -Page custom windSetupCreate windPageLeave -!define MUI_PAGE_CUSTOMFUNCTION_SHOW windCopyShow -!insertmacro MUI_PAGE_INSTFILES -Page custom windDoneCreate windDoneLeave -``` - -and add `SetAutoClose true` at the end of `Section "Wind"`, so the install page walks on to the done screen by itself rather than waiting for a Next button that has been hidden since `.onGUIInit`. - -Delete the now-unused `WindLaunch` function from Task 3 Step 4; `windDoneLeave` owns the launch. - -- [ ] **Step 3: Build and look at it** - -Run: `build.bat installer` -Expected: compiles clean, and the Task 5 rectangle check now runs for real rather than skipping. - -Then run `dist\Wind-Setup-x64-0.1.0.exe` and check by eye: - -- the window is frameless with rounded corners and centred, -- the loop plays smoothly and wraps without a visible jump, -- hovering Install, Back, minimise and close changes each one, -- dragging the caption strip moves the window, -- the setup screen shows `C:\Program Files\Wind` as drawn text, -- the autostart checkbox toggles and its state survives moving to the next screen, -- the progress bar sits on the drawn trough in Wind's colours, -- the done screen's two checkboxes toggle, and Finish launches Wind only when "Open Wind now" is ticked. - -- [ ] **Step 4: Verify silent install still works** - -The custom pages are the most likely thing to break `/S`, because a creator that shows a dialog with nothing in it never returns. - -Run: `pwsh -File tools\installer_check.ps1` from an elevated shell. -Expected: `all checks passed`. If it hangs, a `${If} ${Silent} Abort` guard is missing from a page creator. - -- [ ] **Step 5: Commit** - -```bash -git add installer/pages.nsh installer/wind.nsi -git commit -m "feat(installer): the four custom-drawn screens" -``` - ---- - -### Task 10: Documentation and the manual matrix - -**Files:** -- Modify: `docs/VERIFICATION.md` -- Modify: `CLAUDE.md` -- Modify: `installer/README.md` - -- [ ] **Step 1: Add the manual matrix to `docs/VERIFICATION.md`** - -`/S` exercises the section, not the drawn UI, and the drawn UI is most of the code. Record that limit and the cases it leaves to a person, each with what to do and what to expect: - -1. Fresh install on a machine with no Wind: files, ARP entry, Run value, tray icon. -2. Upgrade with Wind running and zoomed: no "file in use" error, and the OS cursor is visible afterwards (which is what proves the clean-quit handshake ran rather than the kill). -3. Upgrade with the Settings window open: WindConfig closes, no orphan process. -4. Uninstall keeping data: `%LOCALAPPDATA%\Wind\magnifier.ini` survives. -5. Uninstall deleting data: it does not. -6. At 100% DPI and at 225%: window centred, art sharp, hit targets land where they look. -7. WebView2 absent: hard to stage, so at minimum confirm the branch is skipped on a machine that has it, and that the tray's Open Settings works after install. - -- [ ] **Step 2: Add the installer to `CLAUDE.md`** - -Under Commands, add `build.bat installer`. Add a short Installer paragraph to Architecture pointing at `installer/README.md` and the spec, and add one gotcha, since it is the class of thing that section exists for: - -> THE INSTALLER IS ELEVATED, WHICH MAKES HKCU AND `%LOCALAPPDATA%` THE WRONG USER'S. An elevated process's HKCU is whichever hive the elevated token owns, so autostart goes in HKLM Run, and Wind is launched with `ShellExecAsUser` rather than `Exec`. A plain `Exec` gives Wind an admin token, and `ResolveIniPath()` then puts magnifier.ini, the profiles and the logs in the administrator's profile where the user never finds them. - -Keep the file under its ~200 line budget; trim if needed. - -- [ ] **Step 3: Finish `installer/README.md`** - -Fill in the file table now that every file exists, and add the regeneration commands with their real arguments. - -- [ ] **Step 4: Commit and open the PR** - -```bash -git add docs/VERIFICATION.md CLAUDE.md installer/README.md -git commit -m "docs(installer): verification matrix, gotcha and file guide" -gh pr create --fill -``` - ---- - -## Self-Review - -**Spec coverage:** - -| Spec section | Task | -|---|---| -| Technology, NSIS choice | 2 | -| Layout | 2, 3, 6, 7, 8, 9 | -| How the picture works | 7 (alpha solve), 8 (compositor) | -| The four screens | 7 (art and copy), 9 (behaviour) | -| Elevation, the two traps | 3 | -| Stopping the running instance | 3 | -| WebView2 | 1 (rule), 3 (implementation) | -| Signing | 4, plus the LICENSE in 2 | -| Uninstall | 2 (files, ARP), 3 (data question) | -| Version as one source of truth | 2 (`!searchparse`), 4 (`Get-WindVersion`) | -| Media | 6 (frames), 7 (overlays) | -| Testing | 1 (doctests), 5 (gate), 10 (matrix) | - -No spec section is unimplemented. - -**Known gaps, deliberate:** the WebView2-absent branch has no automated test because staging a machine without the runtime is not worth the effort for a branch that fires on a small minority; the rule it depends on is unit-tested instead. `over.html`'s visual design is specified by constraint and copy rather than by markup, because it goes through the design skills in Task 7 Step 1 and prescribing the CSS here would pre-empt that. diff --git a/docs/superpowers/plans/2026-09-28-review-fixes.md b/docs/superpowers/plans/2026-09-28-review-fixes.md deleted file mode 100644 index c4f3bd82..00000000 --- a/docs/superpowers/plans/2026-09-28-review-fixes.md +++ /dev/null @@ -1,59 +0,0 @@ -# Code review 2026-09-28: fix plan (issue #274) - -Source: in-depth review of `main` (report: `Documents/Claude/wind-review-2026-09-28.md`), every -finding adversarially verified: 12 confirmed, 1 refuted (the `keepOnTop` "1 s backstop" comment, -already explained by the next comment). One PR, one commit per area, patch bump to 0.10.2. - -## A. Magnification-API thread safety - -1. **Use-after-return across `MagThreadInvoke`'s 250 ms timeout** (`mag_thread.cpp:96`, callers - `mag_host.cpp` setInputTransform/getInputTransform, `hook_transform.cpp` - RequestHookTransformWrite). Fix at the source, not per caller: give `MagCall` a state - (pending / running / done / abandoned). On timeout the caller CAS pending -> abandoned and the - servicer skips an abandoned call; if the servicer already started it, the caller gives it one - more bounded 250 ms wait and then returns regardless (review of this PR: an unbounded wait here - could freeze the tick or the crash filter). Returning early is safe because every caller now - captures by value. Also switch the three `[&]` lambdas to by-value captures where - they do not need out-params, as the file's own contract asks. -2. **`MagHost::setSamplingMode` not marshalled** (`mag_host.cpp:71`): route through - `MagThreadInvoke`, and set `appliedSampling_` only when the call succeeded - (`transform_model.cpp:535`), with a BOUNDED retry (3 tries, 1 s apart, then accept): testing - found mode 0 reports FALSE on every call on this rig (245 of 245 log lines) while working, so - an unbounded retry would have re-issued it every tick. -3. **`RenderEngine` shows/hides the system cursor unmarshalled** (`render_engine.cpp:1355`, - shutdown, the crash filter): route through `MagThreadInvoke` like the transform model's - `ShowSystemCursorMarshalled`; the crash filter tries the marshalled call and falls back to a - direct one. -4. **`EnsureCompositePulse` leaks its thread handle** (`main.cpp:48`): close it at once (the thread - runs for the life of the process). - -## B. Transform model and hybrid switching - -5. **Transform -> render overlap hardcoded to 3 ticks** (`main.cpp:1846`, `1858`): use - `TicksAtHz(3, t.hz)`, like the render -> transform path. -6. **`MpoGhost::create` leaks the previous window on retarget** (`comp_pin.cpp:45`): destroy any - existing window before creating the new one. -7. **`edgeClip` turned off mid-zoom keeps the clip** (`transform_model.cpp:833`): call - `edgeClipManage(false)` when the setting is 0. -8. **`CursorSprite::setScale` is dead code** (`cursor_sprite.cpp:251`): remove it; DWM already - grows the sprite with the zoom (issue #253). - -## C. Settings, config, installer - -9. **`setConfig` ignores a failed ini write** (`config_ui/main.cpp:232`): check the result, log - it, and post `configWriteFailed` to the page, which shows a dialog in the style of the - existing "Couldn't restart Wind" one. -10. **Uninstaller removes the elevating account's data, not the signed-in user's** - (`wind.nsi:200`): resolve the interactive user's `%LOCALAPPDATA%` from the owner of - `explorer.exe` (SID -> `ProfileList` -> profile path), falling back to `$LOCALAPPDATA`. -11. **`LoadConfig` writes the template but returns `Config{}`** (`config.cpp:341`): move the - template into a pure `DefaultIniText()`, write it, and return `ParseConfig` of it; a unit test - pins that the template parses to the struct defaults for the keys it carries. - -## Verification - -- `build.bat test` and `build.bat check`; new tests for `MagThreadInvoke` abandonment (pure part - not possible, so covered by review), `DefaultIniText()` parity, and existing suites. -- `tools/release.ps1` + the elevated `installer_check.ps1` (install/uninstall, signing checks). -- Deploy the signed build to this PC and run a zoom session on the desktop and over a game. -- Independent review of the diff (workflow: reviewers per area + adversarial verify). diff --git a/docs/superpowers/plans/2026-09-28-tracking-modes.md b/docs/superpowers/plans/2026-09-28-tracking-modes.md deleted file mode 100644 index 430725ac..00000000 --- a/docs/superpowers/plans/2026-09-28-tracking-modes.md +++ /dev/null @@ -1,990 +0,0 @@ -# Tracking modes Implementation Plan (issue #276) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** The zoomed view can follow the text caret (on by default), keyboard focus (off by default), and (phase 2) an edge mode where the pointer roams freely and the view pans near the edges. - -**Architecture:** A watcher thread (`focus_track`) owns all accessibility calls (WinEvents, `GetGUIThreadInfo`, UI Automation on an MTA thread) and publishes the latest caret/focus rectangle. RunTick steps a pure owner state machine (`view_target.h`), glides a detached view centre (`view_glide.h`) and builds the frame from `detached_view.h`, suppressing the weld so the pointer never moves. Phase 2 adds `edge_pan.h` for the mouse edge mode. - -**Tech Stack:** C++17 / MSVC, Win32 (`SetWinEventHook`, `GetGUIThreadInfo`), UI Automation COM (`UIAutomation.h`; links `ole32.lib oleaut32.lib uuid.lib oleacc.lib`), doctest, Svelte + Playwright for Settings. - -**Spec:** `docs/superpowers/specs/2026-09-28-tracking-modes-design.md` - -## Global Constraints - -- Tracking NEVER moves the pointer: while the owner is `Caret`, `Focus` or `Returning`, `ex.suppressCursorSync = true` and no `SetCursorPos` runs for tracking. -- Defaults: `trackCaret=1`, `trackFocus=0`, `trackAlign=0` (centred), `mouseAlign=0` (centred), `trackGlideMs=150`, `trackMarginPct=15`, `trackLog=0`. -- Caret and focus glide (95% of the distance in `trackGlideMs`), never snap; the return to the pointer glides too. -- Mouse takes the view back at 3 px of real movement accumulated within 100 ms, or any button down. -- Tracking inactive when not zoomed, in a game session, in Inspect, or while the lock detector reports locked. -- The tick thread never calls UIA/MSAA; only the watcher thread does, COM MTA. -- Pure headers include no `` and get doctests in `tests/`. -- No em-dashes anywhere (code, comments, docs, UI copy, commits). -- Versions: PR A `0.10.3`, PR B `0.10.4` (patch bumps, owner preference). -- Every repo change goes through a branch + PR; merge only after the owner approves that PR. - -## Review Focus - -1. Mouse sensor jitter while typing (1-2 px) must not steal the view: pinned in Task 2 (`jitter below threshold keeps Caret`). -2. A caret rect that is empty, (0,0,0,0), or outside the zoomed monitor must be ignored, never jump the view to the corner: pinned in Task 3 (`degenerate and off-monitor rects are rejected`). -3. Fast typing (a caret event every 30 ms) must glide continuously with no restart jolt: pinned in Task 3 (`retargeting mid-glide never moves backwards`). -4. Uneven tick intervals (VRR, 7-25 ms) must give the same glide in real time: pinned in Task 3 (`glide is time-based`). -5. A rect larger than the view in within-edges mode aligns its top-left instead of oscillating: pinned in Task 3 (`oversized rect aligns top-left`). - ---- - -## Phase 1 (PR A): caret and focus tracking - -### Task 1: Settings keys and defaults - -**Files:** -- Modify: `src/config.h` (Config struct, near `cursorBandAuto`) -- Modify: `src/config.cpp` (ParseConfig keys, `DefaultIniText()` template) -- Test: `tests/test_config.cpp` - -**Interfaces:** -- Produces: `Config::trackCaret` (int, 1), `trackFocus` (0), `trackAlign` (0 centred / 1 edges), `mouseAlign` (0 / 1), `trackGlideMs` (150), `trackMarginPct` (15), `trackLog` (0). - -- [ ] **Step 1: Write the failing test** (append to `tests/test_config.cpp`) - -```cpp -TEST_CASE("tracking settings: defaults and parsing (issue #276)") { - Config d = ParseConfig(""); - CHECK(d.trackCaret == 1); - CHECK(d.trackFocus == 0); - CHECK(d.trackAlign == 0); - CHECK(d.mouseAlign == 0); - CHECK(d.trackGlideMs == 150); - CHECK(d.trackMarginPct == 15); - CHECK(d.trackLog == 0); - Config c = ParseConfig("trackCaret=0\ntrackFocus=1\ntrackAlign=1\nmouseAlign=1\n" - "trackGlideMs=90\ntrackMarginPct=20\ntrackLog=1\n"); - CHECK(c.trackCaret == 0); CHECK(c.trackFocus == 1); CHECK(c.trackAlign == 1); - CHECK(c.mouseAlign == 1); CHECK(c.trackGlideMs == 90); CHECK(c.trackMarginPct == 20); - CHECK(c.trackLog == 1); -} -``` - -- [ ] **Step 2: Run** `build.bat test`. Expected: compile error, `trackCaret` is not a member. - -- [ ] **Step 3: Implement.** In `Config` (config.h): - -```cpp - // Tracking modes (issue #276, hot). The view can follow the text caret and keyboard focus; - // the pointer is never moved by tracking. Caret on by default, focus off. - int trackCaret = 1; - int trackFocus = 0; - int trackAlign = 0; // caret + focus: 0 = centred, 1 = within the edges - int mouseAlign = 0; // mouse: 0 = centred (today), 1 = within the edges (phase 2) - int trackGlideMs = 150; // glide time to 95% of the distance - int trackMarginPct = 15; // within-edges margin, % of the view on each side - int trackLog = 0; // hidden: log every resolved caret/focus event with its source -``` - -In ParseConfig, next to `cursorBandAuto`: - -```cpp - else if (key == "trackCaret") c.trackCaret = std::stoi(val); - else if (key == "trackFocus") c.trackFocus = std::stoi(val); - else if (key == "trackAlign") c.trackAlign = std::stoi(val); - else if (key == "mouseAlign") c.mouseAlign = std::stoi(val); - else if (key == "trackGlideMs") c.trackGlideMs = std::stoi(val); - else if (key == "trackMarginPct") c.trackMarginPct = std::stoi(val); - else if (key == "trackLog") c.trackLog = std::stoi(val); -``` - -In `DefaultIniText()`, after the `cursorBandAuto` block (trackLog stays out of the template): - -```cpp - "; trackCaret: 1=the zoomed view follows the text cursor while you type; 0=off\n" - "trackCaret=1\n" - "; trackFocus: 1=the zoomed view follows keyboard focus (Tab, menus); 0=off\n" - "trackFocus=0\n" - "; trackAlign: text cursor and focus, 0=keep centred, 1=keep within the edges\n" - "trackAlign=0\n" - "; mouseAlign: mouse pointer, 0=keep centred, 1=keep within the edges\n" - "mouseAlign=0\n" - "; trackGlideMs: how long the view takes to glide to the caret/focus/pointer (ms)\n" - "trackGlideMs=150\n" - "; trackMarginPct: within-the-edges margin, percent of the view on each side\n" - "trackMarginPct=15\n" -``` - -- [ ] **Step 4: Run** `build.bat test`. Expected: PASS, including the generated "template parses to the struct defaults" test (it now covers the new keys). -- [ ] **Step 5: Commit** `feat(config): tracking mode settings (#276)`. - -### Task 2: Owner state machine (`view_target.h`) - -**Files:** -- Create: `src/view_target.h` -- Test: `tests/test_view_target.cpp` - -**Interfaces:** -- Produces: - -```cpp -namespace wind { -enum class ViewOwner { Mouse, Caret, Focus, Returning }; -enum class TrackKind { None = 0, Caret = 1, Focus = 2 }; -struct TrackSnapshot { TrackKind kind = TrackKind::None; unsigned seq = 0; double l = 0, t = 0, r = 0, b = 0; }; -struct ViewOwnerState { ViewOwner owner = ViewOwner::Mouse; unsigned lastSeq = 0; double moveAccum = 0; double moveWindowMs = 0; }; -struct ViewOwnerInputs { - bool enabled = false; // zoomed && !game && !inspect && !locked - bool trackCaret = false, trackFocus = false; - double mouseDx = 0, mouseDy = 0; // real pointer movement this tick, px - bool buttonDown = false; - double dtMs = 0; - TrackSnapshot snap; -}; -ViewOwner StepViewOwner(ViewOwnerState& s, const ViewOwnerInputs& in); -void FinishReturn(ViewOwnerState& s); // caller: the return glide arrived -inline constexpr double kMouseTakeoverPx = 3.0, kMouseTakeoverWindowMs = 100.0; -} -``` - -- [ ] **Step 1: Write the failing tests** (`tests/test_view_target.cpp`) - -```cpp -#include "doctest.h" -#include "../src/view_target.h" -using namespace wind; - -static ViewOwnerInputs Base() { - ViewOwnerInputs in; in.enabled = true; in.trackCaret = true; in.trackFocus = true; in.dtMs = 7; - return in; -} -static TrackSnapshot Snap(TrackKind k, unsigned seq) { TrackSnapshot s; s.kind = k; s.seq = seq; s.l = 100; s.t = 100; s.r = 102; s.b = 120; return s; } - -TEST_CASE("a new caret event takes the view; a repeat of the same seq does not re-trigger") { - ViewOwnerState s; auto in = Base(); in.snap = Snap(TrackKind::Caret, 1); - CHECK(StepViewOwner(s, in) == ViewOwner::Caret); - CHECK(StepViewOwner(s, in) == ViewOwner::Caret); -} -TEST_CASE("disabled kinds and disabled tracking leave the mouse in charge") { - ViewOwnerState s; auto in = Base(); in.trackCaret = false; in.snap = Snap(TrackKind::Caret, 1); - CHECK(StepViewOwner(s, in) == ViewOwner::Mouse); - ViewOwnerState s2; auto in2 = Base(); in2.enabled = false; in2.snap = Snap(TrackKind::Focus, 1); - CHECK(StepViewOwner(s2, in2) == ViewOwner::Mouse); -} -TEST_CASE("jitter below threshold keeps Caret") { - ViewOwnerState s; auto in = Base(); in.snap = Snap(TrackKind::Caret, 1); - StepViewOwner(s, in); - in.mouseDx = 1; StepViewOwner(s, in); - in.mouseDx = 1; CHECK(StepViewOwner(s, in) == ViewOwner::Caret); // 2 px total -} -TEST_CASE("real mouse movement or a button starts the return") { - ViewOwnerState s; auto in = Base(); in.snap = Snap(TrackKind::Caret, 1); - StepViewOwner(s, in); - in.mouseDx = 4; CHECK(StepViewOwner(s, in) == ViewOwner::Returning); - FinishReturn(s); CHECK(s.owner == ViewOwner::Mouse); - ViewOwnerState s2; auto in2 = Base(); in2.snap = Snap(TrackKind::Focus, 1); - StepViewOwner(s2, in2); - in2.buttonDown = true; CHECK(StepViewOwner(s2, in2) == ViewOwner::Returning); -} -TEST_CASE("slow drift spread over more than the window never accumulates to a takeover") { - ViewOwnerState s; auto in = Base(); in.snap = Snap(TrackKind::Caret, 1); - StepViewOwner(s, in); - in.dtMs = 60; - for (int i = 0; i < 10; ++i) { in.mouseDx = 1; CHECK(StepViewOwner(s, in) == ViewOwner::Caret); } -} -TEST_CASE("a caret event while returning takes the view again; mouse movement while Mouse stays Mouse") { - ViewOwnerState s; auto in = Base(); in.snap = Snap(TrackKind::Caret, 1); - StepViewOwner(s, in); in.mouseDx = 5; StepViewOwner(s, in); - CHECK(s.owner == ViewOwner::Returning); - in.mouseDx = 0; in.snap = Snap(TrackKind::Caret, 2); - CHECK(StepViewOwner(s, in) == ViewOwner::Caret); - ViewOwnerState m; auto mi = Base(); mi.mouseDx = 50; - CHECK(StepViewOwner(m, mi) == ViewOwner::Mouse); -} -TEST_CASE("tracking turned off mid-caret goes straight back to the mouse") { - ViewOwnerState s; auto in = Base(); in.snap = Snap(TrackKind::Caret, 1); - StepViewOwner(s, in); in.enabled = false; - CHECK(StepViewOwner(s, in) == ViewOwner::Returning); -} -``` - -- [ ] **Step 2: Run** `build.bat test`. Expected: compile error (missing header). - -- [ ] **Step 3: Implement** `src/view_target.h` - -```cpp -#pragma once -// Who owns the zoomed view (issue #276). Pure: no , tests/test_view_target.cpp. -// Most recent input wins; the mouse takes the view back only on REAL movement (3 px within 100 ms) -// or a button, so sensor jitter while typing never steals it. Leaving Caret/Focus always goes via -// Returning, a glide back to the pointer that the caller ends with FinishReturn(). -#include -namespace wind { -enum class ViewOwner { Mouse, Caret, Focus, Returning }; -enum class TrackKind { None = 0, Caret = 1, Focus = 2 }; -struct TrackSnapshot { TrackKind kind = TrackKind::None; unsigned seq = 0; double l = 0, t = 0, r = 0, b = 0; }; -struct ViewOwnerState { ViewOwner owner = ViewOwner::Mouse; unsigned lastSeq = 0; double moveAccum = 0; double moveWindowMs = 0; }; -struct ViewOwnerInputs { - bool enabled = false; - bool trackCaret = false, trackFocus = false; - double mouseDx = 0, mouseDy = 0; - bool buttonDown = false; - double dtMs = 0; - TrackSnapshot snap; -}; -inline constexpr double kMouseTakeoverPx = 3.0, kMouseTakeoverWindowMs = 100.0; - -inline void FinishReturn(ViewOwnerState& s) { s.owner = ViewOwner::Mouse; } - -inline ViewOwner StepViewOwner(ViewOwnerState& s, const ViewOwnerInputs& in) { - const bool detached = s.owner == ViewOwner::Caret || s.owner == ViewOwner::Focus; - if (!in.enabled) { - if (detached) s.owner = ViewOwner::Returning; - s.lastSeq = in.snap.seq; // events seen while disabled never fire later - return s.owner; - } - // Mouse activity: accumulate movement inside a sliding window. - const double step = std::fabs(in.mouseDx) + std::fabs(in.mouseDy); - if (step > 0) { - if (s.moveWindowMs > kMouseTakeoverWindowMs) { s.moveAccum = 0; s.moveWindowMs = 0; } - s.moveAccum += step; - } - s.moveWindowMs += in.dtMs; - if (s.moveWindowMs > kMouseTakeoverWindowMs && step == 0) { s.moveAccum = 0; s.moveWindowMs = 0; } - const bool mouseActive = in.buttonDown || s.moveAccum >= kMouseTakeoverPx; - if (mouseActive) { - s.moveAccum = 0; s.moveWindowMs = 0; - if (detached) s.owner = ViewOwner::Returning; - s.lastSeq = in.snap.seq; // the mouse wins this tick - return s.owner; - } - // A new tracking event. - if (in.snap.seq != s.lastSeq) { - s.lastSeq = in.snap.seq; - if (in.snap.kind == TrackKind::Caret && in.trackCaret) s.owner = ViewOwner::Caret; - else if (in.snap.kind == TrackKind::Focus && in.trackFocus) s.owner = ViewOwner::Focus; - } - return s.owner; -} -} // namespace wind -``` - -- [ ] **Step 4: Run** `build.bat test`. Expected: PASS. -- [ ] **Step 5: Commit** `feat(tracking): view owner state machine (#276)`. - -### Task 3: Glide and target geometry (`view_glide.h`) - -**Files:** -- Create: `src/view_glide.h` -- Test: `tests/test_view_glide.cpp` - -**Interfaces:** -- Consumes: nothing. -- Produces: - -```cpp -namespace wind { -double GlideToward(double cur, double target, double dtMs, double glideMs); -struct TrackRect { double l, t, r, b; }; -// Returns false (and leaves out untouched) for a rect that is empty, degenerate or off-monitor. -bool TrackTargetCenter(const TrackRect& rc, double curCx, double curCy, double level, - int monW, int monH, int align /*0 centred, 1 edges*/, int marginPct, - double& outCx, double& outCy); -} -``` -Rect coordinates are monitor-local physical pixels (the caller subtracts the monitor origin). - -- [ ] **Step 1: Write the failing tests** (`tests/test_view_glide.cpp`) - -```cpp -#include "doctest.h" -#include "../src/view_glide.h" -using namespace wind; - -TEST_CASE("glide is time-based: two 7 ms steps equal one 14 ms step") { - double a = GlideToward(GlideToward(0, 100, 7, 150), 100, 7, 150); - double b = GlideToward(0, 100, 14, 150); - CHECK(a == doctest::Approx(b).epsilon(1e-9)); -} -TEST_CASE("glide covers 95% of the distance in glideMs and never overshoots") { - double v = 0; for (int i = 0; i < 150; ++i) v = GlideToward(v, 1000, 1, 150); - CHECK(v == doctest::Approx(950).epsilon(0.01)); - CHECK(v <= 1000); - CHECK(GlideToward(0, 1000, 0, 150) == 0); // no time, no motion - CHECK(GlideToward(0, 1000, 5, 0) == 1000); // glideMs 0 means snap -} -TEST_CASE("retargeting mid-glide never moves backwards") { - double v = 0; v = GlideToward(v, 100, 30, 150); - double w = GlideToward(v, 110, 30, 150); - CHECK(w > v); -} -TEST_CASE("centred: the target is the rect centre, clamped to the monitor") { - double cx, cy; - REQUIRE(TrackTargetCenter({1000, 500, 1002, 520}, 0, 0, 3, 3840, 2160, 0, 15, cx, cy)); - CHECK(cx == doctest::Approx(1001)); CHECK(cy == doctest::Approx(510)); -} -TEST_CASE("degenerate and off-monitor rects are rejected") { - double cx = 7, cy = 7; - CHECK_FALSE(TrackTargetCenter({0, 0, 0, 0}, 0, 0, 3, 3840, 2160, 0, 15, cx, cy)); - CHECK_FALSE(TrackTargetCenter({500, 500, 400, 600}, 0, 0, 3, 3840, 2160, 0, 15, cx, cy)); - CHECK_FALSE(TrackTargetCenter({-900, 100, -880, 120}, 0, 0, 3, 3840, 2160, 0, 15, cx, cy)); - CHECK_FALSE(TrackTargetCenter({4000, 100, 4010, 120}, 0, 0, 3, 3840, 2160, 0, 15, cx, cy)); - CHECK(cx == 7); CHECK(cy == 7); -} -TEST_CASE("within edges: a rect already inside the margin does not move the view") { - // level 4 on 3840x2160: view 960x540 around (1920,1080) -> x 1440..2400, y 810..1350 - double cx, cy; - REQUIRE(TrackTargetCenter({1900, 1000, 1902, 1020}, 1920, 1080, 4, 3840, 2160, 1, 15, cx, cy)); - CHECK(cx == doctest::Approx(1920)); CHECK(cy == doctest::Approx(1080)); -} -TEST_CASE("within edges: a rect past the right margin moves the view just enough") { - // right margin edge = 2400 - 0.15*960 = 2256; rect right 2300 -> shift 44 - double cx, cy; - REQUIRE(TrackTargetCenter({2298, 1000, 2300, 1020}, 1920, 1080, 4, 3840, 2160, 1, 15, cx, cy)); - CHECK(cx == doctest::Approx(1964)); CHECK(cy == doctest::Approx(1080)); -} -TEST_CASE("oversized rect aligns top-left") { - // a 2000 px wide rect in a 960 px view: its left edge goes to the left margin - // (1000 - 144 + 480 = 1336, inside the monitor clamp band 480..3360) - double cx, cy; - REQUIRE(TrackTargetCenter({1000, 1000, 3000, 1020}, 1920, 1080, 4, 3840, 2160, 1, 15, cx, cy)); - CHECK(cx == doctest::Approx(1000 - 0.15 * 960 + 480)); -} -``` - -- [ ] **Step 2: Run** `build.bat test`. Expected: compile error. - -- [ ] **Step 3: Implement** `src/view_glide.h` - -```cpp -#pragma once -// Glide and target geometry for tracking (issue #276). Pure; tests/test_view_glide.cpp. -#include -namespace wind { - -// Time-based ease: 95% of the gap in glideMs whatever the tick interval (VRR-safe, the same -// reasoning as CursorMapper::setTickDeltaMs). keep = 0.05^(dt/glideMs). -inline double GlideToward(double cur, double target, double dtMs, double glideMs) { - if (glideMs <= 0.0) return target; - if (dtMs <= 0.0) return cur; - const double keep = std::pow(0.05, dtMs / glideMs); - return target + (cur - target) * keep; -} - -struct TrackRect { double l, t, r, b; }; - -inline bool TrackTargetCenter(const TrackRect& rc, double curCx, double curCy, double level, - int monW, int monH, int align, int marginPct, - double& outCx, double& outCy) { - if (level < 1.0) level = 1.0; - const double w = rc.r - rc.l, h = rc.b - rc.t; - if (w < 0 || h < 0 || (w == 0 && h == 0)) return false; // empty / inverted - if (rc.l == 0 && rc.t == 0 && rc.r == 0 && rc.b == 0) return false; // the classic bogus caret - if (rc.r < 0 || rc.b < 0 || rc.l > monW || rc.t > monH) return false; // not on this monitor - const double vw = monW / level, vh = monH / level; - double cx = curCx, cy = curCy; - if (align == 0) { - cx = (rc.l + rc.r) / 2.0; - cy = (rc.t + rc.b) / 2.0; - } else { - const double mx = vw * (marginPct / 100.0), my = vh * (marginPct / 100.0); - auto axis = [](double c, double lo, double hi, double v, double m) { - const double inLo = c - v / 2 + m, inHi = c + v / 2 - m; // the comfortable band - if (hi - lo > inHi - inLo) return lo - m + v / 2; // oversized: align its start - if (lo < inLo) return c - (inLo - lo); - if (hi > inHi) return c + (hi - inHi); - return c; - }; - cx = axis(curCx, rc.l, rc.r, vw, mx); - cy = axis(curCy, rc.t, rc.b, vh, my); - } - // Clamp so the view stays on the monitor (ComputeOffsetF clamps too; keeping the centre - // consistent avoids a glide that aims past the edge and then stalls). - const double minX = vw / 2, maxX = monW - vw / 2, minY = vh / 2, maxY = monH - vh / 2; - outCx = cx < minX ? minX : (cx > maxX ? maxX : cx); - outCy = cy < minY ? minY : (cy > maxY ? maxY : cy); - return true; -} -} // namespace wind -``` - -- [ ] **Step 4: Run** `build.bat test`. Expected: PASS. (Note: the centred test's expected values sit inside the clamp band at level 3.) -- [ ] **Step 5: Commit** `feat(tracking): glide and target geometry (#276)`. - -### Task 4: Detached frame (`detached_view.h`) - -**Files:** -- Create: `src/detached_view.h` -- Test: `tests/test_detached_view.cpp` - -**Interfaces:** -- Consumes: `MapResult` (`src/cursor_mapper.h`), `ComputeOffsetF` (`src/transform.h`). -- Produces: `MapResult DetachedMap(double viewCx, double viewCy, double ptrX, double ptrY, double level, int monW, int monH);` View at (viewCx, viewCy); `cursorScreen*` and `clickDesktop*` report the real pointer (monitor-local px); `center*` is the view centre (the transform model derives its source from it). - -- [ ] **Step 1: Write the failing test** - -```cpp -#include "doctest.h" -#include "../src/detached_view.h" -using namespace wind; - -TEST_CASE("detached map: view from the given centre, cursor fields from the real pointer") { - MapResult r = DetachedMap(1920, 1080, 1000, 500, 4, 3840, 2160); - CHECK(r.centerX == doctest::Approx(1920)); CHECK(r.centerY == doctest::Approx(1080)); - CHECK(r.srcLeft == doctest::Approx(1920 - 480)); CHECK(r.srcTop == doctest::Approx(1080 - 270)); - CHECK(r.clickDesktopX == 1000); CHECK(r.clickDesktopY == 500); - CHECK(r.cursorScreenX == doctest::Approx((1000 - 1440) * 4.0)); // off to the left: not visible - CHECK(r.cursorScreenY == doctest::Approx((500 - 810) * 4.0)); -} -``` - -- [ ] **Step 2: Run** `build.bat test`. Expected: compile error. -- [ ] **Step 3: Implement** - -```cpp -#pragma once -// A frame whose view is NOT centred on the pointer (issue #276): caret/focus tracking and, later, -// mouse edge mode. The view comes from (viewCx, viewCy); the cursor fields report the REAL pointer, -// so the sprite / drawn cursor sit where the pointer actually is and scroll out of view with the -// content. Pure; tests/test_detached_view.cpp. -#include "cursor_mapper.h" -#include "transform.h" -namespace wind { -inline MapResult DetachedMap(double viewCx, double viewCy, double ptrX, double ptrY, double level, - int monW, int monH) { - if (level < 1.0) level = 1.0; - const OffsetF o = ComputeOffsetF(viewCx, viewCy, level, monW, monH); - MapResult r; - r.srcLeft = o.x; r.srcTop = o.y; - r.centerX = viewCx; r.centerY = viewCy; - r.cursorScreenX = (ptrX - o.x) * level; - r.cursorScreenY = (ptrY - o.y) * level; - r.clickDesktopX = static_cast(ptrX + (ptrX >= 0 ? 0.5 : -0.5)); - r.clickDesktopY = static_cast(ptrY + (ptrY >= 0 ? 0.5 : -0.5)); - return r; -} -} // namespace wind -``` - -- [ ] **Step 4: Run** `build.bat test`. Expected: PASS. If `ComputeOffsetF` clamps differently than assumed, adjust the expected `srcLeft/srcTop` to its documented formula in `src/transform.h`, not the other way round. -- [ ] **Step 5: Commit** `feat(tracking): detached view frame (#276)`. - -### Task 5: The watcher thread (`focus_track`) - -**Files:** -- Create: `src/focus_track.h`, `src/focus_track.cpp` -- Modify: `build.bat` (add `oleaut32.lib uuid.lib` to both app link lines if missing; `ole32.lib` is already there) - -**Interfaces:** -- Consumes: `TrackSnapshot`, `TrackKind` (Task 2), `wind::Log`. -- Produces: - -```cpp -namespace wind { -class FocusTracker { -public: - bool start(); // spawns the thread; idempotent - void stop(); // joins; unhooks; CoUninitialize on the thread - void setActive(bool on, bool wantCaret, bool wantFocus, bool log); // tick thread, cheap - TrackSnapshot snapshot() const; // tick thread, copies under a mutex -}; -} -``` -All rects published in physical virtual-desktop pixels (Wind is Per-Monitor-V2 aware; UIA returns physical px to a PMv2 client). - -- [ ] **Step 1: Header** `src/focus_track.h` - -```cpp -#pragma once -// Caret and keyboard-focus watcher (issue #276). One thread owns every accessibility call: -// WinEvents (out of context), GetGUIThreadInfo, and UI Automation on a COM MTA. The tick thread only -// flips setActive() and copies snapshot(); it never waits on UIA. See the spec, section 4.2. -#include "view_target.h" -#include -#include -#include -namespace wind { -class FocusTracker { -public: - ~FocusTracker() { stop(); } - bool start(); - void stop(); - void setActive(bool on, bool wantCaret, bool wantFocus, bool log); - TrackSnapshot snapshot() const { std::lock_guard g(mu_); return snap_; } -private: - void run(); - friend struct FocusTrackImpl; - std::thread th_; - std::atomic tid_{0}; - std::atomic active_{false}, wantCaret_{false}, wantFocus_{false}, log_{false}; - mutable std::mutex mu_; - TrackSnapshot snap_; - unsigned seq_ = 0; - void publish(TrackKind k, double l, double t, double r, double b, const char* src); -}; -} // namespace wind -``` - -- [ ] **Step 2: Implementation** `src/focus_track.cpp` - -```cpp -#include "focus_track.h" -#include "logging.h" -#include -#include -#include -#include -#include -#pragma comment(lib, "oleacc.lib") -namespace wind { - -static const UINT kWakeMsg = WM_APP + 0x61; // an event arrived: resolve after coalescing -static const UINT_PTR kCoalesceTimer = 1, kPollTimer = 2; -static FocusTracker* g_self = nullptr; // WinEvent callbacks have no context pointer - -struct FocusTrackImpl { - static void CALLBACK OnWinEvent(HWINEVENTHOOK, DWORD ev, HWND hwnd, LONG obj, LONG child, DWORD, DWORD) { - if (!g_self || !g_self->active_.load()) return; - if (ev == EVENT_OBJECT_LOCATIONCHANGE && obj != OBJID_CARET) return; - PostThreadMessageW(g_self->tid_.load(), kWakeMsg, (WPARAM)ev, 0); - } -}; - -// UIA focus-changed handler: just wakes the thread (resolution happens there, coalesced). -class FocusHandler : public IUIAutomationFocusChangedEventHandler { - LONG refs_ = 1; -public: - ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement(&refs_); } - ULONG STDMETHODCALLTYPE Release() override { LONG r = InterlockedDecrement(&refs_); if (!r) delete this; return r; } - HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** pp) override { - if (riid == __uuidof(IUnknown) || riid == __uuidof(IUIAutomationFocusChangedEventHandler)) { *pp = this; AddRef(); return S_OK; } - *pp = nullptr; return E_NOINTERFACE; - } - HRESULT STDMETHODCALLTYPE HandleFocusChangedEvent(IUIAutomationElement*) override { - if (g_self && g_self->active_.load()) PostThreadMessageW(g_self->tid_.load(), kWakeMsg, (WPARAM)EVENT_OBJECT_FOCUS, 0); - return S_OK; - } -}; - -static bool IsOwnOrTooltip(HWND h) { - if (!h) return true; - DWORD pid = 0; GetWindowThreadProcessId(h, &pid); - if (pid == GetCurrentProcessId()) return true; - wchar_t cls[64] = {}; GetClassNameW(h, cls, 64); - return wcscmp(cls, L"tooltips_class32") == 0 || wcscmp(cls, L"Xaml_WindowedPopupClass") == 0; -} - -// Classic Win32 caret of the foreground thread, in screen px. False when there is none. -static bool Win32Caret(RECT& out) { - HWND fg = GetForegroundWindow(); - if (IsOwnOrTooltip(fg)) return false; - GUITHREADINFO gi{ sizeof(gi) }; - if (!GetGUIThreadInfo(GetWindowThreadProcessId(fg, nullptr), &gi) || !gi.hwndCaret) return false; - RECT rc = gi.rcCaret; - if (rc.right <= rc.left && rc.bottom <= rc.top) return false; - POINT a{ rc.left, rc.top }, b{ rc.right, rc.bottom }; - if (!ClientToScreen(gi.hwndCaret, &a) || !ClientToScreen(gi.hwndCaret, &b)) return false; - out = { a.x, a.y, b.x, b.y }; - return true; -} - -static bool RangeRect(IUIAutomationTextRange* range, RECT& out) { - SAFEARRAY* sa = nullptr; - if (FAILED(range->GetBoundingRectangles(&sa)) || !sa) return false; - bool ok = false; - double* d = nullptr; - LONG n = sa->rgsabound[0].cElements; - if (n >= 4 && SUCCEEDED(SafeArrayAccessData(sa, (void**)&d))) { - out = { (LONG)d[0], (LONG)d[1], (LONG)(d[0] + (d[2] > 1 ? d[2] : 1)), (LONG)(d[1] + d[3]) }; - ok = d[3] > 0; - SafeArrayUnaccessData(sa); - } else if (n == 0) { - // An empty caret range has no rectangle: widen it by one character, then use its left edge. - IUIAutomationTextRange* wide = nullptr; - if (SUCCEEDED(range->Clone(&wide)) && wide) { - int moved = 0; - if (SUCCEEDED(wide->ExpandToEnclosingUnit(TextUnit_Character)) && - SUCCEEDED(wide->GetBoundingRectangles(&sa)) && sa && sa->rgsabound[0].cElements >= 4 && - SUCCEEDED(SafeArrayAccessData(sa, (void**)&d))) { - out = { (LONG)d[0], (LONG)d[1], (LONG)d[0] + 2, (LONG)(d[1] + d[3]) }; - ok = d[3] > 0; - SafeArrayUnaccessData(sa); - } - (void)moved; - wide->Release(); - } - } - if (sa) SafeArrayDestroy(sa); - return ok; -} - -bool FocusTracker::start() { - if (th_.joinable()) return true; - g_self = this; - th_ = std::thread([this] { run(); }); - return true; -} -void FocusTracker::stop() { - const unsigned long t = tid_.load(); - if (t) PostThreadMessageW(t, WM_QUIT, 0, 0); - if (th_.joinable()) th_.join(); - if (g_self == this) g_self = nullptr; -} -void FocusTracker::setActive(bool on, bool wantCaret, bool wantFocus, bool log) { - wantCaret_ = wantCaret; wantFocus_ = wantFocus; log_ = log; - const bool was = active_.exchange(on); - if (on && !was) { const unsigned long t = tid_.load(); if (t) PostThreadMessageW(t, kWakeMsg, 0, 0); } -} -void FocusTracker::publish(TrackKind k, double l, double t, double r, double b, const char* src) { - { - std::lock_guard g(mu_); - if (snap_.kind == k && snap_.l == l && snap_.t == t && snap_.r == r && snap_.b == b) return; // unchanged - snap_ = { k, ++seq_, l, t, r, b }; - } - if (log_) wind::Log(wind::LogLevel::Info, "track", "%s via %s: %.0f,%.0f %.0fx%.0f", - k == TrackKind::Caret ? "caret" : "focus", src, l, t, r - l, b - t); -} - -void FocusTracker::run() { - tid_ = GetCurrentThreadId(); - MSG m; PeekMessageW(&m, nullptr, WM_USER, WM_USER, PM_NOREMOVE); // make the queue exist - CoInitializeEx(nullptr, COINIT_MULTITHREADED); - IUIAutomation* uia = nullptr; - CoCreateInstance(__uuidof(CUIAutomation), nullptr, CLSCTX_INPROC_SERVER, __uuidof(IUIAutomation), (void**)&uia); - FocusHandler* fh = nullptr; - if (uia) { fh = new FocusHandler(); if (FAILED(uia->AddFocusChangedEventHandler(nullptr, fh))) { fh->Release(); fh = nullptr; } } - HWINEVENTHOOK h1 = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); - HWINEVENTHOOK h2 = SetWinEventHook(EVENT_SYSTEM_MENUPOPUPSTART, EVENT_SYSTEM_MENUPOPUPSTART, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); - HWINEVENTHOOK h3 = SetWinEventHook(EVENT_OBJECT_FOCUS, EVENT_OBJECT_FOCUS, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); - HWINEVENTHOOK h4 = SetWinEventHook(EVENT_OBJECT_LOCATIONCHANGE, EVENT_OBJECT_LOCATIONCHANGE, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); - SetTimer(nullptr, kPollTimer, 16, nullptr); // 60 Hz backstop, work only while active - bool pendingFocus = false, pendingCaret = false; - UINT_PTR coalesce = 0; - - auto resolve = [&](bool focusChanged) { - if (!active_.load()) return; - HWND fg = GetForegroundWindow(); - if (IsOwnOrTooltip(fg)) return; - RECT rc{}; - // 1. Caret, fastest source first. - if (wantCaret_.load()) { - if (Win32Caret(rc)) { publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, "win32"); return; } - IUIAutomationElement* el = nullptr; - if (uia && SUCCEEDED(uia->GetFocusedElement(&el)) && el) { - IUIAutomationTextPattern2* tp2 = nullptr; - if (SUCCEEDED(el->GetCurrentPatternAs(UIA_TextPattern2Id, __uuidof(IUIAutomationTextPattern2), (void**)&tp2)) && tp2) { - BOOL active = FALSE; IUIAutomationTextRange* cr = nullptr; - if (SUCCEEDED(tp2->GetCaretRange(&active, &cr)) && cr) { - if (active && RangeRect(cr, rc)) { cr->Release(); tp2->Release(); el->Release(); - publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, "uia-caret"); return; } - cr->Release(); - } - tp2->Release(); - } - IUIAutomationTextPattern* tp = nullptr; - if (SUCCEEDED(el->GetCurrentPatternAs(UIA_TextPatternId, __uuidof(IUIAutomationTextPattern), (void**)&tp)) && tp) { - IUIAutomationTextRangeArray* sel = nullptr; - if (SUCCEEDED(tp->GetSelection(&sel)) && sel) { - int n = 0; sel->get_Length(&n); - IUIAutomationTextRange* r0 = nullptr; - if (n > 0 && SUCCEEDED(sel->GetElement(0, &r0)) && r0) { - bool ok = RangeRect(r0, rc); r0->Release(); - if (ok) { sel->Release(); tp->Release(); el->Release(); - publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, "uia-selection"); return; } - } - sel->Release(); - } - tp->Release(); - } - // 2. Focus: the element's own bounds, only on a real focus change. - if (focusChanged && wantFocus_.load()) { - RECT b{}; - if (SUCCEEDED(el->get_CurrentBoundingRectangle(&b)) && b.right > b.left && b.bottom > b.top) { - el->Release(); publish(TrackKind::Focus, b.left, b.top, b.right, b.bottom, "uia-focus"); return; - } - } - el->Release(); - } - } else if (focusChanged && wantFocus_.load() && uia) { - IUIAutomationElement* el = nullptr; RECT b{}; - if (SUCCEEDED(uia->GetFocusedElement(&el)) && el) { - if (SUCCEEDED(el->get_CurrentBoundingRectangle(&b)) && b.right > b.left && b.bottom > b.top) - publish(TrackKind::Focus, b.left, b.top, b.right, b.bottom, "uia-focus"); - el->Release(); - } - } - }; - - while (GetMessageW(&m, nullptr, 0, 0) > 0) { - if (m.message == kWakeMsg) { - if (m.wParam == EVENT_OBJECT_FOCUS || m.wParam == EVENT_SYSTEM_FOREGROUND || m.wParam == EVENT_SYSTEM_MENUPOPUPSTART) pendingFocus = true; - else pendingCaret = true; - if (!coalesce) coalesce = SetTimer(nullptr, kCoalesceTimer, 30, nullptr); // NVDA's ~30 ms - } else if (m.message == WM_TIMER && m.wParam == coalesce && coalesce) { - KillTimer(nullptr, coalesce); coalesce = 0; - resolve(pendingFocus); pendingFocus = pendingCaret = false; - } else if (m.message == WM_TIMER) { - if (active_.load() && wantCaret_.load()) resolve(false); // backstop poll - } - TranslateMessage(&m); DispatchMessageW(&m); - } - KillTimer(nullptr, kPollTimer); - for (HWINEVENTHOOK h : { h1, h2, h3, h4 }) if (h) UnhookWinEvent(h); - if (uia && fh) uia->RemoveFocusChangedEventHandler(fh); - if (fh) fh->Release(); - if (uia) uia->Release(); - CoUninitialize(); - tid_ = 0; -} -} // namespace wind -``` - -Notes for the implementer: -- `SetTimer(nullptr, id, ...)` ignores `id` and returns a new one; compare `m.wParam` against the returned value (as above for `coalesce`); the poll timer is the other `WM_TIMER`. -- The poll only runs work while `active_` is true, so an idle Wind does nothing. -- Filtering the caret to the foreground window is done by querying the foreground thread and the focused element only; stale carets of background windows never enter. - -- [ ] **Step 3: Build** `build.bat` and `build.bat check`. Expected: no errors. Add `oleacc.lib` handled by the pragma; add `oleaut32.lib uuid.lib` to the link lines in `build.bat` (both the normal and `uiaccess` targets) if the linker reports `SafeArray*` or `IID_*` unresolved. -- [ ] **Step 4: Commit** `feat(tracking): caret and focus watcher thread (#276)`. - -### Task 6: RunTick integration - -**Files:** -- Modify: `src/main.cpp` (tick state struct near `restOverlapTicks` at ~line 284; the regime seam before `t.mapper.update` at ~line 1452; `ex.suppressCursorSync` at ~line 1673; startup/shutdown where `g_input` starts/stops) - -**Interfaces:** -- Consumes: `FocusTracker` (Task 5), `StepViewOwner`/`FinishReturn` (Task 2), `GlideToward`/`TrackTargetCenter` (Task 3), `DetachedMap` (Task 4), Config keys (Task 1). - -- [ ] **Step 1: State and lifetime.** Add to the tick state struct: - -```cpp - // Tracking (issue #276): who owns the view, and the glided detached centre (monitor-local px). - wind::ViewOwnerState viewOwner; - double viewCx = 0.0, viewCy = 0.0; - bool viewDetached = false; // last tick drew a detached frame -``` - -Add a file-scope `static wind::FocusTracker g_track;`, call `g_track.start()` next to where the input router starts, and `g_track.stop()` on every shutdown path where the input router is stopped. - -- [ ] **Step 2: Step the owner and build the detached frame.** Immediately after `MapResult r = t.mapper.update(freeCursor ? 0 : dx, freeCursor ? 0 : dy, lvl);`: - -```cpp - // --- Tracking (issue #276): caret / focus own the view; the pointer is never moved. --- - const bool trackEnabled = lvl > 1.001 && !inspect && !t.detector.locked() && !fsGameSession && - (t.cfg.trackCaret != 0 || t.cfg.trackFocus != 0); - g_track.setActive(trackEnabled, t.cfg.trackCaret != 0, t.cfg.trackFocus != 0, t.cfg.trackLog != 0); - { - wind::ViewOwnerInputs vi; - vi.enabled = trackEnabled; - vi.trackCaret = t.cfg.trackCaret != 0; vi.trackFocus = t.cfg.trackFocus != 0; - vi.mouseDx = curDx; vi.mouseDy = curDy; - vi.buttonDown = (GetAsyncKeyState(VK_LBUTTON) | GetAsyncKeyState(VK_RBUTTON) | GetAsyncKeyState(VK_MBUTTON)) & 0x8000; - vi.dtMs = dt * 1000.0; - vi.snap = g_track.snapshot(); - const bool wasDetached = t.viewOwner.owner != wind::ViewOwner::Mouse; - const wind::ViewOwner owner = wind::StepViewOwner(t.viewOwner, vi); - if (owner != wind::ViewOwner::Mouse) { - if (!wasDetached) { t.viewCx = r.centerX; t.viewCy = r.centerY; } // glide from where we are - const double ptrX = cur.x - t.mon.x, ptrY = cur.y - t.mon.y; - double tx = t.viewCx, ty = t.viewCy; - if (owner == wind::ViewOwner::Returning) { - tx = ptrX; ty = ptrY; // centred return; phase 2 swaps in EdgePanCenter for mouseAlign=1 - } else { - const wind::TrackRect rc{ vi.snap.l - t.mon.x, vi.snap.t - t.mon.y, vi.snap.r - t.mon.x, vi.snap.b - t.mon.y }; - double ox, oy; - if (wind::TrackTargetCenter(rc, t.viewCx, t.viewCy, lvl, t.mon.w, t.mon.h, - t.cfg.trackAlign, t.cfg.trackMarginPct, ox, oy)) { tx = ox; ty = oy; } - } - t.viewCx = wind::GlideToward(t.viewCx, tx, vi.dtMs, t.cfg.trackGlideMs); - t.viewCy = wind::GlideToward(t.viewCy, ty, vi.dtMs, t.cfg.trackGlideMs); - if (owner == wind::ViewOwner::Returning && - std::fabs(t.viewCx - tx) < 1.0 && std::fabs(t.viewCy - ty) < 1.0) { - wind::FinishReturn(t.viewOwner); - t.mapper.reset(ptrX, ptrY); // hand back exactly at the pointer - t.lastSetVirtual = cur; - } else { - r = wind::DetachedMap(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h); - t.mapper.reset(t.viewCx, t.viewCy); // hybrid switches and the next tick start here - t.lastSetVirtual = cur; // measure the next hand motion from here - t.viewDetached = true; - } - } else { - t.viewDetached = false; - } - } -``` - -`fsGameSession` means: use the existing variable the tick already computes for "foreground covers the monitor / game" (search `fsGame` near `ex.fsGame`); if it is computed later in the tick, hoist that computation above this block rather than re-deriving it. - -- [ ] **Step 3: Never weld while detached.** Change `ex.suppressCursorSync = dragFollow || freeCursor;` to: - -```cpp - ex.suppressCursorSync = dragFollow || freeCursor || t.viewDetached; // tracking never moves the pointer (#276) -``` - -- [ ] **Step 4: Disarm the hook write path while detached.** The input hook can write the transform from pointer events (`txHookWrite`, the `hookWrite` condition near `wind::PublishHookTransform`); it would drag the view back to the pointer. Add `&& !t.viewDetached` to that condition: - -```cpp - const bool hookWrite = freeCursor && t.cfg.txHookWrite != 0 && wind::MagThreadOwned() && - tmWall != nullptr && lvl > 1.0 && levelSettled && !t.viewDetached; -``` - -If that condition is evaluated before the tracking block in the tick, use `t.viewOwner.owner != wind::ViewOwner::Mouse` (the state from the previous tick) instead of `t.viewDetached`. - -- [ ] **Step 5: Build and unit tests** `build.bat test` then `build.bat` and `build.bat check`. Expected: all pass, no errors. -- [ ] **Step 6: Commit** `feat(tracking): drive the view from caret and focus in RunTick (#276)`. - -### Task 7: Settings rows - -**Files:** -- Modify: `ui/src/settings-schema.js` (new "Tracking" section after the cursor section) -- Modify: `ui/src/lib/icons.js` if a section icon is required (reuse an existing icon if the schema allows) -- Test: `ui/tests/settings.spec.js` - -- [ ] **Step 1: Write the failing Playwright test** (append) - -```js -test('Tracking section: caret on, focus off, centred by default (issue #276)', async ({ page }) => { - await page.goto('/'); - const caret = page.getByRole('switch', { name: 'Follow the text cursor' }); - const focus = page.getByRole('switch', { name: 'Follow keyboard focus' }); - await expect(caret).toBeChecked(); - await expect(focus).not.toBeChecked(); - await focus.click(); - await page.getByRole('button', { name: 'Apply' }).click(); - const sets = await page.evaluate(() => window.__sets.filter(m => m.type === 'setConfig' && m.key === 'trackFocus')); - expect(sets.at(-1).value).toBe('1'); -}); -``` - -Match the role/name query to how existing toggle rows are exposed in `Row.svelte` (the existing tests show the pattern); if toggles are exposed as `checkbox`, use that role. - -- [ ] **Step 2: Run** `npx playwright test` in `ui/`. Expected: FAIL (rows missing). -- [ ] **Step 3: Add the section** to `settings-schema.js`: - -```js - { id:'tracking', label:'Tracking', icon:'cursor', desc:'What the zoomed view follows besides the mouse.', rows: [ - { key:'trackCaret', type:'toggle', label:'Follow the text cursor', desc:'While you type, the view glides to the text cursor. The mouse pointer stays where it was.', def:'1' }, - { key:'trackFocus', type:'toggle', label:'Follow keyboard focus', desc:'When you move with Tab or the arrow keys, the view glides to the selected control.', def:'0' }, - { key:'trackAlign', type:'select', label:'Keep the text cursor and focus', options:['0','1'], optionLabels:{ '0':'Centred', '1':'Within the edges' }, def:'0' }, - ]}, -``` - -Use the exact field names the other rows in the file use (`type`, `options`, `optionLabels`, `def`); copy the shape of an existing toggle and select row rather than trusting this snippet if they differ. - -- [ ] **Step 4: Run** `npx playwright test`. Expected: all pass. -- [ ] **Step 5: Commit** `feat(settings): Tracking section (#276)`. - -### Task 8: Field verification, docs, PR A - -- [ ] **Step 1:** Bump `src/version.h` to `0.10.3`. -- [ ] **Step 2:** Deploy the signed build (`tools/uiaccess_setup.ps1`, elevated, absolute path), set `trackLog=1`, zoom 3x. -- [ ] **Step 3: Field matrix** (owner at the PC; record each result and the logged source): Notepad (expect `win32`), Word, Chrome text box and Google Docs (expect `uia-caret`), VS Code editor, Prism and Windows Terminal (expect `uia-selection`), Settings with Tab and `trackFocus=1` (expect `uia-focus`), a right-click menu, the Start menu search box. For each: the view glides to the caret, moving the mouse returns the view to the pointer, typing with a hand resting on the mouse does not steal the view. -- [ ] **Step 4: No-regression checks:** zoom over DOOM (game: tracking inactive, unchanged), Inspect toggle, drag a window while zoomed, the Snipping Tool. -- [ ] **Step 5: Docs:** add a "Tracking modes" block to `CLAUDE.md` (owner model, weld veto, watcher thread, sources and `trackLog`), a section in `docs/architecture/07-cursor.md`, and record the field matrix results (which source per app, any gaps) in `docs/TRACKING-FINDINGS.md`. -- [ ] **Step 6:** `tools/release.ps1` elevated through pwsh 7 (installer checks), then open PR A and ask the owner "merge?". - -## Phase 2 (PR B): mouse edge mode - -### Task 9: Edge pan geometry (`edge_pan.h`) - -**Files:** -- Create: `src/edge_pan.h` -- Test: `tests/test_edge_pan.cpp` - -**Interfaces:** -- Produces: `void EdgePanCenter(double curCx, double curCy, double ptrX, double ptrY, double level, int monW, int monH, int marginPct, double maxSrcX, double maxSrcY, double& outCx, double& outCy);` Keeps the pointer inside the margin band of the view; applies the MPO walls (`maxSrcX/Y < 0` = none) to the source rect directly. - -- [ ] **Step 1: Write the failing tests** - -```cpp -#include "doctest.h" -#include "../src/edge_pan.h" -using namespace wind; - -TEST_CASE("edge pan: a pointer inside the band leaves the view alone") { - double cx, cy; // level 4, 3840x2160: view 960x540 at (1920,1080), band x 1584..2256 - EdgePanCenter(1920, 1080, 2000, 1100, 4, 3840, 2160, 15, -1, -1, cx, cy); - CHECK(cx == doctest::Approx(1920)); CHECK(cy == doctest::Approx(1080)); -} -TEST_CASE("edge pan: past the right band edge the view follows just enough") { - double cx, cy; - EdgePanCenter(1920, 1080, 2300, 1080, 4, 3840, 2160, 15, -1, -1, cx, cy); - CHECK(cx == doctest::Approx(1920 + 44)); CHECK(cy == doctest::Approx(1080)); -} -TEST_CASE("edge pan: the view never leaves the monitor") { - double cx, cy; - EdgePanCenter(480, 270, 0, 0, 4, 3840, 2160, 15, -1, -1, cx, cy); - CHECK(cx == doctest::Approx(480)); CHECK(cy == doctest::Approx(270)); -} -TEST_CASE("edge pan: the MPO wall bounds the source left edge directly") { - double cx, cy; // wall srcLeft <= 2000 -> centre <= 2480 - EdgePanCenter(2400, 1080, 3800, 1080, 4, 3840, 2160, 15, 2000, -1, cx, cy); - CHECK(cx == doctest::Approx(2480)); -} -``` - -- [ ] **Step 2: Run** `build.bat test`. Expected: compile error. -- [ ] **Step 3: Implement** - -```cpp -#pragma once -// Mouse edge mode (issue #276, phase 2): the view moves only when the pointer leaves the comfortable -// band (the view minus a margin on each side), and then just far enough. Pure; tests/test_edge_pan.cpp. -namespace wind { -inline void EdgePanCenter(double curCx, double curCy, double ptrX, double ptrY, double level, - int monW, int monH, int marginPct, double maxSrcX, double maxSrcY, - double& outCx, double& outCy) { - if (level < 1.0) level = 1.0; - const double vw = monW / level, vh = monH / level; - const double mx = vw * marginPct / 100.0, my = vh * marginPct / 100.0; - auto axis = [](double c, double p, double v, double m) { - const double lo = c - v / 2 + m, hi = c + v / 2 - m; - if (p < lo) return c - (lo - p); - if (p > hi) return c + (p - hi); - return c; - }; - double cx = axis(curCx, ptrX, vw, mx), cy = axis(curCy, ptrY, vh, my); - if (maxSrcX >= 0 && cx - vw / 2 > maxSrcX) cx = maxSrcX + vw / 2; - if (maxSrcY >= 0 && cy - vh / 2 > maxSrcY) cy = maxSrcY + vh / 2; - const double minX = vw / 2, maxX = monW - vw / 2, minY = vh / 2, maxY = monH - vh / 2; - outCx = cx < minX ? minX : (cx > maxX ? maxX : cx); - outCy = cy < minY ? minY : (cy > maxY ? maxY : cy); -} -} // namespace wind -``` - -- [ ] **Step 4: Run** `build.bat test`. Expected: PASS. -- [ ] **Step 5: Commit** `feat(tracking): edge pan geometry (#276)`. - -### Task 10: Edge mode in RunTick, Settings, PR B - -**Files:** -- Modify: `src/main.cpp` (the tracking block from Task 6, the free-cursor reset, `ex.suppressCursorSync`) -- Modify: `ui/src/settings-schema.js`, `ui/tests/settings.spec.js` - -- [ ] **Step 1: Edge mode for the mouse.** In the tracking block, when the owner is `Mouse`, `t.cfg.mouseAlign == 1`, and the session is free (`!inspect && !t.detector.locked()`): - -```cpp - if (owner == wind::ViewOwner::Mouse && t.cfg.mouseAlign == 1 && !inspect && !t.detector.locked()) { - if (!t.viewDetached) { t.viewCx = r.centerX; t.viewCy = r.centerY; } - const double ptrX = cur.x - t.mon.x, ptrY = cur.y - t.mon.y; - double ecx, ecy; - wind::EdgePanCenter(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h, - t.cfg.trackMarginPct, wallX, wallY, ecx, ecy); - t.viewCx = ecx; t.viewCy = ecy; // no glide: the view is pushed by the hand directly - r = wind::DetachedMap(t.viewCx, t.viewCy, ptrX, ptrY, lvl, t.mon.w, t.mon.h); - t.mapper.reset(t.viewCx, t.viewCy); - t.lastSetVirtual = cur; - t.viewDetached = true; - } -``` - -`wallX/wallY` are the values the tick already passes to `mapper.setMaxSourceLeft/Top` (reuse them; `-1` when no wall). In the `Returning` branch, when `mouseAlign == 1`, compute the return target with `EdgePanCenter` instead of centring on the pointer. - -- [ ] **Step 2: Settings row** in the Tracking section: - -```js - { key:'mouseAlign', type:'select', label:'Keep the mouse pointer', options:['0','1'], optionLabels:{ '0':'Centred', '1':'Within the edges' }, def:'0' }, -``` - -plus a Playwright test that the select defaults to "Centred" and writes `mouseAlign=1` on Apply. - -- [ ] **Step 3: Tests and build:** `build.bat test`, `build.bat`, `build.bat check`, `npx playwright test`. -- [ ] **Step 4: Field check with the owner:** edges mode on the desktop (pointer roams, view pans at 15% margin, clicks land where the pointer is), both engines (render via `model=hybrid` + `desktopTransform=0`, transform via the default), a mouselook game stays centred, caret tracking still returns to the pointer the edges way. -- [ ] **Step 5:** Bump to `0.10.4`, update CLAUDE.md / `docs/architecture/07-cursor.md`, `tools/release.ps1` elevated, open PR B, ask "merge?". diff --git a/docs/superpowers/plans/2026-09-29-colour-filters.md b/docs/superpowers/plans/2026-09-29-colour-filters.md deleted file mode 100644 index 585c1055..00000000 --- a/docs/superpowers/plans/2026-09-29-colour-filters.md +++ /dev/null @@ -1,92 +0,0 @@ -# Colour Filters Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Invert / greyscale / warm / two-colour tint filters and a dim control, at 1x and zoomed, with an optional toggle hotkey. - -**Architecture:** One DWM colour matrix (`MagSetFullscreenColorEffect`) applied through the Magnification runtime owner thread, built by a pure `color_matrix.h`, applied and lifetime-managed by `color_filter.*`, decided per tick in RunTick. - -**Tech Stack:** C++17 / MSVC, Magnification API, doctest; Svelte settings UI, Playwright. - -**Spec:** `docs/superpowers/specs/2026-09-29-colour-filters-design.md` - -## Global Constraints - -- No em-dashes anywhere (code, comments, docs, UI copy). -- Pure headers must not include ``. -- Every Magnification call runs on the owner thread via `wind::MagThreadInvoke`; the runtime is held only through `MagApiAcquire`/`MagApiRelease`. -- Nothing may cost anything when no filter is on and dim is 100% (no context, no per-tick syscalls). -- Identity must be restored on every exit path (disable, toggle, shutdown, crash filter, atexit). -- Branch `feat/288-colour-filters`, commits `type(scope): subject` with the attribution trailer. - -## Review Focus - -1. Wind killed (Task Manager) while a filter is on: the screen must not stay filtered (spike decides the mechanism). -2. Zoom in and out with a filter on: no flash of unfiltered or double-filtered frames at the transitions. -3. Render engine while zoomed: the magnified picture filtered exactly once. -4. HDR desktop: the filter still looks right (invert of HDR white). -5. Filter at 1x plus a game that toggles its cursor: the known context tax, only while a 1x filter is on. - ---- - -### Task 1: Spike (throwaway probe, not committed) - -**Files:** scratchpad only. - -- [ ] Probe exe: `MagInitialize`, `MagSetFullscreenColorEffect(invert)` at level 1; read a known white pixel through Desktop Duplication before and after. Records whether DDA sees the effect (decides 4.1 single vs render-shader path). -- [ ] Same probe, then `TerminateProcess` itself with the effect on: does the screen return to normal? (decides 4.4 crash handling). -- [ ] Repeat the pixel read with HDR on, if the display supports it. -- [ ] Record results in the spec section 4.1 / 4.4 and in `docs/COLOUR-FILTER-FINDINGS.md`. - -### Task 2: Pure matrices - -**Files:** Create `src/color_matrix.h`, `tests/test_color_matrix.cpp`. - -**Interfaces (produces):** `enum class ColorFilter { Off=0, Invert, Greyscale, Warm, YellowOnBlack, WhiteOnBlue, GreenOnBlack };` `struct ColorMatrix { float m[5][5]; };` `ColorMatrix BuildColorMatrix(ColorFilter f, double warm01, double dim01);` `bool IsIdentity(const ColorMatrix&);` `ColorMatrix Multiply(const ColorMatrix&, const ColorMatrix&);` `RGB ApplyToRgb(const ColorMatrix&, double r, double g, double b)` (test helper). - -- [ ] Write the tests: identity for (Off, any, 1.0); invert maps white to black and black to white; greyscale maps pure red to 0.2126 grey; warm strength 1 keeps red, reduces blue to 0.4; yellow-on-black maps white page to black background and black text to yellow; dim 0.5 halves; Invert + dim composes. -- [ ] Run `build.bat test`, see them fail; implement; see them pass. -- [ ] Commit `feat(color): pure colour matrices (#288)`. - -### Task 3: Config - -**Files:** `src/config.h`, `src/config.cpp`, `tests/test_config.cpp`. - -- [ ] Keys: `colorFilter` (0-6, default 0), `colorWarmPct` (10-100, default 50), `colorDimPct` (20-100, default 100), `colorAt1x` (default 1), `colorToggleVk`/`colorToggleMods` (default 0 = no hotkey). Parse, clamp, template lines. -- [ ] Tests: defaults, parse, clamps. -- [ ] Commit `feat(config): colour filter keys (#288)`. - -### Task 4: Controller - -**Files:** Create `src/color_filter.h`, `src/color_filter.cpp`. - -**Interfaces:** `class ColorFilterController { void apply(const ColorMatrix& want, bool needOwnHold); void shutdown(); }` plus a static `RestoreColorIdentityForCrash()`. - -- [ ] `apply`: if `want` equals the last applied matrix and the hold state matches, return (no syscalls). Else take or drop the own `MagApiAcquire` hold as `needOwnHold` says, then `MagThreadInvoke` a `MagSetFullscreenColorEffect`. Identity + no hold = fully released. -- [ ] `shutdown` and the crash restore write identity (while a runtime exists) and release. -- [ ] Log each change once (`color` tag): filter, dim, hold. -- [ ] Commit `feat(color): colour filter controller (#288)`. - -### Task 5: RunTick, hotkey, exits - -**Files:** `src/main.cpp`. - -- [ ] Per tick: `want = BuildColorMatrix(cfg, toggleOn)`; identity if the model is `magnify`, or if not zoomed and `colorAt1x` is 0. `needOwnHold = !IsIdentity(want) && !zoomed`. Call the controller. When the spike says the render engine filters twice, clear the effect while a render session is active and pass the matrix to the render model instead (Task 5b). -- [ ] Hotkey: register `colorToggleVk/Mods` with `RegisterHotKey` like the hide-cursor hotkey (hot-reload on change); WM_HOTKEY flips `colorToggleOn`. -- [ ] Exit paths: controller `shutdown()` in the normal shutdown, the crash filter and `atexit` next to `RestoreInputState`; a model switch re-applies. -- [ ] (5b, only if the spike requires it) render shader: add a 5x5 matrix to the constant buffer and apply it after the brightness stage. -- [ ] Commit `feat(color): colour filters in RunTick + toggle hotkey (#288)`. - -### Task 6: Settings UI - -**Files:** `ui/src/settings-schema.js`, `ui/tests/settings.spec.js`. - -- [ ] New section `{ id:'colour', label:'Colour' }`: select `colorFilter` (Off, Invert, Greyscale, Warm, Yellow on black, White on blue, Green on black), slider `colorWarmPct` (%), slider `colorDimPct` (%, "100% = normal"), toggle `colorAt1x`, keybind `__colorToggle` (vkKey `colorToggleVk`, modsKey `colorToggleMods`). -- [ ] Playwright: the section renders, choosing Invert writes `colorFilter=1`, dim slider writes `colorDimPct`. -- [ ] Commit `feat(ui): Colour settings section (#288)`. - -### Task 7: Verify, docs, ship - -- [ ] `build.bat test`, Playwright, deploy via `tools\uiaccess_setup.ps1`; owner field test per spec section 5. -- [ ] Docs: `docs/COLOUR-FILTER-FINDINGS.md` (spike results), a section in `docs/architecture/07-cursor.md` or a new chapter entry, README feature line, CLAUDE.md gotcha (effect lifetime / crash restore). -- [ ] Version bump in the PR; review workflow (sonnet reviewers + verifier), owner approves fixes, PR, owner says merge. diff --git a/docs/superpowers/plans/2026-09-29-tray-process.md b/docs/superpowers/plans/2026-09-29-tray-process.md deleted file mode 100644 index 8745d6f8..00000000 --- a/docs/superpowers/plans/2026-09-29-tray-process.md +++ /dev/null @@ -1,63 +0,0 @@ -# Tray Process Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** The tray icon and menu live in a non-UIAccess WindTray.exe so the menu stacks like any app's menu (below the cursor and the Snipping Tool overlay). - -**Architecture:** Wind.exe creates a named shared-memory block with its live status and starts WindTray.exe; the helper owns the icon and menu, reads the block, writes `menuOpen`, and acts through files, ShellExecute and the existing quit event. - -**Tech Stack:** C++17 / MSVC, Win32 (Shell_NotifyIcon, file mapping), doctest, NSIS. - -**Spec:** `docs/superpowers/specs/2026-09-29-tray-process-design.md` - -## Global Constraints - -- No em-dashes anywhere. Pure headers do not include ``. -- WindTray.exe must NOT have a uiAccess manifest; Wind must start it with ShellExecuteW (verified: children do not inherit UIAccess). -- The user-visible tray (icon, tooltip, menu, header, balloons) stays identical. -- Quit goes through `Local\Wind_QuitRequest`, never a kill. -- Branch `feat/291-tray-process`, commits `type(scope): subject` + trailer, patch version bump. - -## Review Focus - -1. Explorer restart while Wind runs: the icon comes back once, never twice. -2. Wind killed (Task Manager): the tray icon disappears (no ghost icon) and WindTray exits. -3. WindTray killed: Wind restarts it; a helper that crashes on start does not spin. -4. Two Wind instances (the eviction handshake on a model relaunch): exactly one tray icon at the end. -5. The menu open while Wind's tick reads `menuOpen`: the weld stays suspended exactly as today. - ---- - -### Task 1: Shared block and status transport - -**Files:** Create `src/tray_ipc.h` (pure layout + version check), `tests/test_tray_ipc.cpp`; modify `src/tray_status.h` (keep labels; add conversion to/from the block), `src/main.cpp` (create the mapping at startup, publish into it each tick instead of the atomics). -- [ ] Tests for the layout check (magic/version mismatch -> "no live data") and the status round trip. -- [ ] Wind creates `Local\Wind_TrayState_v1`, writes `windPid`, publishes level/engine/panning. -- [ ] Decide the diagnostics-export balloon path (shared notify seq/text vs WindConfig-side); implement the smaller. -- [ ] Commit `feat(tray): shared status block (#291)`. - -### Task 2: WindTray.exe - -**Files:** Create `src/tray_app/main.cpp`, `src/tray_app/WindTray.manifest` (asInvoker, PMv2); move `src/tray.cpp` owner-draw/menu/profile code into the helper (shared sources compiled into both where needed); `build.bat` target. -- [ ] Single instance mutex, icon add/remove, `TaskbarCreated` re-add, wait on Wind's process handle -> remove icon and exit. -- [ ] Menu thread as today, header reads the block; `menuOpen` written around TrackPopupMenu. -- [ ] Actions: Settings (WindConfig from own folder), Quit (quit event), Profiles (SwitchToProfile; engine change relaunches Wind.exe from own folder). -- [ ] Commit `feat(tray): WindTray.exe helper owns the tray icon and menu (#291)`. - -### Task 3: Wind.exe side - -**Files:** `src/main.cpp`, `src/tray.cpp/.h` (removed or reduced). -- [ ] Remove the tray icon/menu from Wind; start WindTray with ShellExecuteW + PID; restart it if it exits (<= 3 per minute); read `menuOpen` for `suppressCursorSync`. -- [ ] Commit `refactor(tray): Wind.exe no longer owns the tray (#291)`. - -### Task 4: Packaging - -**Files:** `tools/uiaccess_setup.ps1`, `installer/wind.nsi`, `tools/installer_check.ps1`, `.github/workflows/release.yml` / `alpha.yml` if they list files. -- [ ] Build, deploy, install, uninstall and upgrade include WindTray.exe; installer_check asserts it. -- [ ] Commit `build(tray): ship WindTray.exe (#291)`. - -### Task 5: Verify, docs, ship - -- [ ] Unit tests, build, deploy; owner field test per spec section 6 (owner runs every UI check). -- [ ] Docs: CLAUDE.md (three binaries; the UIAccess stacking reason), `docs/architecture/` process section, installer README. -- [ ] Review workflow, owner approves fixes, PR, owner says merge. diff --git a/docs/superpowers/plans/2026-09-30-cursor-tint.md b/docs/superpowers/plans/2026-09-30-cursor-tint.md deleted file mode 100644 index 90172949..00000000 --- a/docs/superpowers/plans/2026-09-30-cursor-tint.md +++ /dev/null @@ -1,14 +0,0 @@ -# Tinted pointer at 1x: implementation plan - -**Spec:** `docs/superpowers/specs/2026-09-30-cursor-tint-design.md`. Branch `feat/288-colour-filters`. - -1. `src/cursor_tint_pixels.h` (pure) + `tests/test_cursor_tint.cpp`: `TintArgb(pixels, n, matrix)` and - `MonoToArgb(andBits, xorBits, w, h, out)` with the outline rule. -2. `src/cursor_tint.{h,cpp}`: `CursorTint` with `capture()`, `apply(matrix)`, `restore()`, - `invalidate()`, `applied()`; builds tinted cursors with GetIconInfo/GetDIBits/CreateIconIndirect; - logs each apply/restore with its duration. -3. `src/main.cpp`: capture after `RestoreInputState`; idle-tick `UpdateCursorTint` (colour, idle, - magnify, fullscreen game every 250 ms); `restore()` at the start of `enterActive`; `invalidate()` on - active -> idle; `WM_SETTINGCHANGE`/`SPI_SETCURSORS` recaptures; `restore()` at shutdown. -4. Build, unit + UI tests, deploy, field-verify per the spec, document in - `docs/COLOUR-FILTER-FINDINGS.md`. diff --git a/docs/superpowers/plans/2026-09-30-event-driven-idle.md b/docs/superpowers/plans/2026-09-30-event-driven-idle.md deleted file mode 100644 index 27e27ce5..00000000 --- a/docs/superpowers/plans/2026-09-30-event-driven-idle.md +++ /dev/null @@ -1,102 +0,0 @@ -# Event-driven idle loop (issue #71): spec + plan - -## Goal -At 1x with nothing happening, Wind's main loop sleeps instead of ticking at the monitor refresh -(144 Hz here, ~6% of one core). It wakes at once on anything that can start a zoom, and on a slow -housekeeping timeout for the checks that have no event. Zoom-in latency must not get worse. -Owner request 2026-09-30 ("should the loop always run ... what's the benefit"). The zoom-in/out -frame spike is a separate issue and out of scope. - -## Findings that shape the design (inventory 2026-09-30) -- The idle wait is `SetWaitableTimer` + `WaitForSingleObject(timer)` at `pacedHz`, re-armed every - iteration (src/main.cpp main loop, the `!renderPresentPaces && !dwmPaces && !pacedByPulse` branch). -- Zoom triggers reach the main thread only as atomics set on the hook thread (MouseProc/KbProc) or - as WM_HOTKEY (quick zoom, hide cursor). Nothing wakes a sleeping loop today. -- WM_INPUT (Raw Input, RIDEV_INPUTSINK) arrives for EVERY mouse move system-wide, so the wait must - NOT include QS_RAWINPUT (QS_ALLINPUT/QS_INPUT would wake at the mouse polling rate). -- Housekeeping that rides the tick with its own wall-clock gates: config watch (250 ms), cover - probe / launch quiesce (250 ms), noSwallowApps probe (100 ms), cursor-tint fullscreen probe - (250 ms), HDR re-read (1 s), keyboard-hook eviction watchdog (250 ms dwell), transform idle - context release (deadline ~1.2 s). All tolerate ~100 ms granularity. -- The config gate accumulates tick `dt`; the zoom controller clamps `dt` to 50 ms. After a sleep the - first tick's `dt` is the whole sleep: fine for the config gate, but it would make the first zoom - step up to 50 ms of ramp (a visible jump) and log idle gaps as hitches in the tray pacing readout. -- The caret/focus tracker is switched off only from the zoomed view block; a zoom-out that snaps - straight to 1.0 can leave it ACTIVE at 1x, where its 16 ms backstop timer resolves the caret via - UIA ~60 times a second. Its thread also keeps the 16 ms timer running while inactive. - -## Design -1. **Idle mode** (`TickState::idleSleepOk`, computed at the end of RunTick): true only when not - active and not Inspect, no zoom direction or pan key held, no wheel steps pending, no quick-zoom - request pending, `restAfterReveal == nullptr`, `revealPending == 0`, the last active tick was - >= 500 ms ago (teardown, rests and the tint re-apply run at full rate first), the mouse hook is - installed, and EITHER the keyboard hook is active OR no keyboard bind is configured (with the hook - gone, keyboard binds are polled with GetAsyncKeyState and must keep the refresh-rate tick). - The magnify model counts as idle between presses (its holds keep the loop awake). -2. **Wake event**: an auto-reset `g_idleWake` event owned by InputRouter (`wakeEvent()` getter). - The hooks `SetEvent` it only on edges that matter, never on plain moves or unbound keys: - bound-key down/up (inside the existing bound-key block of KbProc), a matched button/click bind - down and its up (MouseProc, XBUTTON path via `setButtonState`), and a wheel step added. SetEvent - runs after the atomics are published. -3. **Idle wait**: when `idleSleepOk`, the loop replaces the timer wait with - `MsgWaitForMultipleObjectsEx(1, &wake, 100, QS_POSTMESSAGE | QS_SENDMESSAGE | QS_HOTKEY | QS_TIMER, 0)`. - 100 ms keeps every housekeeping gate within its budget. The quit event and config watch stay - polled by RunTick (not in the wait set: the quit event is auto-reset and the config handle is - re-armed only inside RunTick's gate, so either would be consumed or busy-spin). Messages keep - being drained at the top of every iteration as today (WM_INPUT included, so the raw-UP safety - net still runs, just on the next wake). -4. **First tick after a sleep** (`TickState::wokeFromIdle`): RunTick keeps the full `dt` for the - housekeeping gates but clamps the MOTION `dt` to one refresh interval, and skips the tray - `ticks.push` and the diagnostics hitch count for that tick. -5. **Tracker**: `g_track.setActive(false, ...)` in the active->idle teardown branch; the tracker - thread runs its backstop timer at 16 ms while active and 250 ms while inactive (the shell-panel - re-check keeps working, only slower, and matters only when zoomed). - -## Out of scope -The zoom-in/out frame spike; the render/transform paced paths; the device-lost branch; txPace=2. - -## Plan -- [ ] T1 `InputRouter`: `g_idleWake` (CreateEventW auto-reset in the constructor or start(), closed - in stop()), `HANDLE wakeEvent() const`, a static `WakeMain()` helper; SetEvent at the edges in - item 2. No logging, no allocation in the hooks. -- [ ] T2 `TickState`: `bool idleSleepOk = false, wokeFromIdle = false; unsigned long long lastActiveMs`. - End of RunTick: compute `idleSleepOk` per item 1 (reuse `inHeld/outHeld` and the pan held state - already computed in the tick; `lastActiveMs` stamped whenever `active`). -- [ ] T3 main loop: when `ts.idleSleepOk` and the timer branch would run, do the MsgWait instead and - set `ts.wokeFromIdle = true`; else unchanged. -- [ ] T4 RunTick prelude: when `wokeFromIdle`, skip `ticks.push` and clamp the motion dt: introduce - `rawDt` for `sinceCheck` and diagnostics, `dt = min(rawDt, 1.0 / max(hz, 30))` for everything - else; clear the flag. -- [ ] T5 tracker: setActive(false) at the active->idle edge; tracker timer 16 ms active / 250 ms idle. -- [ ] T6 verify: unit tests (the pure idle predicate in a new `src/idle_policy.h` with doctest - cases for every condition in item 1); build; deploy; measure Wind CPU at 1x for 10 s before/after - (target: well under 1% of one core); live SendInput check that side-button, keyboard, wheel and - click zooms still start, quick-zoom hotkey still toggles, and a config edit still hot-reloads - within ~0.5 s; zoom-in latency sanity (first level change after the press within one frame). -- [ ] T7 docs (CLAUDE.md one line, docs/architecture/02-tick-loop.md section), version 0.15.3, - code review workflow, PR, deploy the branch build, recommend, ask the owner "merge?" (no standing approval). - -## Review Focus -1. A zoom key pressed while the loop sleeps: wakes within ~1 ms, and the first zoom step is one - frame's worth, not a 50 ms jump. -2. Keyboard hook suspended (noSwallowApps app in front) or evicted with keyboard binds set: the loop - must not sleep, or keyboard zoom would lag up to 100 ms. -3. A busy-spin: any wait object or message left signalled makes the wait return immediately forever - (check the auto-reset event and that WM_INPUT is excluded from the wake mask). -4. Magnify model: holding the zoom key must keep ticking every frame (nativeZoomTick notches). -5. The tray menu path that calls RunTick from WM_TIMER must still work while the loop sleeps. - -## Amendments after the plan review (2026-09-30) -- Messages are drained right after the wait returns (a hotkey was otherwise seen 100 ms late). -- The wake is raised in `PublishButtonHeld` (covers side buttons AND the left/right/middle click - binds, press and release), on the first down / up of a bound key (no auto-repeat wakes) and per - wheel step. -- The predicate is evaluated live in `IdleNow` right before the wait (the magnify model returned - before the end of RunTick, and a cached flag could be stale). -- The wake event is created before the WIND_NOHOOK return and closed after the hook thread joins; any - wait result other than the event, the quit event, a message or the timeout falls back to the timer. -- The quit event is in the wait set (instant quit). QS_TIMER dropped (Wind has no thread timers). -- The focus tracker installs its LOCATIONCHANGE hook and 16 ms poll only while active (250 ms panel - re-check otherwise), switched by a dedicated thread message. -- Code review fixes: the wake tick skips the tray pacing ring and diagnostics, raw motion from before - an activation is zeroed, and a bound key the OS reports held keeps the loop awake (evicted hook). diff --git a/docs/superpowers/plans/2026-09-30-keyboard-panning.md b/docs/superpowers/plans/2026-09-30-keyboard-panning.md deleted file mode 100644 index 659f7d7c..00000000 --- a/docs/superpowers/plans/2026-09-30-keyboard-panning.md +++ /dev/null @@ -1,128 +0,0 @@ -# Keyboard Panning Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (native) to implement -> this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** While zoomed, Ctrl+Alt+arrows (rebindable) nudge on tap and pan on hold, like Windows Magnifier. - -**Architecture:** A pure `KeyPan` motion model feeds a new `ViewOwner::Keys` on the existing -detached-view path (#276). The keyboard hook swallows pan keys only while the tick says "armed" -(zoomed), so at 1x they reach the app. - -**Tech Stack:** C++17 (MSVC), doctest, Svelte + Playwright. - -**Spec:** `docs/superpowers/specs/2026-09-30-keyboard-panning-design.md` - -## Global Constraints -- No em-dashes anywhere (code, comments, docs, UI copy). -- Pure files (`keyboard_pan.h`, `view_target.h`, `config.cpp` parse half) never include ``. -- Defaults: pan keys 37/39/38/40 with mods 3 (Ctrl+Alt); `panSpeed` 1.0, range 0.25-4. -- Nudge = 1/8 screen; hold threshold 250 ms; continuous = `panSpeed x 0.5` screens/s; ease-in ~150 ms; glide-out ~120 ms. -- Swallow decision once per press (existing `firstDown` rule); pan slots match only while armed. -- Version 0.15.0. Live checks on the owner's PC only while it is idle (GetLastInputInfo > 60 s). - -## Review Focus -1. A pan key pressed at 1x, then the user zooms while still holding it: must not be swallowed mid-press (the app saw the DOWN), must not strand the key. -2. Zoom out while a pan key is held: the swallowed UP stays balanced; panning stops; the view returns to the mouse path at 1x. -3. Panning at high zoom toward the bottom-right with nearest sampling and MPO on: the centre must respect the MPO wall (never produce |tx| past the #148 limit). -4. IntelliJ at 1x: Ctrl+Alt+Left/Right must reach it (armed flag false). -5. Mouse move after a pan: pointer placed in the view, no jump of the view; a click after a pan (button, no move) gives the view back without warping. - ---- - -### Task 1: Pure motion model `KeyPan` -**Files:** Create `src/keyboard_pan.h`, `tests/test_keyboard_pan.cpp`. -**Produces:** `struct KeyPan { void step(const bool held[4], double dtMs, double level, int monW, int monH, double speed, double& dx, double& dy); bool active() const; void reset(); }`; index order Left, Right, Up, Down. - -- [ ] Write tests: tap (held one tick of 7 ms then released, stepped 1 s) moves exactly `monW/8/level` left (+-1%) at level 2 and 8; hold 2 s at speed 1 moves ~`nudge + (2-0.25) x 0.5 x monW / level` minus ease terms (assert within 10%); after release `active()` becomes false within 0.6 s; Left+Up moves both axes; Left+Right held gives 0 continuous motion (the two nudges cancel); speed 2 is ~2x the continuous distance of speed 1. -- [ ] Run `build.bat test`, expect FAIL (missing header). -- [ ] Implement: -```cpp -#pragma once -// Keyboard panning (issue #287): tap = one nudge (1/8 screen), hold > 250 ms = continuous pan with -// ease-in, release glides out. Screen-space rates, returned as desktop-pixel deltas. Pure. -#include -namespace wind { -struct KeyPan { - static constexpr double kNudgeFrac = 0.125, kHoldMs = 250.0, kScreensPerSec = 0.5; - static constexpr double kEaseInMs = 150.0, kGlideMs = 120.0; - double heldMs[4] = {0, 0, 0, 0}; - double nudgeX = 0, nudgeY = 0; // screen px still to travel from nudges - double vx = 0, vy = 0; // continuous velocity, screen px/s - void reset() { *this = KeyPan{}; } - bool active() const { - return heldMs[0] > 0 || heldMs[1] > 0 || heldMs[2] > 0 || heldMs[3] > 0 || - std::fabs(nudgeX) > 0.5 || std::fabs(nudgeY) > 0.5 || std::fabs(vx) > 1 || std::fabs(vy) > 1; - } - void step(const bool held[4], double dtMs, double level, int monW, int monH, double speed, - double& dx, double& dy) { - if (level < 1.0) level = 1.0; - const double sgn[4] = { -1, 1, -1, 1 }; - double tvx = 0, tvy = 0; - for (int i = 0; i < 4; ++i) { - const bool horiz = i < 2; - if (held[i]) { - if (heldMs[i] == 0) (horiz ? nudgeX : nudgeY) += sgn[i] * kNudgeFrac * (horiz ? monW : monH); - heldMs[i] += dtMs > 0 ? dtMs : 0.001; - if (heldMs[i] >= kHoldMs) (horiz ? tvx : tvy) += sgn[i] * speed * kScreensPerSec * (horiz ? monW : monH); - } else heldMs[i] = 0; - } - const double tau = (std::fabs(tvx) + std::fabs(tvy) > 0) ? kEaseInMs / 3 : kGlideMs / 3; - const double a = 1.0 - std::exp(-dtMs / tau); - vx += (tvx - vx) * a; vy += (tvy - vy) * a; - const double g = 1.0 - std::exp(-dtMs / (kGlideMs / 3)); - const double nx = nudgeX * g, ny = nudgeY * g; - nudgeX -= nx; nudgeY -= ny; - dx = (nx + vx * dtMs / 1000.0) / level; - dy = (ny + vy * dtMs / 1000.0) / level; - if (std::fabs(vx) < 1 && tvx == 0) vx = 0; - if (std::fabs(vy) < 1 && tvy == 0) vy = 0; - } -}; -} // namespace wind -``` -- [ ] Run tests, expect PASS (tune constants only if a test shows the feel is off; keep the spec's numbers). -- [ ] Commit `feat(pan): pure keyboard-pan motion model (#287)`. - -### Task 2: Config keys and rules -**Files:** Modify `src/config.h`, `src/config.cpp` (parse, clamp `panSpeed`, sanitise the four binds with `CheckKeyBind`, ini template block), `tests/test_config.cpp`, `tests/fixtures/keybind_cases.txt`. -**Produces:** `Config::panLeftVk, panLeftMods, panRightVk, panRightMods, panUpVk, panUpMods, panDownVk, panDownMods` (int), `Config::panSpeed` (double). - -- [ ] Tests: `ParseConfig("")` gives 37/3, 39/3, 38/3, 40/3 and 1.0; `panSpeed=9` clamps to 4; `panLeftVk=65\npanLeftMods=0` (bare A) reads as 0; fixture lines `key 25 3 ok`, `key 26 3 ok`, `key 27 3 ok`, `key 28 3 ok`. -- [ ] Implement, run `build.bat test`, commit `feat(config): pan keys and pan speed (#287)`. - -### Task 3: `ViewOwner::Keys` -**Files:** Modify `src/view_target.h`, `tests/test_view_target.cpp`. -**Produces:** `enum class ViewOwner { Mouse, Caret, Focus, Keys }`; `ViewOwnerInputs::panning` (bool). - -- [ ] Tests: `panning=true` with no mouse motion makes owner Keys; mouse movement of 3 px then sets owner Mouse with `warpPointer`; a button press returns Mouse without `warpPointer`; `enabled=false` resets to Mouse; with panning and a gated caret event in the same tick, Keys wins (explicit request beats the caret). -- [ ] Implement: after the mouse block, `if (in.panning) { s.owner = ViewOwner::Keys; s.lastSeq = in.snap.seq; return s.owner; }`. Update the `kName` log table in main.cpp to include "keys". -- [ ] Run tests, commit `feat(view): Keys owner for keyboard panning (#287)`. - -### Task 4: Hook arming -**Files:** Modify `src/input_router.h`, `src/input_router.cpp`. -**Produces:** `void setPanKeys(const int vk[4], const int mods[4]); void setPanArmed(bool);` pan slots in `isBoundKey` and, while armed, in `keyBindMatches`. - -- [ ] Implement with atomics like the zoom slots; `setPanKeys` clears pressed/swallowed records only for VKs that changed (same as `setKeys`). -- [ ] Build (`build.bat`), commit `feat(input): pan keys swallowed only while zoomed (#287)`. - -### Task 5: Tick wiring -**Files:** Modify `src/main.cpp`. -- [ ] At start and on hot-reload: `g_input.setPanKeys(...)`. -- [ ] Each tick: `g_input.setPanArmed(lvl > 1.001 && !t.model->selfDrivenZoom() && !inspect && !t.detector.locked())` (published before the view block). -- [ ] Read `held[4]` via `comboHeld(t.cfg.panLeftVk, t.cfg.panLeftMods)` etc. only while armed; step `t.keyPan` with the tick dt (clamped to 50 ms); `vi.panning = armed && t.keyPan.active()`. -- [ ] Owner logic enabled = `trackEnabled || panEnabled` where `panEnabled = lvl > 1.001 && !panel && !inspect && !t.detector.locked()`; keep `g_track.setActive(trackEnabled, ...)` unchanged. -- [ ] Owner Keys branch: seed `viewCx/Cy` from `r` when coming from Mouse; add the KeyPan delta; clamp the centre to `[w/(2 lvl), w - w/(2 lvl)]` (same for y) and, when `wallNeeded`, to the MPO wall as mouse edge mode does; `r = DetachedMap(...)`, `t.mapper.reset`, `t.lastSetVirtual = cur`, `t.viewDetached = true`. -- [ ] `t.keyPan.reset()` on zoom-out to idle and when disarmed. -- [ ] Build, deploy (`tools\uiaccess_setup.ps1`, elevated), commit `feat(pan): keyboard panning in the tick loop (#287)`. - -### Task 6: Settings UI -**Files:** Modify `ui/src/settings-schema.js` (four keybind rows after Zoom out: `{ key:'__panLeft', type:'keybind', label:'Pan left', vkKey:'panLeftVk', modsKey:'panLeftMods' }` etc., section desc mentions "while zoomed"; `panSpeed` slider after Zoom-out speed, 0.25-4 step 0.05 def 1.0 unit 'times', desc "How fast holding a pan key moves the view."), `ui/src/Settings.svelte` (`kbDefaults` pan entries 37/3 ...), `ui/src/lib/keybindRules.js` (`KEY_SLOTS` pan rows), `ui/tests/settings.spec.js`. -- [ ] Tests: rows read `Ctrl+Alt+Left/Right/Up/Down` by default; capture Ctrl+Alt+PageUp on Pan up writes `panUpVk=33`, `panUpMods=3`; a bare letter is refused; slider writes `panSpeed`. -- [ ] `npx playwright test`, commit `feat(ui): pan keybinds and pan speed (#287)`. - -### Task 7: Verify and ship -- [ ] `build.bat test` and `npx playwright test` green; `build.bat` + `build.bat config` clean. -- [ ] Deploy the signed build. When the PC has been idle 60 s: scratchpad script (SendInput, `trackLog=1` temporarily) checks: at 1x Ctrl+Alt+Left reaches a test window; zoomed to ~4x it does not, and the log shows `view mouse -> keys`; a tap moves the view centre by ~screen/8/level; a 1 s hold moves further; a mouse move logs the pointer placed in the view. Restore the ini afterwards. -- [ ] Docs: README controls line, CLAUDE.md one line under INPUT SWALLOWING, `docs/architecture/06-input.md` and `07-cursor.md` short sections. Version 0.15.0. -- [ ] Push, open PR closing #287, ask the owner "merge?". diff --git a/docs/superpowers/plans/2026-09-30-wheel-zoom-and-safe-keybinds.md b/docs/superpowers/plans/2026-09-30-wheel-zoom-and-safe-keybinds.md deleted file mode 100644 index 5947020a..00000000 --- a/docs/superpowers/plans/2026-09-30-wheel-zoom-and-safe-keybinds.md +++ /dev/null @@ -1,29 +0,0 @@ -# Scroll-wheel zoom and safe keybinds: implementation plan - -**Spec:** `docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md`. -Branch `feat/285-wheel-zoom` (worktree `Wind-wheel`). - -## Global constraints -- No em-dashes. Pure headers do not include ``. -- The hook stays cheap: no allocation or blocking work in the mouse/keyboard hook callbacks. -- Swallows stay balanced (only swallow an up whose down was swallowed); wheel notches have no up. - -## Tasks -1. **Rules** - `src/keybind_rules.h` (`CheckKeyBind`, `CheckWheelBind`, `CheckClickBind`, reason codes) and - `tests/fixtures/keybind_cases.txt` + `tests/test_keybind_rules.cpp`. `ParseConfig` sanitises every - bind with them (replaces the bare `IsForbiddenBindVk` sanitising; the hook keeps its own - never-swallow check). Commit `feat(keybinds): one safety rule set for every bind (#285)`. -2. **Mask keystroke** - input router: when a swallowed key-down belongs to a combo with Alt or Win, - inject VK 0xE8 down/up once. Test the decision as a pure function. Commit. -3. **Clicks** - config `...ButtonMods` per zoom slot, button values 3-5; the mouse hook swallows a - matching click down/up as a balanced pair and reports it held; RunTick's inHeld/outHeld include it. - Pure matcher tested (mods subset, balanced up). Commit. -4. **Wheel** - config `zoomWheelMods`, `zoomWheelStepPct`; `WheelAccum` (pure: 120-unit accumulation) - and `ZoomController::stepTarget(n, step)` + target glide in `tick` (pure, tested); mouse hook swallows - matching notches and queues whole steps to the tick; RunTick feeds them to the controller. Commit. -5. **UI** - `ui/src/lib/keybindRules.js` (+ tests against the shared case list), `KeybindCapture` - refuses with a reason (visible + live region), a wheel-capture row and a step slider in the Keybinds - section. Playwright tests per spec section 7. Commit. -6. **Verify + ship** - unit + UI tests, build, deploy, owner's-PC checks per spec section 7 (SendInput), - docs (CLAUDE.md input-swallowing gotcha: the new rule set and the mask keystroke; README feature - line), version 0.12.x minor bump, review workflow, PR, owner says merge. diff --git a/docs/superpowers/plans/2026-10-01-settings-redesign.md b/docs/superpowers/plans/2026-10-01-settings-redesign.md deleted file mode 100644 index dd3f72e9..00000000 --- a/docs/superpowers/plans/2026-10-01-settings-redesign.md +++ /dev/null @@ -1,170 +0,0 @@ -# Settings redesign Implementation Plan - -> **For agentic workers:** execute task by task in order (later tasks consume earlier interfaces). -> Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rebuild WindConfig's Settings UI to match `docs/design/settings-2026-10/FINAL-reference.png` -exactly, and switch settings to instant-apply with an explicit Save (session layer), per the spec. - -**Architecture:** The live `magnifier.ini` becomes the session; the active profile file becomes the -saved state. Pure diff/merge helpers in `src/profiles.*`; the core resets the session at start; the -host gains save/discard/persist messages; the Svelte app (upgraded to Svelte 5) is rebuilt as a shell -(title bar, sidebar, banner, cards, capsule) over a regrouped schema. - -**Tech Stack:** C++17 / MSVC, doctest, WebView2, Svelte 5 + Vite, Playwright. - -**Spec:** `docs/superpowers/specs/2026-10-01-settings-redesign-design.md` - -## Global Constraints -- No em-dash characters anywhere (code, comments, docs, UI copy, commits). -- Every ini key keeps its name and meaning; no setting is added or removed (except the UI-only - "Show advanced settings" row, whose key stays parsed and ignored). -- Pure-logic files must not include ``. -- Exact visual tokens come from `docs/design/settings-2026-10/FINAL-v10-grey.html`; do not invent values. -- Branch `feat/303-settings-redesign` in the worktree `Wind-settings`; one PR; version bump to 0.16.0 inside it. -- Gates before the PR: `build.bat test` (doctest exit 0), `build.bat config`, `cd ui && npx playwright test`. - -## Review Focus -1. A restart Wind triggers itself (engine change, profile switch with model change) must keep the session; a plain restart must reset it. -2. Keybind captures persist immediately even while other changes are unsaved, and Save afterwards does not lose them. -3. Tray Quit with unsaved changes while Settings is closed still prompts (the tray decides from the files, not from the UI). -4. Light theme: the aurora image, capsule, cards and sidebar highlight all read correctly on white. -5. Search reaches Advanced rows and keyboard-only use (Tab, Esc, Ctrl+F) works with visible focus. - ---- - -### Task 1: Session helpers (pure) - -**Files:** Modify `src/profiles.h`, `src/profiles.cpp`; Test `tests/test_profiles.cpp` (or the existing profiles test file). - -**Interfaces (produces):** -- `bool SessionDiffers(const std::string& liveText, const std::string& profileText);` - true when any profile-scoped key differs (`IsGlobalProfileKey` keys ignored; a key missing on one - side compares as missing, not as a default; values compared trimmed). -- `std::string UpdateProfileKey(const std::string& profileText, const std::string& key, const std::string& value);` - thin wrapper over `UpdateIniText`, refusing global keys (returns input unchanged). - -- [ ] Write failing doctest cases: identical texts -> false; one changed key -> true; only `uiTheme` - differs -> false; key present only in live -> true; CRLF vs LF and trailing spaces -> false; - `UpdateProfileKey(..., "uiTheme", ...)` leaves text unchanged. -- [ ] `build.bat test` -> the new cases fail. -- [ ] Implement with `ReadIniValues` + `IsGlobalProfileKey`. -- [ ] `build.bat test` -> pass. Commit `feat(profiles): session diff helpers (#303)`. - -### Task 2: Core resets the session at start - -**Files:** Modify `src/main.cpp` (startup, before the first `ParseConfig`), `src/profiles_io.h` (I/O helper). - -**Interfaces:** `void ResetSessionToProfile(const std::wstring& iniPath);` in `profiles_io.h`: -if `%LOCALAPPDATA%\Wind\session.keep` exists, delete it and return; else, when the active profile file -exists, write `MakeLiveText(profileText, liveText)` to the ini atomically (only if `SessionDiffers`). -`std::wstring SessionKeepPath();` shared with the host. - -- [ ] Call it right after `EnsureProfilesSeeded(iniPath)` in `src/main.cpp`. -- [ ] Log one Info line when a session was reset or kept. -- [ ] Build `build.bat` and `build.bat test`. Commit `feat(core): unsaved settings reset when Wind starts (#303)`. - -### Task 3: Host bridge for the session model - -**Files:** Modify `src/config_ui/main.cpp`; `ui/src/bridge.js`. - -**Interfaces (host messages):** -- `getConfig` reply adds `"saved":{...}` (the active profile's values merged over live globals) and `"profiles":{names,active}`. -- `setConfig {key,value}`: writes the live ini only (the live-bound mirror is removed). -- `setConfigPersist {key,value}`: writes live AND the active profile (`UpdateProfileKey`); used by keybinds. -- `saveSession`: writes `MakeProfileText(live)` to the active profile; replies `{type:'sessionSaved',ok}`. -- `discardSession`: writes `MakeLiveText(profile, live)` to the ini; replies `{type:'config',...}` (fresh). -- `window` `restartWind` (and model-changing profile switches) write `SessionKeepPath()` first. -- `window` `quitWind`: unchanged message; the UI prompts first. -- Initial config injection: before `Navigate`, `AddScriptToExecuteOnDocumentCreated` sets - `window.__windInit = {values, saved, profiles, theme}`; `getConfig()` in bridge.js resolves from it on first call. -- Window background set from `uiTheme` (and system theme for auto) via `ICoreWebView2Controller2::put_DefaultBackgroundColor` and the class brush, so no white flash. -- Remove the `draft` / `restoreDraft` path (settings are live now); keep engine recreate. - -- [ ] Implement; update `bridge.js` (`saveSession()`, `discardSession()`, `setConfigPersist(k,v)`, init fast path). -- [ ] `build.bat config` builds. Commit `feat(config): session save/discard bridge (#303)`. - -### Task 4: Tray follows the app theme and guards Quit - -**Files:** Modify `src/tray_app/tray_draw.h` (`MakePalette(bool dark)`), `src/tray_app/tray_menu.cpp`. - -- [ ] Resolve dark/light from the ini's `uiTheme` on each open (`auto` -> `SystemUsesLightTheme()`). -- [ ] Before `RequestWindQuit()`: read live + active profile, `SessionDiffers` -> TaskDialog "You have - unsaved settings" with Save / Discard / Cancel. Save writes `MakeProfileText(live)` to the profile. -- [ ] Build `build.bat tray`. Commit `feat(tray): theme from Wind, unsaved prompt on Quit (#303)`. - -### Task 5: Svelte 5 upgrade - -**Files:** `ui/package.json`, `ui/vite.config.*`, existing components only as needed to compile. - -- [ ] Bump `svelte` to ^5, `@sveltejs/vite-plugin-svelte` to the Svelte 5 major, `vite` as required. -- [ ] `npm install`, `npm run build`, `npx playwright test` green on the OLD UI before any redesign - (fix only what the upgrade breaks). Commit `chore(ui): Svelte 5 (#303)`. - -### Task 6: Design tokens, shell and icons - -**Files:** Create `ui/src/design/tokens.css`, `ui/src/design/icons.js` (terminal icon set from the -mockup: zoom, move, cursor, typing, colour, general, adv, about, search, auto/light/dark), -`ui/src/shell/TitleBar.svelte`, `ui/src/shell/Sidebar.svelte`, `ui/src/shell/Banner.svelte`, -`ui/src/shell/Card.svelte`, `ui/src/shell/SaveCapsule.svelte`; copy `c-grey.jpg` to `ui/public/`. - -- [ ] Port every token and measurement from `FINAL-v10-grey.html` (dark + light), including the - v10 overrides (fainter lines, soft grey fill highlight, 8px items, 10px cards). -- [ ] Each shell component renders in isolation with props only (no bridge calls). -- [ ] Commit `feat(ui): design tokens and shell components (#303)`. - -### Task 7: Regrouped schema and pages - -**Files:** Rewrite `ui/src/settings-schema.js` to the spec's group table (ids: zoom, move, cursor, -typing, colour, general, advanced, about; each group: label, icon, desc, cards[{caption, rows}]); -restyle `ui/src/lib/Row.svelte` controls into `ui/src/controls/` (Keycaps, Slider with end-cap, -Toggle, Select, AppList, HighRes, EngineRow with inline "Restart Wind"). - -- [ ] Every row of the old schema appears exactly once (a test enumerates old keys vs new). -- [ ] Commit `feat(ui): task-based groups and restyled controls (#303)`. - -### Task 8: Session flow in the UI - -**Files:** Rewrite `ui/src/Settings.svelte` (state, prompts), `ui/src/lib/ProfileMenu.svelte` -> -`ui/src/general/Profiles.svelte` (General page). - -- [ ] Changes call `setConfig` at once; keybinds call `setConfigPersist`; dirty = values vs saved. -- [ ] Capsule Save -> `saveSession()`; Discard -> `discardSession()` then reload values. -- [ ] Close prompt (Save / Discard / Keep for this session); Quit Wind prompt (Save / Discard / Cancel); - profile switch prompt (Save / Discard / Cancel); MPO and engine confirm flows preserved. -- [ ] Commit `feat(ui): instant apply with Save, session prompts (#303)`. - -### Task 9: Search - -**Files:** Create `ui/src/search/search.js` (pure: query -> matching rows across groups by label, -description, card caption and group label; case-insensitive, word-prefix) and `ui/src/search/Results.svelte`. - -- [ ] Ctrl+F focuses search; typing shows results grouped by group; Enter jumps to the first; - Esc clears and returns. Commit `feat(ui): settings search (#303)`. - -### Task 10: Onboarding tokens -- [ ] Onboarding uses the new tokens/buttons; steps unchanged. Commit `style(ui): onboarding on new tokens (#303)`. - -### Task 11: Start-up measurement -- [ ] Host logs launch-to-first-paint (NavigationCompleted + first `ready` message from the page); - record the old build's number and the new one in the PR. Lazy-load onboarding and the banner image. - Commit `perf(config): measured faster first paint (#303)`. - -### Task 12: Tests -- [ ] Update `ui/tests/settings.spec.js`, `a11y.spec.js`, `onboarding.spec.js` to the new UI and add the - spec's Playwright cases (groups, search, instant apply, capsule, Save/Discard, three prompts, - keybind persist, theme cycle, light theme, focus order). -- [ ] Commit `test(ui): redesign coverage (#303)`. - -### Task 13: Match the reference -- [ ] Playwright screenshot of the built UI (dark, Zoom page, two unsaved changes) at 1120x760 next to - `FINAL-reference.png`; a reviewer lists every visible difference; fix until none remain or each is - justified (for example real data differs). Light theme against `FINAL-v10-grey-light.png`. -- [ ] Commit `style(ui): pixel match to the reference (#303)`. - -### Task 14: Docs, version, PR, deploy -- [ ] Update `docs/architecture/` (settings UI + session model), the CLAUDE.md lines about the - staged Apply/Discard footer and live-bound profiles, bump `src/version.h` to 0.16.0. -- [ ] Gates green; push; open the PR (issue #303) with before/after start-up numbers and screenshots. -- [ ] Deploy to `C:\Program Files\Wind` via `tools\uiaccess_setup.ps1`, run the spec's manual checks, - then ask Max "merge?". diff --git a/docs/superpowers/plans/2026-10-01-tray-flyout.md b/docs/superpowers/plans/2026-10-01-tray-flyout.md deleted file mode 100644 index 63cf2479..00000000 --- a/docs/superpowers/plans/2026-10-01-tray-flyout.md +++ /dev/null @@ -1,70 +0,0 @@ -# Tray flyout Implementation Plan - -> **For agentic workers:** execute task by task in order. Steps use checkbox (`- [ ]`) syntax. - -**Goal:** Replace WindTray's owner-drawn menu with the j01 flyout (quick controls) and add the -Settings "Tray menu" tab that chooses and orders them. - -**Architecture:** Pure item-list logic in `src/tray_items.*` (parse/serialise/defaults, shared by -WindTray and the config host). WindTray gains a Direct2D flyout window (`src/tray_app/flyout_*`) that -reads `TrayShared` + the ini and writes the live ini. The core gains a tray command event for Hide -cursor. The Settings UI gains a group with two drag-reorder lists. - -**Tech Stack:** C++17 / MSVC, Direct2D, DirectWrite, WIC, doctest, Svelte 5, Playwright. - -**Spec:** `docs/superpowers/specs/2026-10-01-tray-flyout-design.md` - -## Global Constraints -- Branch `feat/313-tray-flyout` (worktree `Wind-tray`), stacked on `feat/303-settings-redesign`; - the PR targets main and is merged only after #312. -- No em-dashes. Pure files without ``. WindTray stays non-UIAccess. -- Visual tokens come from `docs/design/tray-2026-10/j01-calm-tint.html` and - `docs/design/settings-2026-10/FINAL-v10-grey.html`; nothing invented. -- Version bump to 0.17.0 in the PR. Gates: `build.bat test`, `build.bat`, `build.bat tray`, - `build.bat config`, `cd ui && npx playwright test`. - -## Review Focus -1. "Keep within the edges" writes BOTH mouseAlign and trackAlign, and reads ON only when both are 1 (a hand-edited mixed state shows OFF; clicking sets both). -2. Dragging a slider must not flood the ini (throttle) and the final value must land on release. -3. Flyout placement with the taskbar on the left/top/right and on a secondary monitor, at 100-250% DPI. -4. Dismissal: clicking outside, Esc, alt-tab and the tray icon itself all close it exactly once (no reopen flicker). -5. Disabled items and unknown keys in `traySliders`/`trayToggles` from an older or hand-edited ini. - ---- - -### Task 1: Tray item model (pure) -**Files:** Create `src/tray_items.h/.cpp`; Test `tests/test_tray_items.cpp`; add the five keys to `IsGlobalProfileKey`. -**Interfaces:** `struct TrayItem { std::string key; bool on; };` -`struct TrayLayout { bool perf; std::vector sliders, toggles; };` -`TrayLayout ParseTrayLayout(const IniValues&);` `void WriteTrayLayout(const TrayLayout&, IniValues&);` -`const std::vector& EligibleSliders(); const std::vector& EligibleToggles();` -- [ ] Failing tests: defaults (perf off, warmth+brightness on), round trip, order kept, unknown keys dropped, missing eligible items appended off, cap (more than 4 enabled sliders are read as off, in list order; toggles uncapped). Sliders: colorWarmPct, colorDimPct, maxLevel, zoomInSpeed, zoomOutSpeed, panSpeed, cursorSmoothing, zoomEaseOutMs. Toggles: trackCaret, trackFocus, keepEdges (the combined mouseAlign + trackAlign item). -- [ ] Implement; `build.bat test` green; commit `feat(tray): tray layout model (#313)`. - -### Task 2: (removed) -No action buttons were chosen, so the core needs no tray command. Skip. - -### Task 3: Flyout rendering -**Files:** Create `src/tray_app/flyout_window.cpp/.h` (window, placement, dismissal), `src/tray_app/flyout_draw.cpp/.h` (D2D/DWrite drawing from a view model), embed `c-grey.jpg` in `wind_tray.rc`; remove the HMENU path from `tray_menu.cpp` (keep icon/IPC parts). -- [ ] Placement maths as a pure function with doctest cases (four taskbar edges, multi-monitor, DPI). -- [ ] `--render-test ` renders the flyout with fake status to a file. -- [ ] Commit `feat(tray): flyout window and drawing (#313)`. - -### Task 4: Flyout interaction -- [ ] Hit-testing, slider drag (throttled ini writes, final on release), chips, tooltips, keyboard, profile list popup, Settings and Quit (with the #303 Quit prompt), live performance refresh at ~10 Hz only while open. -- [ ] doctest for the throttle and value formatting; commit `feat(tray): quick controls (#313)`. - -### Task 5: Settings "Tray menu" tab -**Files:** `ui/src/settings-schema.js` (group under a TRAY label), `ui/src/tray/TrayMenuPage.svelte`, `ui/src/tray/DragList.svelte` (reusable smooth drag list with keyboard reorder), icons added to `ui/src/design/icons.js`. -- [ ] Performance toggle card; Sliders and Toggles cards; icons without background; checkmarks; count "N of 4" on Sliders (Toggles show "N on"); at the slider cap unchecked checkmarks are disabled with "Uncheck one to add another"; writes the five keys. -- [ ] Commit `feat(ui): Tray menu tab (#313)`. - -### Task 6: Tests -- [ ] Playwright: tab render, check/uncheck, drag in both lists, keyboard reorder, keys written. Commit `test: tray flyout and tab (#313)`. - -### Task 7: Match the references -- [ ] `WindTray.exe --render-test` vs `j01-calm-tint-dark.png`/`-light.png`; Settings tab screenshot vs `k01` (icons without background). List every difference, fix until none or justified. Commit `style: match the tray references (#313)`. - -### Task 8: Docs, version, PR -- [ ] Update `docs/architecture` (tray chapter), the CLAUDE.md "Three binaries" tray notes and `docs/superpowers/specs/2026-08-28-tray-menu-design.md` (mark superseded). Bump `src/version.h` to 0.17.0. -- [ ] Gates green; push; PR closing #313 with base main, noting it stacks on #312. Do not merge. diff --git a/docs/superpowers/plans/2026-10-01-zoom-in-response.md b/docs/superpowers/plans/2026-10-01-zoom-in-response.md deleted file mode 100644 index af474ec5..00000000 --- a/docs/superpowers/plans/2026-10-01-zoom-in-response.md +++ /dev/null @@ -1,94 +0,0 @@ -# Zoom-in response (issue #310): spec + plan - -## Goal -Make the zoom feel immediate and smooth: measure the real response (press to first magnified -frame on screen) and the zoom-in/out stutter, and fix what earlier reviews already pinned down. -Owner request 2026-10-01. Build and review now; ALL measuring waits for the owner's go-ahead (the -screen is off overnight), so every behaviour change is behind an ini knob the harness can flip. - -## Known causes (reviews 2026-09-30, docs/HITCH-FINDINGS.md) -1. **Slow enter tick (transform):** 14-16 ms median, tail to 50 ms. `TransformModel::setActive(true)` - shows the cursor sprite and then BLOCKS on two `DwmFlush()` calls (~14 ms) before blanking the - system cursor, so the hand-off never blinks (#221). The zoom's first frame waits behind it. -2. **Caret jump at zoom-in (~3% of zoom-ins):** enabling the caret/focus tracker publishes the - current caret; within 1 s of a key press that reads as a new keyboard-driven caret event, so the - view lurches to the caret and the pointer is then warped back (`view mouse -> caret` at the - same millisecond as `transform session`). -3. **DWM's zoom-in spike in games (~35-42 ms, once per zoom):** DWM builds its magnification - machinery when a session starts; the context is released 1.2 s after a zoom (`txIdleReleaseMs`) - because a live context taxes every cursor change in games (#148). A longer linger trades one - for the other; only measurement can pick the value. - -## Design -- **A. Zoom timeline (`zoomTrace`, ini, default 0).** One log line per zoom-in and per zoom-out: - - in: press -> wake/tick start (the hook stamps QPC on every edge it wakes the loop for), - enter-tick total, inside it `setActive` (bridge + ensureMag) and the first present, the time - from press to the first DWM composite after the enter tick (the loop's post-tick DwmFlush - return), and the durations of the next 6 ticks. - - out: teardown tick total and `setActive(false)` (identity park) time. - Cheap QPC reads only; nothing logged when off. -- **B. Non-blocking cursor bridge (`txEnterBridge`, ini, default 1).** 0 = today's synchronous - DwmFlush pair. 1 = show the sprite at the pointer as today, but instead of blocking, keep the - system cursor visible for the next 2 ticks (each zoomed transform tick is one composite: the loop - is DwmFlush-paced) and blank it on the third; present()'s hide branch waits for the same count. - Same overlap the pair guaranteed, without stalling the enter tick. -- **C. Tracker settle at zoom-in (always on).** When the view-owner logic becomes enabled, events - for the next 150 ms only re-baseline `lastSeq` (they are the tracker's activation publish, not - typing). Mouse takeover and keyboard panning are unaffected. Pure, unit-tested. -- **D. A/B harness (runs only on the owner's go-ahead).** `tools/zoom_response_ab.ps1`: N zoom - cycles (side buttons, 1.5 s apart) per configuration, on the desktop and in a game in FOCUS - (the owner launches it), with PresentMon on `dwm.exe` (desktop) or the game, `zoomTrace=1`. - Configurations: `txEnterBridge` 0/1 x `txIdleReleaseMs` 1200/15000. Reports per config: - press->first-composite median/p90/max, enter-tick duration, frame spikes (> 2x median frame - time) within 200 ms of each zoom-in and zoom-out, and re-runs any config where an outlier - appears twice. Restores the ini afterwards; dims the screen between runs (colorDimPct 1, off - during runs) per the owner's request. - -## Out of scope -Changing defaults beyond B/C before data; render-engine reveal gating; the colour filter. - -## Plan -- [ ] T1 `view_target.h`: `ViewOwnerState::{wasEnabled, settleMs}`, `kEnableSettleMs = 150`; - tests in `tests/test_view_target.cpp` (activation publish within 150 ms never takes the view; - after it, a key-driven caret event does; mouse/pan still work inside the window). -- [ ] T2 config: `zoomTrace` (0/1), `txEnterBridge` (0/1, default 1); parse + template + test. -- [ ] T3 `transform_model`: deferred bridge (`bridgeTicks_`), present() hide branch gated on it; - `setActive(true)` reports its own split (bridgeMs, ensureMagMs) via a small struct getter. -- [ ] T4 `input_router`: `lastEdgeQpc()` stamped in `WakeMain`. `main.cpp`: the zoom timeline - (enter-tick stamps, post-tick DwmFlush return, next 6 tick durations, zoom-out teardown); one - Info log line each, only when `zoomTrace`. -- [ ] T5 harness script + README note; build; unit tests; deploy (no input injection, no - measurement until the owner says go). -- [ ] T6 code review workflow (reviewer + adversarial verify), fixes, docs (HITCH-FINDINGS, - architecture 05-transform-engine), version 0.15.4. Report "ready to test". - -## Review Focus -1. Deferred bridge: the real cursor and the sprite both visible for 2 frames must stay aligned - (at ~1.0x the transform is identity); a zoom-out or engine switch before the counter elapses - must not leave the cursor blanked or the counter stuck. -2. Inspect, game-inspect and mouselook (app-hidden cursor) paths: the bridge must not fight them. -3. The settle window must not swallow a real caret move typed right after zooming (150 ms). -4. zoomTrace must cost nothing when off and must not allocate or log inside the hook. -5. The harness must never leave the user's ini changed or the screen undimmed afterwards. - -## Amendments after the plan review (2026-10-01) -- **C rewritten.** The caret jump is a stale `lastSeq`, not the tracker's activation publish (the - tracker already baselines its first caret after activation, and a same-millisecond jump cannot - come from its 30 ms coalesce). `StepViewOwner` runs only while zoomed, so an event published just - before a zoom-out read as new typing at the next zoom-in. Fix: re-baseline `lastSeq` on the - rising edge of tracking-active (zoom-in, or tracking turning back on mid-zoom), nothing swallowed - afterwards; tracking events are ignored while tracking is off. -- **B deferred until measured.** The deferred bridge would show two misaligned cursors during the - first ramp frames (the sprite is magnified at the lens point, the welded real pointer is not), and - would move the 14-cursor blank into the live context (#189). Whether the DwmFlush pair is what the - user feels is unmeasured; the timeline below decides it. B only ever addressed the enter-tick - median, not the 50 ms tail or DWM's 35-42 ms machinery build. -- **Timeline stamps.** The press is stamped only on zoom-in rising edges (first since taken, stale - after 500 ms); the first composite after the enter tick comes from `DwmGetCompositionTimingInfo` - (cFrame/qpcCompose), not the loop's DwmFlush (absent on the enter tick). `setActive(true)` reports - bridge / ensureMag / warm. -- **Harness measures what the user sees.** Screen BitBlt sampling of a textured off-centre region: - press -> first visible change, and ramp stall frames (a still frame inside the first 300 ms of the - ramp). Plus PresentMon (dwm.exe or the game), the zoomtrace lines, caret jumps (trackLog). ABAB - blocks; one discarded cycle after each ini flip; 3 s after zoom-out so the cold config is cold; - still and moving pointer. Old (main) vs new build at test time for the optical metrics. diff --git a/docs/superpowers/plans/2026-10-02-settings-structure.md b/docs/superpowers/plans/2026-10-02-settings-structure.md deleted file mode 100644 index f02794b4..00000000 --- a/docs/superpowers/plans/2026-10-02-settings-structure.md +++ /dev/null @@ -1,42 +0,0 @@ -# Settings structure, hotkeys and themes: implementation plan (#318) - -Spec: `docs/superpowers/specs/2026-10-02-settings-structure-design.md` (+ the decisions log next to it). -Branch `feat/318-settings-structure` (worktree `Wind-settings2`), stacked on `feat/315-tray-tools`. -Version 0.19.0. No em-dashes. Gates: `build.bat test`, `build.bat`, `build.bat tray`, `build.bat config`, -`cd ui && npx playwright test` (run `cmd.exe /c ".\build.bat X"` from PowerShell, never from bash). -Reference mockups: `C:/Users/Admin/Documents/Claude/wind-settings-mockups/ia/ia07.html` (structure, copy, -hotkeys, profile dialogs), `ia08.html` + `palettes08.cjs`, `palettes-b1.cjs`, `palettes-b2.cjs` (themes). - -## Review focus -1. Old inis: `zoomWheelMods` migrates once into free zoom slots (both full -> keep the wheel mods setting - and log), unknown `uiPalette` reads grey, missing `panKeysOn`/`hideCursorOn`/`cursorLockOn` read 1. -2. A wheel binding never swallows plain scrolling (only with its modifiers), and a disabled extra key is - neither bound nor swallowed. -3. Session model still holds: every new UI control is a session change except keybinds (persist at once, - as today); `uiPalette` and `showAdvanced` are global and survive profile switches. -4. Advanced rows hidden when the switch is off but found by search; no section with fewer than 2 rows. -5. Every theme x mode (grey, ember, ocean, hicon; dark and light) in Settings and in the tray flyout render test. - -## Tasks -1. **Core** (`src/config.*`, `src/input_router.*`, `src/main.cpp`, `src/profiles.cpp`): button codes 6/7 = - wheel up/down in the zoom button slots (+ButtonMods), per-notch zoom on a matching wheel event, the - migration from `zoomWheelMods`, the three `*On` switches gating bind + swallow, `uiPalette` as a global - UI-only key. doctest for parse, migration, gating and the wheel match. -2. **Settings structure + copy** (`ui/src/settings-schema.js`, `ui/src/shell/*`, `ui/src/Settings.svelte`, - search): tabs/order/divider, opens on Hotkeys, sections and copy from the spec, renderExclude row gone, - title-bar theme button gone, `showAdvanced` reveals adv rows inline, search finds hidden adv rows. -3. **Hotkeys page** (`ui/src/controls/*`): one-box bindings with + inside, click to re-record, hover x, - limits (2 for zoom, 1 otherwise, max 2 modifiers), wheel capture on zoom rows, pan box with Arrow keys - and the unset state, Extra keys switches (dim the binding when off). -4. **Preferences** (`ui/src/general/*` or a new `ui/src/prefs/*`): Mode three-way, Profile dropdown with - trash + confirm dialog and the New dialog, Troubleshooting section; theme tokens - `ui/src/design/themes.css` generated from the mockup palettes (4 themes x 2 modes (grey, ember, ocean, hicon)) applied by - `uiPalette` + mode; a plain temporary one-row theme picker (final layout comes from Max's mockup pick). -5. **Tray theming** (`src/tray_app/flyout_*`): `flyout_palettes.h` with the same 4 x 2 palettes, the - flyout reads `uiPalette`/`uiTheme` on open, sharp radii for hicon; render-test flag `--palette `. -6. **Docs, version, PR**: spec/plan final, CLAUDE.md notes, `src/version.h` 0.19.0, gates, push, PR that - closes #318 and says it stacks on #316. - -## Status (2026-10-02) -All six tasks are built on `feat/318-settings-structure` (version 0.19.0). The picker is mockup option A with four -themes; see "As built" in the spec. Not pushed and not merged: waiting for Max's review of the PR. diff --git a/docs/superpowers/plans/2026-10-02-tray-tools.md b/docs/superpowers/plans/2026-10-02-tray-tools.md deleted file mode 100644 index f1b5375a..00000000 --- a/docs/superpowers/plans/2026-10-02-tray-tools.md +++ /dev/null @@ -1,47 +0,0 @@ -# Tray tools Implementation Plan (rewritten 2026-10-02) - -**Goal:** A main-engine dropdown and a stretched segmented toggle group in the tray flyout, per -`docs/superpowers/specs/2026-10-02-tray-tools-design.md`. - -**Branch:** `feat/315-tray-tools` (worktree `Wind-tray2`), stacked on `feat/313-tray-flyout`; PR #316 -targets main after #312 and #314. Version stays 0.18.0. No em-dashes. Pure logic without ``. -Gates: `build.bat test`, `build.bat`, `build.bat tray`, `build.bat config`, `cd ui && npx playwright test`. - -## Max's decisions (2026-10-02) -1. Engine dropdown = the MAIN engine (`model`), a full-width dropdown with a chevron, never disabled; a pick - restarts Wind automatically and is a session change like Settings. -2. Mouse lock, Pass keys and Pause Wind are removed entirely. -3. (Superseded 2026-10-02 by mockup v02) Toggles are ONE segmented group stretched to the full content - width (3, 2 or 1), the engine is a full-width dropdown 8 DIP below it, no zoom readout. - -## Review Focus -1. A pick must write `model`, keep the unsaved session (`session.keep`) and relaunch Wind; a failed - relaunch must put the old `model` back. -2. The segments fill the content width exactly (1 px separators); hit testing, focus and drawing use the same rects; the engine list is as wide as, and aligned to, the dropdown. -3. No trace of the removed tools: the shared block is back to version 1, and old ini keys are dropped silently. - -### Task 1: Remove the rejected tools -Revert the core (`src/main.cpp` foreground publishing and pause gating, `src/input_router.*`), the shared -block (`src/tray_ipc.h` back to v1, `src/tray_host.cpp`), the tray (`tools.cpp`, `chime.cpp`, icon badge, -`tray_publish.h`, `chime_wav.h`, `make_chimes.mjs`, `assets/sounds`, the `.rc` resources, `winmm`), the -tests for them, and the Settings rows and icons. - -### Task 2: Item model -`src/tray_items.*`: toggles are `trackCaret`, `trackFocus`, `keepEdges`, `engine`; unknown keys dropped -(tests). - -### Task 3: Layout -v02 (2026-10-02): `LayoutSegments` (pure) stretches N segments over the content width. `ComputeGeometry` -takes the toggle count and `hasEngine` and returns `segBar`, `seg[]`, `engine`. `SegNeighbor` (Left/Right) -and `VerticalNeighbor` (Up/Down). Tests: 3/2/1 stretched, no toggles, no engine, hit tests, list aligned. -(Replaced the centred 4-per-row chip layout, `LayoutChips` and `ChipNeighbor`, which are gone.) - -### Task 4: Engine dropdown -Pure options/labels/pick rule (`flyout_tools.h`); dropdown drawing; list popup under the dropdown, same width; -`engine_dropdown.cpp` (write, session.keep, relaunch, revert on failure). Render-test cases. - -### Task 5: Settings Tray menu tab -`ui/src/tray/*`: one new item, "Magnifier engine", with icon and description; Playwright tests. - -### Task 6: Docs, PR -Spec and plan (this rewrite), CLAUDE.md tray notes, architecture overview; gates; push to PR #316. diff --git a/docs/superpowers/specs/2026-05-24-magnifier-design.md b/docs/superpowers/specs/2026-05-24-magnifier-design.md deleted file mode 100644 index 0b678018..00000000 --- a/docs/superpowers/specs/2026-05-24-magnifier-design.md +++ /dev/null @@ -1,201 +0,0 @@ -# Fullscreen Magnifier - Design Spec - -- **Date:** 2026-05-24 -- **Status:** Approved (brainstorming complete; pending written-spec review) -- **Working directory:** `Wind` (product name TBD; referred to here as "the magnifier") - -## 1. Summary - -A lightweight, standalone **fullscreen magnifier** for Windows that replaces the -built-in `Magnify.exe`. It provides smooth, continuously-variable zoom with minimal -performance cost, and - the defining feature - **the magnified view keeps tracking -mouse movement even when a game hides, clips, or center-locks the OS cursor**. - -It is intended as a better everyday magnifier that is *also* genuinely usable while -gaming, where the Windows magnifier is heavy and loses cursor tracking. - -## 2. Goals - -1. **Fullscreen magnification** of the whole display. -2. **Responsive** - reacts immediately to input. -3. **Light** - negligible CPU/GPU impact; built with gaming in mind. -4. **Smooth, gradual zoom** - continuously variable, not the 25%/100% steps of the - Windows magnifier. -5. **Works in games** - must not be defeated by `ShowCursor`, `ClipCursor`, - `SetCursorPos`, or raw-input center-locking. **The lens must remain movable even - when the game hides/clips/locks the cursor.** This is the primary differentiator - and the core problem being solved. - -## 3. Non-goals (v1) - -- **Exclusive-fullscreen games.** v1 magnifies the desktop, normal apps, and games in - **borderless / windowed-fullscreen** mode (the modern default). True exclusive - fullscreen bypasses the desktop compositor and is out of scope. Documented as a - future "Mode 2" (capture-based engine). -- DLL injection into games (the Magnifier-In-Games approach) - kept as a documented - optional future fallback, not built in v1. -- Color filters, lens shapes/effects, and a full GUI settings window. - -## 4. Prior art (in sibling repos, reused as knowledge only) - -- **`Magnifier-In-Games`** - C + MinHook DLL that injects into a game and neuters - `ShowCursor`/`SetCursor`/`ClipCursor`/`SetCursorPos` so the *Windows* magnifier can - follow the OS cursor. We solve the same problem differently (Raw Input, no - injection), because we own the magnifier this time. Its build setup (`cl.exe` via - `vswhere` in `build.bat`) is the template for ours. -- **`HoldToZoom`** - AHK controller for the Windows magnifier's native zoom on mouse - side buttons, with a robust triple-mechanism modifier-release. We reuse its - control ergonomics (forward/back buttons, hold-to-repeat) and its - release-safety pattern (explicit release + physical-state watchdog + on-exit reset). - -## 5. Technical approach - -### 5.1 Zoom engine - Windows Magnification API - -Use the fullscreen transform of the Magnification API: -`MagInitialize()` -> `MagSetFullscreenTransform(level, xOffset, yOffset)` -> -`MagUninitialize()`. - -- It runs on the **DWM GPU compositor** - the same path `Magnify.exe`'s full-screen - mode uses - so there is **no per-frame screen copy**; impact is negligible. -- `level` is a **float**, so calling it every tick with an interpolated value yields - genuinely **smooth, continuous zoom** (goal #4). -- It magnifies everything composited by DWM: desktop, apps, and borderless games. It - is **not** affected by the game's cursor calls (goal #5, visual half). -- `MagSetInputTransform` is **not** used (it needs UIAccess / a signed binary in - Program Files). We do a purely visual transform, so we avoid that deployment burden. - -**Offset math.** `xOffset/yOffset` is the top-left, in unmagnified screen pixels, of -the source region being magnified. The visible region is `screenW/level` x -`screenH/level`. To center the view on the virtual lens center `C`: - -``` -viewW = screenW / level; viewH = screenH / level -xOffset = clamp(C.x - viewW/2, 0, screenW - viewW) -yOffset = clamp(C.y - viewH/2, 0, screenH - viewH) -``` - -### 5.2 Cursor tracking - Raw Input, no injection (the core feature) - -The lens always follows the cursor. To survive cursor lock/hide/clip without -injecting into the game, **our own process** reads mouse movement at the HID level: - -- Register Raw Input for the mouse with `RIDEV_INPUTSINK` (so `WM_INPUT` arrives even - when the game is foreground). Raw deltas (`lLastX`/`lLastY`, `MOUSE_MOVE_RELATIVE`) - are reported independently of `ShowCursor`, `ClipCursor`, and `SetCursorPos` - the - same signal the game itself reads to aim. -- **Auto-blend tracker:** - - **Free mode** (desktop, windowed apps): the OS cursor moves normally; set the - virtual center to `GetCursorPos()`. - - **Locked mode** (game has frozen/clipped/centered the cursor): detected when - `GetCursorPos()` stops changing while raw deltas keep arriving. Integrate raw - deltas (scaled by a configurable sensitivity) into the virtual center. - - On return to free movement, resync the virtual center to `GetCursorPos()`. -- The virtual center is always clamped to screen bounds. A **recenter** binding snaps - it back to screen center. - -This keeps the lens movable in cursor-locked games (goal #5, control half) with **no -code running inside the game process** - lighter and anti-cheat-safe. - -### 5.3 Zoom control - continuous hold-to-zoom on mouse side buttons - -- **Forward button (XButton2, default): hold to ramp zoom IN.** -- **Back button (XButton1, default): hold to ramp zoom OUT.** -- **Release freezes the level** wherever it stopped (the back button is how you return - toward 1.0x). Bindings are configurable. -- While a button is held, `level` ramps **multiplicatively** (perceptually-linear: - `level *= zoomFactor^dt`), clamped to `[1.0, maxLevel]`. Multiplicative ramping makes - the zoom feel even across the whole range. -- **Release safety (from HoldToZoom):** (1) explicit stop on button-up, (2) a - per-tick **physical-state watchdog** (`GetAsyncKeyState`/physical key state) that - stops ramping if the button is no longer physically down even when an up event was - missed, and (3) an on-exit handler that resets the transform. A dropped button-up - can never strand the screen mid-zoom. - -## 6. Module breakdown (one concern each; pure logic isolated for testing) - -- **`ZoomController`** *(pure logic, unit-tested)* - hold-to-zoom state machine. - Input: button down/up + elapsed time + config. Output: current `level`. No I/O. -- **`Tracker`** *(pure decision logic + thin Raw Input I/O)* - virtual lens center. - The blend / lock-detection / delta-integration / clamp math is pure and tested; the - only I/O is `WM_INPUT` registration and `GetCursorPos`. -- **`Transform`** *(pure logic, unit-tested)* - converts `(center, level, screenRect)` - into clamped `(xOffset, yOffset)` per the offset math above. -- **`MagnifierEngine`** *(I/O)* - wraps `MagInitialize` / - `MagSetFullscreenTransform` / `MagUninitialize`. -- **`InputRouter`** *(I/O)* - registers Raw Input and a low-level keyboard/mouse hook; - routes button/key events to `ZoomController` and raw deltas to `Tracker`. -- **`Config`** - loads/saves an INI file next to the exe; hot-reloads on change. -- **`TrayApp` / `main`** - single-instance mutex, tray icon (enable/disable, edit - config, quit), message loop, and the update tick that wires it all together. - -## 7. Data flow / update loop - -``` -WM_INPUT (raw deltas) ---------------> Tracker.update() -hook (button/key events) -----------> ZoomController.update() / recenter - -every tick (display refresh, capped ~144 Hz): - level = ZoomController.level() - center = Tracker.center() - offsets = Transform(center, level, screenRect) - MagnifierEngine.setTransform(level, offsets) -``` - -Each tick is a couple of cheap compositor calls. Input updates shared state -asynchronously; the tick just reads it. - -## 8. Error handling & lifecycle - -- Declare **Per-Monitor-V2 DPI awareness** (manifest) so offset pixel math is correct - on scaled displays. -- If `MagInitialize` fails, show a tray balloon explaining why and exit cleanly. -- On quit / crash / exit, reset to `MagSetFullscreenTransform(1.0, 0, 0)` then - `MagUninitialize` - never leave the screen zoomed. -- **Single instance** via a named mutex. -- **Multi-monitor:** v1 magnifies the monitor under the cursor. Exact - fullscreen-transform behavior across monitors is verified during implementation; if - the API only transforms the primary monitor, that limitation is documented for v1. - -## 9. Testing / verification loop (day one) - -- **Unit tests** (single-header C++ harness, e.g. doctest) for the pure modules: - - `ZoomController`: ramp reaches `maxLevel` on sustained hold, ramps down to exactly - 1.0, freezes on release, watchdog stops on missed button-up, multiplicative curve - is monotonic. - - `Tracker`: free->locked->free transitions, delta integration + sensitivity, - clamping, recenter. - - `Transform`: centering and edge clamping at several levels and screen sizes. -- **Build gate:** compiles clean at `/W4`. -- **Manual smoke checklist:** desktop zoom in/out feel; a borderless game with the - cursor hidden/locked (confirm the lens still pans and zooms); Task Manager check - that CPU/GPU cost is negligible while idle-zoomed and while panning. - -## 10. Defaults (configurable via INI) - -| Setting | Default | -|---|---| -| Zoom-in binding | Mouse **XButton2** (forward), held | -| Zoom-out binding | Mouse **XButton1** (back), held | -| Recenter binding | Configurable key (unset by default) | -| Max zoom | **8.0x** | -| Zoom speed | reaches 1.0x -> 8.0x in ~1.2 s of continuous hold (multiplicative) | -| Raw-input pan sensitivity | 1.0 (scales locked-mode panning) | -| Tick rate | display refresh, capped ~144 Hz | -| Config file | `magnifier.ini` next to the exe, hot-reloaded | -| Enabled on launch | yes; runs from the tray | - -## 11. Build & toolchain - -- **C++**, built with `cl.exe` located via `vswhere`, driven by `build.bat` (mirrors - `Magnifier-In-Games`). Links `Magnification.lib` and `user32.lib`. -- App manifest sets Per-Monitor-V2 DPI awareness. -- No external runtime dependencies; single small `.exe`. - -## 12. Future modes (explicitly deferred) - -- **Mode 2 - exclusive fullscreen:** Desktop Duplication (`IDXGIOutputDuplication`) + - a Direct3D fullscreen overlay, for games that refuse borderless. Heavier; opt-in. -- **MIG injection fallback:** bundle/port the existing DLL-hook approach for - pathological games where Raw Input tracking is insufficient. -- Color filters / lens shapes, GUI settings window. diff --git a/docs/superpowers/specs/2026-05-26-auto-cursor-sensitivity-design.md b/docs/superpowers/specs/2026-05-26-auto-cursor-sensitivity-design.md deleted file mode 100644 index 656d461a..00000000 --- a/docs/superpowers/specs/2026-05-26-auto-cursor-sensitivity-design.md +++ /dev/null @@ -1,191 +0,0 @@ -# Wind - Auto-Match Cursor Sensitivity (Design Spec) - -**Date:** 2026-05-26 -**Status:** Approved for planning -**Branch:** `feat/auto-sensitivity` off `feat/own-renderer` -**Issue:** #38 - -## Goal - -Make the magnifier's pan speed automatically match the user's real Windows cursor, -including mouse acceleration ("Enhance pointer precision"), instead of a fixed -`cursorSensitivity` multiplier. Moving the mouse should pan the lens exactly as far -as it would move the real cursor on the desktop. - -## Background / why - -Wind pans the lens by integrating **raw mouse input** (mickeys) times a fixed -`cursorSensitivity` (default 1.0). Raw mickeys are linear; the real Windows cursor -is not. Diagnosing the reporter's machine: pointer-speed slider is neutral -(`MouseSensitivity=10`, ~1.0 gain) but **acceleration is ON** (`MouseSpeed=1`, the -Windows default; `SmoothMouseXCurve` present). So slow hand motion moves the real -cursor *less* than 1:1 and fast motion *more*, while Wind does a flat 1:1 - slow -precise pans feel too fast, fast pans too slow. Because the slider is already -neutral, a linear "match the speed slider" fix would compute ~1.0 and change nothing -for this user; the entire mismatch is the acceleration curve. - -Replicating Windows pointer ballistics on the raw stream (the `SmoothMouseXCurve` -algorithm) is complex, poll-rate/timing dependent, version-sensitive, and hard to -verify. Instead we let Windows do the ballistics and read the result. - -### The constraint - -While zoomed, Wind hides the OS cursor (`MagShowSystemCursor(FALSE)`) and -`SetCursorPos`-es it every frame to sit under the drawn cursor for click -hit-testing. So `GetCursorPos` normally reflects *our own* `SetCursorPos`, not the -user's hand - which is why the engine has used raw input. The key realization: if we -read `GetCursorPos` at the **start** of each tick (before re-setting it), the delta -from where *we* last put it equals exactly how far the OS moved the cursor from real -input in between - with full acceleration already applied. The hidden cursor's -logical position still tracks input + acceleration; hiding only affects visibility. - -## Decisions (locked during brainstorming) - -1. **Oracle approach.** Free desktop panning = the OS cursor's own per-tick pixel - delta (exact acceleration match, no ballistics math). Chosen over reimplementing - ballistics (too fragile) and over a linear-only fix (wouldn't help the accel-on - case). -2. **Raw input retained** for two jobs: detecting a game cursor-lock, and panning - while locked (relative-mouse mode, where linear raw is the correct feel and - acceleration does not apply). -3. **`cursorSensitivity` repurposed** to scale raw panning *only while locked* - (default 1.0). Free desktop panning ignores it (it must be exactly 1:1 with the OS). - -## Architecture - -### Per-tick flow (`main.cpp`, Win32 side; only while zoomed) - -1. `GetCursorPos` -> `P`. `cursorDelta = P - lastSetVirtual`, where `lastSetVirtual` - is the virtual-desktop point we `SetCursorPos`'d last tick. This delta carries - Windows' acceleration. -2. Drain raw mickeys (`rawDx/rawDy`, as today). `GetClipCursor` -> is the clip rect - confined (strictly smaller than the virtual desktop)? -3. Feed `(rawMag, cursorMag, clipConfined)` to `LockDetector` -> `locked` (bool). -4. Resolve the pan delta: **free** -> `cursorDelta`; **locked** -> - `round(raw * cfg.cursorSensitivity)`. -5. Clamp the resolved delta to a sane per-tick maximum (so a hard flick that throws - the OS cursor across the desktop cannot teleport the lens). -6. `mapper.update(dx, dy, level)` integrates the delta, clamps the center to the - monitor, returns `clickDesktop` (local px). -7. `renderFrame` does `SetCursorPos(clickDesktop + origin)`; `main` stores - `lastSetVirtual = clickDesktop + origin`. - -`cursorDelta` is a delta, so it is origin-independent (same in virtual and local -space) and is fed directly to the local-space mapper. - -### `LockDetector` (new pure unit: `src/lock_detector.{h,cpp}`) - -Decides free vs locked from per-tick signals, with hysteresis so it cannot flap -per-tick. Pure (no ``), unit-tested. - -```cpp -class LockDetector { -public: - // clipConfined: a smaller-than-virtual-desktop clip rect is active (ClipCursor lock). - // rawMag: |rawDx|+|rawDy| this tick. cursorMag: |cursorDx|+|cursorDy| this tick. - // Returns true if the cursor is considered locked (pan from raw, not the OS cursor). - bool update(bool clipConfined, int rawMag, int cursorMag); - bool locked() const; - void reset(); // back to free (call on zoom-in / recenter / retarget) -private: - bool locked_ = false; - int lockStreak_ = 0; // consecutive ticks of (raw active, cursor frozen) - int freeStreak_ = 0; // consecutive ticks of (cursor tracking raw) -}; -``` - -Logic: -- `clipConfined` true -> immediately locked (direct, reliable signal; the common - fullscreen-game case). -- Else heuristic: a tick is "lock-evidence" when `rawMag >= kRawActive` AND - `cursorMag <= kCursorFrozen` (mouse moving but OS cursor not). Accumulate - `lockStreak_`; at `>= kLockTicks` consecutive, set locked. A tick is - "free-evidence" when `cursorMag > kCursorFrozen` (cursor is moving with input); - accumulate `freeStreak_`; at `>= kFreeTicks`, clear locked. The opposite streak - resets when its evidence breaks. Constants chosen so normal slow accel'd desktop - motion (cursor moves sub-pixel but accumulates within a few ticks) never reaches - `kLockTicks`, while a truly clipped cursor (frozen indefinitely) does. - -Because the caller integrates a *delta* in both regimes, a momentary wrong call only -mis-sizes one tick's delta; it never snaps the lens position. This is the structural -reason it avoids the old `Tracker` free/locked flicker (issue #3), which snapped the -absolute lens center between two derivations. - -### `CursorMapper` change (`src/cursor_mapper.{h,cpp}`) - -`update` integrates an already-resolved pixel delta instead of `raw * sensitivity`: - -- Signature stays `MapResult update(int dx, int dy, double level)` but `dx/dy` now - mean "pixel delta to apply to the lens center this tick" (the caller resolved - free-vs-locked and any scaling). -- Body: `tx_ += dx; ty_ += dy;` (drop the `* sens_`), then the existing clamp + ease - + output computation, unchanged. -- Remove the `sensitivity` constructor parameter and the `sens_` member (scaling now - lives in the caller's locked-mode branch only). Constructor becomes - `CursorMapper(int screenW, int screenH, double smoothing = 0.0)`. -- Sub-pixel preserved: `clickDesktop = round(cx_)` is used only for the integer - `SetCursorPos`; `cursorDelta` subtracts the same rounded `lastSetVirtual`, so the - fractional part of `cx_` never drifts. - -### Config (`src/config.{h,cpp}`) - -`cursorSensitivity` (double, default 1.0) is **repurposed**: it now scales raw-input -panning **only while a game has locked the cursor**. Free desktop panning -auto-matches the OS pointer and ignores it. Update the struct comment and the -default-ini comment to say so. No key added or removed (avoids churn; the value is -still meaningful for the locked case). - -## Error handling / edge cases - -- **Zoom-in / recenter / monitor-retarget:** set `lastSetVirtual` to the current - `GetCursorPos` (first `cursorDelta` = 0) and `LockDetector::reset()` (start free). -- **Monitor edge:** `GetCursorPos` clamps at the desktop edge -> `cursorDelta` - truncates; `cx_` clamps to `[0, monW] x [0, monH]` -> consistent, no runaway. -- **Hard flick across monitors:** the per-tick delta clamp (step 5) bounds a single - tick's pan; `cx_` clamping bounds the destination. No teleport. -- **`SetCursorPos` vs acceleration:** Windows acceleration is velocity-based on the - input stream, independent of absolute position, so our per-frame `SetCursorPos` - should not perturb the accel applied to subsequent real input. This is the one - assumption to confirm live (see Testing). - -## Testing / verification - -- **Pure unit tests (new + changed):** - - `LockDetector`: clipConfined -> immediate lock; raw-active+cursor-frozen for - `kLockTicks` -> lock; cursor-tracking for `kFreeTicks` -> free; hysteresis (a - single contrary tick does not flip); `reset()` returns to free; slow-motion - pattern (small raw, occasional cursor move) stays free. - - `CursorMapper`: `update(dx,dy,level)` integrates the delta directly (no - sensitivity scaling); clamps to bounds; smoothing eases; sub-pixel center - retained. Update the existing sensitivity-based cases to the new delta meaning. -- **Build:** `build.bat` clean `/W4`; `build.bat test` passes (existing + new cases). - Add `lock_detector.cpp` + `cursor_mapper.cpp` to the test build list in - `build.bat` (lock_detector is new; cursor_mapper already there). -- **Manual (the Win32 oracle, user-run, single monitor):** - - Slow precise pan and fast pan should now feel like the user's real cursor (the - acceleration match) - the primary success criterion. - - A cursor-locking game (ClipCursor) should still pan via raw and stay responsive. - - No drift, no runaway at edges, clean zoom-in/out and recenter. - - Confirms the `SetCursorPos`-vs-acceleration assumption holds in practice. - -## Out of scope (YAGNI) - -- Reimplementing Windows pointer ballistics / reading `SmoothMouseXCurve`. -- A linear pointer-speed-slider scalar (the oracle subsumes it - it matches whatever - Windows does, slider and accel together). -- Per-game lock profiles or configurable lock thresholds (constants are internal; - revisit only if a real game misbehaves). -- Changing the click-hit-test `SetCursorPos` behavior or the render/capture path. - -## Files touched - -- **New:** `src/lock_detector.h`, `src/lock_detector.cpp`, `tests/test_lock_detector.cpp`. -- **Modified:** `src/cursor_mapper.h`/`.cpp` (delta integration, drop `sensitivity`), - `tests/test_cursor_mapper.cpp` (update the sensitivity-based cases to the delta - meaning), `src/main.cpp` (oracle wiring: GetCursorPos delta, GetClipCursor, - LockDetector, lastSetVirtual, per-tick delta clamp, mapper construction without - sensitivity), `src/config.h`/`.cpp` (repurpose `cursorSensitivity` comments), - `build.bat` (add `lock_detector.cpp` to the test build), `CLAUDE.md` (note the - oracle + lock-fallback model). -- **Unchanged:** `render_engine.*`, `png_dump`, `hdr_info`, `cursor_decode`, - `transform`, `zoom_controller`, `input_router`. diff --git a/docs/superpowers/specs/2026-05-26-comptr-raii-design.md b/docs/superpowers/specs/2026-05-26-comptr-raii-design.md deleted file mode 100644 index f69bd327..00000000 --- a/docs/superpowers/specs/2026-05-26-comptr-raii-design.md +++ /dev/null @@ -1,161 +0,0 @@ -# Wind - ComPtr RAII for render_engine State (PR-B Design Spec) - -**Date:** 2026-05-26 -**Status:** Approved for planning -**Branch:** `feat/comptr-raii` off `feat/structure-refactor` (stacked PR; base = `feat/structure-refactor`) -**Issue:** #36 (PR-B follow-up to the structure refactor #34) - -## Goal - -Convert the ~20 persistent COM members of `RenderEngine::State` from raw pointers to -`Microsoft::WRL::ComPtr` and delete the hand-maintained ~20-line `shutdown()` -release list - COM cleanup becomes automatic via the `State` destructor. This is a -**safety/hygiene** change only: it is behavior-preserving, adds no feature, fixes no -known bug, and does **not** change performance. It is explicitly the lowest-value and -highest-risk of the audit refactors and was split out (PR-B) so a regression is -bisectable on a surface that CI cannot test. - -## Background / why - -Every Direct3D 11 / DXGI object in `State` (device, context, swapchain, RTV, the -Desktop Duplication, the magnify + cursor shader pipelines, the desktop-copy texture -and SRV, blend states, samplers, the cursor texture and SRV) is currently a raw -pointer freed by an explicit `SafeRelease(...)` in `shutdown()`. The current code is -correct, so this does not fix a bug. The value is preventive: - -- `initialize()` has ~18 early-return failure paths; with raw pointers a future edit - can leak a partially-created object. ComPtr makes cleanup automatic regardless of - the exit path. -- A new member can be forgotten in the release list. With ComPtr there is no list. -- Removes a class of double-release / use-after-release footguns. -- Deletes ~20 lines of boilerplate. - -There is **no per-frame cost**: ComPtr member access compiles to the same raw-pointer -access (`operator->`, `.Get()` are zero-cost), and no ComPtr is copied per frame, so -there is no reference-count churn in the hot path. - -## Decisions (locked during brainstorming) - -1. **Stacked branch.** PR #35 (structure refactor PR-A) is still open and reorganized - the same `render_engine.cpp`. Branch off `feat/structure-refactor` and target the - PR at it, to proceed now without conflicts; it merges after #35. -2. **State members only.** Convert the ~20 persistent COM members. Local COM - temporaries (in `selectOutput`, `recreateDupl`, `capture`, `initialize`, - `recreateRtv`, `retarget`, `dumpBackbufferPng`) keep their existing raw-pointer + - `SafeRelease` handling - that code is already correct and is the trickiest - ref-counting (e.g. `selectOutput`'s match/first logic, the `AcquireNextFrame` - loop); churning it adds risk for no benefit. `com_util.h` (`SafeRelease`) survives - for those locals and for `png_dump`. `png_dump` is untouched. -3. **`shutdown()` drops the release list, relies on the destructor.** Documented as - safe for this windowed BLT-model engine (see below). The genuine RAII win. - -## Architecture - -### Member declarations - -`render_engine.cpp` adds `#include ` and, inside `namespace wind`, -`using Microsoft::WRL::ComPtr;`. Each persistent COM member changes from -`ID3D11X* m = nullptr;` to `ComPtr m;` (ComPtr default-constructs to null, so -the `= nullptr` is dropped). The members: - -`device` (ID3D11Device), `ctx` (ID3D11DeviceContext), `swap` (IDXGISwapChain), -`rtv` (ID3D11RenderTargetView), `dupl` (IDXGIOutputDuplication), -`desktopCopy` (ID3D11Texture2D), `desktopSRV` (ID3D11ShaderResourceView), -`vs`/`cvs` (ID3D11VertexShader), `ps`/`cps` (ID3D11PixelShader), -`cb`/`ccb` (ID3D11Buffer), `sampLinear`/`sampPoint` (ID3D11SamplerState), -`blend`/`blendInvert` (ID3D11BlendState), `cursorTex` (ID3D11Texture2D), -`cursorSRV` (ID3D11ShaderResourceView). - -### Three mechanical edit rules (applied at every member touch-site) - -1. **Create / re-create** - a member's address passed to a `CreateX` / - `DuplicateOutput` / `DuplicateOutput1` call (`&s_->member`): use - `s_->member.ReleaseAndGetAddressOf()`. This always-safe form releases any prior - object first, which is required for the re-created members (`dupl`, `rtv`, - `desktopCopy`, `desktopSRV`, `cursorTex`, `cursorSRV`) and harmless for the - once-created ones. (None of the State-member creates use the `(void**)` - QueryInterface/GetBuffer form - those targets are all locals - so no `void**` cast - is needed for members.) -2. **Pass as a raw argument** - a member used as an `ID3D11X*` value (e.g. `device` - to `DuplicateOutput1`, `desktopCopy` to `CopyResource`, `vs`/`ps`/`blend` to - `*SetShader`/`OMSetBlendState`): use `.Get()`. For the array-argument sites that - passed `&member` (`OMSetRenderTargets(1, &rtv, ...)`, - `VSSetConstantBuffers(0, 1, &cb)`, `PSSetShaderResources(0, 1, &desktopSRV)`, the - cursor pass equivalents): use `.GetAddressOf()`. The sampler choice - `p.bilinear ? sampLinear : sampPoint` becomes - `(p.bilinear ? sampLinear : sampPoint).Get()` stored in a local `ID3D11SamplerState*` - before `&samp`. -3. **Manual release** - `SafeRelease(s_->member)` (in `ensureDesktopCopy`, - `updateCursorTexture`, `recreateDupl`, `recreateRtv`, `retarget`, - `invalidateCapture`): use `s_->member.Reset()`. - -Member method calls via `->` (`dupl->AcquireNextFrame`, `swap->Present`, -`ctx->CopyResource`, `device->CreateX`, etc.) are unchanged. Null tests -(`!s_->sampLinear`, `if (desktopCopy && ...)`) work via ComPtr's explicit -`operator bool`. `dumpBackbufferPng` passes `s_->device.Get()` / `s_->ctx.Get()` to -`SaveTextureToPng` (which keeps its raw `ID3D11Device*` signature). - -### `shutdown()` - -Delete the entire `SafeRelease(...)` block. Keep the cursor restore -(`MagShowSystemCursor(TRUE)` / `MagUninitialize` / `SystemParametersInfoW(SPI_SETCURSORS)`), -`DestroyWindow`, and `ready = false`. The ComPtr members release automatically when -`State` is destroyed - `~RenderEngine()` runs `shutdown()` then `delete s_`, so the -release happens immediately after `shutdown()` returns (functionally identical timing -to today). - -**Why the auto-release order is safe here:** -- `device` is declared first in `State`, so it is destroyed **last** - matching the - old list's "device released last". -- D3D11 child objects (views, shaders, buffers, textures, the duplication) are - independently reference-counted; releasing them in any order relative to the device - is supported. -- The swapchain is **windowed BLT-model** (no fullscreen state, no - HWND-must-outlive-swapchain constraint that fullscreen swapchains have), so its - release after `DestroyWindow` is safe. (`MakeWindowAssociation` is also benign at - teardown.) - -`CursorRestoreFilter` (the crash-time cursor-restore net) is unchanged. - -## Error handling - -No new runtime behavior. The only logic change is *where* COM release happens -(destructor vs explicit list) and the member-access syntax. The existing `RLog` -failure logging (added in PR-A) and all control flow are unchanged. - -## Testing / verification - -This D3D code is not covered by automated tests, so correctness is argued from the -edit rules plus a manual pass: - -- **Unit tests unchanged:** `transform`/`zoom_controller`/`config`/`cursor_mapper` - are untouched; `build.bat test` must still pass (32 cases / 94 assertions). -- **Build:** `build.bat` clean under `/W4` (a wrong `.Get()`/`.GetAddressOf()` mix - usually fails to compile, which is a useful guard). `` is header-only; - no new link library. -- **Per-site review:** every converted member touch-site is checked against the three - rules (reviewers diff the change site-by-site). -- **Manual single-monitor run (the testable path), with emphasis on:** - - **Re-creation churn:** zoom in/out repeatedly - exercises `recreateRtv`, - `invalidateCapture`, `ensureDesktopCopy`, `updateCursorTexture` (the - `ReleaseAndGetAddressOf` / `Reset` sites). Cursor shape changes (hover text - fields) exercise `updateCursorTexture`. - - **Clean shutdown:** quit via Ctrl+Alt+Q and the tray - the OS cursor must be - restored, no crash, clean exit (this is what the deleted release list used to do - explicitly). - - **`WIND_SELFTEST=1`** still produces `wind_selftest.png` (exercises - `dumpBackbufferPng` -> `SaveTextureToPng` with `.Get()`). - -## Out of scope - -- Local COM temporaries (stay raw + `SafeRelease`). -- `png_dump`'s WIC locals and `com_util.h` (both stay). -- Any behavior, performance, API, or feature change. - -## Files touched - -- **Modified:** `src/render_engine.cpp` only (add `` + `using` alias; - convert the ~20 member declarations; apply the 3 edit rules at every member - touch-site; gut `shutdown()`'s release list). -- **Unchanged:** `com_util.h`, `png_dump.{h,cpp}`, `main.cpp`, all pure units, - `build.bat`. diff --git a/docs/superpowers/specs/2026-05-26-multi-monitor-follow-cursor-design.md b/docs/superpowers/specs/2026-05-26-multi-monitor-follow-cursor-design.md deleted file mode 100644 index cb063aaf..00000000 --- a/docs/superpowers/specs/2026-05-26-multi-monitor-follow-cursor-design.md +++ /dev/null @@ -1,229 +0,0 @@ -# Wind - Multi-Monitor Follow-Cursor (Design Spec) - -**Date:** 2026-05-26 -**Status:** Approved for planning -**Branch:** to be created off `feat/own-renderer` (e.g. `feat/multi-monitor`) -**Issue:** multi-monitor follow-cursor (from the 2026-05-25 code audit) - -## Goal - -On each zoom-in, magnify whichever monitor the cursor is currently on - instead -of always magnifying the primary monitor. Today every monitor-related path -silently assumes the primary monitor at the virtual-desktop origin, so on a -multi-monitor setup zooming on a secondary display magnifies the wrong region -and mis-routes clicks. This makes Wind correct on any monitor layout while -leaving the single-monitor behavior byte-for-byte unchanged. - -## Background / why - -The whole render pipeline implicitly assumes **local monitor pixels == virtual -desktop pixels**, which is only true for the primary monitor at origin `(0,0)`. -The primary-only assumptions: - -- `main.cpp` - `GetSystemMetrics(SM_CXSCREEN/SM_CYSCREEN)` reads the primary - size only. -- `render_engine.cpp` `recreateDupl()` - `adapter->EnumOutputs(0)` always grabs - the first output, not the cursor's monitor. -- `render_engine.cpp` `initialize()` - the overlay is created at `(0,0,w,h)`, - i.e. always at the virtual-desktop origin (the primary). -- `main.cpp` zoom-in / recenter - `GetCursorPos` (virtual coords) is fed - straight into `CursorMapper`, which works in local `[0,sw] x [0,sh]` coords - and clamps to them. -- `render_engine.cpp` `renderFrame()` - `SetCursorPos(clickDesktop)` where - `clickDesktop` is actually *local* coords from the mapper. - -On the primary monitor all of these coincide, so the bug is invisible on a -single display; on a secondary monitor they diverge (negative or offset -coordinates), producing a wrong source region, a mis-placed overlay, and clicks -landing on the wrong monitor. - -## Decisions (locked during brainstorming) - -1. **Retarget timing: on zoom-in only.** Each zoom-in magnifies the monitor the - cursor is on. While zoomed you stay on that monitor; to switch monitors you - zoom out and zoom back in on the other one. Continuous cross-monitor panning - while zoomed was considered and rejected (much more complex, visually jarring, - and hard to verify without the hardware). -2. **No auto-zoom-out on monitor change.** While zoomed, the OS cursor is pinned - to the active monitor (every frame `SetCursorPos` clamps it to - `[0, monitorW] x [0, monitorH]`), so the cursor can never reach another - monitor mid-zoom. "Cursor arrived on a new monitor while zoomed" is an - unreachable state - nothing to detect, nothing to auto-zoom-out. The natural - flow (release -> move to the other monitor -> zoom in, which retargets) is the - only path and already works. -3. **Config kill-switch `multiMonitor` (default 1).** `multiMonitor=0` reverts to - the exact legacy behavior (primary monitor, `EnumOutputs(0)`), editable via the - hot-reloaded ini without a rebuild - safety for the untestable multi-monitor - path. -4. **Multi-GPU degrades gracefully, does not chase across adapters.** If the - target monitor is on a different GPU than our D3D device, we keep the current - monitor rather than rebuild the entire device. Documented limit. -5. **Pure logic untouched.** `CursorMapper` and `transform` stay in local - monitor space exactly as today - no changes, no test churn there. - -## Architecture - -### Coordinate model - -Introduce one concept: the **target monitor origin** `(originX, originY)` = the -monitor's top-left in virtual-desktop pixels. Everything else stays in *local* -monitor pixels (the mapper, `ComputeOffsetF`, the shaders, the overlay UVs). -Conversion happens at exactly the two OS boundaries: - -- **Reading the cursor:** `mapper.reset(GetCursorPos - origin)` -> local. -- **Writing the cursor:** `SetCursorPos(clickDesktop + origin)` -> virtual. - -Because the process is Per-Monitor-V2 DPI aware, every coordinate we touch -(`MonitorFromPoint`, `GetMonitorInfo` rect, `DXGI_OUTPUT_DESC.DesktopCoordinates`, -`GetCursorPos`) is in the unified **physical-pixel** virtual-desktop space, so the -arithmetic is consistent across mixed-DPI and mixed-resolution monitors. - -### New shared type (`render_engine.h`) - -```cpp -struct MonitorTarget { - int x = 0, y = 0; // top-left in virtual-desktop pixels - int w = 0, h = 0; // size in physical pixels - wchar_t device[32] = {}; // GDI/DXGI device name (\\.\DISPLAYn); 32 = CCHDEVICENAME -}; -``` - -`device` matches the GDI monitor to its DXGI output **by name** (robust; avoids -rect-comparison ambiguity). `render_engine.h` is a Win32-side header (not in the -pure test set), and `wchar_t[32]` needs no ``. - -### Monitor detection (`main.cpp` helpers) - -- `MonitorTarget MonitorUnderCursor()` - `MonitorFromPoint(GetCursorPos, - MONITOR_DEFAULTTOPRIMARY)` + `GetMonitorInfoW` -> fills rect + `szDevice`. - Falls back to `PrimaryMonitor()` if the query fails. -- `MonitorTarget PrimaryMonitor()` - origin `(0,0)`, `SM_CXSCREEN/CYSCREEN`, empty - device name. Used for `multiMonitor=0` and as the universal fallback. - -### Engine changes (`render_engine.cpp` / `.h`) - -1. **`initialize(const MonitorTarget&, int zorderBand, bool hdrTonemap)`** - - replaces `initialize(int w, int h, ...)`. Stores `originX/originY`, `sw/sh`, - and `targetDevice`; creates the overlay at `(x, y, w, h)` instead of - `(0, 0, w, h)`. - -2. **`recreateDupl()` output selection** - enumerate the **device's own adapter** - outputs and pick the one whose `DXGI_OUTPUT_DESC.DeviceName == targetDevice`; - fall back to `EnumOutputs(0)` when the device name is empty or unmatched. On a - single-monitor system this is byte-for-byte the current behavior (one output, - name matches or empty -> output 0). - -3. **`bool retarget(const MonitorTarget&)`** (new): - - Returns `true` as a no-op if the target equals the current one (cheap string - + rect compare). - - **Validates first:** finds the matching output on our adapter. If not found - (e.g. the monitor is on a second GPU), **returns `false` and changes - nothing** - we never display monitor A's pixels on monitor B's overlay. - - Otherwise reconfigures, all while the overlay is still at alpha 0 (pre-reveal - during zoom-in, so no flash): - - `SetWindowPos` to the new rect (keeps topmost, no activate). - - If the size changed: release the RTV -> `swap->ResizeBuffers(1, w, h, - BGRA8, 0)` -> recreate the RTV. - - Recreate `desktopCopy` at the new size (see `ensureDesktopCopy` below). - - Drop the duplication and set the fresh-capture flags (`dupl=null`, - `haveDesktop=false`, `freshCapture=true`, `prevSrcValid=false`) - identical - to `invalidateCapture()`, so the drain-to-latest still runs. - - Reset `lastClickX/Y = INT_MIN` so the first `SetCursorPos` on the new - monitor is not skipped. - - Update `originX/originY`, `sw/sh`, `targetDevice`. - -4. **`ensureDesktopCopy` becomes size-aware** - track `copyW/copyH` in addition to - `copyFormat`, and recreate when either differs. Today it keys only on format, so - a same-format / different-size monitor would otherwise keep a stale-size texture. - -5. **`debugInfo` / origin getters** - expose `originX/originY` (or pass them back - from `retarget`) so `main.cpp` can keep its mapper in sync and the - verification paths can convert coordinates correctly. - -### Zoom-in flow (`main.cpp` `RunTick`) - -`TickState` gains `int originX, originY`. On the zoom-in transition: - -```text -if (zoomIn): - if (cfg.multiMonitor): - MonitorTarget t = MonitorUnderCursor() - if renderEngine.retarget(t): # no-op if same monitor - if t changed: # different monitor, succeeded - originX/originY = t.x/t.y - mapper = CursorMapper(t.w, t.h, sensitivity, smoothing) # new size - sw/sh = t.w/t.h - # retarget()==false -> keep previous monitor/origin/mapper (graceful) - POINT pt; GetCursorPos(&pt) - mapper.reset(pt.x - originX, pt.y - originY) # convert to local - hideSystemCursor(true) - invalidateCapture() # (skip if retarget already invalidated) - ... existing render + reveal sequence, unchanged ... -``` - -Filling render params: `p.clickDesktopX = r.clickDesktopX + originX` (and `Y`), -so the engine's `SetCursorPos` receives virtual coords. `cursorScreenX/Y` stay -local (overlay-relative) and are unchanged. The recenter path and the -config-reload mapper rebuild apply the same `- origin` convention. - -### Cost - -`retarget()` fires only when the monitor **changes** between zoom-ins: -- **Same monitor** (the common case): a string + rect compare, no - reconfiguration, no allocation. -- **Different monitor:** a one-time reconfiguration folded into the zoom-in - transition, which already invalidates and drains the capture every zoom-in. - -There is **no per-frame overhead** and no change to the steady-state render loop. - -## Error handling / edge cases - -- **Detection failure** (`MonitorFromPoint`/`GetMonitorInfo` fail): fall back to - `PrimaryMonitor()`; behave as single-monitor. -- **Output not found on our adapter** (multi-GPU): `retarget()` returns `false`; - keep the current monitor. The magnifier appears on the previous monitor rather - than crashing or showing the wrong content. -- **`ResizeBuffers` / RTV recreate failure:** log via `RLog`; leave the engine in - its prior valid state (return `false`); the zoom-in proceeds on the old monitor. -- **`multiMonitor=0`:** `initialize` uses `PrimaryMonitor()`, `retarget` is never - called, `recreateDupl` falls back to `EnumOutputs(0)` (empty device name) - - identical to today. - -## Testing / verification - -- **Pure unit tests:** unchanged - `CursorMapper`/`transform` are untouched. - `build.bat test` must still pass (existing 31 cases / 91 assertions). One new - `test_config` case for `multiMonitor` parsing + default. -- **Single-monitor regression (the testable path):** the dev machine is single - display. With `device` empty and origin `(0,0)`, the new code paths reduce to - the current behavior (`EnumOutputs(0)`, overlay at `(0,0)`, no origin offset). - A normal zoom session must look and behave identically; this is the primary - guard against regression. -- **Multi-monitor (cannot be tested here):** heavy `RLog` instrumentation around - detection + `retarget` (chosen device name, matched output index, old/new rect, - ResizeBuffers result) so that if the user ever runs two monitors, - `%TEMP%\wind_render.log` shows exactly what was selected and why. Correctness is - argued from the coordinate model rather than observed. - -## Out of scope (YAGNI) - -- Continuous cross-monitor panning while zoomed. -- Spanning all monitors with a single overlay / multiple simultaneous captures. -- Chasing a monitor onto a different GPU (multi-GPU follow). -- Auto-zoom-out on monitor change (unreachable state; see Decision 2). - -## Files touched - -- `src/render_engine.h` - `MonitorTarget` struct; `initialize` signature; - `retarget` declaration; origin getter. -- `src/render_engine.cpp` - overlay placement, output-by-name selection, - `retarget`, size-aware `ensureDesktopCopy`, `RLog` instrumentation, smoke-test - call site. -- `src/main.cpp` - `MonitorUnderCursor`/`PrimaryMonitor` helpers; `TickState` - origin fields; zoom-in retarget + origin-corrected coords; `clickDesktop + - origin`; selftest/pacingtest call sites. -- `src/config.h` / `src/config.cpp` - `multiMonitor` key (default 1) + default-ini - line. -- `tests/test_config.cpp` - `multiMonitor` parse + default assertions. -- `tools/uiaccess_setup.ps1` - add `multiMonitor=1` to the deployed ini. -- `CLAUDE.md` - note multi-monitor follow-cursor + the multi-GPU limit. diff --git a/docs/superpowers/specs/2026-05-26-structure-refactor-design.md b/docs/superpowers/specs/2026-05-26-structure-refactor-design.md deleted file mode 100644 index 767db1f5..00000000 --- a/docs/superpowers/specs/2026-05-26-structure-refactor-design.md +++ /dev/null @@ -1,172 +0,0 @@ -# Wind - Structure Refactor, PR-A (Design Spec) - -**Date:** 2026-05-26 -**Status:** Approved for planning -**Branch:** `feat/structure-refactor` off `feat/own-renderer` -**Issue:** #34 (from the 2026-05-25 code audit) - -## Goal - -Purely internal cleanup with **zero behavior change**. Shrink the 1046-line -`render_engine.cpp` by extracting four self-contained concerns into their own -units, remove three duplicated parameter-fill blocks and a duplicated button -mapping in `main.cpp`, and log the currently-silent failure points in -`initialize`. Each result is smaller, single-responsibility, and easier to reason -about. The ComPtr RAII migration (retiring the hand-maintained `shutdown()` -release list) is explicitly deferred to a separate follow-up (PR-B), because it -touches ~40+ COM call sites and lifetime/ordering, and none of this D3D code is -covered by automated tests - isolating it keeps any regression bisectable. - -## Background / why - -The 2026-05-25 audit flagged `render_engine.cpp` as doing too much: alongside the -actual engine (device, swapchain, capture, render, retarget, initialize) it also -holds DisplayConfig HDR queries, HCURSOR decoding, the HLSL shader sources, and -the WIC PNG-dump used only for verification. `main.cpp` repeats the same -`RenderFrameParams` fill in three places (the tick and the two self-test blocks) -and encodes the "xbutton id -> zoom in/out held" mapping twice (the `WH_MOUSE_LL` -hook in `input_router.cpp` and the `WM_INPUT` path in `main.cpp`), with the -configured button ids stored in two places. Several `return false` paths in -`initialize` are silent, so an init failure on a user's machine leaves no trace. - -None of this changes runtime behavior. It is the kind of tidying a good developer -does while in the code, and it was explicitly requested. - -## Decisions (locked during brainstorming) - -1. **Two PRs.** PR-A (this spec) = file splits + `main.cpp` de-dup + HRESULT - logging, all behavior-identical mechanical moves. PR-B (separate spec/plan) = - ComPtr RAII. Sequencing the risky lifetime change on its own makes a - regression bisectable on an untestable-by-CI surface. -2. **Branch off the merged `feat/own-renderer`.** Multi-monitor (PR #33) is - already merged, so there is no conflict; the refactor includes that code. -3. **Verbatim moves.** The extracted code (HDR queries, cursor decode, shaders, - PNG encode) moves unchanged - same logic, just relocated behind a clear - interface. No "while I'm here" rewrites. -4. **`build.bat` is not modified.** The app build globs `src\*.cpp` (new `.cpp` - files are picked up automatically); the test build lists only the pure files, - which correctly excludes the new Win32 units. - -## Architecture - -### New files (each: one responsibility, well-defined interface) - -- **`src/com_util.h`** (header-only) - the `SafeRelease(T*&)` template, shared - by `render_engine.cpp` and `png_dump.cpp` (removes the duplicate). Naturally - shrinks/retires in PR-B. - -- **`src/hdr_info.{h,cpp}`** - `bool GetHdrEnabled();` and - `double GetSDRWhiteNits();`. DisplayConfig queries for whether Windows HDR is - on and the SDR white level (nits). No engine state; depends only on - ``. Moved verbatim from `render_engine.cpp`. - -- **`src/cursor_decode.{h,cpp}`** - `bool DecodeCursorBGRA(HCURSOR hc, - std::vector& out, int& w, int& h, int& hotX, int& hotY, bool& - isInvert);`. Decodes an `HCURSOR` to top-down 32bpp BGRA, handling color and - invert-style (I-beam) cursors. Moved verbatim. - -- **`src/render_shaders.h`** (header-only) - the `kMagHLSL` and `kCursorHLSL` - shader source strings (as `inline` constants) and the `MagCB` constant-buffer - struct (kept beside the HLSL whose `cbuffer` layout it must mirror). Consumed - only by `render_engine.cpp`. - -- **`src/png_dump.{h,cpp}`** - `bool SaveTextureToPng(ID3D11Device* dev, - ID3D11DeviceContext* ctx, ID3D11Texture2D* tex, const wchar_t* path);`. The WIC - staging-copy + PNG-encode currently inside `RenderEngine::dumpBackbufferPng`. - Verification-only. - -### `render_engine.cpp` after - -Keeps the engine proper: `State`, `selectOutput`, `recreateDupl`, -`ensureDesktopCopy`, `capture`, `recreateRtv`, `retarget`, `updateCursorTexture`, -`render`, `initialize`, `setVisible`, `invalidateCapture`, `renderFrame`, -`hideSystemCursor`, `shutdown`, plus the small `CompileShader` D3D helper, -`OverlayProc`, `CursorRestoreFilter`, and `RLog`. It `#include`s the new headers. - -- `updateCursorTexture` calls `DecodeCursorBGRA` from `cursor_decode.h` (was a - file-local static). -- `recreateDupl` / `initialize` call `GetHdrEnabled` / `GetSDRWhiteNits` from - `hdr_info.h`. -- The shader pipeline setup references `kMagHLSL` / `kCursorHLSL` / `MagCB` from - `render_shaders.h`. -- `RenderEngine::dumpBackbufferPng` becomes a thin wrapper: get back-buffer 0, - call `SaveTextureToPng(device, ctx, back, path)`, release. `dumpFrame` is - unchanged (it renders then calls `dumpBackbufferPng`). -- `SafeRelease` comes from `com_util.h` (local definition removed). - -Net: roughly 1046 -> ~770 lines, focused on the engine. - -### `main.cpp` de-dup - -- **`FillRenderParams`**: a `static void FillRenderParams(RenderFrameParams& p, - const MapResult& r, const Config& cfg, const MonitorTarget& mon, double level)` - that fills the common fields (level, srcLeft/srcTop, cursorScreen, `clickDesktop - + mon.x/y`, cursorScaleWithZoom, bilinear, motionBlur/strength, brightness, - `cursorMode = CursorModeFromCfg(cfg)`, the cfg-derived `vsync`). `RunTick` calls - it directly. The `WIND_SELFTEST` and `WIND_PACINGTEST` blocks call it, then - override only the fields they intentionally differ on (selftest: `cursorMode = - 1`, `vsync = true`; pacingtest: `cursorMode = 1`, `vsync = cfg.vsync`, - `motionBlur = false`). This collapses ~10 repeated assignments x3. - -- **Button-state unify**: move the configured button ids into `InputRouter` - members and add `void InputRouter::setButtonState(int xbuttonId, bool down)` - (maps id -> `inHeld`/`outHeld`) and `bool InputRouter::isZoomButton(int - xbuttonId) const`. The `WH_MOUSE_LL` hook (`MouseProc` in `input_router.cpp`) - and the `WM_INPUT` path (`main.cpp`) both call `setButtonState`; the hook keeps - its swallow decision via `isZoomButton`. `main.cpp`'s `SetZoomButton` function - and its `g_zoomInBtnId` / `g_zoomOutBtnId` statics are removed. The file-static - `g_inButtonId` / `g_outButtonId` in `input_router.cpp` become `InputRouter` - members set in `start()`. - -### HRESULT logging in `initialize` - -Add an `RLog(...)` carrying the `HRESULT` (where one exists) immediately before -each currently-silent `return false` in `initialize`: `D3D11CreateDevice`, the -`QueryInterface(IDXGIDevice1)` / `GetAdapter` / `GetParent(IDXGIFactory)` chain, -`CreateSwapChain`, `GetBuffer`, `CreateRenderTargetView`, the vertex/pixel shader -compiles and creates, `CreateBuffer` (x2), the blend states (x2), and the -samplers. Diagnostic-only; control flow is unchanged. - -## Error handling - -No new runtime behavior. The only additions are `RLog` lines on existing failure -paths. The extracted functions keep their existing return-value contracts -(`DecodeCursorBGRA` returns false on failure; the HDR queries return their current -defaults; `SaveTextureToPng` returns false on any WIC/D3D failure exactly as the -inlined code did). - -## Testing / verification - -- **Unit tests unchanged.** `transform`, `zoom_controller`, `config`, - `cursor_mapper` are untouched; `build.bat test` must still pass (32 cases / 94 - assertions). No new pure units are introduced (the extracted code is all - Win32/D3D). -- **App build.** `build.bat` must stay clean (no warnings under `/W4`). The new - `.cpp` files compile via the existing `src\*.cpp` glob. -- **Behavior-identical manual check (single monitor, the testable path):** zoom - in/out, sub-pixel pan, click routing, cursor visibility modes, and - `WIND_SELFTEST=1` PNG dump must all be identical to before. The one - behavior-adjacent change to confirm is the input unify: the configured side - button still zooms, and browser back/forward are still swallowed while Wind - runs. -- **Diff discipline:** the moved code should be verbatim (a reviewer can diff the - extracted function against the original). Any change beyond relocation + - include wiring + the documented `main.cpp` de-dup is out of scope. - -## Out of scope (PR-B and beyond) - -- ComPtr RAII for the COM objects and retiring the hand-maintained `shutdown()` - release list. -- Splitting the core engine logic (capture/render/retarget/initialize) - it is - cohesive and stays in `render_engine.cpp`. -- Any behavior, performance, or API change. - -## Files touched - -- **New:** `src/com_util.h`, `src/hdr_info.{h,cpp}`, `src/cursor_decode.{h,cpp}`, - `src/render_shaders.h`, `src/png_dump.{h,cpp}`. -- **Modified:** `src/render_engine.cpp` (extractions + includes + dumpBackbufferPng - wrapper), `src/main.cpp` (FillRenderParams, button-state via InputRouter, - remove SetZoomButton/statics), `src/input_router.{h,cpp}` (button ids as - members, `setButtonState`/`isZoomButton`). -- **Unchanged:** `build.bat`, all pure-logic units and their tests. diff --git a/docs/superpowers/specs/2026-05-27-config-ui-design.md b/docs/superpowers/specs/2026-05-27-config-ui-design.md deleted file mode 100644 index 4b3e0b8a..00000000 --- a/docs/superpowers/specs/2026-05-27-config-ui-design.md +++ /dev/null @@ -1,201 +0,0 @@ -# Wind Config UI - Design - -**Branch:** `feat/config-ui` (stacked on `feat/zoom-config` so the settings schema matches the -current config keys; rebase onto `main` after that merges). -**Status:** approved (design), ready for implementation plan. - -## Goal - -Give real users a clean GUI to adjust Wind's settings, plus a guided first-launch onboarding - -without touching the magnifier's performance. The app still starts to the tray; the user opens the -config from there. On first launch the config opens automatically as a guided onboarding flow -(set a keybind + a couple of feel prefs), distinct from the normal settings. The look should adapt -to OS light/dark, be easy to restyle and extend, and leave room to add a user DB / licensing later. - -## Locked decisions (from brainstorming) - -1. **Separate process**, communicating only via `magnifier.ini` (which the core already hot-reloads - via its dir-watch). The zoom core is never touched by UI work -> zero perf coupling. -2. **Web frontend (HTML/CSS/JS) in a thin C++ WebView2 host** - stays in the existing C++/MSVC - toolchain; no bundled browser runtime. -3. **Separate `WindConfig.exe`** (not a `Wind.exe --config` mode) - keeps the perf-critical core - lean and free of the WebView2 dependency, and doesn't complicate the core's UIAccess signing. -4. **Keep `magnifier.ini`** as the shared format. The host does **surgical** key read/writes that - preserve comments, order, and hand-edits. Core parser unchanged. -5. **Svelte + Vite** frontend (tiny compiled output, good structure as it grows). Adds a Node build - step for the UI only. -6. **Onboarding = guided essentials:** Welcome -> set zoom keybind -> zoom-speed + smooth-zoom -> - "you're set (lives in the tray)". - -## Phasing - -**MVP (this plan):** functional settings end to end - `WindConfig.exe` host + bridge, the surgical -ini module, a schema-driven Svelte settings screen with **live-apply**, the `build.bat config` -target, and a tray **"Open Settings"** entry that launches it. Styling is clean-but-basic. - -**Later (separate plans):** the guided **onboarding** flow + first-launch auto-spawn (`onboarded` -key), and the **visual polish** (full Tabby-style theming, light/dark tokens, sidebar sections). -The MVP is built so these slot in without rework (schema-driven UI, mode flag reserved). - -## Architecture & data flow - -``` -Wind.exe (core, perf-critical) WindConfig.exe (new, on-demand) - - magnifier + tray - Win32 window hosting WebView2 - - hot-reloads magnifier.ini (dir-watch) - loads built Svelte UI - - first launch: spawns WindConfig --onboard - JS<->C++ bridge - - tray "Open Settings": spawns WindConfig - surgical read/write of magnifier.ini - \ / - \ / - v v - magnifier.ini (the ONLY shared contract) -``` - -The UI writes a key -> the core's dir-watch fires -> the core reloads and applies it live (the user -sees it by zooming). No IPC, no protocol between the two processes. Writes are **atomic** -(write temp file, then `MoveFileEx` replace) so the core never reads a half-written file. - -## `WindConfig.exe` - the C++ host - -Thin and single-purpose: - -- Creates a borderless-or-standard Win32 window, initializes the WebView2 Evergreen runtime - (`CreateCoreWebView2EnvironmentWithOptions`). -- Serves the built Svelte assets from a folder next to the exe via - `ICoreWebView2::SetVirtualHostNameToFolderMapping` (e.g. `https://wind.config/` -> - `./ui/`), and navigates to `https://wind.config/index.html`. (Virtual-host mapping avoids - `file://` SPA quirks.) -- Runs a small **message bridge**. JS posts JSON messages (`window.chrome.webview.postMessage`); - the host handles them and replies via `PostWebMessageAsJson`: - - `{type:"getConfig"}` -> `{type:"config", values:{key:value,...}}` (parsed from the ini; missing - keys fall back to the core's defaults, which the host also knows). - - `{type:"setConfig", key, value}` -> surgical ini update (see below), then ack. - - `{type:"completeOnboarding"}` -> set `onboarded=1`. - - `{type:"openIniInEditor"}` -> launch the ini in the default editor (power-user escape). - - `{type:"close"}` -> close the window. -- Keybind capture happens in JS (see frontend); the host just persists the resulting value. -- Launched with `--onboard` to start in the onboarding flow; otherwise normal settings. The host - forwards this mode to the UI via a query param or an initial message. - -### Surgical ini read/write (host, unit-tested pure logic) - -A small module (mirrors the style/testability of `src/config.cpp`'s pure half): -- **Read:** parse `key=value` lines into a map (reuse the same trim/comment rules as the core). -- **Write `setValue(key, value)`:** load the file's lines; if a non-comment line matches `key=`, - replace its value in place (keep the rest of the line/section/comments); else append `key=value` - at the end. Write to a temp file in the same dir, then atomically replace the original. -- Preserves comments, ordering, and unknown keys (hand-edits survive). Never rewrites the whole file - from a template. - -## Svelte frontend - -A single Svelte+Vite app with two entry flows selected by the launch mode: - -### Settings (normal mode) - Tabby-style -- **Left sidebar:** sections - **Zoom**, **Cursor**, **Display**, **Advanced**, **About**. -- **Main panel:** grouped rows, each = label + short description + control. Control types: - toggle, slider (with a live numeric readout), number input, dropdown, and a **keybind capture**. -- **Live apply:** every control change calls `setConfig` immediately (no Save button); the core - hot-reloads and the change is live. A **"Reset to defaults"** affordance (per-section or global). -- Section -> setting mapping (initial): - - **Zoom:** keybind(s), `zoomInSpeed`, `zoomOutSpeed`, `smoothZoom`, `smoothZoomAccel`, - `smoothZoomRamp`, `maxLevel`. - - **Cursor:** `cursorSensitivity`, `cursorSmoothing`, `cursorScaleWithZoom`, `cursorVisibility`. - - **Display:** `bilinear`, `brightness`, `hdrTonemap`, `multiMonitor`. - - **Advanced:** `vsync`, `dwmFlush`, `cropCapture`, `diagnostics`, `zorderBand`, "Edit config file". - - **About:** version, links (GitHub etc.), placeholder for a future account/licensing area. - -### Onboarding (`--onboard`) - guided essentials -A centered card with steps: **Welcome** -> **Set zoom keybind** (capture a key or a mouse -side-button) -> **Feel** (`zoomInSpeed`/`zoomOutSpeed` slider + `smoothZoom` toggle) -> **Done** -("Wind lives in your tray - open Settings any time"). Writes live as you go; **Finish** sends -`completeOnboarding` and closes. - -### Schema-driven -A single `settings-schema` (one entry per setting: `iniKey`, `type`, `label`, `description`, -`section`, and `min`/`max`/`step` or `options`) drives both the rendering and the value mapping. -Adding a setting = one schema entry; adding a section = a sidebar entry. This keeps the UI and the -ini keys in sync and makes the UI trivial to extend. - -### Keybind capture -A capture control that, while focused, listens for `keydown` (-> Virtual-Key code, for -`zoomInVk`/`zoomOutVk`) and `pointerdown`/`mousedown` with `event.button === 3/4` (-> XBUTTON1/2, -for `zoomInButton`/`zoomOutButton`). Shows the captured binding; writes the matching ini key. Lets -the user pick a mouse side-button (the default) or a keyboard key. - -## Theming - -CSS custom properties (design tokens) in one file; `@media (prefers-color-scheme: dark/light)` -switches the token set automatically. Tabby-like visuals: accent-colored active sidebar item, -rounded toggles, generous spacing, section headers. Restyling = edit the tokens file. - -## First-launch detection & tray (core changes) - -- Add `onboarded` (int, default `0`) to `Config` + the default-ini text (the only core-side addition - besides spawning). -- `Wind.exe` startup (off the hot path): if `onboarded == 0` (covers a freshly created ini too), - `ShellExecute`/`CreateProcess` `WindConfig.exe --onboard`, then continue to the tray as normal. - The onboarding flow sets `onboarded=1` on finish, so it never auto-opens again. -- Tray menu: **Open Settings** (spawn `WindConfig.exe`), keep **Edit config file** (opens the ini in - the editor) for power users, **Quit**. (Replaces today's notepad-only "Edit config".) -- The core never blocks on or waits for the UI; it just spawns it and moves on. - -## Build & packaging - -- New `ui/` folder: Svelte + Vite project. `npm install` + `npm run build` -> `ui/dist/`. -- `WindConfig.exe` built by MSVC (a new target in `build.bat`, e.g. `build.bat config`), linking - the WebView2 loader (`WebView2LoaderStatic.lib` or the NuGet/SDK headers vendored under - `third_party/`). -- Shipping layout: `Wind.exe`, `WindConfig.exe`, and the built UI assets in `./ui/` beside them. -- WebView2 runtime: rely on the **Evergreen** runtime (preinstalled on Windows 11; a bootstrapper - can be added for older Win10 if needed - out of scope for v1). -- Dev loop: load `ui/dist` (rebuild on change), or point the host at the Vite dev server during - development. - -## Performance isolation (the hard requirement) - -`WindConfig.exe` is a separate, normal-priority process that exists only while open. It shares -nothing with the core except the ini file, and writes it atomically. The core's render loop, -threads, pacing, and hot-reload are untouched. There is no measurable path by which the config UI -can affect zoom performance. - -## Extensibility / future licensing - -- New setting = one schema entry; new section = one sidebar entry + panel. The host bridge already - exposes generic get/set. -- A future **account / licensing** screen is just another section/flow in the Svelte app; the host - can gain a network call or talk to a license server. License tokens / user data live in their own - store (e.g. a separate file or the OS credential store), **not** in `magnifier.ini`. The - separate-process web architecture is well suited to an account UI. - -## Testing - -- **Host surgical ini module:** pure unit tests (doctest, like `tests/test_config.cpp`) - update an - existing key in place, append a missing key, preserve comments / order / unknown keys, and a - read-modify-write round-trip. Atomic-replace verified by writing and re-reading. -- **UI (Playwright E2E)** against the built Svelte app with a **mock bridge** (a stub that records - `setConfig` calls and serves `getConfig`): settings render from a schema, toggling a control emits - the correct `setConfig{key,value}`, the onboarding flow steps through and emits - `completeOnboarding`, and light/dark render correctly via the emulated color scheme. (Matches the - project rule to prefer Playwright for UI.) -- **Manual:** first-run auto-onboarding, tray "Open Settings", and live-apply (adjust a value, then - zoom to see it change). - -## File structure (new) - -- `src/config_ui/`: the C++ host - `main.cpp` (window + WebView2 + bridge), - `ini_edit.{h,cpp}` (pure surgical read/write, unit-tested). Built to `WindConfig.exe`. -- `ui/`: Svelte + Vite app - `src/` (App, Settings, Onboarding, components, `settings-schema.ts`, - theme tokens), `package.json`, `vite.config.*`. -- `tests/test_ini_edit.cpp`: host ini-module unit tests. -- `ui/tests/`: Playwright specs. -- `build.bat`: a `config` target for `WindConfig.exe`. -- Core: `src/config.{h,cpp}` gains `onboarded`; `src/main.cpp` gains the first-launch spawn; - `src/tray.cpp` gains "Open Settings". - -## Out of scope (v1) - -- Actual login / licensing / user DB (architecture leaves room; not built now). -- Config sync across machines. -- A WebView2 bootstrapper for old Windows 10 (rely on Evergreen for now). -- In-window live magnifier preview (the live-apply + zoom-to-see loop covers it). diff --git a/docs/superpowers/specs/2026-05-31-logging-observability-design.md b/docs/superpowers/specs/2026-05-31-logging-observability-design.md deleted file mode 100644 index 82c31bdd..00000000 --- a/docs/superpowers/specs/2026-05-31-logging-observability-design.md +++ /dev/null @@ -1,169 +0,0 @@ -# Logging / Observability Design - -**Status:** Approved (brainstorm). Issue #81. -**Date:** 2026-05-31 - -## Goal - -Make customer-reported bugs diagnosable without reproducing them locally. Wind runs across -display configurations the author cannot fully test (resolution, HDR, refresh, rotation, DPI -scaling, VRR, GPU vendor/driver, PC specs). When a customer hits a bug or crash, a single -artifact they send back should contain enough to diagnose and fix it. - -## Constraints - -- **Local now, server later.** No network code today. The subsystem must produce a self-contained - diagnostic bundle that is written/zipped locally now, and can later be POSTed to a server by - swapping only the delivery step. The bundle format is the stable seam. -- **Zero hot-path cost.** Wind's value is a smooth, low-latency magnifier. Logging must add no - per-frame work. This is the primary non-functional requirement. -- **Lean.** No third-party crash/telemetry SDK. Standard Win32 (`MiniDumpWriteDump`, DXGI/WMI - queries) only. No new heavyweight dependency. -- **Program Files safe.** The deployed UIAccess build runs from `C:\Program Files\Wind` (read-only - for the non-admin runtime), so all log/dump writes go to a per-user-writable location. - -## Approach - -Approach B from the brainstorm: a single unified logging module that all code uses, replacing the -three current ad-hoc loggers. Chosen over a minimal bolt-on (keeps the scattered ephemeral -`%TEMP%` logs, awkward server path) and over a third-party SDK (heavyweight, backend-oriented, -premature while local-only). - -## Architecture - -A new module `src/logging.{h,cpp}` exposing one API used everywhere: - -``` -namespace wind { - enum class LogLevel { Info, Warn, Error }; - void LogInit(const wchar_t* processTag); // "core" or "config"; resolves path, rotates, opens - void Log(LogLevel lvl, const char* category, const char* fmt, ...); - void LogShutdown(); // flush + close -} -``` - -- Pure, testable helpers (line formatting, rotation decision, snapshot string assembly) live in a - section compiled into the `WIND_TESTS` build with no ``. The Win32 I/O (file handle, - path resolution, `MiniDumpWriteDump`, device/monitor queries) is excluded from the test build, - matching the existing pure/Win32 split. -- Replaces `RLog` (render), `SiLog` (single-instance), and the always-on parts of `DiagLog`. The - opt-in frame-pacing trace (`diagnostics=1`) keeps its own path and behaviour, unchanged. -- Both binaries link it. `Wind.exe` calls `LogInit(L"core")`, `WindConfig.exe` calls - `LogInit(L"config")`. Each process writes its **own** file, so there is no cross-process file - contention and no shared-handle locking. - -## Performance design (primary requirement) - -- **Event-driven only.** `Log()` is called on state transitions: startup, shutdown, zoom in/out - begin/end, device-lost and recovery, monitor retarget, config hot-reload, HDR toggle, and on any - warning/error. All are rare (human-scale or error-scale, not frame-scale). -- The per-frame tick loop and `renderFrame` contain **no** `Log()` calls. This is a hard rule and - is grep-verifiable (no `Log(` inside the frame path). -- Net steady-state cost on the hot path: **zero**. The system snapshot runs once at startup; the - crash handler only runs while already crashing. -- **Synchronous, buffered.** A single log file handle is kept open per process (not reopened per - line as `RLog` does today). Writes are a formatted append plus a flush on `Warn`/`Error`. Because - events are infrequent, this costs nothing measurable and needs no background thread. An async ring - buffer is explicitly deferred (YAGNI) until/unless hot-path tracing is ever required. - -## Log store - -- Location: `%LOCALAPPDATA%\Wind\logs\` (the same per-user-writable root the ini already falls back - to, via `ResolveIniPath`'s base; works in the Program Files deploy). Created on first run. -- Files: `wind-core.log` and `wind-config.log`. -- Format, one line per event: - `2026-05-31T08:14:22.137Z WARN render recreateDupl failed hr=0x887A0005` - (UTC ISO-8601 timestamp with ms, fixed-width level, category, message). UTC avoids timezone - ambiguity when reading a customer's log. -- **Rotation (bounded disk).** On `LogInit`, if the current file exceeds **1 MB** it is rotated: - `wind-core.log` -> `wind-core.1.log` -> `wind-core.2.log`, keeping **3 generations** (~3 MB max - per process). Crash artifacts (below) keep the **most recent 3** dump+summary pairs; older ones - are deleted on startup. Total footprint is bounded and self-pruning, so a customer's disk never - grows unbounded. - -## System snapshot - -Written once at the top of every session (right after `LogInit`), as a labelled block in the log: - -- Wind version (from `VERSIONINFO`) and build flavour (normal vs uiaccess). -- OS: Windows build number + edition. -- CPU model, logical core count, total RAM. -- GPU: adapter description + driver version (per DXGI adapter; note the active adapter). -- Monitor topology, per monitor: device name, resolution, refresh rate, HDR enabled, rotation, DPI - scaling percent, and VRR / adaptive-sync state where queryable. -- The active resolved `Config` (the same values the core is running with). - -This block is the direct answer to "works on my machine": one glance shows the customer's exact -display and hardware reality. - -## Crash handler - -Extends the existing `SetUnhandledExceptionFilter` net (which today only restores the cursor) to -also, before the process dies, write into the log folder: - -- A **minidump**: `wind-crash-.dmp` via `MiniDumpWriteDump` (normal + thread/handle - data). Opens in Visual Studio / WinDbg against the matching PDB to give the exact faulting call - stack. -- A **text summary**: `wind-crash-.txt` with the exception code, faulting module + - offset, a register snapshot, and the system-snapshot block. Readable with no tools for a quick - first look. - -The handler does the minimum safe work (no allocation-heavy paths) since the process is already -unwinding a fault. Cursor restoration remains. - -## Export + the server seam - -- An **"Export diagnostics"** action: a tray menu item on `Wind.exe` and a button in the WindConfig - settings UI. It zips the entire `%LOCALAPPDATA%\Wind\logs\` folder into - `Wind-diagnostics-.zip` on the Desktop and reveals it in Explorer. -- The code is split into **produce-the-bundle** (gather + zip) and **deliver-the-bundle** (save to - Desktop). The future server is a second delivery implementation (`POST` the same zip) selected at - the call site. The bundle contents and layout are the stable contract; nothing else changes when - the server arrives. -- Zip creation uses Windows' built-in PowerShell `Compress-Archive` (spawned via `CreateProcess`), - not a vendored zip library. Decision made during implementation: it avoids adding a ~250 KB - vendored dependency for a rare, user-initiated action, and the spawn cost (~300 ms, once per - click) is irrelevant for a manual export. To work when both `Wind.exe` and `WindConfig.exe` are - running (each holds its own log open), `ZipLogDir` first stage-copies the log files to a temp dir - with `CopyFileW` (which can read a file held open with `FILE_SHARE_READ` by another process), zips - that copy, then deletes it - so the live log handle is never closed and no lines are dropped. (The - original plan specified a vendored `miniz` writer; this is the recorded deviation.) - -## Build changes - -- Enable PDB generation for the release/uiaccess builds (`/Zi` compile, `/DEBUG` link) and **archive - the PDB per shipped version** (a minidump is only useful against its matching PDB). PDBs are not - shipped to customers; they are kept by the author alongside each release. -- Add a `VERSIONINFO` resource (in the existing `src/wind.rc`) so the version appears in Explorer - file properties and in every log line / crash report. A single source-of-truth version constant - feeds both the resource and the snapshot. - -## Scope - -In scope: the logger module, the log store + rotation, the system snapshot, the crash handler -(minidump + summary), the export-to-zip action on both binaries, and the build changes -(PDB + VERSIONINFO). Applies to both `Wind.exe` and `WindConfig.exe`. - -Out of scope (deliberately, for the server phase): any network/upload code, automatic collection, -consent/privacy UI, a third-party SDK, and any per-frame tracing beyond today's opt-in -`diagnostics=1` path. - -## Testing - -- **Unit (doctest, pure):** log-line formatting (timestamp/level/category layout), the rotation - decision (size threshold, generation shifting, count cap), and snapshot string assembly from - injected values. These compile into the existing `WIND_TESTS` build with no ``. -- **Manual / inspection (Win32):** force an unhandled exception (a gated self-test trigger) and - confirm a dump + summary are written and the dump opens with a correct stack; force a device-lost - and confirm the event is logged; trigger "Export diagnostics" and confirm the zip contains the - logs + snapshot; verify the snapshot values against a known machine. -- Verify the no-per-frame-logging rule by grep over the frame path. - -## Risks / notes - -- `MiniDumpWriteDump` from inside a faulting process must avoid heap-heavy work; keep the handler - minimal and pre-resolve the dump path at startup. -- VRR / adaptive-sync state is not uniformly queryable across drivers; capture it where available - and record "unknown" otherwise rather than failing the snapshot. -- The deployed build is UIAccess (higher integrity); confirm the log folder and zip write succeed - from that context (they target `%LOCALAPPDATA%`, which is writable, matching the ini path logic). diff --git a/docs/superpowers/specs/2026-06-03-quick-zoom-design.md b/docs/superpowers/specs/2026-06-03-quick-zoom-design.md deleted file mode 100644 index 2c0778e9..00000000 --- a/docs/superpowers/specs/2026-06-03-quick-zoom-design.md +++ /dev/null @@ -1,195 +0,0 @@ -# Quick zoom (double-tap toggle) - design - -Date: 2026-06-03 -Status: shipped (with revisions below) - -## Shipped revisions (post-implementation, per user iteration) - -The toggle/store/restore arithmetic (`ApplyQuickZoom`, `ZoomController::setLevel`) shipped as -designed, but the TRIGGER changed during testing: - -- The double-tap detector (`QuickZoomDetector`) was replaced. Quick zoom now fires on a held - MODIFIER key (Ctrl / Alt / Shift, configurable via `quickZoomModifier`) + a tap of either zoom - key. "None" disables quick zoom (it doubles as the on/off switch; the separate `quickZoom` enable - field was removed). The dedicated-hotkey variant that was briefly added was also removed. -- `quickZoomDefault` stays at 4.0 (400%) and is no longer surfaced in the config UI (ini-only). -- Unrelated addition this session: a "Show advanced settings" toggle (About section, key - `showAdvanced`) that hides rows flagged `advanced` in the config UI; plus an animated SVG checkbox - restyle. UI-only, no core coupling. -Related: `src/zoom_controller.{h,cpp}`, `src/main.cpp` (`RunTick`), `src/config.{h,cpp}`, -`ui/src/settings-schema.js` - -## Summary - -A double-tap on either zoom key (zoom-in or zoom-out, mouse side-button or keyboard, primary or -alternate binding) toggles the magnifier between fully zoomed out (1.0x, "0%") and a remembered -zoom level. Both keys do the same thing. - -- Zoomed in -> double-tap -> snap out to 1.0x. The level being left is remembered as the - "last quick-zoom level", but ONLY if it was more than 200% (level > 2.0). -- At 1.0x -> double-tap -> snap in to the last remembered level (or a configurable default of 4x - if nothing has been remembered yet this session). -- Levels at or below 200% are never remembered, so a quick-tap out from a shallow zoom does not - overwrite a previously remembered deeper level. - -Example (consistent with the user's intent): manually zoom to 540% -> double-tap -> 0% (540% -remembered) -> double-tap -> 540%. If at less than 200% -> double-tap -> 0% (nothing remembered) --> double-tap -> the previously remembered level (or 400% default if none). - -Internally the zoom level is a raw multiplier where 1.0 = no magnification ("0%"), 2.0 = "200%", -etc. All thresholds below are stated in that multiplier space. - -## Decisions (from brainstorming) - -- Hold-vs-double-tap: keep hold-to-zoom unchanged; detect the double-tap independently on top, and - let quick-zoom win. The tiny ramp produced by the two taps is immediately overridden by the snap. -- Transition: instant snap (no animated glide). -- Unset target: use a configurable default level (default 4x / 400%). -- Persistence: in-memory per session (no core-side ini write); resets on app restart. -- Binding scope: all configured zoom bindings are watched (mouse side-buttons + keyboard, primary + - alternate). A double-tap is two quick taps of the SAME channel; double-tapping either the zoom-in - or zoom-out channel fires the same toggle. -- Default level: 400% (4x), configurable. -- Config knobs: an enable toggle (default on) plus an adjustable double-tap window (default 300 ms). -- The 200% remember-threshold is a fixed constant (not user-configurable). - -## Architecture - -The work splits cleanly into pure logic (unit-testable, no ``) and the Win32 tick glue, -matching the project's existing separation. The pure logic lives in `zoom_controller.{h,cpp}`, -which is already compiled into both the app build (`src\*.cpp`) and the test build (`build.bat` -line 81), so no build-file changes are needed. - -### 1. Pure logic - `zoom_controller.h/.cpp` - -`ZoomController::setLevel(double l)` -: Clamp `l` to `[minLevel_, maxLevel_]` and set `level_` directly. This is the instant snap. - `dir_` is left untouched: after a double-tap the keys are released, so `ResolveDirection` - returns `None` on the next tick and `tick()` does nothing. (If the user keeps the second tap - held, hold-to-zoom resumes ramping from the snapped level, which is acceptable.) - -`QuickZoomDetector` -: Two independent channels (in, out). Each remembers the timestamp of its last down-edge. - -``` -class QuickZoomDetector { -public: - void setWindow(double seconds); // double-tap window - bool update(bool inEdge, bool outEdge, double nowSeconds); // fires once per completed double-tap - void reset(); -private: - double window_ = 0.3; - double lastInDown_ = -1e9; - double lastOutDown_ = -1e9; -}; -``` - -`update` logic, per channel whose edge fired this tick: -- if `nowSeconds - lastDown <= window_`: set `fire = true` and consume (`lastDown = -1e9`). -- else: record `lastDown = nowSeconds`. -- return `fire`. - -Two down-edges of the same channel within the window fire exactly once; consuming resets the -channel so a triple-tap is one double-tap then a fresh start. A double-tap of either channel -returns `true`, and the caller applies the same toggle regardless of which channel fired. - -### 2. Toggle application - `RunTick` (`src/main.cpp`) - -Placed AFTER `t.zoom.tick(...)` and BEFORE `double lvl = t.zoom.level();`, so the snap flows -through the same-tick zoom-in/zoom-out transition logic that already keys off `lvl` vs -`t.prevLvl`. No rendering special-casing: snap-out (to 1.0) triggers the existing zoom-out -transition (overlay hide, cursor restore); snap-in triggers the existing zoom-in transition -(monitor retarget, mapper reset to cursor, cursor hide, capture invalidate, reveal). - -``` -inEdge = inHeld && !t.prevInHeld; -outEdge = outHeld && !t.prevOutHeld; -t.prevInHeld = inHeld; t.prevOutHeld = outHeld; -if (t.cfg.quickZoom) { - t.quickZoom.setWindow(t.cfg.quickZoomWindowMs / 1000.0); // live, like setProfile - double nowSec = double(now.QuadPart) / double(t.freq.QuadPart); - if (t.quickZoom.update(inEdge, outEdge, nowSec)) { - double cur = t.zoom.level(); - if (cur > 1.0 + kEps) { // zoomed -> snap out to 0% - if (cur > kQuickZoomStoreThreshold) // > 2.0 (200%) - t.quickZoomStored = cur; - t.zoom.setLevel(1.0); - } else { // at 0% -> snap in - double target = (t.quickZoomStored > 0.0) - ? t.quickZoomStored - : t.cfg.quickZoomDefault; - t.zoom.setLevel(std::min(target, t.cfg.maxLevel)); - } - } -} -``` - -`nowSec` uses the QPC value already read at the top of `RunTick` (`now`, `t.freq`). -`kQuickZoomStoreThreshold = 2.0`. `quickZoomStored = 0.0` is the "nothing remembered yet" -sentinel (it is only ever set to a value > 2.0, so 0 unambiguously means unset). - -The store/restore arithmetic (the > 200% rule, the default fallback, the maxLevel clamp) is -extracted into a tiny pure free function so it can be unit-tested without the Win32 tick: - -``` -// pure, in zoom_controller -struct QuickZoomResult { double newLevel; double newStored; }; -QuickZoomResult ApplyQuickZoom(double cur, double stored, double def, double maxLevel); -``` - -New `TickState` fields: `double quickZoomStored = 0.0;`, `bool prevInHeld = false;`, -`bool prevOutHeld = false;`, `QuickZoomDetector quickZoom;`. - -### 3. Config + UI - -`Config` (src/config.h) gains, with defaults preserved when the key is missing: -- `int quickZoom = 1;` // enable (default on) -- `int quickZoomWindowMs = 300;` // double-tap window in milliseconds -- `double quickZoomDefault = 4.0;` // level used when nothing has been remembered yet - -Parsed in `config.cpp` alongside the existing keys. All three are hot-reloadable: the tick reads -`cfg.quickZoom`/`cfg.quickZoomDefault` directly and re-applies the window each tick via -`setWindow`, mirroring how the zoom profile is applied live. - -UI (`ui/src/settings-schema.js`), in the existing **Zoom** section: -- toggle: "Quick zoom (double-tap)" -> `quickZoom`, def 1 -- slider (gated on `quickZoom`): "Double-tap window (ms)" -> `quickZoomWindowMs`, e.g. 150-600, - step 25, def 300 -- slider (gated on `quickZoom` via `dependsOn`): "Quick-zoom default" -> `quickZoomDefault`, - min 2, max 50 (matching the maxLevel slider range), step 0.5, def 4.0 - -The WindConfig bridge round-trips arbitrary config keys, so no bridge change is required. - -### 4. Tests (`tests/`, pure) - -`QuickZoomDetector`: -- double-tap inside the window fires exactly once -- two taps outside the window do not fire -- channels are independent (in-tap then out-tap does not fire) -- either channel double-tapped fires -- triple-tap = one fire, then the third tap starts a fresh sequence -- a changed window via `setWindow` is respected - -`ZoomController::setLevel`: clamps to `[min, max]`. - -`ApplyQuickZoom`: -- zoomed and cur > 2.0 -> newLevel 1.0, newStored = cur -- zoomed and cur <= 2.0 -> newLevel 1.0, newStored unchanged (shallow zoom not remembered) -- at 1.0 with a stored level -> newLevel = stored (clamped to maxLevel) -- at 1.0 with nothing stored -> newLevel = default (clamped to maxLevel) - -## Out of scope / non-goals - -- No animated glide (instant snap only). -- No persistence across restarts (in-memory only). -- The 200% remember-threshold is not user-configurable. -- No mixed-channel double-tap (an in-tap followed by an out-tap does not trigger). - -## Edge cases - -- Snapping out hides the overlay and restores the OS cursor via the existing zoom-out transition. -- Snapping in to a level above `maxLevel` is clamped to `maxLevel`. -- If the user holds the second tap of a double-tap, hold-to-zoom resumes from the snapped level - (acceptable; quick-zoom still landed the snap first). -- Very fast taps (< one tick, ~7 ms at 144Hz / ~16 ms at 60Hz) are below human double-tap speed, - so tick-rate edge detection is sufficient; no hook-thread timing is needed. diff --git a/docs/superpowers/specs/2026-06-07-outline-lowzoom-idle-design.md b/docs/superpowers/specs/2026-06-07-outline-lowzoom-idle-design.md deleted file mode 100644 index 2cc9124a..00000000 --- a/docs/superpowers/specs/2026-06-07-outline-lowzoom-idle-design.md +++ /dev/null @@ -1,197 +0,0 @@ -# Edge outline: low-zoom-only and idle-hide - design - -Date: 2026-06-07 -Status: approved -Issue: #94 (builds on the edge outline, #92) - -## Problem - -The edge outline (a solid frame drawn while zoomed) is always on whenever zoom is active. Two -refinements were requested: - -1. **Only at low zoom.** The outline is most useful at low zoom, where the magnified view looks - almost like the normal desktop. At high zoom it is obvious you are zoomed, so the frame is just - visual noise. Users should be able to limit the outline to low zoom levels. -2. **Hide when idle.** Once the outline has signalled "you are zoomed", it can fade away while the - cursor is still, then reappear the instant the user moves. This keeps it as a transient cue - rather than a permanent border. - -Both are opt-in and off by default, so the current always-on behavior is unchanged unless enabled. - -## Behavior - -- **Low-zoom-only**: when `outlineLowZoomOnly` is on, the outline draws only while - `1.0 < level <= outlineLowZoomMax`. Past the cutoff it disappears; zooming back below it returns. - Default cutoff `2.0` (200%). -- **Idle-hide**: when `outlineIdleHide` is on, the outline fades out over `0.3s` once the cursor - has been still for `outlineIdleSeconds` (default `7.0`). Any cursor motion resets the timer and - restores the outline instantly (no fade-in). The fade duration is fixed at 0.3s (not configurable). -- The two are independent and compose: if low-zoom gating hides the outline, idle state is moot; if - the outline is visible, the idle fade applies on top. -- "Cursor still" means no hand motion this tick (free-pan OS-cursor delta and raw-input delta both - zero), so it works in both free desktop panning and game-locked (relative-mouse) panning. -- The idle timer accumulates only while zoomed and resets on each zoom-in, so every zoom session - starts with the outline fully shown. - -## Config - -New fields on `Config` (`src/config.h`), parsed and clamped in `src/config.cpp`, all -hot-reloadable, all default to preserving current behavior: - -```cpp -int outlineLowZoomOnly = 0; // 1 = only show the outline at/below outlineLowZoomMax -double outlineLowZoomMax = 2.0; // zoom level cutoff (clamped [1.0, 50.0]) -int outlineIdleHide = 0; // 1 = fade the outline out after outlineIdleSeconds of no motion -double outlineIdleSeconds = 7.0; // idle timeout before fade (clamped [0.5, 60.0]) -``` - -Parse branches mirror the existing keys (`stoi`/`stod`). Clamp block additions: - -```cpp -c.outlineLowZoomMax = clampd(c.outlineLowZoomMax, 1.0, 50.0); -c.outlineIdleSeconds = clampd(c.outlineIdleSeconds, 0.5, 60.0); -``` - -Default-ini template (`LoadConfig`) gains documented lines for the four keys, after the existing -`outlineColor` line. - -## Pure logic (unit-tested) - -Two small pure helpers, added to the pure section of `src/config.cpp` and declared in `config.h`: - -```cpp -// Whether the outline should be shown at this zoom level, given the master toggle and the -// optional low-zoom cutoff. (The level > 1.0 "are we zoomed" gate stays in the render pass.) -bool OutlineVisibleAtLevel(const Config& c, double level) { - if (c.outline == 0) return false; - if (c.outlineLowZoomOnly != 0 && level > c.outlineLowZoomMax) return false; - return true; -} - -// Idle-fade alpha: full (1.0) until `idleSeconds` reaches `threshold`, then ramps linearly to 0 -// over `fadeDuration`. Pure and deterministic so the fade ramp is unit-testable. -// Caller passes the accumulated idle time; this maps it to an alpha. -double OutlineIdleAlpha(double idleSeconds, double threshold, double fadeDuration) { - if (fadeDuration <= 0.0) return idleSeconds >= threshold ? 0.0 : 1.0; - double over = (idleSeconds - threshold) / fadeDuration; - if (over <= 0.0) return 1.0; - if (over >= 1.0) return 0.0; - return 1.0 - over; -} -``` - -`OutlineVisibleAtLevel` folds in the master `outline` toggle, so `FillRenderParams` uses it as the -single source of truth for `p.outline`. - -## Render wiring - -### RenderFrameParams (`src/render_engine.h`) - -Add one field: - -```cpp -float outlineAlpha; // 0..1 fade for the edge outline (1 = solid); <=0 skips the draw -``` - -### FillRenderParams (`src/main.cpp`) - -Replace the current `p.outline = (cfg.outline != 0);` with the gated form, and default the alpha: - -```cpp -p.outline = OutlineVisibleAtLevel(cfg, level); -p.outlineThicknessPx = cfg.outlineThickness; -float orr = 0.357f, og = 0.357f, ob = 0.839f; // #5b5bd6 fallback (accent) -ParseHexColor(cfg.outlineColor, orr, og, ob); -p.outlineR = orr; p.outlineG = og; p.outlineB = ob; -p.outlineAlpha = 1.0f; // RunTick lowers this when idle-hide is active -``` - -The self-test / pacing-test harnesses call `FillRenderParams`, so they get `outlineAlpha = 1.0` -and the low-zoom gating for free. - -### RunTick idle timer (`src/main.cpp`) - -`TickState` gains: - -```cpp -double outlineIdleSec = 0.0; // seconds the cursor has been still (drives the outline idle fade) -``` - -In the zoomed branch, after `FillRenderParams(p, ...)` and after `dx/dy` (and the underlying -`curDx/curDy`, `rawDx/rawDy`) are known, compute motion and the fade: - -```cpp -const bool moved = (std::abs(curDx) + std::abs(curDy) + std::abs(rawDx) + std::abs(rawDy)) > 0; -if (t.cfg.outlineIdleHide && p.outline) { - t.outlineIdleSec = moved ? 0.0 : (t.outlineIdleSec + dt); - p.outlineAlpha = (float)OutlineIdleAlpha(t.outlineIdleSec, t.cfg.outlineIdleSeconds, 0.3); -} else { - t.outlineIdleSec = 0.0; // keep it ready for when idle-hide is toggled on mid-session -} -``` - -Reset `t.outlineIdleSec = 0.0` on the zoom-in rising edge (where `zoomIn` is handled) so each -session starts fully shown. - -`dt` is already computed at the top of `RunTick`; `curDx/curDy` and `rawDx/rawDy` are already in -scope in the zoomed branch. - -### Border draw pass (`src/render_engine.cpp`) - -Two changes to the existing edge-outline block in `State::render`: - -1. Gate also on alpha: `if (p.outline && p.level > 1.0 && haveDesktop && p.outlineAlpha > 0.0f)`. -2. Draw with the existing alpha-blend state instead of opaque, and pass the alpha in the color: - - replace `c->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF);` with - `c->OMSetBlendState(blend.Get(), nullptr, 0xFFFFFFFF);` (the `blend` member already used by the - cursor pass: SrcAlpha / InvSrcAlpha). - - change the per-edge constant `bcbv[8]` alpha from `1.0f` to `p.outlineAlpha`. - -At `outlineAlpha == 1.0` the alpha blend yields `color`, so a fully-shown outline stays crisp; -below 1.0 it blends into the magnified content beneath, producing the fade. - -## Config UI (`ui/src/settings-schema.js`) - -Add four rows to the Display section, after the existing `outlineColor` row. No new row type is -needed (reusing `toggle` and `slider`); the generic bridge round-trips the keys. - -```js -{ key:'outlineLowZoomOnly', type:'toggle', label:'Only at low zoom', - desc:'Hide the outline once you zoom past the cutoff.', def:0, dependsOn:'outline' }, -{ key:'outlineLowZoomMax', type:'slider', label:'Low-zoom cutoff', - desc:'Show only at or below this zoom (2 = 200%).', min:1.25, max:8, step:0.25, def:2, - dependsOn:'outlineLowZoomOnly' }, -{ key:'outlineIdleHide', type:'toggle', label:'Hide when idle', - desc:'Fade the outline out when the mouse is still.', def:0, dependsOn:'outline' }, -{ key:'outlineIdleSeconds', type:'slider', label:'Idle timeout (s)', - desc:'Seconds of no movement before it fades.', min:1, max:30, step:1, def:7, - dependsOn:'outlineIdleHide' }, -``` - -`dependsOn` greys a row out when its parent toggle is off (existing Settings.svelte mechanism; -single-key, so the cutoff/timeout sliders depend on their immediate parent toggle, not on `outline` -transitively - acceptable, matching how `smoothZoom*` rows behave). - -## Testing - -- **Unit (doctest):** - - `OutlineVisibleAtLevel`: master off -> false; on + lowZoomOnly off -> true at any level; on + - lowZoomOnly on -> true at/below cutoff, false above; boundary at exactly the cutoff -> true. - - `OutlineIdleAlpha`: 1.0 below threshold; 1.0 at threshold; 0.5 at threshold+half-fade; 0.0 at - threshold+fade and beyond; `fadeDuration <= 0` degenerates to a hard 1.0/0.0 step. - - `ParseConfig`: the four new keys default correctly (0 / 2.0 / 0 / 7.0), parse, and clamp - (`outlineLowZoomMax` and `outlineIdleSeconds` past their bounds). -- **Visual (`WIND_SELFTEST`)**: the self-test renders at 4.0x. With `outlineLowZoomOnly=1` and - `outlineLowZoomMax=2`, the dump should show NO outline (4 > 2); with cutoff `5`, the outline - appears. Confirms the low-zoom gating end to end. -- **Manual**: the idle fade is time-based (beyond the 20-frame self-test), so verify by hand - - enable idle-hide, zoom in, hold the mouse still, confirm the frame fades after the timeout and - snaps back on movement. -- **Build**: `build.bat test`, `build.bat`, `build.bat config`. - -## Out of scope (YAGNI) - -- Configurable fade duration / a fade-in on return (return is intentionally instant). -- Idle detection from keyboard/clicks (cursor motion only). -- A separate high-zoom-only mode (only the low-zoom direction was requested). -- Per-edge or per-monitor variation. diff --git a/docs/superpowers/specs/2026-06-07-zoom-edge-outline-design.md b/docs/superpowers/specs/2026-06-07-zoom-edge-outline-design.md deleted file mode 100644 index ff2a8513..00000000 --- a/docs/superpowers/specs/2026-06-07-zoom-edge-outline-design.md +++ /dev/null @@ -1,189 +0,0 @@ -# Zoom edge outline - design - -Date: 2026-06-07 -Status: approved (pending user spec review) - -## Problem - -When zoomed in only a small amount (e.g. 1.2x-1.5x), it can be hard to tell at a glance -that the magnifier is active - the view looks almost like the normal desktop. Wind needs a -clear, always-visible indicator that zoom is on. A solid outline around the screen edges -makes the zoomed state unmistakable, and is most valuable exactly in the low-zoom case. - -## Behavior - -- While zoomed (`level > 1.0`), draw a solid rectangular outline hugging the four screen - edges of the magnified monitor. -- The overlay is already alpha-0 (invisible) at 1x and only revealed while a zoom session is - active, so the outline appears/disappears naturally with the session. The `level > 1.0` - gate in the draw is belt-and-braces (and also suppresses the outline if a frame is ever - drawn at exactly 1x while visible). -- Default state: **off**. When enabled, the default style is **4px solid, accent color - `#5b5bd6`, fully opaque**. -- Thickness is expressed in **physical pixels** (matches how the renderer already works; no - DPI scaling). Configurable 1-40 px. -- Color is configurable as a hex RGB string; the config UI exposes a native color picker. - -This is purely a visual indicator. It does not affect capture, panning, clicks, the cursor, -or zoom math. - -## Rendering - -All in the own GPU renderer (`render_engine.cpp` + `render_shaders.h`). The outline draws -into the existing capture-excluded overlay (`WDA_EXCLUDEFROMCAPTURE`), so it can never feed -back into Desktop Duplication. - -### Shader (`render_shaders.h`) - -Add a minimal solid-color shader `kBorderHLSL`: - -- Constant buffer (32 bytes): `float2 posClip; float2 sizeClip; float4 color;` -- VS: identical quad expansion to the cursor shader (`id & 1`, `(id >> 1) & 1` -> - `(0,0),(1,0),(0,1),(1,1)`), placing a quad at `posClip + q * sizeClip`. Drawn as a - 4-vertex triangle strip. -- PS: returns the constant `color` (alpha included). No texture, no sampler. - -### Draw pass (`State::render`) - -A new `RenderEngine::State` block draws the outline after the magnify pass and before the -cursor pass, so the outline overlays the magnified image but the cursor still draws on top. - -- Gate: `p.outline && p.level > 1.0 && haveDesktop`. -- Thickness `t = clamp(p.outlineThicknessPx, 1, min(sw, sh) / 2)` (never let the four edge - quads overlap/invert on a tiny target). -- Four quads in physical pixels, converted to clip space with the same mapping the cursor - pass uses (`posClipX = x/sw*2-1`, `posClipY = 1 - y/sh*2`, `sizeClipX = w/sw*2`, - `sizeClipY = -(h/sh*2)`): - - top: x=0, y=0, w=sw, h=t - - bottom: x=0, y=sh - t, w=sw, h=t - - left: x=0, y=t, w=t, h=sh - 2t - - right: x=sw - t, y=t, w=t, h=sh - 2t -- Opaque (blend state `nullptr`, the same opaque state the magnify pass uses) for crisp - edges. The color's alpha is written but with blend off it is effectively 1.0; keeping it - in the cb leaves the door open for a future opacity knob without a shader change. -- Reuses one new constant buffer (`bcb`, 32 bytes), updated once per edge via - `UpdateSubresource` then `Draw(4,0)`. Four trivial draws per frame, only while zoomed. - -### New device resources (`buildDeviceResources` / recovery) - -- `bvs` (vertex shader), `bps` (pixel shader), `bcb` (constant buffer). Compiled/created - alongside the cursor pipeline, and released + rebuilt in `recoverDeviceLost()` exactly like - the other device-dependent resources (so the two paths can't drift). - -### New `RenderFrameParams` fields (`render_engine.h`) - -```cpp -bool outline; // draw the edge outline while zoomed -int outlineThicknessPx; // physical px, clamped in render() -float outlineR, outlineG, outlineB; // 0..1, written straight to the BGRA8 backbuffer so the - // stored value matches the sRGB hex the user picked (no - // linearization; the magnify pass writes sRGB-encoded pixels too) -``` - -## Config - -### Struct + parse (`config.h`, `config.cpp`) - -New fields on `Config`: - -```cpp -int outline = 0; // 0/1, off by default -int outlineThickness = 4; // physical px -std::string outlineColor = "5b5bd6"; // hex RGB, optional leading '#' -``` - -- `ParseConfig`: add `outline`, `outlineThickness` (stoi), `outlineColor` (string). -- Clamp `outlineThickness` to `[1, 40]` in the clamp block. -- `outlineColor` is stored as-is; validation/parsing to RGB happens at use (a malformed value - falls back to the accent default, see below). Keep an optional leading `#` tolerated. - -### Hex parsing helper - -A small pure helper (testable, no ``) converts a hex color string to three floats -in 0..1: - -```cpp -// Parse "#rrggbb" or "rrggbb" -> r,g,b in [0,1]. Returns false (and leaves outputs untouched) -// on any malformed input, so the caller keeps its fallback default. -bool ParseHexColor(const std::string& s, float& r, float& g, float& b); -``` - -Lives next to the config parse logic (pure half). Used by `FillRenderParams`. - -### Wiring (`main.cpp` `FillRenderParams`) - -```cpp -p.outline = (cfg.outline != 0); -p.outlineThicknessPx = cfg.outlineThickness; -float r = 0.357f, g = 0.357f, b = 0.839f; // #5b5bd6 fallback -ParseHexColor(cfg.outlineColor, r, g, b); -p.outlineR = r; p.outlineG = g; p.outlineB = b; -``` - -### Default-ini template (`config.cpp` `LoadConfig`) - -Append documented keys to the written-defaults block: - -``` -; outline: 1 = draw a solid outline around the screen edges while zoomed (an at-a-glance -; "you are zoomed" indicator, handy at low zoom); 0 = off (default) -outline=0 -; outlineThickness: outline width in pixels (1-40) -outlineThickness=4 -; outlineColor: outline color as hex RGB (e.g. 5b5bd6 = Wind accent; leading # optional) -outlineColor=5b5bd6 -``` - -## Config UI - -The WebView2 bridge is generic - `getConfig` enumerates every ini key/value and `setConfig` -writes any key (`UpdateIniText`). No host (`config_ui/main.cpp`) or `ini_edit` changes are -needed; new keys round-trip automatically. Only the Svelte layer changes. - -### New `color` row type (`ui/src/lib/Row.svelte`) - -Add a branch: - -```svelte -{:else if row.type === 'color'} - onChange(e.target.value)} /> -``` - -`` always yields `#rrggbb` (lowercase), which is exactly what the ini -stores. A small style block matches the control to the UI (rounded swatch, themed border). - -### Schema rows (`ui/src/settings-schema.js`, Display section) - -```js -{ key:'outline', type:'toggle', label:'Edge outline', - desc:'Show an outline around the screen while zoomed.', def:0 }, -{ key:'outlineThickness', type:'slider', label:'Outline thickness', - desc:'Width in pixels.', min:1, max:40, step:1, def:4, dependsOn:'outline' }, -{ key:'outlineColor', type:'color', label:'Outline color', - def:'#5b5bd6', dependsOn:'outline' }, -``` - -`dependsOn:'outline'` greys the thickness/color rows out when the outline is off (the same -mechanism `smoothZoom*` rows use). The default `#5b5bd6` carries the leading `#`; the C++ -parser tolerates it. - -## Testing - -- **Unit (doctest):** `ParseHexColor` - valid 6-digit with and without `#`, uppercase, - malformed (wrong length, non-hex chars) returns false and leaves outputs untouched, and the - accent default `5b5bd6` maps to roughly (0.357, 0.357, 0.839). -- **Config parse:** a `ParseConfig` case asserting `outline`, `outlineThickness` (incl. clamp - past 40), and `outlineColor` round-trip. -- **Visual:** the overlay is capture-excluded, so external screenshots can't see it. Verify - in-app via `WIND_SELFTEST=1 Wind.exe` (dumps `wind_selftest.png`) with the outline enabled, - confirming a solid accent frame at the four edges, plus a manual low-zoom check. -- **Build:** `build.bat test` (core + doctest), then `build.bat config` for the UI/host. - -## Out of scope (YAGNI) - -- Opacity / transparency knob (the cb carries alpha, so it's a cheap future add if wanted). -- Glow / gradient / animated pulse styles. -- Rounded corners. -- Per-monitor or per-edge customization. -- Showing the outline only below a zoom threshold (decided: always-on while zoomed). diff --git a/docs/superpowers/specs/2026-06-10-event-driven-render-design.md b/docs/superpowers/specs/2026-06-10-event-driven-render-design.md deleted file mode 100644 index 9c848cbe..00000000 --- a/docs/superpowers/specs/2026-06-10-event-driven-render-design.md +++ /dev/null @@ -1,129 +0,0 @@ -# Event-driven, zero-copy render engine - -Date: 2026-06-10 -Status: approved - -## Problem - -The render engine is smooth and consistent but does unconditional work: - -- While zoomed, `main.cpp` renders every tick at refresh rate even when nothing changed - (no new desktop frame, no cursor movement). On the main PC this idles at ~12% GPU where - Windows Magnifier sits at ~1%. -- Every changed frame does a full-desktop `CopyResource` into a cached texture before - drawing. On an iGPU with no dedicated VRAM (shared DDR bandwidth), the - copy + draw + blt-present + DWM-composite stack does not fit a 144Hz frame budget: - measured ~60% GPU and ~66Hz out of 144Hz on the school PC. - -Windows Magnifier is cheap because its fullscreen mode scales inside DWM itself and DWM -only recomposites on change. The public Magnification API route stays dead (issue #20: -integer-offset wobble and composed-flip game demotion are API-level, UIAccess tested and -did not help). The fix is to make our blt engine behave like DWM: do nothing when nothing -changed, and do the minimum when something did. - -## Goals (tiered) - -1. Idle-zoomed desktop (static screen, still mouse): ~1-2% GPU, Windows-Magnifier territory. -2. Active use and games: remove the per-frame full-desktop copy so the per-frame cost is - draw + present, the floor for a capture-based magnifier. Main PC holds full refresh; - school iGPU reaches 144Hz on desktop and clearly better than 66Hz in games. - -Out of scope: adaptive half-rate capture (Approach C) is deferred until measurements on -the school PC show it is still needed. No dcomp (banned, tried twice), no mag API. - -## Design - -### 1. Frame-skip gate - -Each zoomed tick computes a needsRender decision before any GPU work. Render only if at -least one holds: - -- A new duplication frame arrived AND (its dirty/move rects intersect the current - magnified source rect, or the frame carries no metadata). -- The drawn cursor changed: smoothed sub-pixel center moved beyond epsilon, or the - HCURSOR shape changed. -- The view changed: pan offset or zoom level (zoom ramp animating). -- An animation is live: outline idle-fade (#94), zoom-in alpha reveal. -- A forced redraw: config hot-reload, retarget/monitor switch, capture invalidation. - -If none hold, skip draw and present entirely for that tick. With no presents, DWM stops -recompositing the overlay and GPU drops to idle. - -Details: - -- The decision is a pure function (rect lists + flags + epsilons in, bool out) in a - windows.h-free file so it gets doctest coverage. No `` in the pure file. -- Non-GPU per-tick duties survive a skip: raw-input drain, `SetCursorPos` click sync, - config watch, and the `HWND_TOPMOST` re-assert (throttled; it is a cheap SetWindowPos - and must keep running or an always-on-top app can cover us while we idle). -- Pacing: today the blocking Present (vsync or DwmFlush) paces the loop. On skipped - ticks the loop waits on the existing timer path instead, so input polling cadence is - unchanged. -- `WIND_PACINGTEST` forces continuous rendering (no skip) so the pacing harness stays - meaningful. -- A skipped present leaves the DWM redirection surface showing the last frame, which is - correct by construction (the image is unchanged). The alpha-reveal rule is untouched: - on zoom-in, present the live frame first, then flip layer alpha to 255. - -### 2. Zero-copy capture (deferred ReleaseFrame) - -`capture()` stops copying the desktop into a cached texture. Instead: - -- Hold the acquired DDA frame across ticks. The magnify shader samples the duplication - desktop texture directly (it is always the complete desktop, not a delta). -- `ReleaseFrame` is called right before the next `AcquireNextFrame`, the deferred-release - pattern from the DDA documentation. While we hold a frame, the OS accumulates updates - and delivers them on the next acquire. -- On `WAIT_TIMEOUT` (static desktop) keep holding the previous frame so cursor-move - redraws still have a source. -- SRVs are created per duplication surface and cached keyed by texture pointer (DDA - cycles among a small set of surfaces). -- `ACCESS_LOST`, mode changes, and retarget release the held frame and rebuild via the - existing `invalidateCapture` path. The zoom-in drain-to-latest loop is unchanged, it - just ends holding the final frame instead of copying it. - -This deletes the cached desktop texture, `copyChangedRegions`, and the >50%-dirty crop -heuristic. Per-changed-frame cost drops from full-desktop copy + draw + present to -draw + present. - -Escape hatch: a `captureCopy=1` ini knob (default 0) restores copy-based capture, cropped -to the magnified source region, in case a driver misbehaves with held frames (texture -invalidated while held, SRV creation rejected on the duplication surface). Hot-reloadable -like other render knobs. - -### 3. Explicitly unchanged - -Blt-model swapchain and present rules, layered click-through window styles, -`WDA_EXCLUDEFROMCAPTURE`, cursor decode/draw pipeline and smoothed-center click sync, -multi-monitor retarget and local-pixel pipeline, lock-detection panning, dwmFlush/vsync -knobs, shutdown cursor restore. - -## Error handling - -- SRV creation on the duplication texture fails: log once, fall back to copy mode for the - session (same path as `captureCopy=1`). -- `ReleaseFrame`/`AcquireNextFrame` errors: existing handling (invalidate + recreate - duplication) extended to clear the held-frame state first. -- Skip-gate epsilon too tight (visible stale cursor): epsilon is a named constant covered - by tests; cursor-changed compares the rounded on-screen draw position, not raw floats. - -## Verification - -- Doctest: pure skip-decision function (dirty-rect/view intersection, move rects, - no-metadata frames, cursor epsilon, animation flags, forced redraw). -- Diagnostics: `diagnostics=1` trace gains rendered-vs-skipped counters per second. -- `WIND_PACINGTEST` unchanged output (continuous render). -- Manual matrix with before/after numbers (Task Manager GPU% and PresentMon): - - Main PC, idle-zoomed static desktop: expect ~12% -> ~1-2%. - - Main PC, in-game zoom: full refresh held, no regression. - - School iGPU, desktop: expect 144Hz; in-game: clearly better than 66Hz. -- `WIND_SELFTEST=1` still dumps a correct frame (it renders, so the gate must count it - as a forced redraw). - -## Implementation shape - -One GitHub issue, one branch, one PR. Two independently testable stages: - -1. Frame-skip gate (pure decision function + main.cpp/render_engine wiring + counters). -2. Zero-copy capture (deferred release + SRV sampling + copy-mode escape hatch + deletion - of the cached-texture path). diff --git a/docs/superpowers/specs/2026-06-17-cursor-lock-inspect-mode-design.md b/docs/superpowers/specs/2026-06-17-cursor-lock-inspect-mode-design.md deleted file mode 100644 index af71ffdc..00000000 --- a/docs/superpowers/specs/2026-06-17-cursor-lock-inspect-mode-design.md +++ /dev/null @@ -1,208 +0,0 @@ -# Inspect mode (cursor-lock): design - -Date: 2026-06-17 -Status: SHIPPED (the design below is the original brainstorm; the behavior evolved through testing, -see "Final shipped design" at the bottom of this file for what actually shipped). - -> NOTE: This document captures the original design. The feature went through several rounds of live -> testing and the model changed materially (the center-reticle + click-to-commit was dropped; the -> reticle became a persistent free-look look-point that works at 1x). The authoritative description of -> the shipped behavior is the "Final shipped design" section at the end of this file and the Inspect -> mode gotcha in `CLAUDE.md`. -Related: render engine (`docs/superpowers/specs/2026-05-25-own-renderer-design.md`), -auto cursor sensitivity (`docs/superpowers/specs/2026-05-26-auto-cursor-sensitivity-design.md`), -`LockDetector` (game-lock heuristic, `src/lock_detector.*`). - -## Problem - -Two recurring annoyances while using a magnifier, both rooted in the OS cursor sitting on -whatever you are trying to look at: - -1. **Hover that fires on its own.** Resting the cursor on a video thumbnail starts an - autoplay preview; resting on a media element triggers a hover animation. You wanted to - look, not activate. -2. **Hover you want to keep.** A tooltip appears while the cursor is over a label. To read - it you move toward it, leave the label, and the tooltip vanishes. - -Both want the same primitive: **decouple "where the lens looks" from "where the OS cursor -is."** A non-clickthrough hover-suppression overlay was considered and rejected: it would -solve case 1 but actively breaks case 2 (it prevents the tooltip from ever appearing). - -## Solution overview - -**Inspect mode** ("cursor lock" in code). A toggle, bound to a key, active only while zoomed. - -- **Toggle ON:** the real OS cursor freezes where it is (any active hover stays alive). - Panning continues to move the **lens** from hand motion. A reticle (the normal drawn - cursor) sits at lens center with a small lock indicator beside it. -- **Toggle OFF (no click):** the real cursor warps to the lens-center reticle and normal - follow resumes from there. The frozen hover point is abandoned. -- **Click while locked ("click to commit"):** the real cursor snaps to the reticle, the - click lands there, and lock turns OFF in one motion. This is the always-available escape - hatch so you can never get stranded in lock mode. - -The frozen hover point is "live" only for the duration of the lock. - -## Behavior detail - -### State machine (pure, testable) - -New pure-logic unit `src/cursor_lock.{h,cpp}` (`CursorLockController`) following the -existing pure pattern (`ZoomController`, `LockDetector`, `transform`): NO ``, so it -compiles into the desktop-free `WIND_TESTS` build. - -State: `locked_` (bool, default OFF). - -Per-tick / per-event inputs from `main.cpp`: -- `togglePressed` (rising edge of the bound key) -- `clickPressed` (left/right button-down observed while locked) -- `zoomedIn` (level > 1.0) -- `recenter`, `monitorRetarget` (reset signals) - -Outputs the tick loop consumes: -- `locked()` - is the lock engaged. -- `panFromRaw()` - pan source this tick: raw mickeys while locked, OS-cursor delta while free. - -The freeze and the warp are NOT controller outputs; they live in `main.cpp`. The freeze is a -1px `ClipCursor` asserted while `locked()` and released on every exit path. The lock->free warp -to the reticle is `main.cpp`'s exit-edge `SetCursorPos`, and for a click-commit the `WH_MOUSE_LL` -hook's synchronous warp. The controller is a pure toggle (`suppressSetCursor()` / -`consumeWarpToReticle()` were design-only names that never shipped). - -Transitions: -- **Toggle while zoomed:** flip lock. - - free→locked: freeze (begin suppressing `SetCursorPos`, switch pan to raw). - - locked→free: emit warp-to-reticle, resume follow. -- **Click while locked:** emit warp-to-reticle, force lock OFF (the click itself is fired by - the hook, see below). -- **Auto-clear** on zoom-out, `recenter`, and `monitorRetarget`, the same reset points - `LockDetector.reset()` is already called at. No warp on auto-clear (zoom-out tears down the - overlay; recenter/retarget re-baseline the cursor anyway). -- Toggle is **ignored when not zoomed**. - -### Panning & cursor wiring (`main.cpp`) - -Reuses existing paths: -- **Locked pan** = the existing game-locked branch (`main.cpp:376`): `dx/dy` from - `rawDx/rawDy * cursorSensitivity`. Lens moves with the hand; the OS cursor does not. -- **Frozen cursor implementation:** the freeze is a 1px `ClipCursor` rect centered on the - freeze point (truly stationary -- no `WM_MOUSEMOVE` fires, no pointer ballistics, the OS - cursor cannot drift). This is distinct from merely skipping `SetCursorPos`: a skipped - `SetCursorPos` still allows the cursor to wander if anything else moves it; the 1px clip - enforces the freeze at the OS level. -- **Locked = the SetCursorPos target is overridden to the frozen point.** While locked, - `renderFrame`'s `SetCursorPos` target is the frozen point, a no-op inside the 1px clip, so it - never fights the freeze. -- The reticle is the drawn cursor at lens center, already where it is drawn today - (centered-cursor design), so no positioning change, only the lock indicator. -- While `locked()`, **bypass `LockDetector`** entirely: manual lock supersedes the game-lock - heuristic. Both regimes integrate a delta into the same accumulator, so toggling lock never - snaps the lens position (same anti-flicker invariant as the free/game-locked switch). - -### Click-to-commit (the one genuinely tricky part) - -Today clicks land correctly only because the OS cursor is continuously `SetCursorPos`'d under -the reticle. While locked it is frozen elsewhere, so a raw click would land on the frozen -point, not where you are aiming. - -Fix: the existing `WH_MOUSE_LL` hook (`input_router.cpp`) must, on left/right button-**down** -*while locked*, **synchronously `SetCursorPos`(lens-center desktop point) in the hook callback -before the event propagates**, then signal the tick to clear lock. The down event then lands -at the reticle. The click is **NOT swallowed** (it must reach the underlying app), and the -matching up event follows because the OS cursor was physically moved there. - -Shared state: the tick publishes the current lens-center desktop point each frame to an -`std::atomic`-equivalent (two atomics, or a packed 64-bit) that the hook thread reads. -This is the highest-risk piece and the primary manual-verification target (timing of the warp -vs. the button dispatch). - -### Lock indicator (rendering) - -Add one field to `RenderFrameParams` (e.g. `bool cursorLocked`). When set, the cursor-draw -path in `render_engine.cpp` draws the normal arrow plus a small ring / lock glyph beside it. -Modest addition; reverts to the plain arrow on unlock. If the drawn cursor is hidden -(`cursorMode == 2`), no reticle/indicator is drawn but lock still functions (pan blind, click -commits). - -## Config & UI - -- One new keybind in `config.h`: `cursorLockVk` / `cursorLockMods` (VK + modifier mask, same - bit layout as the zoom combos). **Unbound by default (`0`)**, binding it is the opt-in, so - no separate master enable flag (YAGNI). The bind is **VK-only (no modifier support)** to keep - the keyboard-hook swallow simple and correct: the hook matches on VK alone, and modifier-combo - swallows risk eating modifier keys in other apps. This matches `recenterVk` behavior. -- `config.cpp`: parse + sanitize through `IsForbiddenBindVk` (left/right click, Backspace, - Win keys can never be bound). Round-trips in the ini like `recenterVk`. -- Keyboard-hook swallow: add `cursorLockVk` to `g_input.setKeys(...)` and the re-bind check in - the hot-reload path (`main.cpp:~238-243`), so the toggle key is eaten and never double-fires - into the focused app. Balanced down/up swallow like every other bound key. -- UI: one keybind row in the Svelte settings (`ui/src/settings-schema.js`, surfaced in - `Settings.svelte`), live-applied like the other keybind rows. Label: **"Inspect mode"** - (description: freeze the cursor to keep a hover/tooltip alive while you pan and read). - -## Edge cases - -- **Multi-monitor:** lock auto-clears on retarget; cannot be locked across a monitor switch - (consistent with the "one monitor while zoomed" rule). -- **Click-to-commit inside a clip-locked game:** the game's `ClipCursor` may fight the warp; - documented limitation, same family as the existing raw-input-game caveat in CLAUDE.md. -- **Hide-cursor mode:** reticle/indicator not drawn, lock still works (blind pan + commit). -- **Forbidden keys / safety:** enforced in the same three places as other binds (hook never - swallows them, `ParseConfig` sanitizes, config UI capture refuses). -- **No-hook degradation (`WIND_NOHOOK` / hook-install failure):** with `hookActive()` false, - click-to-commit is unavailable (the `WH_MOUSE_LL` hook is the only warp-commit, so a click - while locked lands at the frozen point). The toggle key still works via the `GetAsyncKeyState` - polling fallback, so the lock is always escapable by toggling off or zooming out: no stranding. - -## Testing - -- **Pure unit tests** `tests/test_cursor_lock.cpp` (doctest) for `CursorLockController`: - `locked()` / `panFromRaw()` across toggle / commitClick / reset / zoom-gating sequences - (including the unzoom-toggle no-op and commitClick idempotence). No ``; added to - the `WIND_TESTS` source list. -- **Manual verification:** `WIND_SELFTEST=1` for the indicator render; live testing for the - click-to-commit warp timing (real thumbnail autoplay + a real tooltip), and that the toggle - key is swallowed from the focused app. - -## Out of scope (YAGNI) - -- The rejected hover-suppression overlay. -- A separate "frozen point" on-screen marker (chose the subtle arrow+ring indicator instead). -- A hold-to-lock variant or a master enable flag: the single unbound-by-default toggle is the - whole opt-in surface. - -## Final shipped design (supersedes the brainstorm above) - -After several rounds of live testing the model settled on a "freeze + free-look reticle" that works at -every zoom level. The center-reticle aiming, the `WH_MOUSE_LL` click-to-commit, the hover-suppression -overlay, and the brief `SetSystemCursor` experiment were all dropped. - -**Behavior** -- Toggle on: the real OS cursor FREEZES in place (1px `ClipCursor` at `frozenCursor`) and is hidden, so - any hover/tooltip stays alive. A crosshair "look point" appears. -- The look point is driven by Raw Input (the frozen cursor cannot move, but HID mickeys still arrive). - Moving the mouse moves the look point and pans the magnified view; the crosshair is drawn at the - look point. -- It works at every zoom and PERSISTS at 1x: the overlay stays active while Inspect is on - (`active = zoomed || inspect`), the look point roams the full screen at 1x, and the crosshair never - vanishes or snaps across the zoom boundary. -- A left click lands at the frozen point (the cursor is pinned there). Toggle off (or zoom out to idle) - releases the clip, warps the cursor to the look point, and resumes normal follow. - -**Implementation (all in `main.cpp` RunTick; no mouse hook)** -- The look point IS the `CursorMapper` center: while Inspect is on the pan delta comes from raw mickeys - (not the OS-cursor delta), and the crosshair is drawn at the mapper's `cursorScreen` (centered when - the lens can center, toward an edge at the desktop boundary, and equal to the roaming center at level - 1.0 - which is what makes the 1x look point roam the full screen). -- The crosshair is `render_engine`'s own 32x32 reticle sprite (`crosshairSRV`), drawn when - `RenderFrameParams.cursorLocked` is set, scaled by zoom. -- `CursorLockController` is a plain on/off toggle (`toggle`/`reset`/`locked`). -- The 1px `ClipCursor` is released on every exit (toggle-off-while-zoomed, teardown-to-idle, - device-lost recovery, `shutdown`, the crash filter, atexit `RestoreInputState`); verified by an - adversarial clip-lifecycle trace - no stranded-clip path. - -**Considered and dropped during testing** -- Inspect at 1x with the cursor still frozen-immovable (felt stuck) -> the look point must roam at 1x. -- A plain crosshair *cursor* (no freeze) -> "just a normal cursor that looks like a crosshair". -- Left-click-to-exit Inspect -> reverted as a regression; a click simply lands at the frozen point and - Inspect stays on until toggled off. diff --git a/docs/superpowers/specs/2026-07-06-transform-model-design.md b/docs/superpowers/specs/2026-07-06-transform-model-design.md deleted file mode 100644 index 7427a89b..00000000 --- a/docs/superpowers/specs/2026-07-06-transform-model-design.md +++ /dev/null @@ -1,211 +0,0 @@ -# Second magnifier model: DWM fullscreen-transform (low-GPU) - -Date: 2026-07-06 -Issue: #126 -Status: Design approved (brainstorm complete), pre-implementation-plan - -## Goal - -Add a second, selectable magnification **model** to Wind: a DWM -fullscreen-transform magnifier ported from Bloom. The existing capture-and-render -overlay stays the default (`model=render`). The transform model -(`model=transform`) is a near-zero-GPU opt-in alternative, chosen from WindConfig. - -Wind stays the product. The transform model is a lighter engine option, not a -replacement. - -## The two models (why keep both) - -| | Render model (current, default) | Transform model (to add) | -|---|---|---| -| Mechanism | DXGI Desktop Duplication + D3D11, magnify a float source rect onto a layered overlay | `MagSetFullscreenTransform` / private `SetMagnificationDesktopMagnification`; DWM applies it | -| GPU cost | Continuous capture + render every active tick | Near-zero (DWM does the scaling) | -| Cursor | Draws the real decoded cursor centered on the overlay | Hides OS cursor, draws a scene-locked sprite welded to the transform | -| Covers shell | Yes (uiAccess `CreateWindowInBand`, `zorderBand`) | No (DWM transform is desktop content only) | -| Flip-game fps while panning | Overlay already composited; steady | Drops (transform forces composition); `smoothPan` trades it for a steady capped rate | -| Multi-monitor | Retargets per monitor (`multiMonitor`) | Desktop-wide (whole virtual screen) | -| Fidelity/control | Full (sharpness, HDR tonemap, bilinear, outline) | Limited to what DWM exposes | - -The models are complementary: render is higher-fidelity and GPU-costly; -transform is featherweight and better for low-power/battery and for users who -dislike a capture overlay. - -## Key finding: the transform model fixes what killed Wind's old mag engine - -Wind previously had `engine=mag` (a `MagnifierEngine` wrapping -`MagInitialize` / `MagSetFullscreenTransform` / `MagSetInputTransform`) and -removed it, partly because of an **unfixable click dead-zone**: the OS-drawn -magnified cursor diverged from the real cursor near clamped screen edges, so -clicks missed. - -Bloom's refinements retire that reason: - -- **Cursor dead-zone -> solved.** Bloom hides the OS cursor - (`MagShowSystemCursor` + a blank-cursor `SetSystemCursor` scheme) and draws - its own sprite welded to the transform, so the visible cursor never diverges - from the click point. No `MagSetInputTransform` (so no uiAccess needed for - clicks); clicks route by the same `SetCursorPos` sync the render model already - uses. -- **Whole-pixel jumpiness -> solved.** Pan via the sub-pixel screen translation - (`tx = round(-offset * level)`) the private channel accepts, which is - level-times finer than the integer offset the old engine used. -- **Flip-game pan stutter -> mitigated.** `smoothPan` pins composition with a - 1px invisible window so panning is steady, matching the built-in Magnifier. - -So this is not re-adding the deleted engine; it is re-adding it with the fixes -that make it viable. - -## Language reality: port, do not embed - -Bloom is C#/.NET AOT; Wind is C++17. Do not embed the .NET runtime. Port -Bloom's transform model to C++. Wind already has C++ equivalents for most of -the surrounding machinery; only the thin Magnification wrapper and the cursor -sprite genuinely need porting. - -### Reused from Wind (no port) -- Zoom: `ZoomController` (`zoom_controller.{h,cpp}`). -- Cursor tracking / pan center + hidden/locked-cursor game case: `CursorMapper` - (`MapResult`), `LockDetector`, `mouse_ballistics`, `cursor_lock`. -- Input swallowing: `InputRouter` (`WH_MOUSE_LL` + `WH_KEYBOARD_LL`). -- Config, tray, hot-reload, uiAccess, single-instance, DPI, logging: all Wind's. -- Cursor bitmap decode: `cursor_decode` (the sprite reuses this decoder). - -### Ported from Bloom into Wind C++ (the transform model itself) -1. `TransformModel` (new) implementing `IMagnifierModel`: `MagInitialize`, - `MagSetFullscreenTransform`, plus resolve and prefer the private - `SetMagnificationDesktopMagnification` (`GetProcAddress` on user32) for the - `fastPan` sub-pixel path, with crash-safe reset. From Bloom - `MagnifierHost` + `MagnifierController`. -2. Transform math into the existing pure `transform.{h,cpp}` (no ``, - doctest-testable): add a screen-translation function alongside the existing - `ComputeOffsetF`. From Bloom `CameraMath.ComputeScreenTranslation`. -3. Cursor sprite: topmost layered click-through window mirroring the cursor - welded to the transform, with the blank-cursor scheme, polarity-adaptive - caret, and reliable restore (`SPI_SETCURSORS` + a SendInput repaint nudge). - From Bloom `CursorSprite` + `CursorBlanker`. Reuses `cursor_decode`. -4. `CompositionPin` (new): 1px invisible topmost pin for `smoothPan`. From - Bloom `CompositionPin`. - -## Architecture: the engine seam (Option A - shared interface) - -Wind's tick is currently monolithic: `RunTick(TickState&)` computes a -`MapResult` from `CursorMapper` plus the zoom level, calls `FillRenderParams()` -to build a `RenderFrameParams`, then `renderEngine.renderFrame(p)`. There are -about four call sites (the main tick plus startup priming in `wWinMain`). - -Introduce a minimal interface so both models sit behind one call site: - -```cpp -// Small, model-agnostic per-tick inputs. RunTick already computes all of these. -struct ModelFrame { - double level; // current zoom (>= 1.0) - double centerX, centerY; // lens center, screen px (from MapResult) - int clickDesktopX, clickDesktopY; // SetCursorPos target, desktop px - bool drawCursor; // whether a cursor should be shown this tick - // render-only knobs (sharpness, outline, hdr, bilinear...) are NOT here; - // the render adapter reads them straight from Config. -}; - -struct IMagnifierModel { - virtual ~IMagnifierModel() = default; - virtual bool initialize(const MonitorTarget& monitor) = 0; - virtual void shutdown() = 0; - virtual void hideSystemCursor(bool hide) = 0; - virtual void setVisible(bool visible) = 0; - virtual void apply(const ModelFrame& f) = 0; // per active tick - virtual bool ready() const = 0; -}; -``` - -- `RenderModel` adapts the existing `RenderEngine`: `apply()` fills a - `RenderFrameParams` from the `ModelFrame` + `Config` (essentially today's - `FillRenderParams` path) and calls `renderFrame`. `RenderEngine`'s internals - are untouched; the adapter is the only new render-side code. -- `TransformModel` implements `IMagnifierModel` directly: no capture/GPU pass. - `apply()` computes the sub-pixel screen translation from `level` + center and - calls the Magnification API (private channel when `fastPan`), then updates the - cursor sprite and (if `smoothPan`) asserts the composition pin. -- `RunTick` computes zoom + `MapResult` once (shared), builds a `ModelFrame`, - and calls `activeModel->apply(frame)`. `wWinMain` constructs the model chosen - by `Config::model` and holds it as `IMagnifierModel*` (owning). The - render-specific priming path (`primeReveal`, `invalidateCapture`, device-lost - recovery) is render-only and stays behind the render adapter / a - `dynamic_cast` or a capability query used only where it already lives; the - transform model no-ops those. - -`RenderFrameParams` / `renderFrame` do NOT belong on the shared interface (they -are capture-specific). The interface is the smaller shared surface. - -Rejected: Option B (resurrect the historical `if (useRender) {...} else {...}` -dispatch). Faster to write but re-introduces the branchy `TickState` the team -deleted once. Option A keeps the divergent internals behind one call site. - -## Config + UI surface - -### Config (`config.{h,cpp}`) -- Add `std::string model = "render";` to `Config` (values `render` | - `transform`). Parse in `ParseConfig`; unknown value falls back to `render`. -- Add transform-only keys: `int fastPan = 1;`, `int smoothPan = 0;`, - `int cursorSprite = 1;`. (Bloom's defaults; `cursorSprite` on so the - dead-zone fix is active by default for the transform model.) -- Add a commented block to the `magnifier.ini` template + README. - -### Config app (`ui/src/settings-schema.js` + `Settings.svelte`) -- Add one row selecting the model, e.g. in a new top section or the Display - section: - `{ key:'model', type:'select', label:'Magnifier model', - desc:'Render = high-fidelity GPU overlay. Transform = low-GPU, DWM-based.', - options:['render','transform'], def:'render' }` -- Gating: the schema currently supports only truthy predicates - (`dependsOn`, `requires`, `requiresNot`). Model-specific rows need a - **value-match** gate. Add a small predicate (e.g. - `showIf:{ key:'model', eq:'transform' }`) interpreted by the row-visibility - logic in `Settings.svelte`. Apply it so: - - transform-only rows (`fastPan`, `smoothPan`, `cursorSprite`) show only when - `model=transform`; - - render-only rows (`sharpness`, `hdrTonemap`, `bilinear`, `zorderBand` if - surfaced, outline group, `cropCapture`) show only when `model=render`. -- The schema stays declarative; the only component change is the added - visibility predicate. - -### Switching (restart-only) -Changing `model` in WindConfig writes the ini. The core reads `model` at launch -and constructs that model. If the ini's `model` changes while the core is -running, the hot-reload path does not tear down and rebuild the engine live; a -tray note / next-launch behavior applies. (No mid-zoom engine hot-swap; that -was explicitly deferred to avoid teardown/rebuild edge cases.) - -## Per-model limitations (document, do not fight) -- Transform model cannot cover the shell (Start / taskbar / tray flyouts); - the `zorderBand` uiAccess trick only helps the render overlay. Document as a - per-model limitation. -- Flip-game panning fps drop is intrinsic to the transform model (the reason - `smoothPan` exists). Render model does not have it. -- Transform is desktop-wide (whole virtual screen), not per-monitor; reconcile - with `multiMonitor` semantics (transform ignores it, or clamps to the - cursor's monitor rect - decide in the plan). -- Keep clicks on `SetCursorPos` sync; do NOT reintroduce - `MagSetInputTransform` (the old uiAccess dependency / dead-zone path). - -## House rules (Wind) -- No em-dashes (U+2014) anywhere. -- Pure-logic files must not include `` (keeps doctest tests - desktop-free). The transform math port goes in the pure `transform.{h,cpp}`; - all DWM / Magnification / sprite / pin calls go in Win32 files. -- Feature work: this issue -> `feat/transform-model` branch -> one PR. `render` - stays the default at every commit, so the branch is always shippable. - -## Testing / verification -- Pure math (screen translation, clamping) gets doctest unit tests in the - `transform` test file, mirroring the existing `ComputeOffsetF` coverage. -- Manual verification loop (Wind has no headless render test): build, run, - switch `model=transform` in the ini, confirm zoom + sub-pixel pan + the - scene-locked cursor (including caret) + `fastPan`/`smoothPan`, and confirm - `model=render` is byte-for-byte the current behavior. The acceptance bar is - visual smoothness parity with Bloom's transform model and no cursor - divergence at screen edges. - -## Out of scope (this PR) -- Mid-zoom hot-swap between models. -- Auto-selecting the model by hardware (battery / iGPU). -- Making the transform model cover the shell. diff --git a/docs/superpowers/specs/2026-07-09-config-restart-and-lifecycle-design.md b/docs/superpowers/specs/2026-07-09-config-restart-and-lifecycle-design.md deleted file mode 100644 index 1462325a..00000000 --- a/docs/superpowers/specs/2026-07-09-config-restart-and-lifecycle-design.md +++ /dev/null @@ -1,186 +0,0 @@ -# Config restart prompt + config/magnifier lifecycle coupling - -Date: 2026-07-09 -Status: approved (design) - -Two independent features in the WindConfig UI and its relationship to the Wind -magnifier process. - -## Context - -`model` (render | transform) is read once at Wind launch and is deliberately not -hot-swappable (`config.h:65`). Every other key hot-reloads: Wind watches the ini -directory and re-reads on change (`main.cpp:200-220`). So changing `model` in the -config UI writes the ini, Wind reloads, ignores `model`, and resets the zoom -level - which reads to the user as "Apply soft-rebooted it but nothing changed." -The only way to actually switch model today is to quit and relaunch Wind by hand. - -Separately, the config window currently outlives the magnifier: quitting Wind -(Ctrl+Alt+Q, tray Quit, or a crash) leaves an orphaned WindConfig window that is -configuring a process that no longer exists. - -### Constraints discovered in the existing code - -- **UIPI blocks `PostMessage` from WindConfig to Wind.** The deployed `Wind.exe` - is UIAccess (higher integrity) than the normal-IL `WindConfig.exe`, so window - messages are silently swallowed. The codebase already works around this with a - named kernel event, `Local\Wind_QuitRequest` (`config_ui/main.cpp:155-162`, - `main.cpp:1028`), which is not gated by UIPI. Any new signalling must use a - kernel object, not a window message. -- **Wind already has a single-instance eviction handshake** (`main.cpp:801-817`). - A newly launched instance tries the `Local\Wind_Magnifier_SingleInstance` mutex; - if it is held, it signals `Local\Wind_QuitRequest` to the incumbent, waits up to - 3s for the mutex, and falls back to terminating the straggler. **This means a - restart requires no new IPC: relaunching `Wind.exe` evicts the old instance.** - WindConfig's own comment already notes relaunching "would kill+restart it". -- **`WindRunning()` conflates "absent" with "could not tell".** It returns `false` - when `CreateToolhelp32Snapshot` fails (`config_ui/main.cpp:35`), not only when - Wind is gone. -- **WindConfig can show its window while Wind is not running.** If launching - `Wind.exe` fails at startup it shows onboarding anyway (`config_ui/main.cpp:246`). - -## Goals - -1. Changing `model` prompts for confirmation and, on confirm, actually restarts Wind. -2. The config window closes when the magnifier exits, by any means. - -## Non-goals - -- Making `model` hot-swappable. Restart-only is a deliberate, already-locked design decision. -- Changing what the Settings title-bar X does. It closes the config window; a separate - button minimizes. Both stay as they are. -- The open pan-wobble defect and the caret latency. Tracked separately. - ---- - -## Feature A: model change requires a restart - -### Behaviour - -Changing the `model` select to a value different from the saved one opens a modal: - -> **Restart required** -> Changing the magnifier model requires restarting Wind. -> [ Cancel ] [ Restart Wind ] - -- **Restart Wind** - write `model` to the ini, then relaunch `Wind.exe`. -- **Cancel** - revert the select to its previous value. **Write nothing.** -- If Wind is not currently running, skip the modal entirely and just save. There - is nothing to restart, and the next launch picks up the new value. - -### Why Cancel must not write - -Keeping the ini's `model` always equal to the *running* model removes a whole class -of bug. The alternative (write now, apply on next manual restart) creates a state -where the ini says `transform` while the live process is `render`. Everything -downstream then lies: the modal's "did the model change?" comparison, and the -UI's `showIf` gating that shows/hides transform-only and render-only rows. - -### Mechanism - -The modal lives in the web UI (Svelte), not a Win32 `MessageBox`, to match the -app's custom chrome. - -On confirm the UI sends `setConfig{key:"model"}` followed by a new -`window{action:"restartWind"}` message. The native handler calls a `LaunchWind()` -helper - factored out of the existing startup launch at `config_ui/main.cpp:255-262` -- which does `ShellExecuteW` on `ExeDir()\Wind.exe`. - -No new named event is required. The freshly launched Wind performs the existing -eviction handshake: it finds the mutex held, signals `Local\Wind_QuitRequest`, -waits for the incumbent to exit cleanly (restoring the cursor, resetting zoom, -removing the tray icon), then takes ownership and starts on the new `model`. - -Ordering matters: the ini write must complete before the relaunch, because the new -instance reads `model` at startup. - -### Interaction with Feature B - -The relaunched instance appears in the process list *before* it signals the -incumbent to quit, so `WindRunning()` never observes a gap and Feature B's watchdog -will not fire mid-restart. This overlap is what makes the two features safe -together; the consecutive-miss requirement below is the belt to that braces. - -### Error handling - -If `ShellExecuteW` returns <= 32 the relaunch failed. Leave the incumbent Wind -running and untouched (it was never signalled - only a successful launch signals -it), and surface an error in the UI rather than failing silently. - ---- - -## Feature B: config window closes when Wind exits - -### Behaviour - -When the magnifier process goes away - Ctrl+Alt+Q, tray Quit, or a crash - the -config window closes. "The config window should not exist if the magnifier is -offline." - -### Mechanism - -A `WM_TIMER` in WindConfig's `WndProc`, 1s period, calling the existing -`WindRunning()`. On confirmation that Wind is gone, `PostMessageW(g_hwnd, WM_CLOSE, 0, 0)`. - -Chosen over the two alternatives: - -- *Wind signals a `Local\Wind_Shutdown` event on exit.* Instant and cheap, but a - crash never sets the event - the config window would survive exactly the case - where an orphan is most confusing. -- *Wait on Wind's process handle.* Instant and crash-proof, but requires - `OpenProcess` from a lower-IL process against a UIAccess one. It likely works, - but the codebase already documents being burned by a cross-IL handle assumption - here (`config_ui/main.cpp:28-31`), so the feature should not rest on it. - -A <=1s latency is invisible for "the window went away when I quit the app", and a -poll cannot be defeated by a crash or a privilege boundary. - -### Two guards, both load-bearing - -1. **Require 2 consecutive misses before closing.** `WindRunning()` returns `false` - on `CreateToolhelp32Snapshot` failure, not just on absence. A single transient - failure must not close the user's config window. -2. **Arm only after Wind has been observed running at least once.** WindConfig can - legitimately display a window while Wind is down: if `Wind.exe` fails to launch - at startup it shows onboarding anyway. An unconditionally-armed watchdog would - close that window immediately, hiding the very error the user needs to see. - -### Decision helper (unit-testable) - -The guard logic is pure and gets a doctest, matching how the repo tests its other -pure logic (`transform.cpp`, `zoom_controller.cpp`): - -```cpp -// Returns true when the config window should close. -// running: this poll's WindRunning() result. -// armed: set once running has ever been observed true. -// misses: consecutive false observations; caller-owned, reset on true. -bool ShouldCloseOnWindGone(bool running, bool& armed, int& misses); -``` - -Cases: never-armed + not running -> false (never closes). Armed + one miss -> -false. Armed + two consecutive misses -> true. A `true` between misses resets the -counter. - ---- - -## Testing - -Unit (doctest): `ShouldCloseOnWindGone` across the four cases above. - -Manual verification: - -1. Switch model, Cancel -> dropdown reverts; ini `model` unchanged; Wind untouched. -2. Switch model, Restart Wind -> Wind restarts and comes up on the new model - (verify by the transform-only rows appearing, and by observed behaviour); - ini `model` matches the running model; tray icon returns exactly once. -3. Quit Wind with Ctrl+Alt+Q while config is open -> config closes within ~1s. -4. Kill `Wind.exe` from Task Manager -> config closes (crash path). -5. Launch WindConfig with Wind not running -> Wind launches, config stays open. -6. Change a hot-reloadable key (e.g. `outlineThickness`) -> no modal, applies live. - -## Issue / PR mapping - -- **Feature A** belongs to the model selector, issue #126, on `feat/transform-model`. -- **Feature B** is independent config-app lifecycle. It gets its own issue and PR, - landed after #126 to avoid tangling the branches. diff --git a/docs/superpowers/specs/2026-07-10-silent-model-restart-and-swap-hotkey-design.md b/docs/superpowers/specs/2026-07-10-silent-model-restart-and-swap-hotkey-design.md deleted file mode 100644 index 8dc70db8..00000000 --- a/docs/superpowers/specs/2026-07-10-silent-model-restart-and-swap-hotkey-design.md +++ /dev/null @@ -1,180 +0,0 @@ -# Silent model restart + swap-model hotkey - -Date: 2026-07-10 -Status: approved (design) - -Two related changes to how the magnifier `model` (render | transform) is switched. - -## Context - -`model` is read once at Wind launch and is deliberately not hot-swappable -(`config.h:66`); the two values select entirely separate engines (DXGI capture + -D3D11 overlay vs `MagSetFullscreenTransform`). Every other config key hot-reloads -(the core dir-watches the ini). Switching `model` therefore requires relaunching -`Wind.exe`. - -The previous design (`2026-07-09-config-restart-and-lifecycle-design.md`, Feature A) -gated a model change in WindConfig behind a "Restart required" confirmation modal. -In practice a deliberate Apply of a model change and a hot-reload look identical to -the user, so the confirm step is friction with no information value. And there is -today no way to switch model without opening the settings UI. - -The existing restart mechanism needs no new IPC: relaunching `Wind.exe` while an -instance is running triggers the single-instance eviction handshake -(`main.cpp:820-832`) - the new instance signals `Local\Wind_QuitRequest` to the -incumbent, waits for it to exit cleanly (cursor restore, tray removal, Inspect clip -release), then takes ownership. WindConfig already relies on this via its -`restartWind` handler (`config_ui/main.cpp:175-181`). - -The INI is edited key-by-key with the pure helpers in `src/config_ui/ini_edit.h` -(`ReadIniValues`, `UpdateIniText`) - no ``, so they are reusable from the -core as well as WindConfig. - -## Goals - -1. Applying a model change in WindConfig restarts Wind silently - no confirm modal. -2. An optional, unbound-by-default hotkey alternates `render` <-> `transform` from - anywhere, restarting Wind onto the flipped model. - -## Non-goals - -- Making `model` hot-swappable in-process. Restart-only remains a locked design - decision; both features restart. -- Changing Feature B (config-window-closes-when-Wind-exits) from the prior spec. - ---- - -## Feature A: model row applies + restarts silently - -### Behaviour - -Changing the `model` select and pressing **Apply** writes the new `model` (plus any -other pending edits) to the ini and relaunches Wind immediately. No modal. - -The relaunch-failure path is preserved: if `Wind.exe` cannot be launched, the UI -shows a "Couldn't restart Wind" box AND reverts so the ini's `model` again equals the -running process's model. - -### Mechanism (`ui/src/Settings.svelte`) - -- `apply()`: when `String(values.model) !== String(saved.model)`, call `commit()` - then `windowControl('restartWind')`. Otherwise just `commit()`. -- Remove the confirm branch: `confirmRestart`, `cancelRestart`, and the - "Restart required" modal markup. -- Keep the `restartFailed` message handler and its error box. On `restartFailed`: - set `values.model` back to `saved.model` and `setConfig('model', saved.model)` to - rewrite the ini, then show the box. - -### Why revert on failure - -Keeping the ini's `model` equal to the *running* model is load-bearing: the schema's -`showIf` gating shows/hides transform-only and render-only rows off the ini value. A -failed relaunch that left `model=transform` in the ini while the live process runs -`render` would reveal rows for an engine that is not loaded. On success, ini == new -== running, so the invariant holds without a confirm step. - -The native `restartWind` handler (`config_ui/main.cpp`) is unchanged - it already -`ShellExecute`s `Wind.exe` and posts `restartFailed` on `<= 32`. - ---- - -## Feature B: optional swap-model hotkey - -### Behaviour - -A new keybind, unbound by default. When bound and pressed - zoomed or idle - Wind -flips `model` to the other value, persists it, and restarts onto the flipped model. -Pressing again flips back. The restart is the same brief teardown as Feature A -(active zoom ends, cursor restores, tray icon re-adds over ~0.5-1s); this was -confirmed acceptable. - -### Config (`config.h` / `config.cpp`) - -- New field `int swapModelVk = 0;` (0 = unbound). VK-only, no modifiers, mirroring - `cursorLockVk`. -- Parse `swapModelVk` in `ParseConfig`; run it through `sanitizeVk` (so - `IsForbiddenBindVk` keys - clicks, Backspace, Win - can never be bound). -- Add `swapModelVk=0` with a comment to the default-ini template in `LoadConfig`. - -### Core (`input_router` / `main.cpp`) - -- `swapModelVk` joins the LL-keyboard-hook key set via `g_input.setKeys(...)` so it - is swallowed (never double-fires into the focused app) and is authoritative for - down-state, exactly like `recenterVk` / `cursorLockVk`. `setKeys` gains a parameter; - its two call sites (startup `main.cpp:920`, hot-reload `main.cpp:246`) pass the new - vk, and the hot-reload guard compares `swapModelVk` too. Because the *binding* is - hot-reloadable, changing the key in settings re-arms the hook live; only *pressing* - it restarts. -- In `RunTick`, rising-edge detect `swapModelVk` (a `swapKeyWasDown` flag beside - `recenterKeyWasDown`), processed unconditionally so it works whether or not zoomed. - On the rising edge: - 1. `flipped = wind::FlipModel(t.cfg.model)`. - 2. Read the ini (`ResolveIniPath()`), `wind::UpdateIniText(text, "model", flipped)`, - write it back atomically. - 3. `ShellExecuteW(nullptr, L"open", \Wind.exe, ...)`. The new instance's - eviction handshake stops this one. No new IPC. - 4. If `ShellExecuteW` returns `<= 32`: do not quit; rewrite the ini's `model` back - to `t.cfg.model` and log the failure (the core has no UI to prompt). -- `ini_edit.cpp` is added to the `Wind.exe` compile set (it is pure; currently only - linked into `WindConfig.exe`). The core gains a small read-file + atomic-write - next to its existing `config.cpp` file I/O, or reuses a shared helper. - -### Pure helper (`config.cpp`, `WIND_TESTS`-visible) - -```cpp -// render <-> transform. Any non-"transform" input maps to "render" (matches the -// ParseConfig fallback), so an unknown/corrupt value flips to a known-good one. -std::string FlipModel(const std::string& model); // "transform" -> "render"; anything else -> "transform" -``` - -Precisely: `FlipModel("render") == "transform"`, `FlipModel("transform") == "render"`, -and any other value returns `"transform"` (so a corrupt `model` still flips to a valid -engine rather than sticking). - -### Config UI (`ui/src/settings-schema.js` / `Settings.svelte`) - -- One `keybind` row in the **Display** section, immediately under the `model` select: - ```js - { key:'__swapModel', type:'keybind', label:'Swap model hotkey', - desc:'Press to switch between Render and Transform (restarts Wind). Right-click to clear.', - vkKey:'swapModelVk' } - ``` - VK-only (no `modsKey`, no `buttonKey`), like `__cursorLock`. Placing it in the - Display section - not Keybinds - keeps the swap control beside what it swaps. -- Add `swapModelVk:'0'` to the `kbDefaults` map in `onMount`, so it loads and is - written live through the existing keybind `live()` path (hot-reload; no restart to - bind). - -### System-wide swallow (accepted tradeoff) - -While bound, `swapModelVk` is eaten from the focused app like every other Wind bind, -and per the documented raw-input limitation it still reaches raw-input games. The -user picks a key accordingly. - ---- - -## Testing - -Unit (doctest, pure): -- `FlipModel`: render->transform, transform->render, unknown->transform (round-trips). -- `UpdateIniText` replacing `model` in place (extend existing ini_edit coverage). - -Manual: -1. Settings: change model, Apply -> Wind restarts silently onto the new model - (transform-only vs render-only rows switch), no modal, tray returns once, cursor - restored. Ini `model` matches the running model. -2. Bind the swap hotkey; press while zoomed -> restarts on the other model. Press - again -> back. Cursor restored each time; tray returns once per press. -3. Press the swap hotkey while idle (not zoomed) -> same flip + restart. -4. Bind/clear the swap key in settings -> takes effect live, no restart; forbidden - keys refused by capture. -5. Relaunch-failure (e.g. temporarily rename `Wind.exe`): Settings shows the error - box and reverts the dropdown + ini to the running model; the core logs the failure - and stays on the current model with the ini reverted. - -## Issue / PR mapping - -- **Feature A** (WindConfig-only): its own issue -> branch -> PR. -- **Feature B** (core + config + UI): its own issue -> branch -> PR. - -Both build on the current model work. diff --git a/docs/superpowers/specs/2026-07-11-transform-centered-cursor-design.md b/docs/superpowers/specs/2026-07-11-transform-centered-cursor-design.md deleted file mode 100644 index e6764d61..00000000 --- a/docs/superpowers/specs/2026-07-11-transform-centered-cursor-design.md +++ /dev/null @@ -1,140 +0,0 @@ -# Transform model: centered cursor (decouple the drawn cursor from the OS cursor) - -Date: 2026-07-11 -Status: approved (design) - -## Context - -The transform model currently runs a FREE cursor: the magnification is anchored at the -cursor (`ComputeFixedPointOffset`, `T(L) == L`), so the drawn cursor moves at hand speed -across the screen while the view pans slower (by `1 - 1/level`). Two UX costs follow -directly: the cursor is not centered (a centered cursor is generally friendlier), and it -slides toward the screen edge as you move, until only a few pixels of it remain visible. - -This is the third battle on this ground. The documented history: - -1. **Original port** (issue #129): centered source rect with the sprite welded to the OS - cursor position L. DWM composites the cursor AND layered windows OUTSIDE the fullscreen - magnification (measured: a layered window at desktop P draws at screen P, unscaled), so - the sprite sat at L while its content sat at `T(L)` = screen center. Click drift, zero - at center, growing to the edges, dead zones at the clamps. -2. **Shipped fix** (PR #130, `14eb312`): anchor at the cursor, `off = L*(1 - 1/level)`. - Clicks perfect everywhere; cursor no longer centered. Same trade as the old mag engine - (`2dc5130`). -3. **Failed attempt** (`044257f`, unmerged): edge-triggered pan with a MAGNIFIED sprite. - Dead zones returned, worse with zoom. Failure signature (drag works, hover/fresh clicks - do not; worse with zoom) indicates sprite-placement error (a zoom-scaled sprite has a - zoom-scaled hotspot), not a click-path error. - -Ground truths this design rests on (all measured on this codebase): - -- Mouse clicks land at the OS cursor's desktop position in EVERY Magnification - configuration (`a933656`, exhaustive probes; `MagSetInputTransform` is pen/touch only). -- A layered window at desktop P draws at screen P, unscaled, regardless of the fullscreen - transform (PR #130 marker-window measurement). We therefore control the drawn cursor's - SCREEN position exactly. -- `CursorMapper` already computes, every tick: the centered clamped source rect - (`srcLeft/srcTop`), the lens center (`clickDesktopX/Y` = C), and - `cursorScreenX/Y = (center - src) * level` = `T(C)`, the screen point showing the - lens-center content (`cursor_mapper.cpp:35`). `ComputeMagTransform` was designed to take - the mapper's rect (`transform.h:24-30`). - -## The design - -Do what the render model does: decouple the drawn cursor from the OS cursor. - -- **Source rect**: the mapper's centered clamped `srcLeft/srcTop` (currently ignored by - the transform model) instead of `ComputeFixedPointOffset`. -- **Drawn cursor (sprite)**: at `cursorScreen + monitor origin`. Screen center in steady - state; slides edge-ward only when the source rect clamps at the desktop boundary (same - behavior as the render model). By construction this is `T(C)`: the sprite sits exactly - on the lens-center content. -- **OS cursor**: welded to `clickDesktop` (= C), unchanged plumbing. Clicks land at C, - which is exactly the content under the sprite. No drift at any offset, including the - clamped edges: `cursorScreen` and the click point are linked by the same rect. -- **Zoom-in continuity**: at level slightly above 1.0 the centered rect is nearly the - whole screen and `cursorScreen ~= C`, so the sprite starts at the cursor's real spot - and glides toward center as the zoom deepens. `(cx - o.x) * level` is continuous in - level; there is no snap. - -This also fixes the "view pans slower than the cursor" complaint: centered mode pans the -view 1:1 with hand speed, like the render model. - -### Mode selection (correct by construction) - -Centered mode requires that the thing on screen is a cursor WE draw (or nothing at all). -Whenever a VISIBLE cursor exists that we cannot place, fall back to the anchored offset, -where a real cursor at L is self-consistent (`T(L) == L`): - -| Situation | Offset | Cursor shown | -|---|---|---| -| `cursorSprite=1`, sprite Rendered | centered | sprite at `cursorScreen` | -| `cursorSprite=1`, drawCursor false (hide hotkey / visibility=never) or shape Hidden (app hid its cursor) | centered | none (real cursor blanked/suppressed) | -| `cursorSprite=1`, shape Unsupported (app-custom cursor: not blankable, visibly drawn by DWM at its desktop spot) | anchored | the real app-custom cursor | -| `cursorSprite=0` | anchored | the real cursor | -| `transformCenterCursor=0` | anchored | as today | - -`present()` therefore calls `refreshShape()` FIRST (order swap; today the transform is set -before the sprite block) so the verdict can pick the offset for the same tick. Switching -between centered and anchored mid-zoom (a standard<->custom cursor shape transition) moves -the view in one step; this is rare, correct, and accepted. - -### Config - -- New key `transformCenterCursor` (int, default **1**), hot-reloadable, transform-only. - 0 = today's anchored free cursor. The old mag engine had exactly this knob - (`magCenterCursor`, `11a2665`); given the history, the escape hatch stays one ini edit - away. -- Config UI: toggle row "Center cursor" in the Display section, transform-only - (`showIf model=transform`), `dependsOn: cursorSprite` (centered needs the sprite), - desc noting the cursor stays centered while the view pans. - -### Inspect mode - -In centered mode the crosshair draws at `cursorScreen + origin` (the look point's screen -position under the centered rect) instead of `clickDesktop + origin`. In anchored mode the -current behavior stays (`T(L) == L` makes them the same point there). The frozen-cursor -weld (`ex.clickOverride`) is unchanged. - -### Out of scope - -- The render model: untouched. -- `ComputeFixedPointOffset` stays (anchored fallback + `transformCenterCursor=0`). -- Multi-monitor: the transform model already forces the primary monitor; `+ mon origin` - is kept for correctness but no new multi-monitor behavior is added. - -## Risk, stated honestly - -`044257f`'s note ("geometry says clicks must be accurate for ANY offset, yet they are -not") is the one cloud. If an undiscovered OS behavior breaks clicks at non-anchored -offsets, this design hits it. The evidence points the other way (the click probes above; -the failed attempt's signature matching scaled-sprite math, which this design does not -use: the sprite stays unmagnified). The toggle bounds the damage: one ini edit restores -today's behavior. Live verification is the gate before merge. - -## Testing - -Unit (doctest, pure): -- `transformCenterCursor` parses, defaults to 1 (extend `test_config_model.cpp`). -- Mapper invariant pin: `cursorScreen == (center - src) * level` with the clamped centered - rect at edges (extends existing mapper coverage; documents what the sprite placement - relies on). - -Manual (deployed transform model, `transformCenterCursor=1`): -1. Zoom in mid-screen: cursor sprite sits at screen center; moving the mouse pans the - view 1:1 under it; the sprite stays centered. -2. Move to each screen edge/corner: the sprite slides from center to the edge exactly as - the render model's cursor does; clicks land under the sprite tip everywhere, - including over the taskbar area (the historical dead zone). -3. Hover: tooltips/highlights appear for the item under the sprite. -4. App-custom cursor (e.g. a game or a custom-cursor app): view steps to anchored mode, - real cursor visible and self-consistent; back to centered when the shape returns to a - standard one. -5. Inspect mode: crosshair centered (or edge-shifted per the clamp), look-point panning - and click routing unchanged. -6. `transformCenterCursor=0` hot-reload: behavior returns to today's free cursor. -7. Render model: unchanged. - -## Issue / PR mapping - -One issue, one branch (`feat/transform-centered-cursor`), one PR. diff --git a/docs/superpowers/specs/2026-07-22-magnify-model-design.md b/docs/superpowers/specs/2026-07-22-magnify-model-design.md deleted file mode 100644 index 72b7abd9..00000000 --- a/docs/superpowers/specs/2026-07-22-magnify-model-design.md +++ /dev/null @@ -1,151 +0,0 @@ -# Magnify model: replace the transform model with a driven Windows Magnifier - -Date: 2026-07-22. Status: implemented, AMENDED same day (see the amendment at the bottom: the -control channel changed from injected hotkeys to live registry writes after measurement). - -## Problem - -The transform model (`model=transform`, `MagSetFullscreenTransform` via `mag_host`) exists for one -reason: DRM-protected content (Netflix and friends) blanks in the render model's Desktop -Duplication capture, and the DWM fullscreen transform magnifies it fine. But the model is not good -enough to keep: it stutters, the cursor cannot be centered (7-build effort abandoned, see the -LEDGER on `feat/transform-centered-cursor`), and very high zoom can destabilize the system. - -## Decision - -Remove the transform model entirely. Replace it with a `magnify` model that drives the native -Windows Magnifier (Magnify.exe) with Wind's controls. Windows Magnifier uses the same DWM -fullscreen transform under the hood, so DRM content works, but the OS implementation handles the -view, cursor tracking, and stability itself, better than our transform model ever did. - -## How Windows Magnifier is controlled (verified on this machine) - -- Settings live in `HKCU\Software\Microsoft\ScreenMagnifier`. Relevant values: `Magnification` - (percent), `ZoomIncrement` (percent per step, 5..400; this box currently has 100, which is why - native zoom jumps 100 -> 200 -> 300), `MagnificationMode` (2 = fullscreen), `FollowMouse`, - `MagnifierUIWindowMinimized`, `UseBitmapSmoothing`. -- The only reliable live control channel is the global hotkeys Magnifier itself registers: - `Win+Plus` / `Win+Minus` (injected via `SendInput`). Registry values are read at Magnifier - startup, not live. -- `Win+Esc` quits Magnifier. - -## The magnify model - -A new `MagnifyModel` implements the existing `IMagnifierModel` interface, so the model swap -hotkey, config plumbing, and single-instance relaunch handshake all keep working unchanged. - -- **initialize**: snapshot the user's Magnifier registry values (persisted to - `%LOCALAPPDATA%\Wind\` on first modify so a crash cannot cause Wind to later "restore" its own - values), then write ours: fullscreen mode, `ZoomIncrement=magnifyStep` (default 5), follow - mouse on, toolbar minimized, `Magnification=100`. Magnify.exe is NOT launched yet. -- **present(level)**: the one job is syncing Magnifier's stepped level to Wind's smooth - `ZoomController` level. Compute `targetSteps = round((level*100 - 100) / magnifyStep)`; inject - `Win+Plus`/`Win+Minus` chords (budgeted per tick so Magnifier keeps up) until the internal step - counter matches. First zoom-in launches Magnify.exe (registry pre-set to 100%). Hold-to-zoom, - accelerating ramp, quick-zoom snap, and `maxLevel` therefore all carry over from Wind's - existing zoom semantics for free. Level is clamped to Magnifier's 1600% ceiling. -- **Idle (zoom back to 1x)**: Magnify.exe KEEPS RUNNING at 100% (user decision): instant next - zoom-in, zero visual effect at 100%. The step counter is resynced against the registry - `Magnification` value when possible (verified during implementation; internal counter is the - fallback authority). -- **shutdown / model swap / Wind quit**: `Win+Esc` (Magnifier exits), restore the snapshotted - registry values, delete the snapshot file. - -## Smooth zoom (expectation setting) - -True continuous scaling is impossible through Magnify.exe; stepped hotkeys are its only external -control. Best achievable, and what this spec commits to: 5% steps paced by Wind's smooth ramp -(plus Windows' own step transition animation). Far better than the current 100% jumps, but still -steps, not render-model glass. `magnifyStep` is exposed in config (5/10/25/50) for users who -prefer chunkier steps. - -## What does not apply in magnify mode - -Magnifier owns the view and the cursor, so Wind's cursor pipeline is bypassed: no cursor hide, no -drawn cursor, no cursor-sensitivity scaling, no Inspect mode (the toggle is ignored with a log -line), no zoom outline, no multi-monitor retarget (Magnifier's own fullscreen behavior applies). -The config UI shows only the relevant rows per model, as it does today. - -## Removal scope - -- Delete: `src/transform_model.*`, `src/mag_host.*`, `src/cursor_sprite.*`, - `src/cursor_blanker.*`, `src/comp_pin.*`, `tests/test_transform_model.cpp`. -- `src/transform.{h,cpp}`: keep `OffsetF`/`ComputeOffsetF` (used by `cursor_mapper`); remove - `ComputeFixedPointOffset`, `ComputeMagTransform`, `MagTransform` and their tests. -- Config: remove `fastPan`, `smoothPan`, `cursorSprite`; `model` values become - `render`/`magnify`; a legacy `model=transform` in an existing ini maps to `magnify`; - `FlipModel` flips render <-> magnify; add `magnifyStep` (default 5). Ini template text updated. -- UI (`ui/src/settings-schema.js`): model select becomes render/magnify; transform-only rows - replaced by the magnify rows. (`transform` matches elsewhere in the UI/shaders are CSS/HLSL - matrix transforms, not the model; untouched.) -- Docs: CLAUDE.md architecture/gotchas and README model sections rewritten for magnify. - -## Risks and mitigations - -- **Injected chord pacing**: Magnifier may drop hotkeys injected too fast. Budget per tick and - space the chords; if bursts still drop on zoom-out, fall back to quit-and-relaunch-at-100% - (deterministic reset, still meets the keep-running-feel since relaunch is backgrounded). -- **Win chord side effects**: the SendInput sequence (Win down, Plus tap, Win up) does not open - the Start menu because a key is pressed inside the chord. Wind's own hooks ignore injected - events, and Win keys are never swallowed by design. -- **Registry restore on crash**: the on-disk snapshot file (written once, before first modify) - survives crashes; the next clean run restores from it. - -## AMENDMENT (2026-07-22, after live testing) - -The injected-hotkey channel shipped first and failed live testing on all three fronts: Magnifier -drops roughly HALF of a rapid Win+Plus burst (10 chords at 5 ms spacing -> ~5 applied) and -animates each survivor, so the zoom lagged Wind's ramp ~4-5x and kept zooming for seconds after -release (queued backlog); and the large-residual zoom-out path (Win+Esc + relaunch) made every -zoom-in feel like a cold start. - -Measurement (scratchpad probes magdiag.cpp / magdiag2.cpp, MagGetFullscreenTransform as the -sensor) found the correct channel: **Magnify.exe registry-watches the `Magnification` value and -applies a bare RegSetValue LIVE** - picked up within ~10 ms, eased smoothly (~100 ms trailing), -exact for arbitrary integer percents (137 -> 1.370), no broadcast needed. One trap: **writes -above 1600 are silently IGNORED**, not clamped, so the model must clamp itself. - -The model now writes the ramped level as an integer percent whenever it changes (>= 1%), giving -continuous smooth zoom at Wind's configured speed. Consequences: -- `magnifyStep` / ZoomIncrement is irrelevant and was removed (config, UI, tests). -- Zoom-out writes 100 and Magnifier stays running - the Esc+relaunch path is gone. -- Keystroke injection remains ONLY for Win+Esc (quit on shutdown/model swap). -- `magnify_steps.h` (step math) became `magnify_level.h` (percent mapping + the 1600 clamp). - -## AMENDMENT 2 (2026-07-22, after second live test) - -Streaming per-1% registry writes also failed live: probe 3 (dense trajectory sampling) showed -Magnifier consumes registry changes at ~280 ms animation-window boundaries, and writes arriving -faster degenerate into INSTANT ~40% snaps at each boundary ("slight ease, then a massive step"). -The system animation-effects setting does not change this. A SINGLE write, however, eases -beautifully over ~280 ms regardless of distance, and probe 4 established the hybrid facts: -MagSetFullscreenTransform from Wind's process sticks while Magnify.exe runs (no stomping, even -with mouse movement), and a registry write matching the actual transform is a visual no-op. - -Final design (implemented): during an active ramp Wind sets the fullscreen transform directly -each tick, anchored at the cursor (`MagnifyAnchorOffset`: the desktop point under the cursor -keeps its screen position, so the view zooms toward the pointer) - glass smooth at Wind's -configured speed. When the ramp settles, the transform is snapped to the exact integer percent -and ONE registry write hands the level to Magnifier (visual no-op) so its native panning owns -steady state. Single-tick jumps >= 0.75x (quick zoom) route through the registry to play -Magnifier's eased animation. Magnify.exe is launched at model initialize (never mid-ramp) and -kept running at identity when idle. Extra trap encoded: a same-value registry write fires no -notification, so no code path may rely on one to make Magnifier act. - -## AMENDMENT 3 (2026-07-22, FINAL - shipped design) - -The hybrid (amendment 2) also failed live: Magnifier stomps its stale belief within ~7 ms of -being woken when mouse moves are queued, and its registry handler animates from a stale cached -actual for writes queued during suspension - every resume/sync ordering tried still produced -per-zoom flicker or racy release levels, and an input-event storm ended the attempt. - -Shipped design, per user decision: NATIVE WHEEL-NOTCH DRIVE. Wind holds no zoom state. While a -zoom button is held, it injects Ctrl+Alt+wheel notches (Magnifier's own wheel shortcut) at a -measured 60 ms cadence; Magnifier natively steps by the user's ZoomIncrement, eases each notch, -pans, and draws the cursor. Measured (probe 7): wheel notches register 1:1 with no backlog and -settle ~150 ms after release; injected Win+wheel is inert. `selfDrivenZoom()` bypasses Wind's -whole level pipeline (ZoomController pinned at 1x; quick zoom / mapper / Inspect inactive in -this model). `magnifyStep` (ini + UI, 5..400, default 50) maps to ZoomIncrement, written live -on change and snapshot-restored on exit. All the deleted machinery (suspension, phases, -cursor-anchored transform driving, anchor math) is recorded here and in CLAUDE.md as measured -dead ends - do not re-attempt without new evidence. diff --git a/docs/superpowers/specs/2026-08-02-drag-weld-fight-design.md b/docs/superpowers/specs/2026-08-02-drag-weld-fight-design.md deleted file mode 100644 index 1dc722bc..00000000 --- a/docs/superpowers/specs/2026-08-02-drag-weld-fight-design.md +++ /dev/null @@ -1,94 +0,0 @@ -# Drag flicker: the weld fights the hand (issue #169) - design - -## Problem - -Dragging a window while zoomed makes it flicker between two positions ~85 px apart (probe-measured, -square-wave alternation at sample rate). The window's position tracks the pointer 1:1 at full rate, -so nothing is slow - the OS cursor itself oscillates. Amplitude grows with hand speed (and with the -radius of arc-shaped hand motion, which is speed in disguise). Present in render and transform; -absent at 1x, absent with Wind quit, absent in the native Windows Magnifier. - -## Root cause - -Two defects in the weld/baseline contract, one structural and one behavioural. - -### 1. The motion baseline is assumed, not measured - -The free-pan oracle computes each tick's hand delta as `GetCursorPos() - lastSetVirtual`, and -`lastSetVirtual` is set at end of tick to `clickDesktop` - the point the render model was ASKED to -park the pointer at (main.cpp, "Both models now WELD ... the baseline for next tick's delta is that -point"). That assumption is false in every case where no park landed: - -- The transform model NEVER places the cursor (the transform cursor law), yet its baseline is still - set to the lens centre. -- The render model's park is deduped: it fires only when `clickDesktop` changed a whole pixel - (render_engine.cpp `lastClickX` check), so slow motion skips it while the pointer creeps on. -- A park can also be skipped by early-outs (device lost, parked overlay) or simply fail. - -When the park does not land, the pointer's true position differs from the assumed baseline by some -gap `e`. The next delta then measures `hand + e` and the mapper integrates it: the centre advances -by MORE than the hand, overshoots the pointer, `e` flips sign, and the loop oscillates - an -unstable servo. Amplitude scales with speed and with the smoothing lag, matching the measurements -(and turning smoothing OFF removed damping, making it worse: 537 jumps vs 136). - -### 2. During a drag, the weld itself is the fight - -While a mouse button is held, the OS pointer IS the drag position. The render model re-parks it at -the smoothed lens centre every tick while the hand advances it, so the pointer alternates between -the two and everything that follows the pointer (the dragged window) flickers. This is inherent: -any per-tick repositioning of a pointer that another interaction is actively consuming is a fight. - -## Fix - -Three layers, each independently sound. - -### A. Measured baseline - -After the model presents, read `GetCursorPos()` once and use THAT as `lastSetVirtual`. If the park -landed, this equals the park point (SetCursorPos is synchronous) - identical to today. If no park -landed (transform follow, dedupe skip, failure), it is the pointer's true position and the servo -stays consistent. The sub-tick motion between park and read is counted in the next delta; nothing -is lost. Inspect keeps its explicit frozen-point baseline (the 1px clip pins the pointer; the -measured read would return the same, but explicit is clearer and immune to the click-release -window). The freeze branch likewise keeps `freezePoint` semantics via the same measured read (the -clip confines the pointer, so the read returns the frozen point). - -### B. Drag-follow: suppress the weld while a button is held - -In a FREE (not locked, not gameFreeze, not Inspect) render-model session, when any physical mouse -button is down (`GetAsyncKeyState` on VK_L/R/MBUTTON): - -- Do not park: a new `RenderFrameParams::suppressCursorSync` flag makes renderFrame skip its - SetCursorPos block. -- Pan by the pointer's real motion UNSCALED (`dx = curDx`, the transform FOLLOW design), so the - lens tracks the pointer 1:1. `cursorSensitivity` deliberately does not apply while a button is - held - scaling would desync the lens from the pointer that owns the drag. - -Correctness: the press happened under the welded cursor (weld was live until the button went down), -so it landed at the right point. During the drag the pointer position is the drag position; the -lens follows it, and the drawn centred cursor sits over the same content modulo the smoothing lag -(~0.67 x per-tick speed at the shipped 0.4, settling to zero when the hand slows to drop). The -release lands where the pointer and the window both are - correct by construction. On release the -weld resumes and re-parks the pointer to the lens centre (a few px at most). - -The decision is a pure function, `ShouldDragFollow(renderActive, locked, gameFreeze, inspect, -anyButtonDown)` in a new small header, unit-tested as a truth table. - -### C. Divergence diagnostics (off by default) - -Under `diagnostics=1`, log a once-per-second aggregate while zoomed: parks issued, parks skipped, -max |pointer - centre| divergence. If any oscillation survives, the log pinpoints it. - -## Out of scope - -- The transform model's own drag behaviour is already FOLLOW (never places the cursor); it gains - the measured baseline (fixing its servo) and needs no drag special-case. -- Inspect, game freeze, locked sessions: unchanged. -- The magnify-mode flicker report: Wind touches nothing cursor-related there; needs re-verification - after this fix before it is treated as real (noted on #169). - -## Testing - -- New truth-table unit tests for `ShouldDragFollow`; the 145 existing tests must pass. -- Field: drag windows zoomed (slow, fast, near and far from screen centre), text-selection drags, - click accuracy after a drag, transform-model drag, a game session sanity check. diff --git a/docs/superpowers/specs/2026-08-12-one-model-transform-design.md b/docs/superpowers/specs/2026-08-12-one-model-transform-design.md deleted file mode 100644 index 4fe3b01c..00000000 --- a/docs/superpowers/specs/2026-08-12-one-model-transform-design.md +++ /dev/null @@ -1,98 +0,0 @@ -# One default engine: transform on the desktop - design - -Date: 2026-08-12 -Status: approved-pending-review -Foundation: docs/POINTER-HITTEST-FINDINGS.md (the dead-zone root cause and its fix) - -## Goal - -Make the transform engine viable as the ONE default magnification experience - desktop and -games - by shipping the proven fix: welded cursor + per-change source-rect -`MagSetInputTransform`. Converge gradually; retire nothing until the field says so. - -## What changed today - -Welded transform sessions on the desktop had hard hover dead zones in pointer-framework apps -(Explorer/Settings/shell). Root cause: those frameworks consume the system input transform for -mouse pointer hit-testing under a fullscreen magnification transform (MSDN scopes the API to -pen/touch - wrong). Publishing `MagSetInputTransform(TRUE, srcRect, monitorRect)` on every -transform change (native-Magnifier parity) fixes hover everywhere, 4x-20x field-verified, -with legacy apps unaffected. - -## Constraints that shape the design - -1. **UIAccess dependency**: MagSetInputTransform fails without UIAccess. Dev builds and any - non-UIAccess run CANNOT fix the dead zones -> the transform must VERIFY the publish - succeeds per session, and the desktop pick must fall back to render when it does not. -2. **MPO**: the pan wall stays for transform game sessions on MPO-enabled machines - (unchanged). Desktop sessions were always full-range. -3. **Cursor size**: the transform sprite is DWM-magnified (grows with zoom) - still the open - violation of the constant-size rule. Phase 2 tests the band-16 escape; its outcome does - NOT gate Phase 1 (the render desktop default is unchanged until the flip decision). -4. **Render stays**: non-UIAccess fallback, multiMonitor=1 (transform is desktop-wide), and - the sharpness/bilinear knobs live there. "One model" means one DEFAULT experience, not - deleting engines. - -## Phases - -**P1 - productionize the input transform (this plan).** -- The transform model publishes the source-rect input transform on EVERY transform change in - EVERY session (game and desktop) whenever it is available; clears it at session end - (existing) and on shutdown. -- Availability probe at session start: one publish attempt; failure -> logged once, session - marked `inputTransformOk=false`. -- Rect math extracted pure (`ComputeInputTransformRects`: src from srcLeft/srcTop + monitor - extent/level, dst = monitor rect WITH origin) + doctests. The current code uses a - 0,0-based dst; wrong off-primary. -- New ini knob `desktopTransform` (default 0): hybrid's engine pick treats "desktop with - desktopTransform=1 AND input transform verified" as a transform pick. engine_pick.h gains - the two inputs; doctests updated. `magInputTransform` becomes an internal diagnostic - (modes 0/2 kept for A/B); the shipped default flips to source-rect-on inside transform - sessions regardless of the knob. -- Settings UI: `desktopTransform` as an advanced toggle ("Use the game engine on the desktop - (experimental)"). - -**P2 - constant-size cursor experiment (banded sprite).** -Create the cursor sprite via CreateWindowInBand band 16 positioned in SCREEN space -(cursorScreen). Hypothesis: high-band windows escape the DWM fullscreen transform (native -Magnifier's own fullscreen UI stays unmagnified). If confirmed: crisp constant-size centered -cursor for ALL transform sessions - the cursor-size rule is met and the last UX gap vs render -closes. If refuted: fallback candidates are the 1/level-scaled sprite (constant size, soft at -high zoom) or accepting scale-with-zoom on the transform path (rule stays open). Kill -criterion: banded window is transformed like everything else -> keep current sprite. - -**P3 - parity + endurance (gates the default flip).** -- Cursor-shape churn tax on the desktop: measure re-composite cost of I-beam/hand churn while - zoomed (the live-context tax class) vs render; regression -> stay opt-in. -- Outline: skip on transform in v1 (render-only), unless P2's banded window lands (it could - draw the outline unmagnified for free). -- Zoom-ramp spike comparison desktop transform vs render (the known ~45ms ramp spikes). -- DRM check (Netflix): if transform shows protected content like native Magnifier does, the - magnify model becomes redundant -> separate retirement decision later. -- Weeks of field use with desktopTransform=1 on the rig. - -**P4 - the flip.** If P3 holds: desktopTransform default flips to 1 for UIAccess installs -(render remains the automatic fallback wherever the input transform cannot be verified). -Nothing is deleted. - -## Error handling - -- Publish failure mid-session (e.g. transient): log once per session, keep the session on - transform (games unaffected by the dead zones anyway); the DESKTOP pick requires verified - availability up front. -- Session end/shutdown always clears the input transform (a stale system-wide input mapping - would corrupt pointer input at 1x system-wide - same invariant class as cursor restore). - -## Testing - -- Pure: ComputeInputTransformRects doctests (origin offsets, level edges, rounding); - engine_pick doctests for the two new inputs. -- Field: the TransformProbe profile flow (probeClicks 1/2) IS the validation harness for the - dead zones; endurance via normal daily use with desktopTransform=1. - -## Out of scope (YAGNI) - -- Retiring render or magnify (separate decisions after P3/P4 evidence). -- multiMonitor transform sessions (desktop-wide transform vs per-monitor semantics). -- The MPO-buster window (ROADMAP item, unchanged). -- Free-cursor desktop mode (proven viable, rejected UX). diff --git a/docs/superpowers/specs/2026-08-13-mpo-buster-design.md b/docs/superpowers/specs/2026-08-13-mpo-buster-design.md deleted file mode 100644 index 1c37b956..00000000 --- a/docs/superpowers/specs/2026-08-13-mpo-buster-design.md +++ /dev/null @@ -1,78 +0,0 @@ -# MPO buster + 2D pan wall - design - -Date: 2026-08-13 -Status: approved (Max pre-approved build + MPO validation) -Brainstorm: 5-angle panel + cross-examination (workflow wf_92510600-26a); this spec is its synthesis. - -## Problem - -On MPO-enabled NVIDIA machines, a game surface on a hardware overlay plane makes the driver -pack DWM's magnification translation into a 16-bit field; |src*level| > 32767 wraps and resets -the GPU (issue #148). Verified geometry at 3840x2160 (2px margin): the right strip is -reachable-lethal above 9.538x, the bottom strip above 16.185x, and a CENTER zoom with no pan -overflows X above 18.07x. Native Magnifier does not crash there - working theory: its own -fullscreen-geometry surfaces keep the game off the overlay plane (the parking-law demotion), -though all of Wind's demotion evidence was measured with MPO OFF, so the theory needs one -non-destructive measurement before it is trusted. - -## CRITICAL correctness finding (ships regardless of everything else) - -**The shipped pan wall is X-ONLY** (`setMaxSourceLeft`; CursorMapper has no Y equivalent). -Bottom-right above ~16.19x is reachable-lethal in today's code on MPO-on machines - the field -reports of bottom-right crashes are the unguarded Y axis. Additionally the wall divides by the -CONTROLLER level while writes use the ramp-limited applyLevel, spending the 32767-32000 -headroom on faith. - -## The fix, three layers (fail-closed ladder) - -1. **2D pan wall**: `setMaxSourceTop(32000/lvl)` mirroring the X wall, same session key - (transform game + MPO on + tdrTest != 4). Self-gating: inert below ~16.19x exactly as the - X wall is inert below ~9.54x. tdrTest=4 disables BOTH axes. -2. **2D write-site clamp** (production backstop): at the ComputeMagTransform choke point, - when the session is MPO-exposed, clamp |txX| and |txY| <= 32000 and recompute the public - offsets consistently. Makes never-exceed-32767 structurally true (absorbs the - applyLevel-vs-controller drift and any future caller). -3. **The MPO buster (the actual fix)**: a fullscreen ALPHA-1 (never 0: DWM drops fully - transparent windows - comp_pin's own finding; the alpha-0 parking measurement was MPO-off) - click-through layered ghost window, UNBANDED (it only needs to cover the unbanded game; no - #162 Snipping trade), `WDA_EXCLUDEFROMCAPTURE`, owned by TransformModel as a sibling of - CompositionPin. Shown at `setActive(true)` BEFORE the first write; re-asserted on the - existing 500ms pin cadence; hidden strictly AFTER the identity park in `setActive(false)` - (the pin's exact call sites); destroyed in shutdown. Gate: transform game session AND MPO - on AND `mpoBuster=1` (new hot knob, default 1). The ghost never presents, so the - stale-frame law has no content to flash. - - **Fail-closed wall lift**: the 2D wall LIFTS only while the ghost is verifiably shown - (IsWindowVisible + fullscreen bounds) AND has been up >= ~350ms (demotion settle). Any - failure (create refused, hidden mid-session, knob off) keeps or restores the wall - instantly. The churny/device-lost backstop stays untouched as the last line. - -Rejected: reusing the render overlay as the buster (hybrid-only - strands standalone -model=transform, couples engines across the delicate park/reveal machinery); the 1px pin as -the buster (MPO exists to scan out overlapping surfaces on separate planes - a corner pixel -likely gets its own plane); ETW plane-residency detection (heavy for what fail-closed gating -covers); threshold-gated ghost (a promote/demote race exactly at the danger boundary - -whole-session wins; threshold gating is the documented retreat if game-frametime cost is -measured). - -## Validation protocol (one MPO-on boot; TDR budget 2-4) - -Phase 0 (this rig, MPO re-enabled: delete OverlayTestMode, reboot): -- **Zero-TDR causal probe** (tools/flipwatch.ps1 + vendored PresentMon): baseline a game on - its hardware plane; show the ghost; confirm PresentMode drops to a composited class. If the - game STAYS on its plane, the ghost is refuted before any destructive testing and the 2D - walls remain the shipped guard (plus the corner level-shave fallback design). -- Same boot, piggybacked: native-Magnifier plane ledger (does native demote?) and a - MagGetFullscreenTransform poller (does native simply never write |tx| > 32767? if so the - pan wall IS native parity and the fence is the fix). -Phase 1: ONE no-ghost sensitivity repro (tdrTest=4, far-right, >9.6x) proving the crash still -bites this driver. Phase 2: ghost-on repetitions at far-right AND bottom-right, expect zero -TDRs with the tx log proving the dangerous writes happened. Delete churny_apps.txt between -runs (the backstop otherwise reroutes to render and fakes passes); confirm each session logs -transform. Restore OverlayTestMode=5 + reboot afterward. - -## Out of scope - -- desktopTransform sessions on MPO-on machines (no wall today; the windowed-video overlay - plane risk is speculative - measure in a later MPO session before fencing). -- The installer registry opt-in stays on the roadmap, demoted to optional. -- NVIDIA bug report (the ghost is a workaround, not a fix of their defect). diff --git a/docs/superpowers/specs/2026-08-24-engine-per-window-type.md b/docs/superpowers/specs/2026-08-24-engine-per-window-type.md deleted file mode 100644 index de8de357..00000000 --- a/docs/superpowers/specs/2026-08-24-engine-per-window-type.md +++ /dev/null @@ -1,75 +0,0 @@ -# Per-window-type engine selection - -Auto mode picks one engine per foreground window from a fixed heuristic. This exposes that choice, -and adds the two correctness rules the heuristic was missing - most importantly DRM content, which -the render engine cannot magnify at all. - -## Why - -Max, after watching the engine flap while switching between a terminal, a browser and Playnite: -"we need to improve this auto mode... better to have an advanced button on that settings page that -lets you set the model for each window type", and separately "we also need to handle drm content -switching somehow for netflix, apple tv etc". - -The DRM half is not a preference. Desktop Duplication captures a copy-protected surface as BLACK, -so a render session over Netflix magnifies a black rectangle. Auto currently sends browsers to -render unconditionally (they are on `transformExclude` after crashing dwm.exe at high zoom over -Mica), which means Netflix in a browser is guaranteed to be broken today. - -## Categories - -Classified by `ClassifyWindow` (pure, in `engine_pick.h`) from signals already read per tick: - -| Category | Signal | Notes | -|---|---|---| -| `game` | borderless AND covers the monitor | Games, F11 video. Beats `acrylic` when both apply. | -| `acrylic` | `DWMWA_SYSTEMBACKDROP_TYPE` in {mica, acrylic, tabbed} | See the honesty note below. | -| `desktop` | existing `IsShellDesktopFg` | Win+D / Progman. Never a game, even though it reads as a borderless cover (issue #172). | -| `other` | everything else | | - -Each maps to an ini key (`engineGame`, `engineAcrylic`, `engineDesktop`, `engineOther`) taking -`auto|transform|render`, all defaulting to `auto`. **An untouched install behaves exactly as -before** - that is the safety property, and `auto is byte-for-byte the old behaviour` in -`test_engine_pick.cpp` is what holds it. - -**Honesty note on the acrylic bucket.** Probed against every visible window on the dev machine: -the backdrop attribute is readable cross-process on 13 of 13, but 8 of them report `DWMSBT_AUTO`, -which means "the system decides" rather than "no backdrop". A third-party app painting its own -blur never appears at all. So the bucket catches windows that OPT IN - a subset of what looks -blurred on screen. It is named after the signal for that reason, and the UI copy says "windows that -ask for a Mica or acrylic background". - -## The two rules that outrank the user - -Shown in the UI as fixed, not editable: - -1. **Capture-protected content never gets render.** `GetWindowDisplayAffinity != WDA_NONE` on the - foreground **or any descendant**. The child walk is the point: Netflix or Apple TV inside a - browser leaves the top-level frame unprotected and marks only the video surface, so checking the - foreground alone misses exactly the case this exists for. Our own overlay is capture-excluded by - design (the feedback-loop guard) and was the only protected window in the probe, so windows - belonging to this process are skipped or we would detect ourselves and pin the engine forever. -2. **`transformExclude` apps never get transform.** Unchanged. - -### The conflict, and how it resolves - -Netflix inside a browser fires both. **Protected wins.** Black video every single time is a worse -failure than a rare crash risk that the pan wall and MPO buster already mitigate. Signed off by Max -on 2026-08-24; `Netflix in a browser: protected beats transformExclude` pins the ordering. - -`renderExclude` (exe list, mirrors `transformExclude`) is the manual escape hatch for protected apps -the affinity probe misses. - -## Cost - -Both new detections are cached per foreground HWND alongside the existing exe-derived predicates. -The mid-zoom pick site runs EVERY zoomed tick, and the protection check walks child windows - a -browser has plenty - so doing it at 144Hz would be real waste for a value that can only change when -the foreground does. - -## Known gap - -The display-affinity detection is **unverified against real DRM content** - no protected app was -running when it was built, and the probe's only hit was Wind's own overlay. If it misses, the -symptom is unchanged-from-today (black video on render) and `renderExclude` is the workaround. -Needs a hands-on check with Netflix or Apple TV before this is trusted. diff --git a/docs/superpowers/specs/2026-08-28-tray-menu-design.md b/docs/superpowers/specs/2026-08-28-tray-menu-design.md deleted file mode 100644 index 7a4b4785..00000000 --- a/docs/superpowers/specs/2026-08-28-tray-menu-design.md +++ /dev/null @@ -1,109 +0,0 @@ -> **Superseded (2026-10-01, issue #313):** the owner-drawn menu is replaced by the tray flyout. See `2026-10-01-tray-flyout-design.md`. - -# Tray menu: instrument header (2026-08-28) - -Replace the four bare strings in the tray menu with a **status header plus three actions**, keeping -it a real Win32 menu rather than a custom window. - -Chosen from three rounds of mockups. Round one offered three restyled menus and was rejected - -correctly, they were the same menu with nicer trim. Round two looked at what the well-regarded apps -in this slot actually do (EarTrumpet, iStat Menus, Windows' own volume/network trays) and found they -are **flyout panels with live state**, not menus. Round three settled on the "Instrument" panel with -the warning banner and engine picker removed. - -## What it looks like - -``` -┌────────────────────────────────────┐ -│ 7.4× COMPOSITION │ <- zoom, big; composition fps, right -│ ADVANCED transform · panning │ <- engine pill + one-line state -│ ┌──────────────────────────────┐ │ -│ │ FRAME PACING · 60s 6.9ms │ │ <- sparkline, drawn from the tick ring -│ └──────────────────────────────┘ │ -├────────────────────────────────────┤ -│ Profile Default › │ -│ Settings │ -│ Quit │ -└────────────────────────────────────┘ -``` - -Idle: the zoom figure reads `Idle`, the state line reads `Hold Mouse 5 to zoom`, the sparkline is a -flat dashed rule. The panel must not change height between states. - -## Why an owner-drawn HMENU, not a custom window - -The banner and the engine picker were the only elements that needed live updates while open. Without -them a **snapshot taken when the menu opens** is just as truthful - the numbers are current at that -moment - and the pacing graph draws fine as a still from the existing ring. - -| | owner-drawn menu (chosen) | custom popup window | -|---|---|---| -| the look above | yes | yes | -| live updates while open | no (snapshot) | yes | -| keyboard nav, dismissal, submenus, DPI, screen readers | free | ~500 extra lines, hand-built | -| removes the `TrackPopupMenu` tick-timer hack | no | yes | -| size | ~250 lines | ~800 lines | - -A custom window is the better answer only if the readout must animate. It does not. - -## Scope - -**In:** the header, the three items, dark/light theming, DPI scaling, the "Advanced" rename. - -**Out, deliberately:** -- **Export diagnostics leaves the tray.** It already exists as a button in the Settings UI - (`Settings.svelte:276`), so nothing is lost. Its off-thread worker and the hardened - `DiagDoneMsg` result-slot go with it (git history keeps them). -- **No warning banner.** Drafted as an MPO warning, then dropped: `mpoBuster=1` ships on, so the - pan walls lift on virtually every machine and the banner would be noise. Field-checked - Foundation - at 30x, ten sessions, full reach, no crash. -- **No engine picker.** `model` is read once at launch, so a tray switch would have to restart Wind. -- **No pause, no zoom presets.** - -## New plumbing - -Two one-way, non-blocking reads. Neither may touch the tick path with anything but a plain store. - -### `src/tray_status.h` -A single snapshot the tick loop stores and the tray reads. Plain atomics, no allocation, no lock. - -```cpp -struct TrayStatus { - double level; // 1.0 = idle - const char* engine; // "Advanced" | "Transform" | "Render" | "System" - bool panning; -}; -``` - -### `src/tick_stats.h` -A fixed ring of the last N tick intervals (ms), written one float per tick from the loop that already -computes `dt` for diagnostics, read by the tray to draw the sparkline and the composition figure. -Lock-free single-producer/single-consumer; a torn read costs one wrong pixel and nothing more. - -`kRingCap = 256` at ~144 Hz is ~1.8 s of history. The label says `60s`, so either the ring grows or -the label changes - **the label changes**, since a 256-sample ring is the cheap, correct-by- -construction option and the graph reads the same either way. - -## Drawing - -- `MF_OWNERDRAW` on every item; `WM_MEASUREITEM` / `WM_DRAWITEM` handled in `Tray::HandleMessage`, - which the main `WndProc` already forwards to. -- Header item is `MF_DISABLED | MF_GRAYED` so it cannot be selected but still receives `WM_DRAWITEM`. -- Menu background via `SetMenuInfo` `hbrBack`; item hover per the Windows 11 pattern (soft fill plus - a 3 px accent bar at the left edge), accent `#5b5bd6`. -- Theme from `HKCU\...\Themes\Personalize\AppsUseLightTheme`, read at open time. -- DPI from `GetDpiForWindow`; every metric scaled, nothing hard-coded in pixels. -- Font from `SPI_GETNONCLIENTMETRICS` `lfMenuFont`, so it matches the shell. - -## The "Advanced" rename - -`ui/src/settings-schema.js` line 62 only: `hybrid:'Auto'` becomes `hybrid:'Advanced'`. The four -per-window-type keys (lines 70/75/80/85) keep `auto:'Auto'` - those genuinely are automatic per -category, and renaming them would misdescribe them. - -## Testing - -- Pure logic (label selection, ring statistics, zoom formatting) unit-tested in the doctest suite; - drawing is not, since it needs a device context. -- Manual: dark and light, 100% and 225% DPI, idle and zoomed, a profile switch, and the tick loop - still ticking while the menu is open (the existing 8 ms timer keeps that true). diff --git a/docs/superpowers/specs/2026-09-29-colour-filters-design.md b/docs/superpowers/specs/2026-09-29-colour-filters-design.md deleted file mode 100644 index 3a496df4..00000000 --- a/docs/superpowers/specs/2026-09-29-colour-filters-design.md +++ /dev/null @@ -1,125 +0,0 @@ -# Colour filters (issue #288) - -Date: 2026-09-29. Owner: Max. Status: awaiting approval (spec + plan together). - -## 1. What the owner asked for - -Colour filters for low vision, chosen in Settings and optionally toggled with a hotkey: - -- **Invert colours** -- **Greyscale** -- **Custom tints**: two-colour reading schemes (yellow on black, white on blue, green on black) -- **Warm (orange) tint**, like a night light, with a strength setting -- **Dim**: an artificial "TV brightness" control that darkens the picture (the panel's backlight is - untouched; it is a colour-scale on the image) - -Decisions (2026-09-29): -1. Filters apply **while zoomed and at 1x**. -2. A **toggle hotkey** is optional; a filter can simply be always on. -3. Group the choices in dropdowns rather than a long list of toggles. - -## 2. Behaviour - -- Settings > **Colour** section: - - **Colour filter** (dropdown): Off, Invert, Greyscale, Warm, Yellow on black, White on blue, - Green on black. - - **Warm strength** (slider 10-100%, default 50%): used by Warm. - - **Dim** (slider 100% = off down to 20%, default 100%): composes with any filter (e.g. Invert + - dim), and works on its own with the filter Off. - - **Also when not zoomed** (toggle, default on): off = the filter only shows while zoomed. - - **Toggle colour filter** (keybind, optional): flips the filter (and dim) on and off without - changing the chosen settings. The toggle state resets to "on" when Wind starts. -- The filter covers the whole monitor Wind magnifies, including the cursor, exactly like Windows - Magnifier's colour inversion. -- `model=magnify` (native Windows Magnifier) is out of scope: Windows Magnifier has its own filters; - the rows show a note there. -- Nothing changes for a user with no filter and dim at 100%: no context, no cost (see 4.3). - -## 3. Scope - -In: the five filter kinds above, dim, the 1x option, the hotkey, both engines on the primary -monitor. Out (v1): per-app filters (can ride #286 profiles later), colour-blind correction matrices, -custom colour pickers for tints, multi-monitor secondaries at 1x. - -## 4. Design - -### 4.1 Mechanism: the DWM colour effect - -`MagSetFullscreenColorEffect(MAGCOLOREFFECT*)` (Magnification.dll, the same call Windows Magnifier -uses for inversion) applies a 5x5 colour matrix inside DWM to everything composed on screen. It costs -nothing per frame (done by the compositor) and works at level 1. It needs a live magnification -runtime (`MagApiAcquire`) and, like every Magnification call, must run on the runtime's owner thread -(`MagThreadInvoke`). - -One effect for both engines: the render engine's overlay is itself a window DWM composes, so the -effect lands on the magnified picture too. **This must be verified by the spike (Task 1)**: if Desktop -Duplication captures the desktop AFTER the effect, the render engine would filter twice, and the -render path then needs the effect cleared while its overlay is up and the matrix applied in its pixel -shader instead (the shader already has a brightness stage, `render_shaders.h`). - -### 4.2 Units - -| Unit | Kind | Responsibility | -|---|---|---| -| `src/color_matrix.h` | pure, doctested | Build the 5x5 matrix from (filter, warm strength, dim); identity check; compose | -| `src/color_filter.h/.cpp` | Win32 | Holds a runtime reference while a filter must exist at 1x, applies the matrix (deduped) through the owner thread, restores identity and releases on disable / shutdown / crash | -| `src/main.cpp` RunTick | integration | Decide the wanted matrix per tick (config, toggle state, zoomed, model) and hand it to the controller; the toggle hotkey | -| `src/config.*`, `ui/src/settings-schema.js` | settings | Keys, defaults, the Colour section | - -### 4.3 Runtime lifetime and the 1x cost - -- Zoomed: the engine already holds a runtime; the filter adds nothing. -- At 1x with a filter on: the controller holds its own `MagApiAcquire` reference, so the transform - model's idle release (~1.2 s after zoom-out) does not tear the context down. **Cost, by design:** a - live context adds a DWM re-composite to every cursor shape/visibility change any app makes (the - documented tax; a game that toggles its pointer can hitch). It is paid ONLY while a 1x filter is on; - "Also when not zoomed" off avoids it. -- Filter off (or dim 100% with filter Off, or toggled off): identity is written and the reference is - released, so the idle machine is exactly as today. - -### 4.4 Safety - -- Identity is restored on every exit path: disable, toggle, model switch, `shutdown`, the crash - filter and `atexit` (next to the existing `RestoreInputState`). -- The spike checks whether Windows clears the effect by itself when the process dies. If it does not, - a killed Wind would leave the screen filtered; the crash-path restore is then mandatory, and a - stale effect found at startup is cleared. - -### 4.5 Matrices (row vectors, DWM layout: out = in x M) - -- Invert: `-1` diagonal, `+1` offset row (on RGB). -- Greyscale: luma weights 0.2126 / 0.7152 / 0.0722 in every column. -- Warm (strength s): R x 1, G x (1 - 0.25 s), B x (1 - 0.6 s). -- Two-colour tints: greyscale luma L, then out = bg + L x (fg - bg) for dark-background schemes - (text light) computed from the INVERTED luma so dark text on white pages becomes light on dark: - yellow on black (fg 1,1,0 / bg 0,0,0), white on blue (fg 1,1,1 / bg 0,0,0.5), green on black - (fg 0,1,0 / bg 0,0,0). -- Dim d (0.2-1.0): RGB x d. Composed last with the chosen filter. - -## 5. Testing - -- Doctests for every matrix (identity, invert of white/black, greyscale weights, warm strength ends, - tint endpoints, dim composition, identity detection). -- Playwright for the Colour section rows. -- Spike + field on this PC (signed UIAccess build): each filter at 1x and zoomed, in both engines - (transform desktop, render via `desktopTransform=0`), HDR on/off, hotkey toggle, zoom in/out - transitions with no flash, Wind quit and kill leave the screen clean, no hitch at 1x with no filter. - -## 6. Delivery - -One PR: issue #288 -> `feat/288-colour-filters`, version bump inside the PR (0.12.0 as a feature; -owner may prefer a patch bump), release on merge. - -## Amendment: two sliders only (owner field test, 2026-09-29) - -This supersedes the filter list, the "also when not zoomed" toggle and the hotkey above. - -- The feature is two sliders, always applied (zoomed and at 1x): **Warmth** (`colorWarmPct`, - 0-100, default 0 = off) and **Brightness** (`colorDimPct`, 1-100, default 100). Both neutral = no - colour effect and no held Magnification runtime. -- Warmth follows the blackbody curve from 6500 K to 1200 K (Night light's range), linear in - mireds; 100% is about (1, 0.34, 0). The first version (G x 0.75, B x 0.4 at 100%) read as dim pink. -- Brightness floor 1% (`kMinDim01`; owner, 2026-09-30: 0 was completely black). -- Removed: Invert, Greyscale, the two-colour tints, `colorAt1x`, the toggle hotkey - (`colorFilter`, `colorAt1x`, `colorToggleVk/Mods` in an old ini are ignored). Why: a single - colour matrix cannot keep multi-coloured text readable, see `docs/COLOUR-FILTER-FINDINGS.md`. diff --git a/docs/superpowers/specs/2026-09-30-cursor-tint-design.md b/docs/superpowers/specs/2026-09-30-cursor-tint-design.md deleted file mode 100644 index 4bf5fae4..00000000 --- a/docs/superpowers/specs/2026-09-30-cursor-tint-design.md +++ /dev/null @@ -1,47 +0,0 @@ -# Tinted pointer at 1x (issue #288 follow-up) - -Date: 2026-09-30. Owner: Max. Status: approved to build without review ("plan spec and implement now, -no approval from me"); owner tests the verified build. - -## Problem - -Warmth and Brightness reach everything at 1x except the mouse pointer. Windows draws the pointer on a -hardware cursor plane after composition, which the DWM colour effect never touches (Night light, in -the display pipeline, does). While zoomed Wind draws the cursor itself, so it is filtered there. - -## Design - -While the colour is not neutral, Wind is idle (1x, not Inspect), the engine is not the native -Magnifier, and no fullscreen game is in front, Wind replaces the standard system pointers with tinted -copies (`SetSystemCursor`). They stay hardware pointers: no per-frame cost, no latency. - -- **Source images:** pristine copies of the 14 standard pointers captured at start-up (after the - start-up scheme reload), per-monitor-DPI sized (64x64 at 225% on the owner's PC), recaptured when - the user changes the pointer scheme (`WM_SETTINGCHANGE` + `SPI_SETCURSORS`). -- **Tint:** the same matrix as the screen in sRGB-encoded form (`BuildColorMatrix(w, d, false)`), applied - to each pixel's RGB, alpha kept. Size and hotspot unchanged. -- **Monochrome pointers** (the default text beam inverts what is under it): rebuilt as colour - pointers: black/white pixels kept, inverting pixels drawn white with a 1 px dark outline so the beam - stays visible on light and dark backgrounds, then tinted. -- **Animated pointers** (busy, app starting) are left as they are: a static copy would stop the - animation. -- **Zoom-in:** the pristine pointers go back first, before the engine hides or captures the pointer - (the transform sprite and the render engine draw the real shape and filter it themselves; a tinted - source would be tinted twice). Direct `SetSystemCursor` of the in-memory copies, never a scheme - reload (`SPI_SETCURSORS` rereads the registry and broadcasts to every window: a hitch risk). -- **Zoom-out:** the engines restore the scheme; the next idle tick re-applies the tint. -- **Fullscreen game in front:** pristine pointers (the DWM effect does not reach exclusive fullscreen, - so a tinted arrow would not match). Checked at most every 250 ms while idle. -- **Exit, crash, force-kill:** quitting restores the pristine pointers; the existing start-up and crash - heals (`SPI_SETCURSORS`) restore the scheme, so a force-killed Wind is healed on its next start. -- **No fighting:** Wind only rewrites pointers when its colour, zoom state or the foreground-game state - changes, never on a loop. - -## Testing - -- Doctests: pixel tint (RGB by matrix, alpha kept, identity untouched), monochrome conversion (the - four AND/XOR cases and the outline). -- Field verification on the owner's PC (by Claude, before the owner tests): tinted system pointers - read back with the expected pixels, size and hotspot; the pointer shape Desktop Duplication reports - is the tinted one; zoom-in restores pristine and zoom-out re-tints; a borderless fullscreen window - in front restores pristine; quitting restores; apply/restore timing logged. diff --git a/docs/superpowers/specs/2026-09-30-keyboard-panning-design.md b/docs/superpowers/specs/2026-09-30-keyboard-panning-design.md deleted file mode 100644 index 81ba5510..00000000 --- a/docs/superpowers/specs/2026-09-30-keyboard-panning-design.md +++ /dev/null @@ -1,83 +0,0 @@ -# Keyboard panning (issue #287) - -## 1. Goal -While zoomed, move the magnified view with the keyboard, without touching the mouse, like Windows -Magnifier's Ctrl+Alt+arrow keys. For reading long text and for keeping hands on the keyboard. - -## 2. Owner decisions (2026-09-30) -- **Binds:** four settable keybinds, Pan left / right / up / down. Default **Ctrl+Alt+arrows** - (bound out of the box, unlike the zoom binds), exactly like Windows Magnifier. - AMENDED 2026-09-30 (#307, owner): unbound by default; users turn it on by setting keys. -- **Feel:** **tap to nudge, hold to pan.** A tap moves the view one step; holding pans - continuously with a short ease-in, and the motion glides out on release. One **Pan speed** - slider in Settings. The same feel at every zoom level (defined in screen space). -- **Scope:** the four directions only. No reading helpers (line start / next line). -- **Only while zoomed:** at 1x the keys pass straight to the app (IntelliJ keeps Ctrl+Alt+Left/Right - for navigate back/forward). Zoomed, Wind swallows them like the other keyboard binds. -- Issue #286 (per-app profiles) was dropped the same day; nothing here depends on it. - -## 3. Behaviour -- **Press:** panning starts at once, easing in over ~150 ms to `panSpeed x 1.25` screen widths per - second in EVERY direction (heights for up/down were 56% as fast on 16:9, owner test), scaled by - zoom along one corner-free curve: `(level / 7)^0.6` at low zoom easing into full speed via a - soft minimum (47% at 2x, 93% at 7.5x, 98% at 10x) (#305; piecewise and clamped versions were felt - as speed changing at certain spots), and the tap - step is 1/8 of the width for all four. AMENDED 2026-09-30 after the owner's first test: the original - nudge-on-press made every hold start with a jump, and 0.5 screens/s was too slow. -- **Tap:** a press released within **250 ms** stops that axis and is topped up to exactly **1/8 of - the screen** (screen space, so 1/(8 x level) of the desktop), with a quick ~90 ms glide. -- **Hold:** longer presses only pan (never the tap step) and glide out on release (~120 ms). - Two keys held (e.g. Up + Right) pan diagonally; opposite keys cancel. -- **Pointer:** the view detaches from the pointer (the tracking path, #276). The pointer does not - move while panning; on the next real mouse movement the pointer is placed in the view (the - existing `warpPointer` takeover), a mouse button gives the view back without warping. -- **Edges:** the view centre is clamped so the view never leaves the monitor, and it respects the - MPO wall exactly like mouse edge mode (`kMaxSafeTxMagnitude / level`) so keyboard panning can - never reach the nearest-sampling TDR strip (#148, #242). -- **When it works:** zoomed (level > 1.001), not in Inspect, not while a game holds the mouse - (`detector.locked()`), not over a shell input panel. Unlike caret tracking it does NOT require - tracking to be on, and it is not blocked by a borderless fullscreen app (a pan key is an explicit - request). -- **Magnify model:** Wind's level stays at 1x there, so the keys are never swallowed and Windows - Magnifier receives Ctrl+Alt+arrows and pans natively. No extra code. -- **Caret tracking after a pan:** unchanged rules. The swallowed pan keys never move a caret, so the - next real typing takes the view as today. - -## 4. Design -- **Config** (`src/config.*`): `panLeftVk/panLeftMods`, `panRightVk/…`, `panUpVk/…`, `panDownVk/…` - (defaults 37/3, 39/3, 38/3, 40/3 = Ctrl+Alt+arrows) and `panSpeed` (0.25-4, default 1.0). - Sanitised with `CheckKeyBind` like the zoom keys; ini template documents them. -- **Pure motion** (`src/keyboard_pan.h`, new, tested): `KeyPan` holds per-axis velocity, the - pending nudge distance and per-direction held time. `step(held[4], dtMs, level, monW, monH, - speed) -> (dx, dy)` in desktop pixels; `active()` is true while a key is held or motion remains. -- **View owner** (`src/view_target.h`): new `ViewOwner::Keys`. Input `panning` (KeyPan active) - makes Keys the owner; mouse movement takes the view back with `warpPointer` exactly as from - Caret/Focus. The owner logic runs when tracking OR panning is possible (enabled rule in section 3). -- **Hook** (`src/input_router.*`): `setPanKeys(vk[4], mods[4])` and `setPanArmed(bool)`. Pan keys - are tracked like every bound key (`isBoundKey`), but `keyBindMatches` counts a pan slot only - while armed, so at 1x they pass through. The swallow decision stays once per press, so a press - that began at 1x is never swallowed mid-way, and a swallowed press keeps its balanced UP. -- **Tick** (`src/main.cpp`): publishes `panArmed` (zoomed, not magnify, not Inspect, not locked); - reads the four holds with the existing `comboHeld(vk, mods)`; steps KeyPan; when the owner is - Keys, moves `t.viewCx/Cy` by the delta, clamps, and builds the frame with `DetachedMap` - (the same path the caret owner uses). Hot-reload re-applies the pan keys. -- **Settings UI:** Keybinds section gains Pan left / right / up / down rows (key + modifiers, - shared rules and refusal text) and a **Pan speed** slider. `droppedBinds` covers the pan slots. - -## 5. Out of scope -Reading helpers, per-app behaviour, panning at 1x, rebinding in onboarding. - -## 6. Testing and verification -- Unit: `KeyPan` (tap = exactly one nudge of screen/8 at any level, hold starts after 250 ms and - reaches the set speed, release glides to rest, diagonals, opposite keys cancel, speed scales), - `StepViewOwner` with `panning` (Keys owner, mouse takeover warps, button returns without warp), - config defaults/sanitise, the shared rule fixture (Ctrl+Alt+arrows ok). -- UI (Playwright): the four rows show `Ctrl+Alt+Left` etc. by default, rebinding writes vk + mods, - Pan speed slider writes `panSpeed`. -- Owner's PC (SendInput, signed build, only while the PC is idle): at 1x Ctrl+Alt+Left reaches a - test window; zoomed, it is swallowed and the view moves (trackLog `view mouse -> keys` plus the - view centre); a tap moves one step; a mouse move afterwards places the pointer in the view. - -## 7. Delivery -Branch `feat/287-keyboard-pan`, one PR closing #287, version **0.15.0**, README controls, -CLAUDE.md (one line), docs/architecture 06-input and 07-cursor. diff --git a/docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md b/docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md deleted file mode 100644 index 4609cd4b..00000000 --- a/docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md +++ /dev/null @@ -1,116 +0,0 @@ -# Scroll-wheel zoom and safe keybinds (issue #285) - -Date: 2026-09-30. Owner: Max. Status: awaiting approval (spec + plan together). - -## 1. Goal - -1. Zoom with the mouse wheel while a modifier combo is held (for example Alt+scroll or Ctrl+Alt+scroll). -2. Make the keybind setter safe and complete for every bind row: single keys, key combos with - any mix of Ctrl/Alt/Shift/Win, the wheel with modifiers, mouse side buttons, and left/right/middle - click with modifiers. Anything that - would break normal use of the PC is refused with a spoken and visible reason. - -## 2. Owner decisions (2026-09-30) - -- **Keys alone (no modifier) allowed:** PageUp, PageDown, Home, End, Insert, Delete, the four - arrows, F1-F24, Pause, ScrollLock, numpad keys. Everything else alone is refused: letters, digits, - Space, Enter, Tab, Esc, Backspace, punctuation, CapsLock, PrintScreen, the Apps key, NumLock, - and a bare modifier or Windows key. -- **Combos refused: system-critical only.** App shortcuts (Ctrl+C and so on) stay allowed. -- **Wheel:** needs at least one modifier, and never Shift alone. (AMENDED 2026-09-30, #295: Ctrl - alone is allowed; Wind swallows the notch, so it zooms the screen instead of the page.) Originally - also never Ctrl alone (browser zoom) or Shift alone - (horizontal scroll). Unbound by default. -- **Left/right/middle click** (added the same day): bindable as a hold-to-zoom bind, with the same - modifier rule as the wheel. Never alone; never Ctrl alone or Shift alone (Ctrl/Shift+click select - in every app). - -## 3. The rules (one pure function, mirrored in the UI) - -`src/keybind_rules.h` (pure, doctested) and `ui/src/lib/keybindRules.js` (Playwright/unit-tested), -both checked against one shared case list `tests/fixtures/keybind_cases.txt` so they cannot drift. - -`KeyBindVerdict CheckKeyBind(vk, mods)` returns OK or a reason: - -1. Never bindable at all: left/right click, Backspace (today's `IsForbiddenBindVk`). -2. The main key cannot be a modifier or a Windows key (Ctrl/Alt/Shift/Win alone). -3. No modifier: only the allowed-alone list in section 2. -4. Shift is the only modifier and the key types a character (letter, digit, punctuation, Space): - refused, it would swallow capital letters and symbols. -5. **AltGr (added for this owner's Norwegian keyboard):** Ctrl+Alt (with or without Shift, no Win) - plus a key that types a character is refused, because AltGr sends Ctrl+Alt and those combos - type @ { } [ ] $ and so on. -6. System-critical combos refused: Alt+F4, Alt+Tab, Alt+Shift+Tab, Alt+Esc, Alt+Space, Ctrl+Esc, - Ctrl+Shift+Esc, Ctrl+Alt+Delete, and Windows-reserved Win combos: Win + any letter, digit, - Tab, Space, arrow, Plus/Minus (native Magnifier), comma, period, Pause, PrintScreen. Win with - PageUp/PageDown/Home/End/Insert/Delete/F-keys/numpad stays allowed. -7. Everything else is OK. - -`CheckWheelBind(mods)` and `CheckClickBind(button, mods)` (left/right/middle): at least one modifier; -not exactly Ctrl; not exactly Shift. Side buttons (4/5) may be bound alone, as today, or with modifiers. - -**Bind slots:** each zoom slot's button key (`zoomInButton`, `zoomOutButton` and the `2` slots) gains -values 3 = left, 4 = right, 5 = middle (1/2 stay the side buttons), plus a new `...ButtonMods` mask per -slot (0 = none; required for 3-5). A button bind is held while the button is down and all its -modifiers are held; its down and up are swallowed as a pair (balanced: an up is swallowed only if its -down was, even if the modifiers were released first, so the app never sees a lone up). - -`ParseConfig` applies the same rules to every stored bind: an unsafe bind in an ini (hand-edited or -from an older version) is read as unbound and logged, as `IsForbiddenBindVk` does today. - -## 4. Swallowing combos with Alt or Win - -Wind swallows the main key (or wheel notch, or click) of a bind but not the held modifier. Windows then sees -Alt or Win pressed and released on its own: releasing Win opens Start, releasing Alt moves focus -to the app's menu bar. When Wind swallows an event of a combo that includes Alt or Win, it injects -one masking keystroke (VK 0xE8, unassigned; the standard technique) so the modifier's release is -not a lone tap. Injected with `LLKHF_INJECTED`; Wind's own hook passes it through. - -## 5. Wheel zoom - -- Config: `zoomWheelMods` (modifier mask, 0 = off). AMENDED 2026-09-30 (owner): no separate step - setting. A notch zooms as far as holding the bind does in 0.1 s at the same speed slider - (`zoomInSpeed` up, `zoomOutSpeed` down), so ~10 notches a second feels like holding and faster or - slower scrolling scales from there (x1.19 per notch at speed 1.0, x1.60 at 2.7). The native - Magnifier model passes each notch on as one Magnifier notch (its ZoomIncrement sets the size). -- The mouse hook (`WH_MOUSE_LL`) sees `WM_MOUSEWHEEL`. When the held modifiers include every bit of - `zoomWheelMods` (extra modifiers allowed, like the key combos), the notch is swallowed so the app - under the pointer does not scroll, and counted (high-resolution wheels send partial notches: - deltas accumulate to 120 per step). -- Wheel up = zoom in, down = zoom out. Each step moves a TARGET level by x(1 + step) or /(1 + step), - clamped to [1, maxLevel]; the zoom controller glides to the target with the existing ease-out time - constant, so fast scrolling feels continuous rather than notchy. Holding a zoom key or button - takes over at once (the target is dropped). Zooming out to 1.0 ends the session like any zoom-out. -- Both engines; respects maxLevel and the MPO walls (the level pipeline is unchanged downstream). -- Settings: a "Zoom with the scroll wheel" row whose capture records the modifiers held when the - wheel is turned, plus a "Wheel step" slider. - -## 6. Keybind setter (UI) - -- Every keybind row uses `CheckKeyBind`/`CheckWheelBind`. A refused press keeps the row listening and - says why, visibly and to screen readers ("Alt+F4 is reserved by Windows", "A alone would stop you - typing A"). -- Capture works for keys, combos with Win (Windows may keep some Win combos to itself; the setter is - tested against the ones it can receive, and the reserved ones are refused anyway), mouse side - buttons and left/right/middle click with modifiers (rows that allow buttons), and the wheel (the - wheel row only). Right-click keeps clearing a row when pressed WITHOUT modifiers; a right-click - with modifiers is a capture. -- The existing live-apply, Escape-to-cancel, Tab-leaves and right-click-clears behaviour stays. - -## 7. Testing and verification - -- Doctests: every rule in section 3 through the shared case list; wheel delta accumulation and - target stepping; `ParseConfig` sanitising. -- Playwright: the same case list through `keybindRules.js`; the setter refuses and explains, accepts - PageUp alone, Ctrl+F1, Ctrl+Alt+PageUp, Win+PageUp; the wheel row captures Alt+wheel and refuses - Ctrl+wheel and Shift+wheel; a zoom row captures Ctrl+Alt+left click and refuses a bare left click, - Ctrl+click and Shift+click. -- On the owner's PC, by Claude before the owner tests: real input through SendInput: bind - Ctrl+Alt+wheel, verify zoom in/out, that the app under the pointer does not scroll, that - Ctrl+wheel still zooms a browser page; Ctrl+Alt+left-click held zooms in and the click never - reaches the app, while a plain click still works; Win+PageUp bound, press it and verify Start does not open; - Alt+PageUp bound, verify the focused app's menu bar is not activated. - -## 8. Delivery - -Branch `feat/285-wheel-zoom`, one PR, minor version bump (feature). diff --git a/docs/superpowers/specs/2026-10-02-settings-structure-decisions.md b/docs/superpowers/specs/2026-10-02-settings-structure-decisions.md deleted file mode 100644 index f7661c9d..00000000 --- a/docs/superpowers/specs/2026-10-02-settings-structure-decisions.md +++ /dev/null @@ -1,77 +0,0 @@ -# Settings structure (Max, 2026-10-02) - -## Settled -- Sidebar, top to bottom: **Hotkeys** (Settings opens here, it is set up first), **Zoom**, **View**, **Screen**; - divider; **Preferences**, **Tray menu**, **About**. The bottom group sits under the list (not pinned to the - window bottom). No EXPERT/TRAY labels, no Advanced tab. -- "Colour" renamed **Screen** (it is about screen light: warmth, brightness). -- **View** = pan speed, cursor speed, keep the mouse pointer (+ edge margin), follow text cursor, follow - keyboard focus, keep the text cursor and focus, high resolution cursor. -- **Preferences** = Appearance (built-in theme grid: this grey one, orange sliders, Noctua-style, - cyberpunk black + yellow, ...; themes also restyle the tray flyout), Profiles, and the - **Show advanced settings** switch. -- Advanced settings: ONE global switch in Preferences. ON = every tab shows its advanced rows inline (an - Advanced card at the bottom of the page). OFF = hidden everywhere; search still finds them. -- Expert items spread by topic: - - Zoom advanced: release glide?, zoom-in ease, ease-in duration, magnifier engine, engines per window kind, - Never use Render for. - - Hotkeys advanced: Pass zoom keys to these apps. - - View advanced: pan smoothing, Mouse-locked games. - - Preferences advanced: Frametime logging, Export diagnostics, Open settings file. -- Reference mockup of the earlier round: ia04 ("Advanced on every page") was the closest. - -## Still open -- Exact placement of Release glide (main or advanced) and of High resolution cursor (main or advanced). -- Appearance themes: which ones, what each changes, tray flyout theming. - -## Round ia06 feedback (Max, 2026-10-02) -- Divide pages internally into more sub-sections, but a sub-section needs TWO OR MORE items; a single item - never gets its own section (it joins a neighbour or an uncaptioned card). Hotkeys is the model: split it, - with Hide cursor + Inspect mode as a "Cursor" sub-section. -- Preferences: the troubleshooting rows (Frametime logging, Export diagnostics, Open settings file) are ALWAYS - shown, not tied to the advanced switch, and their section must NOT be called "Advanced" (it reads as if - those are the advanced settings). New name e.g. "Troubleshooting". -- Cards use the real app colour #080808 (the mockup copied the older #1b1b1b reference). -- Dark / light mode moves OUT of the title bar into Preferences (next to the theme picker). The title bar - keeps only minimize / maximize / close. -- Mode row (System / Light / Dark, three-way, default System) sits FIRST in Preferences, above the theme grid. -- Profile row: only a dropdown + "New". Delete = a trash icon on each profile in the dropdown list, with a - confirm popup. "New" opens a popup: name + start from current settings (copy) or default settings. - Rename and Copy buttons are gone (rename: open question). -- Hotkeys: "Zoom with the scroll wheel" is merged into Zoom in / Zoom out. Key capture also detects the - wheel: scrolling up while capturing records "Wheel up" (with any held modifiers) for Zoom in, scrolling - down records "Wheel down" for Zoom out (e.g. Zoom in = Mouse 5 or Ctrl + Wheel up). Core work needed at - build time: wheel bindings per direction instead of one modifier+wheel setting (migrate the old key). -- Key binding rules (Max): Zoom in / Zoom out = up to 2 bindings each; every other hotkey = 1 binding. A - binding is at most 2 modifiers + 1 key/button/wheel direction (modifiers optional). Pan = exactly one - binding of 1-2 modifiers (required), shown as [Ctrl] + [Alt] + one fixed "Arrow keys" cap (not 4 caps). - Mockup: click a binding to re-record it, hover shows a small x to remove it; wheel only on zoom rows. -- Each binding is ONE box with the + inside it, dim (e.g. [Ctrl + Wheel up]); pan is one box [Ctrl + Alt + Arrow keys] with the arrow part dimmer. Separate boxes made + read like "or". -- Hotkeys sections: "Zoom" (Zoom in, Zoom out, + advanced Pass zoom keys) and "Extra keys" (Pan with the - arrow keys, Hide cursor, Inspect mode). Each extra key has an on/off switch on its row, right of the - binding: off frees the keys but KEEPS the binding (shown dimmed) so on restores it. Toggle lives on the - Hotkeys tab, not elsewhere. -- "Never use Render for" (renderExclude, the copy-protected-video app list) is REMOVED from Settings (Max - did not approve it; the core's own detection stays). -- Copy rules (Max): names clear and clean; descriptions one short sentence or a few words saying what the - setting does; no symbols, no product names, no comparisons. Applied in ia07 (see the copy table in chat - 2026-10-02): e.g. Hide cursor -> Hide pointer, Pan speed -> Arrow key speed, Cursor speed -> Mouse speed, - Keep the mouse pointer -> Pointer position, Keep the text cursor and focus -> Text cursor position, - Zoom-in ease -> Soft start, Ease-in duration -> Soft start length, Magnifier engine -> Engine, - Pass zoom keys to these apps -> Share zoom keys with these apps, "tray flyout" -> "tray menu". -- Advanced rows carry NO marker at all (Max rejected icons and badges): the Show advanced settings switch is the only signal. -- Themes mocked up in ia08 (tokens in ia/palettes08.cjs): Wind grey (today), Ember (orange), Cocoa (the - Noctua-style brown + beige; brand name not used), Cyberpunk (black + signal yellow, smaller radii). Each - has designed dark AND light variants; Mode System/Light/Dark switches live; the tray flyout follows both. -- Theme review (Max): LOCKED IN = Wind grey, Ember, Cyberpunk. Cocoa removed. Cyberpunk selections must be - solid saturated yellow (not transparent dark yellow). 27 more candidates (monochrome, colour accents, - character themes) in ia/themes.html: step through, toggle Select, copy the picks list back to Claude. -- FINAL THEME SET (Max, 2026-10-02): Wind grey (grey), Ember (ember), Cyberpunk (cyber), Mono (b1_mono), - Slate (b1_slate), Carbon (b1_carbon), High contrast (b1_hicon), Deep ocean (b2_ocean). Tokens in - palettes08.cjs + palettes-b1.cjs + palettes-b2.cjs. The theme list in Settings must take ONE row (layout - being mocked up in picker.html). Build started 2026-10-02. -- FINAL THEMES, REVISED (Max, 2026-10-02): only FOUR, in this order: Wind grey (grey), Ember (ember), - Deep ocean (ocean), High contrast (hicon, always last). Cyberpunk, Mono, Slate and Carbon are dropped. - With 4 themes the picker fits one row without scrolling. -- Theme picker layout (Max): option A cards (picker.html#A): one row of mini window preview cards with the - name under each, selected card outlined; with four themes no scrolling and no arrow buttons are needed. diff --git a/docs/superpowers/specs/2026-10-02-settings-structure-design.md b/docs/superpowers/specs/2026-10-02-settings-structure-design.md deleted file mode 100644 index 08a4d3db..00000000 --- a/docs/superpowers/specs/2026-10-02-settings-structure-design.md +++ /dev/null @@ -1,87 +0,0 @@ -# Settings structure, hotkey rules and built-in themes (issue #318), 2026-10-02 - -Round 2 of the Settings redesign (#303 / PR #312). Every decision Max made is logged in -`2026-10-02-settings-structure-decisions.md` (copied from the mockup folder); the reference mockups are -`wind-settings-mockups/ia/ia07.html` (structure, copy, hotkeys), `ia08.html` (themes) and -`themes.html` (theme tokens). This file turns them into implementation decisions. Stacks on PR #316. - -## 1. Sidebar and pages -- Order: **Hotkeys** (Settings opens here), **Zoom**, **View**, **Screen**; a thin divider; **Preferences**, - **Tray menu**, **About**. No group labels, no Advanced tab; the bottom group sits under the list. -- Sections inside a page need 2+ rows (a lone row joins a neighbour or an uncaptioned card). -- Final page contents, names and descriptions: exactly the ia07 copy table (decisions log, "Copy rules"). - - Hotkeys: *Zoom* (Zoom in, Zoom out, adv: Share zoom keys with these apps) / *Extra keys* (Pan with the - arrow keys, Hide pointer, Inspect mode; each with an on/off switch). - - Zoom: *Level and speed* (Max zoom, Zoom-in speed, Zoom-out speed, Release glide) / adv *Easing* (Soft - start, Soft start length) / adv *Engine* (Engine + the four per-window engine rows). - - View: *Speed* (Arrow key speed, Mouse speed, adv Pan smoothing) / *Pointer* (Pointer position, Edge - margin when within the edges, High resolution cursor, adv Mouse-locked games) / *Typing and focus* - (Follow the text cursor, Follow keyboard focus, Text cursor position). - - Screen: *Screen light* (Warmth, Brightness). - - Preferences: *General* (Mode, Theme, Profile, Show advanced settings) / *Troubleshooting* (Frame time - logging, Export diagnostics, Open settings file; always visible). - - Tray menu: unchanged content. About: unchanged content. -- **Removed from Settings**: "Never use Render for" (`renderExclude` stays in the ini and the core). -- **Show advanced settings**: a global ini key `showAdvanced` (already exists, global). ON shows the adv rows - inline; OFF hides them everywhere; search still finds hidden rows (selecting one turns nothing on, the - result just shows the row). No marker on advanced rows. -- The dark/light button leaves the title bar (minimize, maximize, close only). - -## 2. Hotkeys -- Binding limits: Zoom in / Zoom out up to 2 bindings each; every other key 1 binding. A binding is at most - 2 modifiers + 1 key, mouse button or wheel direction; modifiers optional. Pan: exactly one binding of - 1-2 modifiers (required) + the arrow keys. -- One box per binding with the + inside (`[Ctrl + Wheel up]`); pan shows `[Ctrl + Alt + Arrow keys]`, with - `[Set modifiers + Arrow keys]` when unset. Click a binding to re-record, hover shows a small x to remove. -- **Wheel as a zoom binding** (replaces the separate scroll-wheel row): capture records `Wheel up` / - `Wheel down` (with held modifiers) on the zoom rows only. Stored as mouse-button codes in the existing - button slots: `6 = wheel up`, `7 = wheel down` (`zoomInButton`/`zoomInButton2`/`...ButtonMods`). The - core zooms one wheel step per notch for a wheel binding (today's wheel behaviour, speed from - zoomInSpeed/zoomOutSpeed) and swallows the notch only when the modifiers match. **Migration**: an ini with - `zoomWheelMods != 0` gets wheel up + those mods in a free Zoom in slot and wheel down + mods in a free - Zoom out slot, then `zoomWheelMods` is cleared (once, on load, written back like other migrations). -- **Extra key switches**: new global-per-profile ini keys `panKeysOn`, `hideCursorOn`, `cursorLockOn` - (default 1). 0 = the core does not bind or swallow those keys at all (the binding stays in the ini and - shows dimmed). - -## 3. Preferences -- **Mode**: three-way System / Light / Dark (equal-width segments, selected one clearly filled). Ini key - `uiTheme` keeps its values (`auto` = System, `light`, `dark`). -- **THEMES ARE FOUR (Max, 2026-10-02; this supersedes any list of eight elsewhere, task prompts included):** - `grey` (Wind grey), `ember` (Ember), `ocean` (Deep ocean, tokens from palettes-b2 `b2_ocean`), `hicon` - (High contrast, tokens from palettes-b1 `b1_hicon`, ALWAYS LAST). Cyberpunk, Mono, Slate and Carbon are - NOT built. The picker is mockup option A (wind-settings-mockups/ia/picker.html#A, picker.css): one row of mini - window preview cards (each drawn in its own theme colours) with the name under it, the selected card - outlined; four fit, so no scrolling, no arrow buttons, no edge fades. -- **Theme**: new global ini key `uiPalette` = `grey` (default) | `ember` | `ocean` | `hicon`. Unknown reads as grey. Like `uiTheme` it is a GLOBAL, UI-only key: - listed in IsGlobalProfileKey (src/profiles.cpp), stripped from the core config text (src/config.cpp), and - ignored by the core hot-reload (src/main.cpp). The picker takes one row (layout chosen - from the picker mockups, built last). -- **Profile**: dropdown + New only. The open list shows a trash icon per profile (hidden when one is left) - with a confirm dialog; New opens a dialog (name, start from current settings or default settings). -- Themes restyle Settings AND the tray flyout. Settings: CSS tokens per theme x mode generated from the - mockup palettes (`ui/src/design/themes.css`). Tray: the flyout reads `uiPalette` + `uiTheme` and uses a - C++ palette table with the same values (`src/tray_app/flyout_palettes.h`), including the sharp radii of High contrast. - -## 4. Out of scope -Profile rename (open question to Max), the theme picker layout (pending mockup choice), any new setting -not listed above. - -## 5b. As built 0.20.0: light mode removed (#324, Max 2026-10-02) - -The Mode row (System / Light / Dark) is gone from Preferences, so General holds Theme, Profile and Show advanced -settings. Settings, onboarding and the tray flyout are always dark; the four themes keep only their dark -blocks (themes.css, flyout_palettes.h and both generators are dark only). `uiTheme` stays in the global and -UI-only key lists as an ignored legacy key and is never written by the UI. WindConfig's pre-paint colour is the -dark colour of the current `uiPalette`. The tray icon still follows the taskbar theme. - -## 5. As built (0.19.0, 2026-10-02) -- Four themes (`grey ember ocean hicon`) end to end: `kUiPalettes`/`NormalizeUiPalette` (removed ids read as grey), - `ui/src/design/themes.css` + `themes.js`, `src/tray_app/flyout_palettes.h`, both generators trimmed to those four. - Tests that used the removed themes now use hicon (sharp radii, flyout on-segments). -- Picker = option A, `ui/src/prefs/ThemePicker.svelte`: four 106 px cards in one row, right-aligned in the Theme row - (the row is no longer `wide`), name under each, selected card outlined, no scrolling, arrows or fades; Left/Right - moves and applies, focus ring for keyboard only. -- Tray item names match Settings: Pan speed is Arrow key speed, Magnifier engine is Engine (ini keys unchanged). -- WindConfig's pre-paint background follows `uiPalette` + `uiTheme` (small table in `src/config_ui/main.cpp`). -- Screenshots of Preferences (4 themes x 2 modes) and the tray flyout (same grid): `docs/design/settings-themes-2026-10-02/`. diff --git a/installer/README.md b/installer/README.md index 74453280..449e4aaa 100644 --- a/installer/README.md +++ b/installer/README.md @@ -1,133 +1,89 @@ # Setup -Setup is a video with the UI composited over it. NSIS cannot play video, so it plays one -itself: every tick it decodes a JPEG through GDI+, alpha-blends the screen's overlay on top, -and copies the result into the bitmap a single static control shows. There are no buttons; -the same tick hit-tests the pointer. +Wind's installer is NSIS with a custom-drawn UI: a looping video with the screens composited over +it. NSIS cannot play video, so every tick it decodes a JPEG frame through GDI+, alpha-blends the +screen's overlay on top and copies the result into one static control; the same tick hit-tests the +pointer. There are no native buttons. -The approach is borrowed from Prism's installer. What differs here is that Wind installs -per-machine and elevated, which the sections have to be careful about, and that there is no -"where it goes" screen: UIAccess is only granted to a signed binary in a secure location, so -an install to `D:\Apps\Wind` would silently disable the features the per-machine install -exists to enable. The path is shown and the reason is given instead of offering a chooser -whose wrong answers are quiet. +Wind installs per-machine and elevated, and there is no install-location screen: UIAccess is granted +only to a signed binary in a secure location, so an install elsewhere would silently disable it. +The elevation rules (HKLM autostart, launching through `explorer.exe`, stopping Wind through +`Local\Wind_QuitRequest`) are in +[docs/architecture/11](../docs/architecture/11-build-test-release.md#the-installer). -## The pieces +## Files -| file | what it is | +| File | What it is | |---|---| -| `wind.nsi` | the entry point: metadata, page order, the install and uninstall sections | -| `app.nsh` | the Wind-specific half: quitting a running Wind, WebView2, autostart, launching | -| `over.html` | the foreground: type, buttons, the caption. The only place copy lives. | -| `make-over.mjs` | renders `over.html` into alpha overlays + `over.nsh` rectangles | -| `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 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 +| `wind.nsi` | Entry point: metadata, page order, install and uninstall sections | +| `app.nsh` | Wind-specific logic: quitting a running Wind, WebView2, autostart, launching | +| `screens.nsh` | The five screens and what each click does | +| `kit.nsh` | The frameless window: size, DPI, GDI+, unpacking | +| `video.nsh` | The player: decode, composite, hover, clicks, dragging | +| `over.html` | The foreground: type, buttons, caption. The only place copy lives | +| `make-over.mjs` | Renders `over.html` into overlays and `over.nsh` rectangles | +| `make-loop.mjs` | Turns a source clip into a looping frame sequence | +| `over.nsh` | Generated: control rectangles in 640x480 units | +| `media//` | Generated: `v/` frames, `o/` overlays, `o/back.png` (shade and caption scrim under every screen). Committed, because CI cannot regenerate it | +| `local-sign.ps1` | Per-PC signing of the UIAccess build | +| `MicrosoftEdgeWebview2Setup.exe` | Microsoft's Evergreen WebView2 bootstrapper | + +## Build ``` build.bat installer ``` -Needs NSIS (`winget install NSIS.NSIS`). That target compiles the script and then runs -`tools\installer_check.ps1`, which verifies things a compile cannot: that every rectangle the -pages read was actually generated, and that a silent install and uninstall round-trip. The -round-trip half needs an elevated shell and skips itself without one. +Needs NSIS (`winget install NSIS.NSIS`). The target compiles the script and runs +`tools\installer_check.ps1`, which checks that every packed file exists, that every rectangle the +screens read was generated, and that a silent install and uninstall round-trip (the round trip +needs an elevated shell). For a release artifact use `tools\release.ps1`, which builds the payload, +signs it when a certificate is configured, and packs the installer. -For a release artifact, use `tools\release.ps1` instead: it builds the payload, signs it when -a certificate is configured, and packs the installer. +## Local signing -## UIAccess on every PC (local signing, issue #261) +Without a code-signing certificate, `release.ps1` also builds the UIAccess variant as `WindUA.exe`, +which makes `wind.nsi` define `LOCAL_SIGN`. Setup then runs `local-sign.ps1`: -Without a code-signing certificate, `release.ps1` also builds the uiAccess variant as -`WindUA.exe`, and its presence makes `wind.nsi` define `LOCAL_SIGN`. Setup then runs -`local-sign.ps1` on the PC it installs to: a fresh `CN=Wind Local Signing` certificate is -trusted in LocalMachine Root and TrustedPublisher, signs `Wind.exe` and `WindConfig.exe`, and -has its private key deleted straight away, so the signatures stay valid but nothing can ever -sign with that root again. Older Wind Local Signing roots are retired on every install, and -the uninstaller removes them (`local-sign.ps1 -Remove`). Setup installs the ordinary build -first and signs the uiAccess one in `$PLUGINSDIR`, copying it over only once its signature -verifies: an unsigned uiAccess `Wind.exe` does not start at all ("A referral was returned from -the server"), and a setup killed mid-signing once left exactly that. So any failure or -interruption leaves the ordinary build, which runs everywhere without UIAccess. A release signed with a real -certificate has no `WindUA.exe` and skips all of it. Verified 2026-09-28: the installed build -logs `token UIAccess=1`, and the elevated `installer_check.ps1` covers the signature, the -deleted key, the single root and the uninstall clean-up. +- A fresh `CN=Wind Local Signing` certificate is trusted in LocalMachine Root and TrustedPublisher, + signs the executables, and has its private key deleted at once, so nothing can sign with that root + again. Older Wind roots are retired on every install; the uninstaller removes them + (`local-sign.ps1 -Remove`). +- Setup installs the ordinary build first and signs the UIAccess build in `$PLUGINSDIR`, copying it + over only after its signature verifies. An unsigned UIAccess `Wind.exe` does not start at all, so + any failure leaves the ordinary build. +- A release signed with a real certificate has no `WindUA.exe` and skips this. -## Changing the words or the layout +## Licence screen -Edit `over.html`, then regenerate both overlay sets: +Two of the five screens are the licence page, before and after its box is ticked +(`windLicenceCreate`/`windLicenceLeave` in `screens.nsh`). Install stays disabled until the box is +ticked. "Read the full licence" copies `LICENSE.txt` to a fresh folder in the user's temp directory +and opens it through `explorer.exe`, so the viewer is not elevated (an elevated NSIS locks +`$PLUGINSDIR` to Administrators). A silent install skips the page; `LICENSE.txt` is installed next to +`Wind.exe` either way. -``` -node installer/make-over.mjs 1440 -node installer/make-over.mjs 960 -``` +## Editing the overlay -They write straight into `media//o`, which is what the installer packs. Do not stage -them anywhere else: an intermediate folder is a thing to forget. - -Renaming a `data-a` attribute renames its rectangle in `over.nsh`. That compiles fine and then -hit-tests against nothing, which is exactly what the rectangle check in `installer_check.ps1` -is there to catch, so run `build.bat installer` after any such edit. - -## Changing the clip - -The footage currently shipping is a blue flow-line abstract, cut from the first 15 seconds of -the source clip: it drifts teal after that, and blue is the family Wind's indigo accent lives -in. It was built with +Edit `over.html`, then regenerate both overlay sets straight into `media//o`: ``` -node installer/make-loop.mjs "wave-abstract-background.1920x1080.mp4" --start 0 --len 15 --fps 24 --fade 36 +node installer/make-over.mjs 1440 +node installer/make-over.mjs 960 ``` -which yielded 324 frames, so `FRAMES` in `video.nsh` is 324 and `TICK` is 42. The wrap measures -RMSE 0.0369 against a natural frame-to-frame range of 0.0099 to 0.0319, so the seam is a little -above the clip's own fastest moment and reads as motion rather than a cut. +Renaming a `data-a` attribute renames its rectangle in `over.nsh`. That compiles and then hit-tests +against nothing, which `installer_check.ps1` catches, so run `build.bat installer` after such an +edit. Each screen is two layers: `back.png` (shared) and a per-screen overlay with type and +controls, so the shared gradient is stored once. -To replace it: +## Replacing the clip ``` node installer/make-loop.mjs "C:\path\to\clip.mp4" --len 16 --fps 24 ``` -It reports how many frames it produced; put that number in `FRAMES` in `video.nsh`, and set -`TICK` to 1000 / the clip's frame rate. The script reads how many frames the source actually -yielded and sizes the loop to fit, and crossfades the tail into the head so the wrap is -smaller than an ordinary frame step. It prints both numbers so you can check. - -Needs `ffmpeg` and ImageMagick (`magick`) on PATH. - -## Two overlay layers - -Each screen is drawn as **two** overlays, not one: `back.png` carries the shade -and the caption scrim, and the per-screen overlay carries only type and controls. They are -identical layers on every screen and every hover state, so baking them together would store -the same full-frame gradient nineteen times: measured, that was 28.3 MB of media against -13.3 MB for the split. The cost is one extra `GdipDrawImageRectI` per tick. - -## What it costs - -Roughly 7.6 MB of frames at 800x600, plus about 4 MB of overlays across both DPI sets and the -1.7 MB WebView2 stub, which packs down to a 12.3 MB installer. Both overlay sets are packed into the -installer and only the matching one is unpacked at runtime. - -The footage ships at one size for every display because it is defocused motion and an upscale -is invisible on it; the type is a separate overlay and renders at the display's own -resolution. +The script sizes the loop to the frames the source yields and crossfades the tail into the head; it +prints the frame count and the wrap error. Put the frame count in `FRAMES` in `video.nsh` and set +`TICK` to 1000 / frame rate. Needs `ffmpeg` and ImageMagick (`magick`) on PATH. The footage ships at +one size for every display; the overlays render at the display's resolution (960 or 1440 set). diff --git a/tools/testenv/README.md b/tools/testenv/README.md index 1cc00493..abe5b8fe 100644 --- a/tools/testenv/README.md +++ b/tools/testenv/README.md @@ -1,21 +1,21 @@ # Wind proving ground Automated, reusable test environment (issue #225): drives Wind with injected input over -controlled backdrop windows and measures what usually breaks - pacing, centering, wobble, +controlled backdrop windows and measures what usually breaks – pacing, centering, wobble, zoom-ramp smoothness, RAM. Fully self-driving; a human is only needed to *watch* (the grid backdrops make flicker and stutter easy to see by eye). ## Running - powershell -File tools\testenv\run.ps1 -Suite rapid # ~1 min smoke while iterating - powershell -File tools\testenv\run.ps1 -Suite quick # ~2 min for riskier changes - powershell -File tools\testenv\run.ps1 -Suite full # ~8 min pre-PR gate - powershell -File tools\testenv\run.ps1 -Suite soak -Minutes 30 - powershell -File tools\testenv\run.ps1 -Suite full -CI # exit 1 on regression vs baselines - powershell -File tools\testenv\run.ps1 -Suite full -UpdateBaseline + pwsh -File tools\testenv\run.ps1 -Suite rapid # ~1 min smoke while iterating + pwsh -File tools\testenv\run.ps1 -Suite quick # ~2 min for riskier changes + pwsh -File tools\testenv\run.ps1 -Suite full # ~8 min pre-PR gate + pwsh -File tools\testenv\run.ps1 -Suite soak -Minutes 30 + pwsh -File tools\testenv\run.ps1 -Suite full -CI # exit 1 on regression vs baselines + pwsh -File tools\testenv\run.ps1 -Suite full -UpdateBaseline **Iteration gate**: `rapid` or `quick` by change risk -> `full` -> PR. Run `stress` before -releases and after engine-level work - its scenarios exist to break the magnifier (overzoom +releases and after engine-level work – its scenarios exist to break the magnifier (overzoom held far past maxLevel while panning, 64-mickey slam pans, violent flick bursts, rapid zoom-storm cycling), and its primary verdict is the health check: Wind still alive, dwm.exe not restarted, no device-lost in the log, level never escaped the configured cap. @@ -25,9 +25,9 @@ not restarted, no device-lost in the log, level never escaped the configured cap 1. Wind is restarted with telemetry enabled; a **full zoom-out reset** runs first (never trusts prior state), and every scenario starts with the cursor at the **same position** (monitor centre) for reproducibility. -2. **Start tone** (880 Hz, short) - hands off the mouse from here. +2. **Start tone** (880 Hz, short) – hands off the mouse from here. 3. Scenarios run: backdrop spawns and takes focus -> zoom in -> movement program -> full reset. -4. **Stop tone** (440 Hz, long) - the hands-off period is over. These are the only two sounds +4. **Stop tone** (440 Hz, long) – the hands-off period is over. These are the only two sounds in the environment; a failed run still ends with the same stop tone. 5. Wind is restarted clean (telemetry off). @@ -38,29 +38,29 @@ worst case), `acrylic` with a **strength ladder** (`glass` = near-pure blur, `li `heavy` = the Prism-class tint), `animated` (scrolling grid, game-like motion). Borderless -> hybrid picks the transform engine; captioned -> render-class. -Acrylic scenarios always run over a controlled **underlay window** - the blur samples whatever +Acrylic scenarios always run over a controlled **underlay window** – the blur samples whatever is behind the acrylic surface, so without one the desktop leaks into the measurement. `solid` underlay = the cheap static case (DWM re-uses the blur); `animated` underlay = video-like -motion beneath, which forces a re-blur every composite - the expensive acrylic case, and the +motion beneath, which forces a re-blur every composite – the expensive acrylic case, and the `acryl-heavy-video` vs `acryl-heavy-zigzag` pair measures exactly that difference. -NOTE: with `desktopTransform=1` in the live ini, everything runs transform - the results table +NOTE: with `desktopTransform=1` in the live ini, everything runs transform – the results table reports the OBSERVED engine per scenario. Programs: `zig` (top<->bottom zig-zag), `pan` (medium), `fast`, `drift` (1-mickey precision -circle - where wobble hides), `hold` (dead stop - wobble at rest), `rezoom` (5 in/out cycles). +circle – where wobble hides), `hold` (dead stop – wobble at rest), `rezoom` (5 in/out cycles). ## Telemetry Wind writes one CSV line per tick when enabled (`src/test_telemetry.h`): level, engine, mapper centre, cursor, welded flag, tick duration, QPC timestamp. The runner's phase marks use the same QPC clock, so scenarios index directly into the file. Enablement travels via -`%LOCALAPPDATA%\Wind\testlog.txt` (a control file - the signed uiAccess build launches +`%LOCALAPPDATA%\Wind\testlog.txt` (a control file – the signed uiAccess build launches brokered, so env vars do not survive; dev builds also honor `WIND_TESTLOG`). Metric notes: - `dtP95/dtP99/hitches`: tick pacing during the movement program. - `devMed/devP95` (cursor vs lens centre) and `jitP95` (per-tick change of that gap) are ~0 BY - CONSTRUCTION in free-cursor follow mode - they become meaningful in weld mode (game locks) + CONSTRUCTION in free-cursor follow mode – they become meaningful in weld mode (game locks) and render-engine sessions. Zero there is not a broken metric. - `maxLevel` confirms the zoom actually engaged (a ~1.0 value means the ramp was blocked - see the launch-quiesce note in lib.ps1's Start-Backdrop). @@ -68,8 +68,8 @@ Metric notes: ## Benchmark mode (bench.ps1): compare magnifiers head to head - powershell -File tools\testenv\bench.ps1 # Wind vs Windows Magnifier - powershell -File tools\testenv\bench.ps1 -Drivers wind,native,external -ExternalSpec zt.json + pwsh -File tools\testenv\bench.ps1 # Wind vs Windows Magnifier + pwsh -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 @@ -85,8 +85,8 @@ Rules and limits: - Measurement rides the DWM fullscreen-transform readback (MagGetFullscreenTransform), which covers Wind's transform engine, the native Magnifier, and fullscreen-mode AT magnifiers. Wind must therefore be running its transform engine for bench runs (desktopTransform=1 - - true on this rig). Render-engine sessions are invisible to this channel. -- The native driver zooms via ONE registry write (which native eases cleanly - the measured- + true on the dev machine). Render-engine sessions are invisible to this channel. +- The native driver zooms via ONE registry write (which native eases cleanly – the measured- safe channel); Wind zooms closed-loop by holding the side button until the readback reaches the target; external magnifiers press their configured hotkey chord until the level lands. - External spec JSON (ZoomText etc.): name, exe, procNames, zoomInVks/zoomOutVks (the chord), @@ -94,20 +94,34 @@ Rules and limits: ## Baselines and CI -`baselines.json` stores per-scenario PASS numbers from this rig (`-UpdateBaseline`). `-CI` +`baselines.json` stores per-scenario PASS numbers from the dev machine (`-UpdateBaseline`). `-CI` compares with tolerance (1.6x + slack) and exits nonzero on regression. Absolute floors (dtP99 > 25 ms, jitP95 > 25 px, backSteps in a non-rezoom scenario) catch catastrophes even without a baseline. GitHub-hosted runners cannot run this (no real GPU/display for Desktop Duplication or the Magnification API), so the push-to-main story is local: `hooks/pre-push.sample` (reminder -gate) and `register_nightly.ps1` (scheduled full run on this rig). +gate) and `register_nightly.ps1` (scheduled full run on the dev machine). ## Files -- `run.ps1` - orchestrator: suites (incl. stress), scenarios, health checks, verdicts, JSON -- `bench.ps1` - cross-magnifier benchmark: scoreboard + results/catalog.csv history -- `lib.ps1` - interop (injection, tones, Wind lifecycle, backdrop mgmt) + telemetry analysis -- `backdrop.ps1` - one backdrop window per child process -- `probe_animated.ps1` - standalone zoom-engagement probe (shakedown diagnostic) -- `baselines.json`, `results/` - this rig's truth (results/ is gitignored) +| File | What it is | +|---|---| +| `run.ps1` | Orchestrator: suites (rapid, quick, full, soak, stress), scenarios, health checks, verdicts, JSON | +| `bench.ps1` | Cross-magnifier benchmark: scoreboard and `results/catalog.csv` history | +| `lib.ps1` | Shared interop (injection, tones, Wind lifecycle, backdrops) and telemetry analysis | +| `backdrop.ps1` | One backdrop window per child process | +| `gamesim.cpp` | Borderless fullscreen D3D11 game simulator with a flip-model swapchain (build output `gamesim.exe` is gitignored) | +| `baselines.json` | Per-scenario PASS numbers from the dev machine | +| `sweep.ps1` | Runs a list of configurations through the gates, cheap gate first, and logs every result | +| `ab_acryl.ps1` | Round-robin multi-config run of the acrylic hitch scenarios (issue #229) | +| `dualcursor.ps1` | Black-backdrop capture test for a second or flickering cursor; sets `spriteCapturable=1` | +| `cursor_area_probe.py` | Optical probe: cursor area and position per captured frame | +| `cursor_flicker.py` | Analyzer for the optical probe: does the cursor oscillate while panning | +| `shimmer_ab.ps1` | Ramp-shimmer A/B per configuration, hand still, blank backdrop | +| `shimmer_probe.py` | Measures cursor pixel churn while the zoom level changes | +| `probe_animated.ps1` | One-off probe of zoom engagement over the animated backdrop | +| `register_nightly.ps1` | Registers or removes the nightly full-suite scheduled task | +| `hooks/pre-push.sample` | Optional pre-push reminder to run the proving ground | + +`results/` holds run output and is gitignored.