From f96749ce42be3cb7e198e1ec76d69a38d0b88aa7 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:39:46 +0200 Subject: [PATCH 1/4] ci(release): publish releases and alphas to this repo (#353) Drop the RELEASES_TOKEN / Wind-releases / IS_PRIVATE pre-launch indirection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- .github/workflows/alpha.yml | 22 +++++++--------------- .github/workflows/release.yml | 29 ++++++----------------------- 2 files changed, 13 insertions(+), 38 deletions(-) diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml index affd7632..38c5336e 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/alpha.yml @@ -85,22 +85,14 @@ jobs: "path=$dst" >> $env:GITHUB_OUTPUT "sha256=$((Get-FileHash $dst -Algorithm SHA256).Hash)" >> $env:GITHUB_OUTPUT - # Same split as release.yml: a private source repo cannot serve downloadable assets, - # so alphas are published to the public releases repo when RELEASES_TOKEN is set. - name: Publish the pre-release shell: pwsh env: - GH_TOKEN: ${{ secrets.RELEASES_TOKEN || github.token }} - TARGET_REPO: ${{ secrets.RELEASES_TOKEN && 'Maxaubert/Wind-releases' || github.repository }} - HAS_TOKEN: ${{ secrets.RELEASES_TOKEN != '' }} - IS_PRIVATE: ${{ github.event.repository.private }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | $ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false - # See release.yml: alphas stay in this private repo until RELEASES_TOKEN is set. - if ($env:IS_PRIVATE -eq 'true' -and $env:HAS_TOKEN -ne 'true') { - Write-Host "::notice::Publishing to the PRIVATE repo. Set RELEASES_TOKEN to publish to Maxaubert/Wind-releases instead." - } $tag = '${{ steps.ver.outputs.tag }}' $exe = '${{ steps.asset.outputs.path }}' $note = '${{ inputs.note }}' @@ -120,7 +112,7 @@ jobs: Unsigned, like the stable build - SmartScreen will warn. "@ $body | Set-Content notes.md -NoNewline - gh release create $tag $exe --repo $env:TARGET_REPO --prerelease --title "Wind ${{ steps.ver.outputs.alpha }}" --notes-file notes.md + gh release create $tag $exe --prerelease --title "Wind ${{ steps.ver.outputs.alpha }}" --notes-file notes.md if ($LASTEXITCODE -ne 0) { throw 'gh release create failed' } # Alphas are disposable and accumulate fast. Keep the newest few so a bisect across recent @@ -128,19 +120,19 @@ jobs: - name: Prune old alphas shell: pwsh env: - GH_TOKEN: ${{ secrets.RELEASES_TOKEN || github.token }} - TARGET_REPO: ${{ secrets.RELEASES_TOKEN && 'Maxaubert/Wind-releases' || github.repository }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | $ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false $keep = 5 - $all = gh release list --repo $env:TARGET_REPO --limit 100 --json tagName,isPrerelease,createdAt | ConvertFrom-Json + $all = gh release list --limit 100 --json tagName,isPrerelease,createdAt | ConvertFrom-Json $alphas = @($all | Where-Object { $_.isPrerelease -and $_.tagName -like '*-alpha.*' } | Sort-Object { [datetime]$_.createdAt } -Descending) if ($alphas.Count -le $keep) { "nothing to prune ($($alphas.Count) alphas)"; exit 0 } foreach ($old in $alphas[$keep..($alphas.Count-1)]) { "pruning $($old.tagName)" - gh release delete $old.tagName --repo $env:TARGET_REPO --yes --cleanup-tag + gh release delete $old.tagName --yes --cleanup-tag } - name: Summary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04d3e5d0..ec983d99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,18 +91,11 @@ jobs: # Create the release the first time this version is seen; afterwards replace the asset # in place so the download always matches main. --clobber is what makes the refresh # idempotent: without it a second push to the same version fails on a duplicate name. - # Assets are published to a SEPARATE PUBLIC repo, because this repo is private and a - # private repo's release assets need an authenticated request to download - i.e. nobody - # can get the installer. RELEASES_TOKEN is a fine-grained PAT with contents:write on - # that public repo and nothing else. While it is unset the job falls back to publishing - # here, so the pipeline keeps working during the move and on a fork. - name: Publish or refresh the release shell: pwsh env: - GH_TOKEN: ${{ secrets.RELEASES_TOKEN || github.token }} - TARGET_REPO: ${{ secrets.RELEASES_TOKEN && 'Maxaubert/Wind-releases' || github.repository }} - HAS_TOKEN: ${{ secrets.RELEASES_TOKEN != '' }} - IS_PRIVATE: ${{ github.event.repository.private }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | # Actions runs pwsh with $ErrorActionPreference='Stop', and PowerShell 7.4 turns a # non-zero NATIVE exit into a throw. `gh release view` exits 1 when the tag does not @@ -113,31 +106,21 @@ jobs: $v = '${{ steps.ver.outputs.version }}' $tag = "v$v" $exe = "dist/Wind-Setup-x64-$v.exe" - # DELIBERATE while Wind is pre-launch: releases go to this PRIVATE repo, so the - # installer sits behind authentication and only Max can fetch it. That is the point - # for now. Setting RELEASES_TOKEN (a fine-grained PAT with Contents: read and write - # on Maxaubert/Wind-releases) is the single switch that moves publishing to the - # public download repo, for when the product goes out to Vispero, Statped or NAV. - if ($env:IS_PRIVATE -eq 'true' -and $env:HAS_TOKEN -ne 'true') { - Write-Host "::notice::Publishing to the PRIVATE repo. The asset will not be publicly downloadable. Set RELEASES_TOKEN to publish to Maxaubert/Wind-releases instead." - } # A stable-named copy of the same installer (#343), so this link never needs updating: # https://github.com//releases/latest/download/Wind-Setup-x64.exe always serves # the newest release. The versioned name stays the primary asset. $stable = "dist/Wind-Setup-x64.exe" Copy-Item $exe $stable -Force - $repo = $env:TARGET_REPO - Write-Host "publishing to $repo" - gh release view $tag --repo $repo 2>$null | Out-Null + gh release view $tag 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { Write-Host "refreshing existing release $tag" - gh release edit $tag --repo $repo --notes-file notes.md + gh release edit $tag --notes-file notes.md if ($LASTEXITCODE -ne 0) { throw 'gh release edit failed' } - gh release upload $tag $exe $stable --repo $repo --clobber + gh release upload $tag $exe $stable --clobber if ($LASTEXITCODE -ne 0) { throw 'gh release upload failed' } } else { Write-Host "creating release $tag" - gh release create $tag $exe $stable --repo $repo --title "Wind $v" --notes-file notes.md + gh release create $tag $exe $stable --title "Wind $v" --notes-file notes.md if ($LASTEXITCODE -ne 0) { throw 'gh release create failed' } } From 0c8dbd93f016c61250df29018b7794b8fee13fd7 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:39:46 +0200 Subject: [PATCH 2/4] refactor(engine): retire the magnify engine and dead config keys (#353) Remove MagnifyModel, the selfDrivenZoom/nativeZoomTick/nativeWheelNotches hooks, supportsInspect, the injected-key skip, TrayEngine::System, magnifyStep and the native Magnifier registry backup. model=magnify and unknown models still load as hybrid. Also drop the unread txKeepAliveMaxLevel key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- installer/app.nsh | 5 +- src/config.cpp | 18 +--- src/config.h | 23 ++--- src/config_ui/main.cpp | 4 +- src/input_router.cpp | 6 -- src/input_router.h | 10 +- src/magnifier_model.h | 11 --- src/magnify_model.cpp | 182 ----------------------------------- src/magnify_model.h | 42 -------- src/main.cpp | 82 +++++----------- src/test_telemetry.h | 2 +- src/tray_app/flyout_tools.h | 5 +- src/tray_ipc.h | 2 +- src/tray_status.h | 3 +- tests/test_config.cpp | 3 +- tests/test_config_model.cpp | 16 ++- tests/test_engine_pick.cpp | 2 +- tests/test_flyout_tools.cpp | 2 +- tests/test_idle_policy.cpp | 2 +- tests/test_lock_detector.cpp | 2 +- tests/test_profiles.cpp | 4 +- tests/test_tray_status.cpp | 1 - tools/pan_wake_probe.ps1 | 5 +- tools/testenv/ab_acryl.ps1 | 2 +- 24 files changed, 61 insertions(+), 373 deletions(-) delete mode 100644 src/magnify_model.cpp delete mode 100644 src/magnify_model.h diff --git a/installer/app.nsh b/installer/app.nsh index fffb2724..2066442a 100644 --- a/installer/app.nsh +++ b/installer/app.nsh @@ -28,9 +28,8 @@ Var LicenceDir ; where "Read the full licence" put its copy, empty until then ; ---- 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 +; CLEANLY, which restores the OS cursor, releases any ClipCursor and releases the shared +; Magnification runtime. 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. ; ; The wait is on Wind's single-instance mutex rather than a tasklist poll, which is both diff --git a/src/config.cpp b/src/config.cpp index a19e182e..b89d1038 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -185,7 +185,6 @@ Config ParseConfig(const std::string& text) { else if (key == "cursorConstantSize") c.cursorConstantSize = std::stoi(val); else if (key == "cursorVisibility") c.cursorVisibility = val; else if (key == "model") c.model = val; - else if (key == "magnifyStep") c.magnifyStep = std::stoi(val); else if (key == "fastPan") c.fastPan = std::stoi(val); else if (key == "smoothPan") c.smoothPan = std::stoi(val); else if (key == "cursorSprite") c.cursorSprite = std::stoi(val); @@ -223,7 +222,6 @@ Config ParseConfig(const std::string& text) { else if (key == "txSamplingMode") c.txSamplingMode = std::stoi(val); else if (key == "txWobbleCage") c.txWobbleCage = std::stoi(val); else if (key == "txWobbleCageSize") c.txWobbleCageSize = std::stoi(val); - else if (key == "txKeepAliveMaxLevel")c.txKeepAliveMaxLevel = std::stoi(val); else if (key == "txWarmMode") c.txWarmMode = std::stoi(val); else if (key == "txTrace") c.txTrace = std::stoi(val); else if (key == "txRestLevel") c.txRestLevel = std::stod(val); @@ -292,9 +290,6 @@ Config ParseConfig(const std::string& text) { if (c.tdrTest > 4) c.tdrTest = 4; if (c.ixDecimate < 1) c.ixDecimate = 1; // 1 = publish every changed tick if (c.ixDecimate > 16) c.ixDecimate = 16; - // 0 is meaningful here (keep-alive OFF, the shipped default), so the floor is 0 not 1. - if (c.txKeepAliveMaxLevel < 0) c.txKeepAliveMaxLevel = 0; - if (c.txKeepAliveMaxLevel > 50) c.txKeepAliveMaxLevel = 50; if (c.txIdleReleaseMs < 0) c.txIdleReleaseMs = 0; if (c.txRestLevel < 1.0) c.txRestLevel = 1.0; if (c.txRestLevel > 1.01) c.txRestLevel = 1.01; // visually identity only @@ -325,16 +320,14 @@ Config ParseConfig(const std::string& text) { // "transform" is a first-class model again (revived for issue #148: the compositor-internal // zoom that stays smooth over heavy games); anything unknown falls back to hybrid, the // product default ("Auto" in the UI) - same fallback as a missing key (struct default). - // "magnify" (System, the Windows Magnifier driver) is no longer offered (Max, 2026-10-02): an old - // ini that still says so runs Auto. The engine code stays, it just cannot be picked. + // "magnify" (the retired Windows Magnifier driver) is unknown too, so an old ini that still + // says so runs Auto. if (c.model != "render" && c.model != "transform" && c.model != "hybrid") c.model = "hybrid"; // (The old transform/hybrid maxLevel<=12 clamp is GONE: the "TDR territory above 12x" was // root-caused 2026-07-26 to NVIDIA's 16-bit MPO plane-programming overflow - see issue // #148 - which the mapper's MPO-aware pan wall now guards at ANY level, so maxLevel is one // shared setting across all models. High levels still cost DWM re-scale time; that is a // perf trade the user owns, not a crash.) - if (c.magnifyStep < 5) c.magnifyStep = 5; // Windows Settings' own range is 5..400 - if (c.magnifyStep > 400) c.magnifyStep = 400; // Reject keybinds to keys Wind must never swallow (see IsForbiddenBindVk). A bound key is // eaten system-wide, so binding e.g. Backspace or the Windows key would make it unusable // everywhere; treat a forbidden bind as unbound regardless of how it got into the ini. @@ -550,9 +543,7 @@ std::string DefaultIniText() { "; model: hybrid = Auto (default): picks render or transform per zoom-in (games get\n" "; the compositor-internal transform, everything else the GPU overlay).\n" "; render = GPU capture+overlay (high fidelity). transform = DWM fullscreen\n" - "; transform only. magnify = drive the native Windows Magnifier (works over DRM\n" - "; video like Netflix, which blanks in render; handles its own cursor; ignores\n" - "; the render-only knobs; max zoom 1600%). Restart to switch.\n" + "; transform only. Anything else runs hybrid. Restart to switch.\n" "model=hybrid\n" "; transformExclude (Auto/hybrid only): exe names that must never get the transform\n" "; engine even when fullscreen+borderless. Fullscreen browser video looks exactly\n" @@ -592,9 +583,6 @@ std::string DefaultIniText() { "; Empty (default) = keys stay swallowed everywhere, as before. The Settings UI\n" "; manages this list (Keybinds -> Release keys in these apps).\n" "noSwallowApps=\n" - "; magnifyStep (magnify only): Windows Magnifier zoom increment, percent points per\n" - "; step (5-400). Lower = smoother and slower. Applies live.\n" - "magnifyStep=50\n" "; multiMonitor: 1=magnify whichever monitor the cursor is on at zoom-in; 0=primary only\n" "multiMonitor=0\n" "; cropCapture (opt-in): 0=always copy all changed regions (cache never stale, default);\n" diff --git a/src/config.h b/src/config.h index 071bbe2e..0e6bf9e1 100644 --- a/src/config.h +++ b/src/config.h @@ -97,9 +97,7 @@ struct Config { // --- Model selection ---------------------------------------------------- // Which magnification model runs. "hybrid" (DEFAULT, "Auto" in the UI) constructs render + // transform and picks per zoom-in (engine_pick.h). "render" = the DXGI capture + D3D11 - // overlay. "magnify" = drive the native Windows Magnifier (Magnify.exe) via injected wheel - // notches; works over DRM-protected video that blanks under Desktop Duplication. - // "transform" = the DWM fullscreen-transform model (MagSetFullscreenTransform, no + // overlay. "transform" = the DWM fullscreen-transform model (MagSetFullscreenTransform, no // Magnify.exe) - revived for issue #148: it magnifies inside the compositor with zero app // presents, the only path that stays smooth while a heavy game renders. Missing or unknown // values fall back to "hybrid" (the product default; the UI schema and new-profile seeding @@ -217,8 +215,8 @@ struct Config { // -1 = leave whatever DWM currently has alone. // The state is global to DWM and resets when DWM restarts, which is why smoothing appeared // to come and go between builds; it is re-applied per magnification context. KNOWN - // INTERACTION: the 1px translation warm write (txWarmMode=1, which replaced the retired - // txKeepAliveMaxLevel keep-alive) puts the view 1px off-true on rest ticks; nearest masks + // INTERACTION: the 1px translation warm write (txWarmMode=1) puts the view 1px off-true on + // rest ticks; nearest masks // that as sub-block noise, smoothing can render it as visible shaking (see txWarmMode). // Wobble cage (issue #229, hot): a visible wobble detector - four ~10px bars boxing the // cursor; the bar the sprite crosses flashes red. Diagnostic, ships 0. The collision test @@ -242,11 +240,6 @@ struct Config { // edit). Fail-closed: the walls lift only while the ghost is verifiably shown + settled. // 0 = walls-only (the pre-#191 fence behavior). No effect when MPO is off. int mpoBuster = 1; - // RETIRED (superseded by txWarmMode, 2026-08-26). This was the level gate on the old 1px - // translation keep-alive; that mechanism lives on as txWarmMode=1 (the shipped mode, see - // WARM-KEEPING below). Parsed and clamped so an old ini or profile carrying the key is still - // accepted, but NOTHING READS IT - do not add a reader. - int txKeepAliveMaxLevel = 8; // WARM-KEEPING (pan-start hitch, measured 2026-08-26 with tools/pan_wake_probe.ps1). At rest // Wind stops writing entirely ("same-value hygiene" below), and DWM then lets its // magnification composition path fall off full rate; the first movement after the pause lands @@ -258,7 +251,7 @@ struct Config { // Native never goes quiet, and the legacy keep-alive matches it - but that one writes a value // 1px OFF THE TRUTH at tick rate, which is the shimmer that retired it in #204. These modes // exist to find a channel that keeps DWM warm without lying about the position: - // 0 = off (no warm-keeping; the old txKeepAliveMaxLevel path is retired, nothing reads it) + // 0 = off (no warm-keeping) // 1 = 1px translation jitter (the shipped mode, see "SHIPPED AS MODE 1" below) // 2 = SAME-VALUE rewrite: re-send the exact transform already applied. Honest by // construction. Rests on DWM re-compositing for an identical write, which the old @@ -452,7 +445,7 @@ struct Config { // pointer every frame, defeating both classic lock tells, so the // lens snaps back instead of panning). The lockApps LIST is the // feature: listed exes run their sessions locked outright, and an - // empty list means off - no separate off switch needed (Max). + // empty list means off - no separate off switch needed (owner decision). // 0 (default) = selected apps only; everywhere else is untouched // classic behaviour. // 1 = global: additionally run the smart tells everywhere (warp- @@ -478,10 +471,6 @@ struct Config { // disabled the 16-bit plane-programming overflow should be gone // (modes 1 and 3 acted through the retired game-session freeze and // are inert; numbers kept reserved so old field notes stay readable) - // Magnify-model-only: Windows Magnifier zoom increment in percent POINTS per wheel notch - // (written to the ScreenMagnifier registry; the user's original value is snapshot-restored - // on exit). Lower = smoother and slower zoom. Clamped 5..400. Live-applies (no restart). - int magnifyStep = 50; // --- Own GPU renderer --------------------------------------------------- // Pan speed multiplier. Free desktop panning auto-matches the OS cursor (DPI + acceleration) and // is then scaled by this (1.0 = exact match, the default); it also scales the raw-input pan while @@ -618,7 +607,7 @@ bool IsExeInList(const std::string& exeName, const std::string& list); // The ini text with UI-ONLY lines removed (uiTheme, showAdvanced, onboarded): the settings app // owns those keys and the core never consumes them, yet every write hot-reloads the core - and // the reload resets the ZoomController, so toggling the app theme while zoomed collapsed the -// zoom to 1x (Max field report). The core compares this stripped form across reloads and skips +// zoom to 1x (field report). The core compares this stripped form across reloads and skips // the reload when nothing it consumes changed. 'profile' stays IN: the core mirrors setConfig // into the active profile, so a profile change must still reload. std::string StripUiOnlyKeys(const std::string& iniText); diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index b26c6481..d9151d77 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -220,7 +220,7 @@ static std::string DoSwitchProfile(const std::string& name) { // Verify by parsed key/value maps, not raw text, so a comment difference never false-fails. if (wind::ReadIniValues(ReadFileUtf8(IniPath())) != wind::ReadIniValues(newLive)) return "Could not write the config file"; - // ParseConfig canonicalizes (legacy "transform" -> magnify mapping, unknown -> render), same + // ParseConfig canonicalizes (unknown or retired models -> hybrid), same // comparison as the tray path, so the two surfaces can never disagree about restarting. const std::string oldModel = wind::ParseConfig(oldLive).model; const bool modelChanged = oldModel != wind::ParseConfig(newLive).model; @@ -588,7 +588,7 @@ static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { } // Not maximized: let Windows keep the left/right/bottom frame (invisible resize borders on // 10/11, outside the visible window) and drop only the caption. The WebView child covers the - // client, so without these frames only the corners could be grabbed (Max, 2026-10-02). + // client, so without these frames only the corners could be grabbed (owner decision). const LONG top = p->rgrc[0].top; const LRESULT r = DefWindowProcW(h, m, w, l); p->rgrc[0].top = top; diff --git a/src/input_router.cpp b/src/input_router.cpp index cf6cb839..cbe7eaff 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -280,12 +280,6 @@ void InputRouter::setKeys(int zoomInVk, int zoomInVk2, int zoomOutVk, int zoomOu static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) { if (code == HC_ACTION && g_router) { auto* ks = reinterpret_cast(lParam); - // Magnify model: our own injected Win+Plus/Minus chords must never be swallowed or - // tracked (NumPad +/- are bindable zoom keys; swallowing our own injection would both - // starve Magnifier and feed back as a phantom zoom press). Gated on the model so - // injected keys from other tools keep working normally under the render model. - if ((ks->flags & LLKHF_INJECTED) && g_router->ignoreInjectedKeys()) - return CallNextHookEx(g_kbHook, code, wParam, lParam); int vk = static_cast(ks->vkCode); bool down = (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN); bool up = (wParam == WM_KEYUP || wParam == WM_SYSKEYUP); diff --git a/src/input_router.h b/src/input_router.h index e4021703..a3a9ecc3 100644 --- a/src/input_router.h +++ b/src/input_router.h @@ -88,7 +88,7 @@ class InputRouter { // down, a button/click bind starting a hold, a wheel zoom step). 0 = none. long long takePressQpc(); long long peekPressQpc() const; - // Any configured keyboard bind physically down per the hook (magnify-model holds, quick zoom). + // Any configured keyboard bind physically down per the hook (quick zoom). bool anyBoundKeyPressed() const; // Swallowed pan presses since the last drain, per slot: a tap whose down AND up both land // between two tick samples is still one nudge (review of #287). @@ -151,13 +151,6 @@ class InputRouter { // False while the foreground app is on noSwallowApps (or a game suspended the keyboard hook): // click and wheel binds then pass through to it too, like the keys ("the app also receives it"). bool keyboardHookWanted() const { return kbHookWanted_.load(std::memory_order_relaxed); } - // Magnify model only: make the keyboard hook skip INJECTED events entirely. The magnify model - // drives Windows Magnifier by injecting Win+Plus/Win+Minus chords, and NumPad +/- are bindable - // zoom keys - without the skip, our own injection would be swallowed by our own hook and - // re-registered as a zoom press (a feedback loop). Off by default so tools that inject keys - // (e.g. AutoHotkey remaps) keep working with the render model. - void setIgnoreInjectedKeys(bool on) { ignoreInjectedKeys_.store(on, std::memory_order_relaxed); } - bool ignoreInjectedKeys() const { return ignoreInjectedKeys_.load(std::memory_order_relaxed); } // True when the LL mouse hook is installed (the normal build). When true the hook is the SOLE // authority for side-button held state; main's WM_INPUT path must NOT also write button state // (Raw Input still delivers the transition even though the hook swallows the legacy message, so @@ -224,7 +217,6 @@ class InputRouter { std::atomic kbHookReinstalls_{0}; // watchdog recoveries this session std::atomic kbHookWanted_{true}; // false while a fullscreen game is foreground std::atomic kbHookRecovering_{false}; // distinguishes watchdog recovery from a resume - std::atomic ignoreInjectedKeys_{false}; // magnify model: kb hook skips LLKHF_INJECTED // Inspect-mode cooked-pixel accumulator (main-thread only: WM_INPUT cooks, the tick drains). BallisticsConfig ballistics_{}; double cookedX_ = 0.0; diff --git a/src/magnifier_model.h b/src/magnifier_model.h index bbbc09d8..e8638787 100644 --- a/src/magnifier_model.h +++ b/src/magnifier_model.h @@ -61,16 +61,5 @@ struct IMagnifierModel { virtual void present(const MapResult& r, double level, const Config& cfg, const MonitorTarget& mon, const PresentExtras& ex) = 0; // the per-tick draw virtual bool coversShell() const = 0; // whether the magnified view covers the shell - virtual bool supportsInspect() const { return true; } // magnify model: false (Magnifier owns - // the view/cursor; no freeze+reticle) - // Magnify model: the model drives its own zoom from raw held-direction and Wind's level - // machinery (ZoomController, mapper, overlay activation) is bypassed entirely. RunTick calls - // nativeZoomTick(dir) every tick (dir: +1 zoom-in held, -1 zoom-out held, 0 idle) and skips - // the rest of the zoom pipeline when selfDrivenZoom() is true. - virtual bool selfDrivenZoom() const { return false; } - virtual void nativeZoomTick(int dir, const Config& cfg) { (void)dir; (void)cfg; } - // Magnify model: pass scroll-wheel zoom notches straight on (#285), one Magnifier notch each - // (steps > 0 = in). Magnifier's own ZoomIncrement sets the size of a notch there. - virtual void nativeWheelNotches(int steps) { (void)steps; } }; } diff --git a/src/magnify_model.cpp b/src/magnify_model.cpp deleted file mode 100644 index c0ebe672..00000000 --- a/src/magnify_model.cpp +++ /dev/null @@ -1,182 +0,0 @@ -#include "magnify_model.h" -#include "logging.h" -#include "pointer_binds.h" // kWindInjectTag (#285) -#include -#include -#include -#include - -namespace wind { -namespace { - -const wchar_t* kMagKey = L"Software\\Microsoft\\ScreenMagnifier"; -// The values we modify at initialize (and therefore snapshot + restore on shutdown). We -// deliberately do NOT touch ZoomIncrement or FollowMouse: the whole point of this design is -// that Magnifier behaves exactly as the user configured it in Windows Settings. -const wchar_t* kSnapshotValues[] = { L"Magnification", L"MagnificationMode", - L"MagnifierUIWindowMinimized", L"ZoomIncrement" }; - -// Wheel-notch cadence. Measured (probe 7): 60 ms notches register 1:1 with no backlog and the -// view settles ~150 ms after the last notch. Faster is unmeasured; slower feels sluggish. -const unsigned long long kNotchIntervalMs = 60; - -int ReadMagDword(const wchar_t* name, int fallback) { - DWORD v = 0, cb = sizeof(v); - if (RegGetValueW(HKEY_CURRENT_USER, kMagKey, name, RRF_RT_REG_DWORD, nullptr, &v, &cb) == ERROR_SUCCESS) - return (int)v; - return fallback; -} - -void WriteMagDword(const wchar_t* name, int value) { - DWORD v = (DWORD)value; - RegSetKeyValueW(HKEY_CURRENT_USER, kMagKey, name, REG_DWORD, &v, sizeof(v)); -} - -// Magnifier's main window class (present whenever Magnify.exe is running, fullscreen mode included). -bool MagnifierWindowPresent() { return FindWindowW(L"MagUIClass", nullptr) != nullptr; } - -// %LOCALAPPDATA%\Wind\magnifier_backup.ini (the same per-user dir the ini fallback and logs use; -// never next to the exe - Program Files is read-only for the non-admin runtime). -std::wstring ResolveBackupPath() { - wchar_t base[MAX_PATH]{}; - if (!GetEnvironmentVariableW(L"LOCALAPPDATA", base, MAX_PATH) || !base[0]) return L""; - std::wstring dir = std::wstring(base) + L"\\Wind"; - CreateDirectoryW(dir.c_str(), nullptr); // idempotent - return dir + L"\\magnifier_backup.ini"; -} - -// Inject a Win+ chord (used only for Win+Esc = quit Magnifier). A key press inside the chord -// keeps the Win tap from opening the Start menu. Injected events carry LLKHF_INJECTED, which our -// own keyboard hook skips in magnify mode. -void InjectWinChord(WORD vk) { - INPUT in[4] = {}; - for (auto& i : in) i.type = INPUT_KEYBOARD; - in[0].ki.wVk = VK_LWIN; - in[1].ki.wVk = vk; - in[2].ki.wVk = vk; in[2].ki.dwFlags = KEYEVENTF_KEYUP; - in[3].ki.wVk = VK_LWIN; in[3].ki.dwFlags = KEYEVENTF_KEYUP; - SendInput(4, in, sizeof(INPUT)); -} - -// One Ctrl+Alt+wheel notch, modifiers held only for the microseconds around the wheel event so -// they can never leak onto the user's own concurrent clicks/keys. Injected events are skipped by -// our keyboard hook in magnify mode (setIgnoreInjectedKeys), and the mouse hook ignores wheels. -void InjectZoomNotch(bool zoomIn) { - INPUT in[5] = {}; - in[0].type = INPUT_KEYBOARD; in[0].ki.wVk = VK_CONTROL; - in[1].type = INPUT_KEYBOARD; in[1].ki.wVk = VK_MENU; - in[2].type = INPUT_MOUSE; in[2].mi.dwFlags = MOUSEEVENTF_WHEEL; - in[2].mi.mouseData = (DWORD)(zoomIn ? WHEEL_DELTA : -WHEEL_DELTA); - for (auto& i : in) { if (i.type == INPUT_MOUSE) i.mi.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; - else i.ki.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; } // never a wheel bind (#285) - in[3].type = INPUT_KEYBOARD; in[3].ki.wVk = VK_MENU; in[3].ki.dwFlags = KEYEVENTF_KEYUP; - in[4].type = INPUT_KEYBOARD; in[4].ki.wVk = VK_CONTROL; in[4].ki.dwFlags = KEYEVENTF_KEYUP; - SendInput(5, in, sizeof(INPUT)); -} - -} // namespace - -void MagnifyModel::launchMagnifier() { - unsigned long long now = GetTickCount64(); - if (now - lastLaunchMs_ < 2000) return; // backoff: launch takes a moment to appear - lastLaunchMs_ = now; - WriteMagDword(L"Magnification", 100); // never launch into a leftover zoom level - HINSTANCE h = ShellExecuteW(nullptr, L"open", L"magnify.exe", nullptr, nullptr, SW_SHOWMINNOACTIVE); - wind::Log(wind::LogLevel::Info, "magnify", "launch magnify.exe -> %s", - ((INT_PTR)h > 32) ? "ok" : "FAILED"); -} - -bool MagnifyModel::initialize(const MonitorTarget&) { - backupPath_ = ResolveBackupPath(); - // One-shot snapshot of the user's Magnifier settings, BEFORE we modify anything. If the file - // already exists we keep it: a previous Wind crashed before restoring, and re-snapshotting now - // would capture OUR values as the user's. - if (!backupPath_.empty() && GetFileAttributesW(backupPath_.c_str()) == INVALID_FILE_ATTRIBUTES) { - std::wofstream f(backupPath_.c_str()); - for (const wchar_t* name : kSnapshotValues) - f << name << L"=" << ReadMagDword(name, -1) << L"\n"; // -1 = value was absent - } - WriteMagDword(L"MagnificationMode", 2); // fullscreen (read at Magnifier startup) - WriteMagDword(L"MagnifierUIWindowMinimized", 1); // keep the toolbar out of the way - launchMagnifier(); - ready_ = true; - wind::Log(wind::LogLevel::Info, "magnify", "initialized (native wheel-notch drive)"); - return true; -} - -void MagnifyModel::nativeZoomTick(int dir, const Config& cfg) { - // Live-apply the configured zoom increment (write only on change: same-value writes are - // pointless, and in this native design Magnifier's key-watch handler re-applying its OWN - // current Magnification value is a no-op, so an increment write while running is safe). - if (cfg.magnifyStep != lastStepPct_) { - WriteMagDword(L"ZoomIncrement", cfg.magnifyStep); - lastStepPct_ = cfg.magnifyStep; - wind::Log(wind::LogLevel::Info, "magnify", "zoom increment -> %d%%", cfg.magnifyStep); - } - if (dir != lastDir_) { - wind::Log(wind::LogLevel::Info, "magnify", "zoom %s", - dir > 0 ? "in (held)" : dir < 0 ? "out (held)" : "released"); - lastDir_ = dir; - } - if (dir == 0) return; - unsigned long long now = GetTickCount64(); - if (now - lastNotchMs_ < kNotchIntervalMs) return; - if (!MagnifierWindowPresent()) { launchMagnifier(); return; } // user closed it: bring it back - lastNotchMs_ = now; - InjectZoomNotch(dir > 0); -} - -// The user is turning the wheel with the bind's modifiers held, which may already include Ctrl or -// Alt. Inject only the ones NOT held: releasing a physically held modifier would clear its logical -// state, and the user's next notch would then miss the bind and scroll the app instead. -static void InjectWheelNotches(int count, bool zoomIn) { - const bool ctrlHeld = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; - const bool altHeld = (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; - INPUT in[4 + 8] = {}; - int n = 0; - auto key = [&](WORD vk, bool up) { - in[n].type = INPUT_KEYBOARD; in[n].ki.wVk = vk; in[n].ki.dwFlags = up ? KEYEVENTF_KEYUP : 0; - in[n].ki.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; ++n; - }; - if (!ctrlHeld) key(VK_CONTROL, false); - if (!altHeld) key(VK_MENU, false); - for (int i = 0; i < count; ++i) { - in[n].type = INPUT_MOUSE; in[n].mi.dwFlags = MOUSEEVENTF_WHEEL; - in[n].mi.mouseData = (DWORD)(zoomIn ? WHEEL_DELTA : -WHEEL_DELTA); - in[n].mi.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; ++n; - } - if (!altHeld) key(VK_MENU, true); - if (!ctrlHeld) key(VK_CONTROL, true); - SendInput((UINT)n, in, sizeof(INPUT)); -} - -void MagnifyModel::nativeWheelNotches(int steps) { - if (steps == 0 || !ready_) return; - if (!MagnifierWindowPresent()) { launchMagnifier(); return; } - int count = steps > 0 ? steps : -steps; - if (count > 8) count = 8; // a long free-spin: Magnifier eases each notch, more is just lag - InjectWheelNotches(count, steps > 0); -} - -void MagnifyModel::shutdown() { - if (!ready_) return; - if (MagnifierWindowPresent()) InjectWinChord(VK_ESCAPE); - // Put the user's Magnifier settings back exactly as we found them (absent values were - // snapshotted as -1: skip them rather than inventing a value). - if (!backupPath_.empty()) { - std::wifstream f(backupPath_.c_str()); - std::wstring line; - while (std::getline(f, line)) { - size_t eq = line.find(L'='); - if (eq == std::wstring::npos) continue; - std::wstring name = line.substr(0, eq); - int value = _wtoi(line.substr(eq + 1).c_str()); - if (value >= 0) WriteMagDword(name.c_str(), value); - } - f.close(); - DeleteFileW(backupPath_.c_str()); - wind::Log(wind::LogLevel::Info, "magnify", "shutdown: Magnifier quit, registry restored"); - } - ready_ = false; -} -} diff --git a/src/magnify_model.h b/src/magnify_model.h deleted file mode 100644 index d40a7aea..00000000 --- a/src/magnify_model.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once -#include "magnifier_model.h" -#include -namespace wind { -// Drives the NATIVE Windows Magnifier (Magnify.exe) like a user would - nothing more. The -// DRM-safe model: Magnifier's DWM fullscreen transform magnifies protected video (Netflix etc.) -// that blanks under the render model's Desktop Duplication capture. -// -// FINAL design after an extensive measured dead-end ledger (see the spec's amendments and -// CLAUDE.md): Wind holds NO zoom state and never touches the transform or the live registry. -// While a zoom button is held, it injects Ctrl+Alt+wheel notches (Magnifier's own wheel-zoom -// shortcut) at a measured-safe cadence; Magnifier does everything else natively - stepping by -// the user's own ZoomIncrement, easing each notch, panning, cursor. Measured: notches at 60 ms -// register 1:1 (no drops, unlike Win+Plus chord bursts) and settle ~150 ms after the last one. -// Injected Win+wheel is INERT - Ctrl+Alt+wheel is the real channel. -class MagnifyModel : public IMagnifierModel { -public: - bool initialize(const MonitorTarget& monitor) override; - void shutdown() override; - bool ready() const override { return ready_; } - void hideSystemCursor(bool) override {} // Magnifier draws and follows the cursor itself - void setActive(bool) override {} // level machinery is bypassed (selfDrivenZoom) - void onActivate() override {} // no capture to prime - void present(const MapResult&, double, const Config&, - const MonitorTarget&, const PresentExtras&) override {} // never active - bool coversShell() const override { return true; } // Magnifier magnifies the shell too - bool supportsInspect() const override { return false; } - bool selfDrivenZoom() const override { return true; } - void nativeZoomTick(int dir, const Config& cfg) override; - void nativeWheelNotches(int steps) override; -private: - void launchMagnifier(); - - bool ready_ = false; - int lastDir_ = 0; // for start/stop logging only - int lastStepPct_ = 0; // last ZoomIncrement written (write only on change; - // 0 = not yet written, forces the first write) - unsigned long long lastNotchMs_ = 0; // wheel-notch cadence gate - unsigned long long lastLaunchMs_ = 0; // relaunch backoff (user may close Magnifier manually) - std::wstring backupPath_; // one-shot registry snapshot (restore on shutdown) -}; -} diff --git a/src/main.cpp b/src/main.cpp index 13d9016d..04efe4cb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -29,7 +29,6 @@ #include "color_filter.h" #include "hdr_info.h" // issue #288 #include "cursor_tint.h" // tinted pointer at 1x (#288) -#include "magnify_model.h" #include "transform_model.h" #include "input_router.h" #include "cursor_mapper.h" @@ -841,23 +840,22 @@ static bool RenderOverlayShown(TickState& t) { return rm && rm->visible(); } static void UpdateColorFilter(TickState& t, bool zoomedNow, bool renderSession, PresentExtras* ex) { - const bool magnify = t.model && t.model->selfDrivenZoom(); // native Magnifier has its own filters (void)zoomedNow; // applies zoomed and at 1x alike (owner decision 2026-09-29) const double w = t.cfg.colorWarmPct / 100.0, d = t.cfg.colorDimPct / 100.0; // Toggling HDR is not guaranteed to raise WM_DISPLAYCHANGE (and may settle after it), so while a // filter is on the state is re-read once a second. A DisplayConfig query is microseconds (the // render engine runs the same kind of query at 4 Hz, CLAUDE.md), so this is not a tick cost. - if (!magnify && (w > 0.0 || d < 1.0)) { + if (w > 0.0 || d < 1.0) { static unsigned long long hdrReadMs = 0; const unsigned long long now = GetTickCount64(); if (now - hdrReadMs >= 1000) { hdrReadMs = now; g_hdrOn.store(PrimaryHdrOn()); } } // The render shader works on sRGB-encoded values (after its HDR->SDR step), in SDR and HDR alike. - const wind::ColorMatrix enc = magnify ? wind::IdentityColorMatrix() : wind::BuildColorMatrix(w, d, false); + const wind::ColorMatrix enc = wind::BuildColorMatrix(w, d, false); const bool inShader = renderSession && !wind::IsIdentity(enc); if (ex) { ex->colorOn = inShader; ex->color = enc; } - const wind::ColorMatrix dwm = (inShader || magnify) ? wind::IdentityColorMatrix() - : wind::BuildColorMatrix(w, d, g_hdrOn.load(std::memory_order_relaxed)); + const wind::ColorMatrix dwm = inShader ? wind::IdentityColorMatrix() + : wind::BuildColorMatrix(w, d, g_hdrOn.load(std::memory_order_relaxed)); g_color.apply(dwm, !wind::IsIdentity(dwm)); } @@ -867,9 +865,7 @@ static void UpdateColorFilter(TickState& t, bool zoomedNow, bool renderSession, // reload the scheme), and a fullscreen app in front (not the desktop) keeps the pristine pointers. static wind::CursorTint g_tint; static void UpdateCursorTint(TickState& t) { - const bool magnify = t.model && t.model->selfDrivenZoom(); - const wind::ColorMatrix enc = magnify ? wind::IdentityColorMatrix() - : wind::BuildColorMatrix(t.cfg.colorWarmPct / 100.0, t.cfg.colorDimPct / 100.0, false); + const wind::ColorMatrix enc = wind::BuildColorMatrix(t.cfg.colorWarmPct / 100.0, t.cfg.colorDimPct / 100.0, false); if (wind::IsIdentity(enc)) { g_tint.restore(true); return; } static unsigned long long checkedMs = 0; static bool fsApp = false; @@ -1155,10 +1151,9 @@ static void RunTick(TickState& t) { // The tell needs no extra bookkeeping: while the hook is alive it SWALLOWS every bound key, so // GetAsyncKeyState can NEVER see one. Poller sees a bound key held + hook still reports it up // => the hook is gone. The dwell keeps the ordinary press-before-callback race from - // false-positiving; the magnify model is excluded outright because its hook deliberately skips - // the injected chords it drives Windows Magnifier with (those are unswallowed by design). + // false-positiving. constexpr unsigned long long kKbHookDeadMs = 250; - if (g_input.kbHookActive() && g_input.swallowEnabled() && !g_input.ignoreInjectedKeys()) { + if (g_input.kbHookActive() && g_input.swallowEnabled()) { const int watched[] = { t.cfg.zoomInVk, t.cfg.zoomInVk2, t.cfg.zoomOutVk, t.cfg.zoomOutVk2, t.cfg.recenterVk, t.cfg.cursorLockVk, t.cfg.panLeftVk, t.cfg.panRightVk, t.cfg.panUpVk, t.cfg.panDownVk }; @@ -1232,8 +1227,7 @@ static void RunTick(TickState& t) { // With the modifier down only binds that include it hold-zoom; the others are quick-zoom taps. t.zoom.setDirection(modKeyDown ? ResolveDirection(inQz, outQz) : ResolveDirection(inHeld, outHeld)); // The wheel zooms at the user's zoom speeds (a notch = 0.1 s of holding the bind). - if (wheelSteps != 0 && !t.model->selfDrivenZoom()) // native Magnifier gets its own notches below - t.zoom.wheelNotches(wheelSteps); + if (wheelSteps != 0) t.zoom.wheelNotches(wheelSteps); // Clamp the dt fed to the zoom so a single long tick (cold first capture, alt-tab, any hitch) // can't jump the zoom level mid-ramp - it should always ease in/out at a steady rate regardless // of frame-time spikes. Raw dt is kept below for the diagnostics block (which must see true @@ -1257,38 +1251,20 @@ static void RunTick(TickState& t) { // block below freezes the real cursor (1px ClipCursor) and roams a raw-driven look point. bool lockDown = comboHeld(t.cfg.cursorLockVk, t.cfg.cursorLockMods); if (lockDown && !t.lockKeyWasDown) { - if (t.model->supportsInspect()) { - // Snapshot cursor visibility at the toggle edge, BEFORE this tick's active block hides it, - // together with whether WE are already hiding it. A not-showing cursor that we did not - // hide is the mouselook-gameplay tell for game-inspect (issue #144) - true at 1x and, in - // a transform FOLLOW session, true while zoomed as well. Both are read here so the pair - // describes the same instant. - CURSORINFO ci{}; ci.cbSize = sizeof(ci); - t.inspectCursorWasShowing = GetCursorInfo(&ci) ? (ci.flags & CURSOR_SHOWING) != 0 : true; - t.inspectMagHidCursor = t.cursorHiddenByUs; - t.cursorLock.toggle(); - } else { - // Magnify model: Windows Magnifier owns the view and cursor; no freeze+reticle exists. - wind::Log(wind::LogLevel::Info, "inspect", "Inspect not available in the magnify model"); - } + // Snapshot cursor visibility at the toggle edge, BEFORE this tick's active block hides it, + // together with whether WE are already hiding it. A not-showing cursor that we did not + // hide is the mouselook-gameplay tell for game-inspect (issue #144) - true at 1x and, in + // a transform FOLLOW session, true while zoomed as well. Both are read here so the pair + // describes the same instant. + CURSORINFO ci{}; ci.cbSize = sizeof(ci); + t.inspectCursorWasShowing = GetCursorInfo(&ci) ? (ci.flags & CURSOR_SHOWING) != 0 : true; + t.inspectMagHidCursor = t.cursorHiddenByUs; + t.cursorLock.toggle(); } t.lockKeyWasDown = lockDown; // Tell the mouse hook whether Inspect is on (so it swallows real clicks and routes them to the look // point - see the commitButton drain in the active block). Published every tick (also clears on off). g_input.state().inspectActive.store(t.cursorLock.locked(), std::memory_order_relaxed); - // Magnify model drives its own zoom natively (Windows Magnifier, wheel notches): feed it the - // held direction and bypass the ENTIRE level pipeline below - the ZoomController stays at 1x, - // the overlay never activates, quick zoom / recenter / mapper never run. (The side-button - // diagnostics block at the bottom is skipped too; the magnify category logs direction edges.) - if (t.model->selfDrivenZoom()) { - int rdx, rdy; g_input.drainRaw(rdx, rdy); // keep the raw accumulator drained - const int nativeDir = (inHeld ? 1 : 0) - (outHeld ? 1 : 0); - t.model->nativeZoomTick(nativeDir, t.cfg); - t.model->nativeWheelNotches(wheelSteps); // every wheel notch becomes one Magnifier notch (#285) - t.prevInHeld = inHeld; t.prevOutHeld = outHeld; - t.prevLvl = 1.0; t.prevActive = false; t.prevInspect = false; - return; - } // Hide-cursor hotkey is registered via RegisterHotKey (WndProc WM_HOTKEY toggles cursorHidden); // this both suppresses the key from reaching other apps and gives rising-edge semantics for // free (MOD_NOREPEAT). No polled check needed here. @@ -1320,7 +1296,6 @@ static void RunTick(TickState& t) { const std::string& mdl = t.cfg.model; ts_.engine = mdl == "transform" ? wind::TrayEngine::Transform : mdl == "render" ? wind::TrayEngine::Render - : mdl == "magnify" ? wind::TrayEngine::System : wind::TrayEngine::Advanced; ts_.panning = lvl > 1.001; wind::PublishTrayStatus(g_trayBlock, ts_); @@ -1438,7 +1413,7 @@ static void RunTick(TickState& t) { 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 - // Warp-lock seeding (issue #221 round 3, Max: any motion-based tell still needs a + // Warp-lock seeding (issue #221 round 3, field report: any motion-based tell still needs a // wiggle as evidence). Zooming in over a COVERING app whose cursor is already // hidden by the APP is mouselook with near-certainty (the game-inspect tell, valid // at this instant because this session has hidden nothing yet) - start LOCKED so @@ -2575,7 +2550,7 @@ static void RunTick(TickState& t) { s.level = lvl; auto* rmT = dynamic_cast(t.model); auto* tmT = dynamic_cast(t.model); - s.engine = rmT ? 'R' : (tmT ? 'T' : (t.model && t.model->selfDrivenZoom() ? 'M' : '-')); + s.engine = rmT ? 'R' : (tmT ? 'T' : '-'); s.mapX = t.mapper.centerX(); s.mapY = t.mapper.centerY(); s.monX = t.mon.x; s.monY = t.mon.y; s.curX = t.lastSetVirtual.x; s.curY = t.lastSetVirtual.y; @@ -2774,8 +2749,8 @@ static void RestoreInputState() { static void AtExitRestore() { RestoreInputState(); } // Crash safety net installed BEFORE the magnifier model is constructed. RenderModel hides the OS -// cursor via the process-scoped Magnification API (auto-reverts on process death), and the magnify -// model never touches the cursor, but the SPI_SETCURSORS reload is kept as a general heal for any +// cursor via the process-scoped Magnification API (auto-reverts on process death), but the +// SPI_SETCURSORS reload is kept as a general heal for any // stale cursor scheme a crashed predecessor left behind. Body mirrors render_engine.cpp's // CursorRestoreFilter (minimal, allocation-light, one-shot via InterlockedExchange, returns // EXCEPTION_CONTINUE_SEARCH so the default handler still reports the crash). RenderEngine:: @@ -2982,21 +2957,14 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // 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. - // The magnify model has no overlay of its own (Windows Magnifier owns the view), so monitor - // targeting is a documented no-op there; it just gets the primary. MonitorTarget startupMon = (cfg.model == "render" && cfg.multiMonitor != 0) ? MonitorUnderCursor() : PrimaryMonitor(); - // --- Magnifier model (render: DXGI Desktop Duplication + D3D11 overlay; magnify: drive the - // native Windows Magnifier via injected Win+Plus/Minus, the DRM-safe fallback) --- + // --- Magnifier model (render: DXGI Desktop Duplication + D3D11 overlay; transform: DWM + // fullscreen transform; hybrid: both, picked per zoom-in) --- std::unique_ptr model; // primary engine (also the hybrid's render half) std::unique_ptr model2; // hybrid only: the transform half - if (cfg.model == "magnify") { - model = std::make_unique(); - // Our injected chords must never be swallowed/tracked by our own keyboard hook - // (NumPad +/- are bindable zoom keys; see InputRouter::setIgnoreInjectedKeys). - g_input.setIgnoreInjectedKeys(true); - } else if (cfg.model == "transform") { + if (cfg.model == "transform") { // Revived for issue #148: the DWM-internal fullscreen transform - zero app presents, so // it holds compositor-rate smoothness over a heavy game where every overlay present path // throttles (measured). Cursor is anchored, not centered (documented model tradeoff). @@ -3161,7 +3129,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { ts.lastMtime = ConfigMTime(iniPath); // Seed the UI-only-change fingerprint from the CURRENT ini, or the first settings write // after launch always reloads (empty fingerprint = "unknown") - the first theme flip of a - // session still collapsed the zoom (Max field report on the StripUiOnlyKeys fix). + // session still collapsed the zoom (field report on the StripUiOnlyKeys fix). ts.lastCoreIni = wind::StripUiOnlyKeys(wind::ReadTextFile(iniPath)); // Watch the directory holding the ini so config hot-reload doesn't stat magnifier.ini every // second on the render thread (see RunTick). LAST_WRITE catches in-place saves; FILE_NAME diff --git a/src/test_telemetry.h b/src/test_telemetry.h index 1543cd3c..f450793d 100644 --- a/src/test_telemetry.h +++ b/src/test_telemetry.h @@ -16,7 +16,7 @@ struct TelemetrySample { double tMs; // harness-relative timestamp (QPC ms, monotonic) double dtMs; // this tick's loop interval int active; // overlay active (zoomed or inspect) - char engine; // 'R' render, 'T' transform, 'M' magnify, '-' none/idle + char engine; // 'R' render, 'T' transform, '-' none/idle double level; // current zoom level double mapX, mapY; // mapper (lens) centre, monitor-local px int monX, monY; // monitor origin (virtual px) - converts mapX/Y to virtual diff --git a/src/tray_app/flyout_tools.h b/src/tray_app/flyout_tools.h index 369b4d14..280fa3ec 100644 --- a/src/tray_app/flyout_tools.h +++ b/src/tray_app/flyout_tools.h @@ -15,8 +15,7 @@ namespace wind { namespace Flyout { inline constexpr const char* kEngineKey = "model"; // The options, in the order and with the labels of the Settings row (ui/src/settings-schema.js: -// hybrid = Auto, render, transform). System (magnify) is not offered any more (Max, 2026-10-02); the -// core reads model=magnify as Auto. +// hybrid = Auto, render, transform). The core reads the retired model=magnify as Auto. inline constexpr int kEngineCount = 3; inline const char* EngineValue(int i) { switch (i) { case 1: return "render"; case 2: return "transform"; default: return "hybrid"; } @@ -45,7 +44,7 @@ inline bool EnginePickChanges(int current, int picked) { struct SegRect { int l = 0, t = 0, r = 0, b = 0; }; -// The toggle group (mockup v02, Max 2026-10-02): `n` segments stretched to fill [left, right) whatever +// The toggle group (mockup v02, owner decision): `n` segments stretched to fill [left, right) whatever // the count, joined by a 1 px separator (`line`) that belongs to no segment. The widths are whole // DIPs: the pixels left over after the equal split go one each to the first segments, so the group // always ends exactly at `right`. n <= 0 gives nothing. diff --git a/src/tray_ipc.h b/src/tray_ipc.h index fd47cf6c..74297086 100644 --- a/src/tray_ipc.h +++ b/src/tray_ipc.h @@ -74,7 +74,7 @@ inline TrayStatus ReadTrayStatus(const TrayShared* b) { if (!TrayBlockValid(b)) return s; s.level = b->level.load(std::memory_order_relaxed); const int32_t e = b->engine.load(std::memory_order_relaxed); - s.engine = (e >= 0 && e <= (int32_t)TrayEngine::System) ? (TrayEngine)e : TrayEngine::Advanced; + s.engine = (e >= 0 && e <= (int32_t)TrayEngine::Render) ? (TrayEngine)e : TrayEngine::Advanced; s.panning = b->panning.load(std::memory_order_relaxed) != 0; return s; } diff --git a/src/tray_status.h b/src/tray_status.h index aa5c47fe..8e38561c 100644 --- a/src/tray_status.h +++ b/src/tray_status.h @@ -9,7 +9,7 @@ namespace wind { // Which engine is actually running this session - NOT what the ini asked for. "Advanced" is the // hybrid model: it is the mode that picks per window type, which is what the name should say. -enum class TrayEngine { Advanced, Transform, Render, System }; +enum class TrayEngine { Advanced, Transform, Render }; struct TrayStatus { double level = 1.0; // 1.0 = not zoomed @@ -25,7 +25,6 @@ inline const wchar_t* EngineLabel(TrayEngine e) { switch (e) { case TrayEngine::Transform: return L"TRANSFORM"; case TrayEngine::Render: return L"RENDER"; - case TrayEngine::System: return L"SYSTEM"; default: return L"ADVANCED"; } } diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 0ea24ad0..0211a395 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -11,7 +11,7 @@ TEST_CASE("defaults when text is empty") { CHECK(c.diagnostics == 0); // Ramp step cap ships ON (issue #219): uncapped, ~15% of high-level zoom-ins stall 35-43ms // then snap 1.2-1.9 levels; capped at 2.5%/tick every measured ramp was even. 0 would - // regress the acrylic-window hitch Max reproduced. + // regress the acrylic-window hitch reproduced on the test machine. CHECK(c.txMaxStepPct == 25); } TEST_CASE("StripUiOnlyKeys drops exactly the UI-owned lines (theme toggle must not hot-reload the core)") { @@ -477,7 +477,6 @@ TEST_CASE("the first-run ini template parses to the struct defaults (issue #274) CHECK(t.engineDesktop == d.engineDesktop); CHECK(t.engineOther == d.engineOther); CHECK(t.noSwallowApps == d.noSwallowApps); - CHECK(t.magnifyStep == d.magnifyStep); CHECK(t.multiMonitor == d.multiMonitor); CHECK(t.cropCapture == d.cropCapture); CHECK(t.gpuPriority == d.gpuPriority); diff --git a/tests/test_config_model.cpp b/tests/test_config_model.cpp index 431e2aca..68804335 100644 --- a/tests/test_config_model.cpp +++ b/tests/test_config_model.cpp @@ -8,17 +8,15 @@ TEST_CASE("model defaults to hybrid (the product default, 'Auto' in the UI)") { CHECK(c.model == "hybrid"); } -TEST_CASE("model=magnify (System, no longer offered) reads as Auto") { - Config c = ParseConfig("model=magnify\n"); - CHECK(c.model == "hybrid"); +TEST_CASE("model=magnify (the retired engine) and unknown models read as hybrid") { + CHECK(ParseConfig("model=magnify\n").model == "hybrid"); + CHECK(ParseConfig("model=bogus\n").model == "hybrid"); } -TEST_CASE("magnifyStep parses and clamps to Windows' 5..400 range") { - CHECK(ParseConfig("").magnifyStep == 50); // shipped default - CHECK(ParseConfig("magnifyStep=25\n").magnifyStep == 25); - CHECK(ParseConfig("magnifyStep=1\n").magnifyStep == 5); - CHECK(ParseConfig("magnifyStep=999\n").magnifyStep == 400); - CHECK(ParseConfig("magnifyStep=-10\n").magnifyStep == 5); +TEST_CASE("an old ini with retired keys still loads") { + Config c = ParseConfig("model=magnify\nmagnifyStep=25\ntxKeepAliveMaxLevel=0\nmaxLevel=6\n"); + CHECK(c.model == "hybrid"); + CHECK(c.maxLevel == doctest::Approx(6.0)); } TEST_CASE("model=transform is a first-class model again (issue #148 revival)") { diff --git a/tests/test_engine_pick.cpp b/tests/test_engine_pick.cpp index 7e58b243..3e2af750 100644 --- a/tests/test_engine_pick.cpp +++ b/tests/test_engine_pick.cpp @@ -168,7 +168,7 @@ TEST_CASE("Netflix in a browser: protected beats transformExclude") { // THE CONFLICT THIS ORDERING EXISTS FOR. A browser is on transformExclude (dwm.exe crashed at // high zoom over Mica), and DRM video inside it is capture-protected. Both rules fire and they // disagree. 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, 2026-08-24. + // risk that the pan wall and MPO buster already mitigate. Owner decision. EnginePickInputs in; in.excluded = true; // browser in.captureProtected = true; // playing DRM content diff --git a/tests/test_flyout_tools.cpp b/tests/test_flyout_tools.cpp index eb3663c9..44e11354 100644 --- a/tests/test_flyout_tools.cpp +++ b/tests/test_flyout_tools.cpp @@ -34,7 +34,7 @@ TEST_CASE("engine: a model value maps to its option, unknown reads as Auto like CHECK(EngineIndex("hybrid") == 0); CHECK(EngineIndex("render") == 1); CHECK(EngineIndex("transform") == 2); - CHECK(EngineIndex("magnify") == 0); // System is no longer offered: reads as Auto + CHECK(EngineIndex("magnify") == 0); // the retired engine reads as Auto CHECK(EngineIndex(" transform ") == 2); CHECK(EngineIndex("") == 0); CHECK(EngineIndex("junk") == 0); diff --git a/tests/test_idle_policy.cpp b/tests/test_idle_policy.cpp index 788b1866..016455d3 100644 --- a/tests/test_idle_policy.cpp +++ b/tests/test_idle_policy.cpp @@ -10,7 +10,7 @@ TEST_CASE("a quiet 1x loop with the hooks up may sleep (#71)") { } TEST_CASE("anything in flight keeps the loop ticking (#71)") { IdleInputs in = Quiet(); in.active = true; CHECK_FALSE(IdleSleepOk(in)); - in = Quiet(); in.anyHold = true; CHECK_FALSE(IdleSleepOk(in)); // magnify model notches + in = Quiet(); in.anyHold = true; CHECK_FALSE(IdleSleepOk(in)); // a held zoom bind in = Quiet(); in.wheelPending = true; CHECK_FALSE(IdleSleepOk(in)); in = Quiet(); in.quickZoomPending = true; CHECK_FALSE(IdleSleepOk(in)); in = Quiet(); in.settling = true; CHECK_FALSE(IdleSleepOk(in)); diff --git a/tests/test_lock_detector.cpp b/tests/test_lock_detector.cpp index 32ebce9b..eed249b7 100644 --- a/tests/test_lock_detector.cpp +++ b/tests/test_lock_detector.cpp @@ -154,7 +154,7 @@ TEST_CASE("warp tell: a fast desktop pan (positions always advancing) never lock CHECK(!d.locked()); } -// Round 2 (Max: gentle mouselook needed ERRATIC motion to engage): the confinement-box tell. +// Round 2 (field report: gentle mouselook needed ERRATIC motion to engage): the confinement-box tell. // Constants: kBoxTicks=24, kBoxRawSum=400, kBoxSpanPx=30. TEST_CASE("box tell: gentle mouselook (small jiggle, streaming mickeys) locks within a window") { diff --git a/tests/test_profiles.cpp b/tests/test_profiles.cpp index 8363034f..2b29d2c3 100644 --- a/tests/test_profiles.cpp +++ b/tests/test_profiles.cpp @@ -65,10 +65,10 @@ TEST_CASE("MakeProfileText strips global keys, keeps everything else verbatim") TEST_CASE("MakeLiveText: profile keys win, globals carry over, pointer set") { const std::string oldLive = "maxLevel=8.0\nmodel=render\nprofile=Default\nonboarded=1\nuiTheme=dark\nshowAdvanced=1\n"; - const std::string prof = "maxLevel=4.0\nmodel=magnify\nzoomInVk=33\n"; + const std::string prof = "maxLevel=4.0\nmodel=transform\nzoomInVk=33\n"; auto v = ReadIniValues(MakeLiveText(prof, oldLive, "Gaming")); CHECK(v["maxLevel"] == "4.0"); - CHECK(v["model"] == "magnify"); + CHECK(v["model"] == "transform"); CHECK(v["zoomInVk"] == "33"); CHECK(v["profile"] == "Gaming"); CHECK(v["onboarded"] == "1"); diff --git a/tests/test_tray_status.cpp b/tests/test_tray_status.cpp index e7de6f48..aed25c42 100644 --- a/tests/test_tray_status.cpp +++ b/tests/test_tray_status.cpp @@ -41,7 +41,6 @@ TEST_CASE("hybrid reads Advanced, not Auto") { CHECK(std::wstring(EngineLabel(TrayEngine::Advanced)) == L"ADVANCED"); CHECK(std::wstring(EngineLabel(TrayEngine::Transform)) == L"TRANSFORM"); CHECK(std::wstring(EngineLabel(TrayEngine::Render)) == L"RENDER"); - CHECK(std::wstring(EngineLabel(TrayEngine::System)) == L"SYSTEM"); } // --- tick statistics ------------------------------------------------------------------- diff --git a/tools/pan_wake_probe.ps1 b/tools/pan_wake_probe.ps1 index f12e3473..d47bd2db 100644 --- a/tools/pan_wake_probe.ps1 +++ b/tools/pan_wake_probe.ps1 @@ -5,7 +5,7 @@ # reverses through zero velocity. Reported as more noticeable in games (DOOM), and absent under # native Windows Magnifier. # -# WHY A NEW PROBE. tools/dwm_wake_probe.ps1 and tools/mag_wake_latency.ps1 already asked a version +# WHY A NEW PROBE. Two earlier desktop probes (removed; see git history) already asked a version # of this and came back clean (DWM 6.94ms median across a 3s idle, zero missed frames; wake write # latency +0.59ms). Both drove the magnifier with SendInput ON THE DESKTOP. Neither could see a # game: a fullscreen game runs on an independent-flip / MPO plane, and the cost of waking DWM's @@ -388,8 +388,7 @@ if ($FocusExe) { # Native Windows Magnifier, for the matched comparison. Same full-screen magnification damage as # Wind, driven by the same injected hand - which an unzoomed control cannot give us. The user's -# Magnifier registry is snapshotted and put back in the finally block below; Wind keeps its own -# backup of these keys for its magnify model, so leaving them modified would poison that too. +# Magnifier registry is snapshotted and put back in the finally block below. $magBackup = $null $magKey = 'HKCU:\Software\Microsoft\ScreenMagnifier' if ($Native) { diff --git a/tools/testenv/ab_acryl.ps1 b/tools/testenv/ab_acryl.ps1 index 068fbf22..a1317aa6 100644 --- a/tools/testenv/ab_acryl.ps1 +++ b/tools/testenv/ab_acryl.ps1 @@ -4,7 +4,7 @@ # rate per 1000 ticks on the three acrylic scenarios plus the solid control, which is the number # the defect is stated in. # -# powershell -File tools\testenv\ab_acryl.ps1 -Configs "txMinOffsetPx=2","txKeepAliveMaxLevel=0","" -Rounds 3 +# powershell -File tools\testenv\ab_acryl.ps1 -Configs "txMinOffsetPx=2","txWarmMode=0","" -Rounds 3 param([string[]]$Configs = @(''), [int]$Rounds = 3) $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'lib.ps1') From 7cfb5148de004269d196190b552cebf24d4f8901 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:39:46 +0200 Subject: [PATCH 3/4] chore: delete closed probes, neutral comment wording, 0.22.2 (#353) Delete flip_breakdown, mag_wake_latency, dwm_wake_probe, mouse_wake_probe and zen_backdrop. Use neutral wording instead of the owner's first name in code comments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/lock_detector.cpp | 2 +- src/tray_app/flyout_draw.cpp | 2 +- src/tray_app/flyout_model.h | 8 +- src/tray_app/flyout_window.cpp | 2 +- src/version.h | 4 +- tools/dwm_wake_probe.ps1 | 143 --------------------------- tools/flip_breakdown.ps1 | 23 ----- tools/mag_perf_run.ps1 | 8 +- tools/mag_wake_latency.ps1 | 139 -------------------------- tools/make_icon.mjs | 2 +- tools/mouse_wake_probe.ps1 | 97 ------------------ tools/testenv/cursor_area_probe.py | 6 +- tools/testenv/cursor_flicker.py | 6 +- tools/testenv/dualcursor.ps1 | 6 +- tools/testenv/lib.ps1 | 6 +- tools/testenv/run.ps1 | 4 +- tools/testenv/shimmer_ab.ps1 | 2 +- tools/testenv/shimmer_probe.py | 2 +- tools/testenv/sweep.ps1 | 2 +- tools/zen_backdrop.ps1 | 66 ------------- ui/src/Onboarding.svelte | 2 +- ui/src/controls/Preview.svelte | 2 +- ui/src/controls/bindings.css | 2 +- ui/src/design/tokens.css | 6 +- ui/src/prefs/NewProfileDialog.svelte | 2 +- ui/src/prefs/ThemePicker.svelte | 2 +- ui/src/shell/ScrollChip.svelte | 2 +- ui/tests/prefs.spec.js | 4 +- 28 files changed, 42 insertions(+), 510 deletions(-) delete mode 100644 tools/dwm_wake_probe.ps1 delete mode 100644 tools/flip_breakdown.ps1 delete mode 100644 tools/mag_wake_latency.ps1 delete mode 100644 tools/mouse_wake_probe.ps1 delete mode 100644 tools/zen_backdrop.ps1 diff --git a/src/lock_detector.cpp b/src/lock_detector.cpp index da835a6a..c61f1edc 100644 --- a/src/lock_detector.cpp +++ b/src/lock_detector.cpp @@ -11,7 +11,7 @@ constexpr int kCursorMoved = 1; // OS cursor moved at least this many px (it tr constexpr int kAnchorTolPx = 6; // "same position" tolerance constexpr int kWarpJumpPx = 100; // a landing only counts as a warp if it came from afar constexpr int kWarpLockReturns = 4; // anchor landings before the lock engages -// Confinement-box tell (issue #221 round 2, Max: gentle mouselook needed ERRATIC motion to +// Confinement-box tell (issue #221 round 2, field report: gentle mouselook needed ERRATIC motion to // engage the anchor tell). Signature: a hand streaming plenty of mickeys while every cursor // position stays inside a tiny box - gentle warping keeps the pointer jiggling around the // recenter point. Precise desktop work never trips it: a careful hand produces proportionally diff --git a/src/tray_app/flyout_draw.cpp b/src/tray_app/flyout_draw.cpp index f6f4e9b6..6c3c9c9a 100644 --- a/src/tray_app/flyout_draw.cpp +++ b/src/tray_app/flyout_draw.cpp @@ -435,7 +435,7 @@ void Painter::Impl::drawHead(const View& v, const Geometry& g) { if (v.p.haveFps) { wchar_t b[24]; wsprintfW(b, L"%d fps", v.p.fps); - text(b, g_s.mono12b.Get(), D2D1::RectF(left, topRow, right, topRow + 28.f), th.fg, // (decorative mark removed, Max 2026-10-02) + text(b, g_s.mono12b.Get(), D2D1::RectF(left, topRow, right, topRow + 28.f), th.fg, // (decorative mark removed by owner decision) DWRITE_TEXT_ALIGNMENT_TRAILING); } // frame row: "Frame" [sparkline] "6.9 ms" diff --git a/src/tray_app/flyout_model.h b/src/tray_app/flyout_model.h index 40cccb7c..d06391d2 100644 --- a/src/tray_app/flyout_model.h +++ b/src/tray_app/flyout_model.h @@ -81,8 +81,8 @@ inline Placement PlaceFlyout(const IRect& icon, const IRect& monitor, const IRec } // Top-left of a w x h window opened at the click point, the way the old TrackPopupMenu tray menu -// was (Max, 2026-10-02: "same position as the old tray menu", including when the icon lives in the -// overflow flyout). Max's choice (2026-10-02): the window opens UP AND TO THE LEFT of the pointer, +// was (owner decision: "same position as the old tray menu", including when the icon lives in the +// overflow flyout). Owner decision: the window opens UP AND TO THE LEFT of the pointer, // its bottom-right corner on the point. It flips right only when there is no room on the left, and // down only when there is no room above, then is clamped to the monitor. Physical pixels. inline Placement PlaceAtPoint(int px, int py, const IRect& monitor, int w, int h) { @@ -115,7 +115,7 @@ inline bool IgnoreIconClick(unsigned long long nowMs, unsigned long long deactiv inline constexpr int kWidth = 300, kBorder = 1, kRadius = 10; inline constexpr int kHeadH = 96; // 18 pad + 28 big + 14 gap + 14 frame row + 22 pad inline constexpr int kRowH = 40, kQsPadY = 8, kPadX = 20, kIcon = 16, kIconGap = 12, kValueW = 48; -// The control area (mockup v02, Max 2026-10-02): ONE segmented toggle group, a full-width 32 px bar, +// The control area (mockup v02, owner decision): ONE segmented toggle group, a full-width 32 px bar, // then the engine dropdown, a full-width 32 px field, 8 px below it. 6 px above the group, 14 px // of padding under the last control. inline constexpr int kSegH = 32, kSegLine = 1, kSegRadius = 8, kCtlTop = 6, kCtlGap = 8, kQsPadBottom = 14; @@ -327,7 +327,7 @@ inline int IniInt(const IniValues& v, const char* key, int def) { // "Keep cursor centred" (key keepEdges, kept so saved tray layouts still work) is ONE segment for two // settings: it reads ON only when BOTH mouseAlign and trackAlign are 0 (centred), so a hand-edited -// mixed state reads OFF (and a click then sets both). Renamed and inverted by Max, 2026-10-02. +// mixed state reads OFF (and a click then sets both). Renamed and inverted by owner decision. inline bool ToggleOn(const std::string& key, const IniValues& ini) { if (key == "keepEdges") return IniInt(ini, "mouseAlign", 0) == 0 && IniInt(ini, "trackAlign", 0) == 0; if (key == "trackCaret") return IniInt(ini, "trackCaret", 1) != 0; diff --git a/src/tray_app/flyout_window.cpp b/src/tray_app/flyout_window.cpp index bbf7a3bf..0edd8fca 100644 --- a/src/tray_app/flyout_window.cpp +++ b/src/tray_app/flyout_window.cpp @@ -752,7 +752,7 @@ bool OpenFlyout() { DestroyWindow(s->hwnd); // WM_DESTROY releases and frees return false; } - // No tooltips: users learn the icons (Max, 2026-10-02). + // No tooltips: users learn the icons (owner decision). { BOOL on = TRUE; // Windows "Show animations in Windows": off = no animation at all if (SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION, 0, &on, 0)) s->animOn = on != FALSE; diff --git a/src/version.h b/src/version.h index bce61be2..f2718e27 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 22 -#define WIND_VER_PATCH 1 +#define WIND_VER_PATCH 2 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.22.1" +#define WIND_VERSION_STR "0.22.2" diff --git a/tools/dwm_wake_probe.ps1 b/tools/dwm_wake_probe.ps1 deleted file mode 100644 index 54d848fe..00000000 --- a/tools/dwm_wake_probe.ps1 +++ /dev/null @@ -1,143 +0,0 @@ -# Does the magnified view pay a SPIKE on the first pan after an idle? (the "not warm" feeling) -# -# transform_model.cpp claims DWM discards its magnification resources when the transform VALUE sits -# still, and pays a rebuild on the next real change - which is what the keep-alive exists to hide. -# Nothing has ever measured it. The write call itself cannot show it (0.02ms avg; the code says so -# and the txwrite log agrees), because the rebuild is DWM's ASYNCHRONOUS work: it lands as skipped -# COMPOSITION frames, not as a slow API call. -# -# So measure composition directly. DwmFlush() returns at a composition boundary, so a loop around it -# samples DWM's real cadence - ~6.9ms on a 144Hz panel - and a stall shows as one long interval. -# -# PAN - inject a move after every flush, record intervals -> the warm baseline -# IDLE - keep flushing, inject nothing, for -IdleMs -> lets DWM park (or not) -# WAKE - resume injecting, record intervals -> the spike, if it is real -# -# Run it at an idle SHORTER than the keep-alive window (700ms) and at one LONGER: the mechanism -# either shows up as a difference between the two or the feeling is something else. -# -# powershell -File tools\dwm_wake_probe.ps1 -IdleMs 300 -# powershell -File tools\dwm_wake_probe.ps1 -IdleMs 3000 -param( - [int]$IdleMs = 3000, - [int]$Trials = 8, - [int]$PanMs = 900, # warm-up pan before each idle - [int]$WakeMs = 400, # window after the idle in which a spike must appear - [int]$ZoomHoldMs = 800 # ~6x with the shipped ramp; stays under txKeepAliveMaxLevel=8 -) -$ErrorActionPreference = 'Stop' - -Add-Type -TypeDefinition @' -using System; using System.Runtime.InteropServices; using System.Threading; -using System.Collections.Generic; using System.Diagnostics; -public static class WP { - [DllImport("dwmapi.dll")] public static extern int DwmFlush(); - [DllImport("Magnification.dll")] public static extern bool MagInitialize(); - [DllImport("Magnification.dll")] public static extern bool MagUninitialize(); - [DllImport("Magnification.dll")] public static extern bool MagGetFullscreenTransform(out float l, out int x, out int y); - [DllImport("user32.dll", SetLastError=true)] public static extern uint SendInput(uint n, INPUT[] p, int cb); - [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr v); - [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i); - [StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx, dy; public uint mouseData, dwFlags, time; public IntPtr dwExtraInfo; } - [StructLayout(LayoutKind.Sequential)] public struct INPUT { public uint type; public MOUSEINPUT mi; } - static int SW, SH; - public static void Screen(int w, int h) { SW = w; SH = h; } - public static bool XBtn(bool down, uint which) { - INPUT[] i = new INPUT[1]; i[0].type = 0; i[0].mi.mouseData = which; - i[0].mi.dwFlags = down ? 0x0080u : 0x0100u; - return SendInput(1, i, Marshal.SizeOf(typeof(INPUT))) == 1; - } - public static bool MoveAbs(int x, int y) { - INPUT[] i = new INPUT[1]; i[0].type = 0; - i[0].mi.dx = (int)((x * 65535L) / (SW - 1)); - i[0].mi.dy = (int)((y * 65535L) / (SH - 1)); - i[0].mi.dwFlags = 0x0001 | 0x8000; - return SendInput(1, i, Marshal.SizeOf(typeof(INPUT))) == 1; - } - public static float Level() { float l; int x, y; MagGetFullscreenTransform(out l, out x, out y); return l; } - - // ONE managed call per trial: a PowerShell loop between flushes would inject its own latency - // into the very cadence being sampled. - public static double[] Trial(int panMs, int idleMs, int wakeMs, out double[] wake) { - List pan = new List(), wk = new List(); - int x = SW / 2, y = SH / 2, dir = 1; - Stopwatch sw = Stopwatch.StartNew(); - DwmFlush(); // align to a composition boundary first - double last = sw.Elapsed.TotalMilliseconds; - // PAN: sweep steadily so every step is a genuine new value (a 2-value alternation could dedupe). - double t0 = last; - while (sw.Elapsed.TotalMilliseconds - t0 < panMs) { - x += 3 * dir; if (x > SW - 600) dir = -1; if (x < 600) dir = 1; - MoveAbs(x, y); - DwmFlush(); - double now = sw.Elapsed.TotalMilliseconds; pan.Add(now - last); last = now; - } - // IDLE: keep flushing (stay phase-locked) but stop moving. - t0 = sw.Elapsed.TotalMilliseconds; - while (sw.Elapsed.TotalMilliseconds - t0 < idleMs) { - DwmFlush(); - last = sw.Elapsed.TotalMilliseconds; - } - // WAKE: resume the identical sweep. Any rebuild cost lands in these intervals. - t0 = sw.Elapsed.TotalMilliseconds; - while (sw.Elapsed.TotalMilliseconds - t0 < wakeMs) { - x += 3 * dir; if (x > SW - 600) dir = -1; if (x < 600) dir = 1; - MoveAbs(x, y); - DwmFlush(); - double now = sw.Elapsed.TotalMilliseconds; wk.Add(now - last); last = now; - } - wake = wk.ToArray(); - return pan.ToArray(); - } -} -'@ - -function Stat($a, $label) { - if ($a.Count -eq 0) { ' {0,-6} ' -f $label; return } - $s = $a | Sort-Object - ' {0,-6} n={1,-5} median={2,5:N2}ms p95={3,6:N2}ms max={4,7:N2}ms over15ms={5}' -f ` - $label, $s.Count, $s[[int]($s.Count*0.5)], $s[[int]($s.Count*0.95)], $s[-1], (@($s | Where-Object { $_ -gt 15 }).Count) -} - -[void][WP]::SetProcessDpiAwarenessContext([IntPtr](-4)) -$scrW = [WP]::GetSystemMetrics(0); $scrH = [WP]::GetSystemMetrics(1) -[WP]::Screen($scrW, $scrH) -if (-not (Get-Process -Name Wind -ErrorAction SilentlyContinue)) { 'Wind is not running.'; return } - -[void][WP]::MoveAbs([int]($scrW/2), [int]($scrH/2)) -Start-Sleep -Milliseconds 250 -[void][WP]::XBtn($true, 2); Start-Sleep -Milliseconds $ZoomHoldMs; [void][WP]::XBtn($false, 2) -Start-Sleep -Milliseconds 500 - -# Confirm we are actually magnified, then drop our own context so it cannot colour the measurement. -[void][WP]::MagInitialize() -$lvl = [WP]::Level() -[void][WP]::MagUninitialize() -'level={0} idle={1}ms trials={2} (keep-alive window is 700ms, gated at <=8x)' -f $lvl, $IdleMs, $Trials -if ($lvl -lt 1.05) { - 'not magnified - aborting' - [void][WP]::XBtn($true,1); Start-Sleep -Milliseconds 2500; [void][WP]::XBtn($false,1) - return -} - -$allPan = New-Object System.Collections.ArrayList -$allWake = New-Object System.Collections.ArrayList -$firstWake = New-Object System.Collections.ArrayList -for ($i = 0; $i -lt $Trials; $i++) { - $wake = $null - $pan = [WP]::Trial($PanMs, $IdleMs, $WakeMs, [ref]$wake) - # Drop each trial's first pan interval - it straddles the alignment flush. - if ($pan.Count -gt 1) { [void]$allPan.AddRange($pan[1..($pan.Count-1)]) } - [void]$allWake.AddRange($wake) - if ($wake.Count -gt 0) { - [void]$firstWake.Add(($wake[0..([Math]::Min(4, $wake.Count-1))] | Measure-Object -Maximum).Maximum) - } -} - -[void][WP]::XBtn($true, 1); Start-Sleep -Milliseconds 2500; [void][WP]::XBtn($false, 1) - -'' -'==== DWM composition intervals, level {0:N1}x, idle {1}ms ====' -f $lvl, $IdleMs -Stat $allPan 'PAN' -Stat $allWake 'WAKE' -' worst of the first 5 intervals after each idle: {0}' -f (($firstWake | ForEach-Object { '{0:N1}' -f $_ }) -join ', ') diff --git a/tools/flip_breakdown.ps1 b/tools/flip_breakdown.ps1 deleted file mode 100644 index 649bcbce..00000000 --- a/tools/flip_breakdown.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# Elevated: set flipPresent=1, relaunch Wind, measure the Wind+dwm GPU breakdown zoomed+panning. -# Tells us whether independent-flip present removes the DWM overlay-composite cost (the dominant ~5.6%). -$ini="$env:LOCALAPPDATA\Wind\magnifier.ini" -(Get-Content $ini) -replace '^flipPresent=\d','flipPresent=1' | Set-Content $ini -Get-Process Wind -EA SilentlyContinue | Stop-Process -Force; Start-Sleep 1 -Start-Process "C:\Program Files\Wind\Wind.exe"; Start-Sleep 3 -Add-Type @" -using System;using System.Runtime.InteropServices; -public class FB{ [DllImport("user32.dll")] public static extern void mouse_event(uint f,int dx,int dy,uint d,UIntPtr e); } -"@ -$wpid=(Get-Process Wind -EA SilentlyContinue|Select-Object -First 1 -Exp Id) -$dpid=(Get-Process dwm -EA SilentlyContinue|Select-Object -First 1 -Exp Id) -function PidGpu($p){ if(-not $p){return 0}; $s=(Get-Counter "\GPU Engine(pid_${p}*)\Utilization Percentage" -EA SilentlyContinue).CounterSamples; if(-not $s){return 0}; [math]::Round((($s|Measure-Object -Property CookedValue -Sum).Sum),1) } -function Nudge($dx,$dy){ [FB]::mouse_event(0x0001,$dx,$dy,0,[UIntPtr]::Zero) } -$out="$env:TEMP\wind_gpu_breakdown.txt" -[FB]::mouse_event(0x0080,0,0,0x0002,[UIntPtr]::Zero); Start-Sleep -Milliseconds 2500 -$w=0;$d=0;$n=0;$t=Get-Date;$dir=1 -while(((Get-Date)-$t).TotalSeconds -lt 12){ for($s=0;$s -lt 8;$s++){ Nudge (45*$dir) (16*$dir); Start-Sleep -Milliseconds 6 }; $dir=-$dir; $w+=PidGpu $wpid; $d+=PidGpu $dpid; $n++ } -[FB]::mouse_event(0x0100,0,0,0x0002,[UIntPtr]::Zero) -Add-Content $out ("flipPresent=1 zoomed+panning: Wind {0}% dwm {1}% (samples {2})" -f [math]::Round($w/$n,1),[math]::Round($d/$n,1),$n) -# restore -(Get-Content $ini) -replace '^flipPresent=\d','flipPresent=0' | Set-Content $ini -Get-Content $out diff --git a/tools/mag_perf_run.ps1 b/tools/mag_perf_run.ps1 index 21c10d01..ae06b034 100644 --- a/tools/mag_perf_run.ps1 +++ b/tools/mag_perf_run.ps1 @@ -16,7 +16,7 @@ param( [ValidateSet('wind','native')] [string]$Driver = 'wind', [ValidateSet('pan','ramp','cycle','rezoom','zigzag')] [string]$Mode = 'pan', # ramp: zoom in/out; cycle: # focus-swap repro; rezoom: session-start bounce - # repro; zigzag: Max's protocol - start at the + # repro; zigzag: the field protocol - start at the # BOTTOM, zoom in, zig-zag climb to the TOP # (both pan axes at once), zoom out [int]$ZigClimb = 2, # zigzag: upward mickeys per step @@ -182,7 +182,7 @@ public static class PF { // sits on a plateau then jumps - that is a perceived hitch no flush metric can see. public static double LastRampMs; // Also counts BACKWARD level motion during an inward ramp (backSteps + total backward level - // travel): the session-start bounce Max reported is the level briefly zooming OUT mid-ramp-in, + // travel): the session-start bounce from the field report is the level briefly zooming OUT mid-ramp-in, // which plateau/jump stats are blind to. public static string WatchRamp(double target, double timeoutS) { var t = System.Diagnostics.Stopwatch.StartNew(); @@ -409,7 +409,7 @@ try { } if ($Mode -eq 'zigzag') { - # Max's zig-zag protocol: focus-swap, cursor to the BOTTOM of the (maximized) target, zoom + # The field zig-zag protocol: focus-swap, cursor to the BOTTOM of the (maximized) target, zoom # to level, zig-zag climb to the TOP (both pan axes), zoom out. Resources sampled across the # whole loop; per-phase compositor gaps + offset cadence + both-axis cursor deviation. [PF]::StartFlushForever() @@ -484,7 +484,7 @@ try { } if ($Mode -eq 'cycle') { - # Max's repro (issue #219): swap focus to another maximized app and back, THEN zoom - the + # The field repro (issue #219): swap focus to another maximized app and back, THEN zoom - the # hitch lives mostly in the zoom-in. Per-phase compositor gaps via the windowed flush stats. [PF]::StartFlushForever() $shell2 = New-Object -ComObject WScript.Shell diff --git a/tools/mag_wake_latency.ps1 b/tools/mag_wake_latency.ps1 deleted file mode 100644 index 2dbe94df..00000000 --- a/tools/mag_wake_latency.ps1 +++ /dev/null @@ -1,139 +0,0 @@ -# Is the FIRST pan after an idle slower to reach the screen than a pan already in progress? -# -# The "feels like the magnifier has to start up" report. dwm_wake_probe.ps1 ruled out the -# compositor: DWM held a rock-steady 6.94ms cadence across a 3s idle at 10.6x, max 8.55ms, zero -# missed frames. That leaves Wind's own path - so measure it end to end. -# -# WARM: inject moves at roughly the tick rate for a while, then time one more move to the moment -# MagGetFullscreenTransform reports a new offset. -# COLD: idle for -IdleMs (no injection at all), then time exactly the same thing. -# -# Same measurement both times, so any difference is the wake cost and nothing else. Sleep is not -# usable for the warm cadence (Start-Sleep -Milliseconds 7 really sleeps ~15.6ms), so the pacing -# spins on QPC. -# -# powershell -File tools\mag_wake_latency.ps1 -IdleMs 3000 -param( - [int]$IdleMs = 3000, - [int]$Trials = 25, - [int]$WarmMoves = 60, # moves injected before each warm sample - [int]$ZoomHoldMs = 800 -) -$ErrorActionPreference = 'Stop' - -Add-Type -TypeDefinition @' -using System; using System.Runtime.InteropServices; using System.Threading; using System.Diagnostics; -public static class WL { - [DllImport("Magnification.dll")] public static extern bool MagInitialize(); - [DllImport("Magnification.dll")] public static extern bool MagUninitialize(); - [DllImport("Magnification.dll")] public static extern bool MagGetFullscreenTransform(out float l, out int x, out int y); - [DllImport("user32.dll", SetLastError=true)] public static extern uint SendInput(uint n, INPUT[] p, int cb); - [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr v); - [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i); - [StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx, dy; public uint mouseData, dwFlags, time; public IntPtr dwExtraInfo; } - [StructLayout(LayoutKind.Sequential)] public struct INPUT { public uint type; public MOUSEINPUT mi; } - static int SW, SH; - public static void Screen(int w, int h) { SW = w; SH = h; } - public static bool XBtn(bool down, uint which) { - INPUT[] i = new INPUT[1]; i[0].type = 0; i[0].mi.mouseData = which; - i[0].mi.dwFlags = down ? 0x0080u : 0x0100u; - return SendInput(1, i, Marshal.SizeOf(typeof(INPUT))) == 1; - } - public static bool MoveAbs(int x, int y) { - INPUT[] i = new INPUT[1]; i[0].type = 0; - i[0].mi.dx = (int)((x * 65535L) / (SW - 1)); - i[0].mi.dy = (int)((y * 65535L) / (SH - 1)); - i[0].mi.dwFlags = 0x0001 | 0x8000; - return SendInput(1, i, Marshal.SizeOf(typeof(INPUT))) == 1; - } - public static float Level() { float l; int x, y; MagGetFullscreenTransform(out l, out x, out y); return l; } - public static bool Init() { return MagInitialize(); } - public static void Fini() { MagUninitialize(); } - - static void SpinMs(Stopwatch sw, double ms) { - double t0 = sw.Elapsed.TotalMilliseconds; - while (sw.Elapsed.TotalMilliseconds - t0 < ms) Thread.SpinWait(200); - } - - // Inject one move and time it to the transform write that answers it. - static double TimeOne(int x, int y, double timeoutMs) { - float l; int ox, oy; - MagGetFullscreenTransform(out l, out ox, out oy); - Stopwatch sw = Stopwatch.StartNew(); - MoveAbs(x, y); - while (sw.Elapsed.TotalMilliseconds < timeoutMs) { - float l2; int nx, ny; - if (MagGetFullscreenTransform(out l2, out nx, out ny) && (nx != ox || ny != oy)) - return sw.Elapsed.TotalMilliseconds; - Thread.SpinWait(80); - } - return -1; - } - - // One trial: a warm sample taken mid-pan, then a cold sample taken after a true idle. - // Returns {warmMs, coldMs}. - public static double[] Trial(int warmMoves, int idleMs, int startX, int y, double tickMs) { - Stopwatch sw = Stopwatch.StartNew(); - int x = startX; - for (int i = 0; i < warmMoves; i++) { x += 3; MoveAbs(x, y); SpinMs(sw, tickMs); } - x += 3; - double warm = TimeOne(x, y, 500.0); - // True idle: nothing injected. Sleep is fine here, the interval is long. - Thread.Sleep(idleMs); - x += 3; - double cold = TimeOne(x, y, 500.0); - return new double[] { warm, cold }; - } -} -'@ - -function Stat($a, $label) { - $v = @($a | Where-Object { $_ -ge 0 }) - if ($v.Count -eq 0) { ' {0,-5} ' -f $label; return } - $s = $v | Sort-Object - ' {0,-5} n={1,-4} median={2,6:N2}ms mean={3,6:N2}ms p95={4,7:N2}ms max={5,7:N2}ms' -f ` - $label, $s.Count, $s[[int]($s.Count*0.5)], ($v | Measure-Object -Average).Average, $s[[int]($s.Count*0.95)], $s[-1] -} - -[void][WL]::SetProcessDpiAwarenessContext([IntPtr](-4)) -$scrW = [WL]::GetSystemMetrics(0); $scrH = [WL]::GetSystemMetrics(1) -[WL]::Screen($scrW, $scrH) -if (-not (Get-Process -Name Wind -ErrorAction SilentlyContinue)) { 'Wind is not running.'; return } - -[void][WL]::MoveAbs([int]($scrW/2), [int]($scrH/2)) -Start-Sleep -Milliseconds 250 -[void][WL]::XBtn($true, 2); Start-Sleep -Milliseconds $ZoomHoldMs; [void][WL]::XBtn($false, 2) -Start-Sleep -Milliseconds 500 - -[void][WL]::Init() -$lvl = [WL]::Level() -'level={0} idle={1}ms trials={2}' -f $lvl, $IdleMs, $Trials -if ($lvl -lt 1.05) { - 'not magnified - aborting' - [void][WL]::Fini(); [void][WL]::XBtn($true,1); Start-Sleep -Milliseconds 2500; [void][WL]::XBtn($false,1) - return -} - -$warm = New-Object System.Collections.ArrayList -$cold = New-Object System.Collections.ArrayList -$startX = 700 -for ($i = 0; $i -lt $Trials; $i++) { - # March the sweep across the screen and fold back, so no trial repeats the same pixels. - if ($startX -gt $scrW - 900) { $startX = 700 } - $r = [WL]::Trial($WarmMoves, $IdleMs, $startX, [int]($scrH/2), 7.0) - [void]$warm.Add($r[0]); [void]$cold.Add($r[1]) - $startX += 250 -} -[void][WL]::Fini() -[void][WL]::XBtn($true, 1); Start-Sleep -Milliseconds 2500; [void][WL]::XBtn($false, 1) - -'' -'==== move -> transform write, level {0:N1}x, idle {1}ms ====' -f $lvl, $IdleMs -Stat $warm 'WARM' -Stat $cold 'COLD' -$w = @($warm | Where-Object { $_ -ge 0 }); $c = @($cold | Where-Object { $_ -ge 0 }) -if ($w.Count -and $c.Count) { - ' cold - warm (median): {0:N2}ms' -f ` - ((($c | Sort-Object)[[int]($c.Count*0.5)]) - (($w | Sort-Object)[[int]($w.Count*0.5)])) -} -' cold samples: {0}' -f (($cold | ForEach-Object { '{0:N1}' -f $_ }) -join ', ') diff --git a/tools/make_icon.mjs b/tools/make_icon.mjs index a63455cf..7990dd54 100644 --- a/tools/make_icon.mjs +++ b/tools/make_icon.mjs @@ -23,7 +23,7 @@ const { chromium } = require(resolve(root, 'ui', 'node_modules', 'playwright')); // Every size the shell asks for between 100% and 300% scaling (small icon 16 x scale, taskbar 24 x scale, // large icon 32 x scale), so Windows never resamples a neighbour frame: a 225% taskbar wants 54 px, the tray -// 36 px, and scaling 64 or 40 down made both soft (Max 2026-10-03, #332). +// 36 px, and scaling 64 or 40 down made both soft (owner decision, #332). const SIZES = [256, 128, 96, 80, 72, 64, 60, 56, 54, 48, 42, 40, 36, 32, 30, 28, 24, 20, 16]; // Taskbar variant: black badge + a solid #202020 edge outline (see the SVG header). The outline // is re-drawn per size at exactly 1 physical px, the thinnest line that never vanishes. The diff --git a/tools/mouse_wake_probe.ps1 b/tools/mouse_wake_probe.ps1 deleted file mode 100644 index 4e20367e..00000000 --- a/tools/mouse_wake_probe.ps1 +++ /dev/null @@ -1,97 +0,0 @@ -# Does the MOUSE itself report slowly for the first moments after it has been idle? -# -# The "feels like the magnifier has to start up when I idle and then pan" report. Two probes ruled -# out the software path at ~11x zoom: -# dwm_wake_probe.ps1 DWM composition across a 3s idle: median 6.94ms, max 8.55ms, zero missed -# frames. The compositor never stalls. -# mag_wake_latency.ps1 move -> transform write: warm 3.81ms median, cold 4.40ms. A 0.59ms -# difference, a tenth of a frame. -# Both drive the magnifier with SendInput, which enters the stack ABOVE the device - so neither can -# see anything the mouse hardware does. A wireless mouse that drops its report rate while idle would -# be invisible to them and would look exactly like this: the first fraction of a second of a pan -# arrives in coarse steps, magnification multiplies the step size, and it reads as a hitch. -# -# Method: spin-poll GetCursorPos and timestamp every CHANGE. That samples the arrival of real -# reports without any raw-input plumbing. A 1000Hz mouse gives ~1ms intervals; a mouse waking at -# 125Hz gives ~8ms. The difference does not need precision to see. -# -# HOW TO RUN IT - this one needs a human hand: -# 1. start it -# 2. let go of the mouse for ~4 seconds -# 3. pan smoothly for ~2 seconds -# 4. repeat 3 or 4 times until it stops -# Zoom state does not matter; this measures the device, not Wind. -param( - [int]$Seconds = 30, - [int]$IdleGapMs = 400, # a gap this long counts as "the mouse was idle" - [int]$AfterN = 15 # intervals to show from each wake -) -$ErrorActionPreference = 'Stop' - -Add-Type -TypeDefinition @' -using System; using System.Runtime.InteropServices; using System.Threading; -using System.Collections.Generic; using System.Diagnostics; -public static class MW { - [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p); - [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr v); - [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; } - // Timestamps of cursor CHANGES, in ms from start. One managed call for the whole run so the - // sampling loop is never interrupted by the host. - public static double[] Sample(int seconds) { - List t = new List(200000); - POINT last; GetCursorPos(out last); - Stopwatch sw = Stopwatch.StartNew(); - while (sw.Elapsed.TotalSeconds < seconds) { - POINT p; - if (GetCursorPos(out p) && (p.X != last.X || p.Y != last.Y)) { - t.Add(sw.Elapsed.TotalMilliseconds); - last = p; - } - Thread.SpinWait(40); - } - return t.ToArray(); - } -} -'@ - -[void][MW]::SetProcessDpiAwarenessContext([IntPtr](-4)) -'' -'Sampling for {0}s. Let go of the mouse for ~4s, then pan for ~2s. Repeat a few times.' -f $Seconds -'' -$t = [MW]::Sample($Seconds) -if ($t.Count -lt 50) { 'Barely any movement recorded - was the mouse moved at all?'; return } - -$iv = for ($i = 1; $i -lt $t.Count; $i++) { $t[$i] - $t[$i-1] } -$moving = @($iv | Where-Object { $_ -lt 50 }) # intervals within a continuous movement -$ms = $moving | Sort-Object -'samples={0} continuous-movement intervals: median={1:N2}ms p95={2:N2}ms (=> ~{3:N0}Hz steady state)' -f ` - $t.Count, $ms[[int]($ms.Count*0.5)], $ms[[int]($ms.Count*0.95)], (1000.0 / $ms[[int]($ms.Count*0.5)]) -'' - -# Always show the gap structure, so a run that found no long pause still says something. A hand -# resting on a high-DPI sensor never goes fully quiet, so do not assume a clean multi-second gap. -$gaps = @($iv | Where-Object { $_ -ge 100 } | Sort-Object -Descending) -'pauses >=100ms: {0} largest: {1}' -f $gaps.Count, ` - (($gaps | Select-Object -First 10 | ForEach-Object { '{0:N0}' -f $_ }) -join ', ') -'' - -$wakes = 0 -for ($i = 1; $i -lt $iv.Count; $i++) { - if ($iv[$i-1] -lt $IdleGapMs) { continue } - $wakes++ - $n = [Math]::Min($AfterN, $iv.Count - $i) - $after = $iv[$i..($i+$n-1)] - $sorted = $after | Sort-Object - 'wake {0}: idle {1,6:N0}ms, then median={2,5:N2}ms max={3,6:N2}ms -> {4}' -f ` - $wakes, $iv[$i-1], $sorted[[int]($sorted.Count/2)], $sorted[-1], ` - (($after | ForEach-Object { '{0:N1}' -f $_ }) -join ' ') -} -if ($wakes -eq 0) { - 'No pause reached {0}ms, so there is no wake to look at. Either the run had no real idle, or' -f $IdleGapMs - 'something keeps nudging the cursor (a virtual-mouse driver, or sensor drift under a resting hand).' -} else { - '' - 'Read it like this: if the intervals right after a wake are several times the steady-state median' - '({0:N2}ms), the mouse is waking up and Wind is only magnifying the result. If they match it is' -f $ms[[int]($ms.Count*0.5)] - 'not the device, and the cause is somewhere else.' -} diff --git a/tools/testenv/cursor_area_probe.py b/tools/testenv/cursor_area_probe.py index 3321cee2..14351a8b 100644 --- a/tools/testenv/cursor_area_probe.py +++ b/tools/testenv/cursor_area_probe.py @@ -1,18 +1,18 @@ -# CURSOR OPTICAL PROBE (issue #229, from Max's black-backdrop idea). +# CURSOR OPTICAL PROBE (issue #229, from the owner's black-backdrop idea). # # Over a solid BLACK backdrop the only bright thing on screen is the cursor, so a brightness # threshold isolates it and we can measure two things per frame: # # area - the count of bright pixels. Catches a genuine SECOND cursor drawn at the same time, # which adds pixels even when the two blobs partly overlap. -# centroid - where those pixels are. Catches the artifact Max actually describes: the cursor +# centroid - where those pixels are. Catches the artifact the field report describes: the cursor # FLICKERING between its centred position and a lagging one, fast enough to read as # two cursors. Only one is drawn per frame, so the area never moves - which is exactly # what the first version measured (1.02 flat) and why it needed this second channel. # # The flicker signature is an oscillation on top of smooth motion, so the analyzer works on the # SECOND difference of the centroid: a hand moving smoothly has a small one, a position alternating -# between two places has a large one that reverses sign every frame. Max reports the effect is +# between two places has a large one that reverses sign every frame. The field report says the effect is # inertia-based - faster movement, more lag - so the driver sweeps pan speed and the relationship # between speed and oscillation is itself part of the evidence. # diff --git a/tools/testenv/cursor_flicker.py b/tools/testenv/cursor_flicker.py index 423d0c29..f071d271 100644 --- a/tools/testenv/cursor_flicker.py +++ b/tools/testenv/cursor_flicker.py @@ -1,6 +1,6 @@ # Analyzer for the cursor optical probe (issue #229): does the cursor OSCILLATE while panning? # -# Max's description of the artifact: the cursor lags the hand, the lag grows with speed (inertia), +# The field report's description of the artifact: the cursor lags the hand, the lag grows with speed (inertia), # and it flickers between the centred and lagging positions fast enough to look like two cursors. # One cursor is drawn per frame, so area is flat - what moves is position. # @@ -68,7 +68,7 @@ def run(tt, xx, yy): # acceleration lasting several frames. The first version of this metric scored that as # oscillation - tens of pixels, rising with speed exactly like a real inertia artifact - # and therefore ranked a welded cursor best simply because a welded cursor hardly moves on - # screen. Max saw through it from the field: the build it condemned looks clean to the eye. + # screen. Field testing saw through it: the build it condemned looks clean to the eye. # Smooth motion with reversals must read ~0 no matter how fast it is. def reversing(n, speed, period=40): t, x, y = [], [], [] @@ -147,7 +147,7 @@ def alternating(sx, sy): speed that is tens of pixels, which is what the first version of this metric reported as "oscillation". It scaled with speed exactly as a real inertia artifact would, and it ranked a welded cursor best for the uninteresting reason that a welded cursor barely moves on screen. - Max caught it from the field: the build it condemned does not wobble to the eye. + Field testing caught it: the build it condemned does not wobble to the eye. A flicker alternates every frame; acceleration holds its sign across many. Taking the smaller of two consecutive residuals ONLY where they have opposite signs isolates the first and diff --git a/tools/testenv/dualcursor.ps1 b/tools/testenv/dualcursor.ps1 index 70ee2372..2b3fb725 100644 --- a/tools/testenv/dualcursor.ps1 +++ b/tools/testenv/dualcursor.ps1 @@ -1,8 +1,8 @@ -# CURSOR FLICKER / DUAL-CURSOR TEST (issue #229, from Max's black-backdrop idea). Black backdrop, +# CURSOR FLICKER / DUAL-CURSOR TEST (issue #229, from the owner's black-backdrop idea). Black backdrop, # zoom in, hold still to establish a baseline, then pan at three speeds while capturing. # # Two artifacts, two channels. A genuine SECOND cursor drawn at the same time adds bright pixels, -# so it shows in AREA. The thing Max actually describes - the cursor lagging the hand and flicking +# so it shows in AREA. The thing the field report describes - the cursor lagging the hand and flicking # between the lagged and centred positions fast enough to look doubled - draws only one cursor per # frame, so area stays flat and what moves is POSITION. The first version measured area alone and # read 1.02 on every configuration including the known-bad ones, which is why position was added. @@ -65,7 +65,7 @@ for ($i = 1; $i -le $Rounds; $i++) { foreach ($spec in $Configs) { $label = if ($spec -eq '') { '(default)' } else { $spec } Use-Config $spec - # SPEED SWEEP. Max reports the lag is inertia-based, so the artifact should grow with hand + # SPEED SWEEP. The field report says the lag is inertia-based, so the artifact should grow with hand # speed - a run at one speed cannot show that, and the relationship is the evidence. foreach ($sp in @(4, 10, 24)) { Reset-Zoom diff --git a/tools/testenv/lib.ps1 b/tools/testenv/lib.ps1 index 6ce44cca..73da81b1 100644 --- a/tools/testenv/lib.ps1 +++ b/tools/testenv/lib.ps1 @@ -1,7 +1,7 @@ # Proving-ground shared library (issue #225). Dot-sourced by run.ps1. # Interop + protocol primitives + telemetry analysis. PS 5.1 compatible. # -# Sound contract (Max's): exactly TWO tones exist in the whole environment - +# Sound contract (owner decision): exactly TWO tones exist in the whole environment - # start (880Hz, short) when a hands-off period begins, stop (440Hz, long) when it ends. # Failures end with the same stop tone; there is no third sound. @@ -131,7 +131,7 @@ public static class TE { Thread.Sleep(650); n++; } } - // WOBBLE STROKES (issue #229, Max's design): one clean stroke per direction with a rest + // WOBBLE STROKES (issue #229, owner design): one clean stroke per direction with a rest // between, then erratic side-to-side. Rests matter as much as the strokes - the view must // come to a dead stop between them, so any residual motion is the artifact, not the input. // Single-axis strokes also make an off-axis excursion unambiguous. Deliberately short and @@ -611,7 +611,7 @@ function Analyze-Telemetry([string]$Path, [object[]]$Phases, [int]$Hz) { if ($c.Length -ge 20 -and ($c[19] -eq '1' -or $c[19] -eq '2')) { $scr = ($c[19] -eq '2') $sl = [double]$c[12] - # RAMP SHAKE (issue #229, Max's report: the high-resolution cursor shakes while + # RAMP SHAKE (issue #229, field report: the high-resolution cursor shakes while # zooming in/out). With the hand still, a centred view must hold the sprite exactly on # the screen centre at EVERY level - so any deviation during a level change is the # shake, measured in screen px. The steady-state branch below deliberately excludes diff --git a/tools/testenv/run.ps1 b/tools/testenv/run.ps1 index 72ce80f3..99e55748 100644 --- a/tools/testenv/run.ps1 +++ b/tools/testenv/run.ps1 @@ -112,8 +112,8 @@ $suites = @{ wobble = @( (S 'wobble-pan' 'noise' $false 0.42 'strokes' 0), # RAMP SHAKE: zoom cycles with the hand completely still. A centred view must hold the - # cursor on the screen centre at every level, so anything that moves is the shake Max - # reports on the high-resolution cursor - and the steady-state checks cannot see it. + # cursor on the screen centre at every level, so anything that moves is the shake the field + # report describes on the high-resolution cursor - and the steady-state checks cannot see it. (S 'wobble-ramp' 'noise' $false 0 'rezoom' 0), # The clamped case: the view pinned against an edge, where the cursor must cross the screen # itself. Field-reported as the worst wobble and invisible to every unclamped scenario. diff --git a/tools/testenv/shimmer_ab.ps1 b/tools/testenv/shimmer_ab.ps1 index 6cf01869..cf09e355 100644 --- a/tools/testenv/shimmer_ab.ps1 +++ b/tools/testenv/shimmer_ab.ps1 @@ -1,4 +1,4 @@ -# Ramp-shimmer A/B (issue #229): does a configuration reduce the cursor churn Max sees while +# Ramp-shimmer A/B (issue #229): does a configuration reduce the cursor churn seen in the field while # zooming with the high-resolution cursor? # # Protocol: blank backdrop so the only thing in the capture patch is the cursor, hand completely diff --git a/tools/testenv/shimmer_probe.py b/tools/testenv/shimmer_probe.py index f90234bb..e46769e4 100644 --- a/tools/testenv/shimmer_probe.py +++ b/tools/testenv/shimmer_probe.py @@ -1,5 +1,5 @@ # Ramp-shimmer probe (issue #229): measures how much the CURSOR's rendered pixels churn while -# the zoom level changes, which is the artifact Max reports on the high-resolution cursor. +# the zoom level changes, which is the artifact the field report describes on the high-resolution cursor. # # WHY THIS AND NOT THE GEOMETRY METRIC. The suite's ramp check says the sprite holds the screen # centre to 0.6px during a ramp, with smoothing on or off - so the shake is not placement. What diff --git a/tools/testenv/sweep.ps1 b/tools/testenv/sweep.ps1 index 7aa664f5..d5bc69eb 100644 --- a/tools/testenv/sweep.ps1 +++ b/tools/testenv/sweep.ps1 @@ -1,7 +1,7 @@ # Autonomous candidate sweep (#229). Runs a list of configurations through the gates and logs # every result, so an overnight session leaves evidence rather than recollection. # -# PROTOCOL (Max's): the cheap gate runs FIRST and a candidate that fails it is thrown out +# PROTOCOL (owner decision): the cheap gate runs FIRST and a candidate that fails it is thrown out # immediately - no candidate that wobbles, shakes, hitches or shrinks the cursor is ever carried # into the longer suites. Survivors get the iterate suite; only those that pass everything are # recorded as wins. diff --git a/tools/zen_backdrop.ps1 b/tools/zen_backdrop.ps1 deleted file mode 100644 index 209b7146..00000000 --- a/tools/zen_backdrop.ps1 +++ /dev/null @@ -1,66 +0,0 @@ -# Reads DWMWA_SYSTEMBACKDROP_TYPE (and cloaked state) for every visible top-level window of a -# process. Written because an "acrylic off" A/B was run and reported as a valid control when the -# window was in fact still ACRYLIC - the setting had not taken effect. Verify, then test. -# -# NOTE ON WHAT THIS PROVES: the attribute is what the app REQUESTED, not what DWM renders. An app -# can request a backdrop and then paint opaque content over it, in which case DWM may do no blur -# work at all. So `none` here is solid proof the backdrop is off, but `ACRYLIC` is not by itself -# proof that DWM is doing the expensive work. The DWM VRAM trace is the ground truth either way. -# -# powershell -ExecutionPolicy Bypass -File tools\zen_backdrop.ps1 # defaults to zen -# powershell -ExecutionPolicy Bypass -File tools\zen_backdrop.ps1 -Name msedge -param([string]$Name = 'zen') - -Add-Type -Namespace BD -Name W -MemberDefinition @' -[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr l); -public delegate bool EnumWindowsProc(IntPtr h, IntPtr l); -[DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid); -[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); -[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h, System.Text.StringBuilder s, int n); -[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder s, int n); -[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); -[DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr h, int attr, out int v, int size); -[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L,T,R,B; } -'@ - -function BdName($v) { - if ($v -eq 0) { return 'auto' } - if ($v -eq 1) { return 'none (OPAQUE)' } - if ($v -eq 2) { return 'MICA' } - if ($v -eq 3) { return 'ACRYLIC' } - if ($v -eq 4) { return 'MICA-ALT' } - return "?$v" -} - -$script:found = New-Object System.Collections.ArrayList -$script:pids = @((Get-Process -Name $Name -ErrorAction SilentlyContinue).Id) -if (-not $script:pids.Count) { "$Name is not running."; return } - -$cb = [BD.W+EnumWindowsProc] { - param($h, $l) - $p = 0 - [void][BD.W]::GetWindowThreadProcessId($h, [ref]$p) - if (($script:pids -contains $p) -and [BD.W]::IsWindowVisible($h)) { - $c = New-Object Text.StringBuilder 256; [void][BD.W]::GetClassName($h, $c, 256) - $t = New-Object Text.StringBuilder 256; [void][BD.W]::GetWindowTextW($h, $t, 256) - $r = New-Object BD.W+RECT; [void][BD.W]::GetWindowRect($h, [ref]$r) - $bd = 0; $hr = [BD.W]::DwmGetWindowAttribute($h, 38, [ref]$bd, 4) # DWMWA_SYSTEMBACKDROP_TYPE - $ck = 0; [void][BD.W]::DwmGetWindowAttribute($h, 14, [ref]$ck, 4) # DWMWA_CLOAKED - $ttl = $t.ToString(); if ($ttl.Length -gt 34) { $ttl = $ttl.Substring(0, 34) } - [void]$script:found.Add([pscustomobject]@{ - Pid = $p; Class = $c.ToString(); W = ($r.R - $r.L); H = ($r.B - $r.T) - Backdrop = (BdName $bd); HR = $hr; Cloaked = $ck; Title = $ttl }) - } - return $true -} -[void][BD.W]::EnumWindows($cb, [IntPtr]::Zero) - -"$Name processes: $($script:pids.Count) visible top-level windows: $($script:found.Count)" -$script:found | Format-Table -AutoSize - -$bad = @($script:found | Where-Object { $_.Backdrop -match 'MICA|ACRYLIC' }) -if ($bad.Count) { - "RESULT: still requesting a backdrop on $($bad.Count) window(s). The opaque swap has NOT taken effect." -} else { - "RESULT: no window is requesting a Mica/Acrylic backdrop. Safe to treat this as a real control run." -} diff --git a/ui/src/Onboarding.svelte b/ui/src/Onboarding.svelte index 7d9361f2..513a36cd 100644 --- a/ui/src/Onboarding.svelte +++ b/ui/src/Onboarding.svelte @@ -152,7 +152,7 @@ .wizdots i { width: 7px; height: 7px; border-radius: 50%; background: var(--track); transition: width var(--dur) var(--ease), background-color var(--dur) var(--ease); } .wizdots i.on { width: 22px; border-radius: 4px; background: var(--accent); } - .wizfoot { flex-shrink: 0; display: flex; align-items: center; gap: 10px; padding: 14px 24px; } /* no divider above the buttons (Max, 2026-10-02) */ + .wizfoot { flex-shrink: 0; display: flex; align-items: center; gap: 10px; padding: 14px 24px; } /* no divider above the buttons (owner decision) */ .skip { margin-right: auto; background: transparent; border: 0; color: var(--fg3); font-size: 12.5px; cursor: pointer; } .skip:hover { color: var(--fg); } .btn { height: 36px; padding: 0 20px; border-radius: var(--rp); border: 1px solid var(--chipb); background: transparent; color: var(--fg2); font: 600 13px var(--s); cursor: pointer; } diff --git a/ui/src/controls/Preview.svelte b/ui/src/controls/Preview.svelte index 5d3ec42a..5ab70f65 100644 --- a/ui/src/controls/Preview.svelte +++ b/ui/src/controls/Preview.svelte @@ -23,7 +23,7 @@ const visible = (r) => !r.showIf || String(values[r.showIf.key]) === String(r.showIf.eq); const set = (key, val) => { values[key] = val; calls.push([key, val]); }; const extra = { - runningModel: 'hybrid', mpoNeedsRestart: false, version: '0.22.1', + runningModel: 'hybrid', mpoNeedsRestart: false, version: '0.22.2', onRestart: () => calls.push('restart'), onAction: (a) => calls.push(['action', a]), onRepo: () => calls.push('repo'), pick: async () => 'RDR2.exe', diff --git a/ui/src/controls/bindings.css b/ui/src/controls/bindings.css index 5049b7d7..63419fc1 100644 --- a/ui/src/controls/bindings.css +++ b/ui/src/controls/bindings.css @@ -6,7 +6,7 @@ .bx .kc.ghost { border-color: transparent; color: var(--fg3); gap: 5px; cursor: pointer; } .bx .kc.ghost:hover { color: var(--fg); border-color: var(--chipb); } .bx .kc.ghost svg { width: 11px; height: 11px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; } -/* The unset pan box shows a key combination, so it keeps the outline of the other key boxes (Max 2026-10-02). */ +/* The unset pan box shows a key combination, so it keeps the outline of the other key boxes (owner decision). */ .bx .kc.ghost.pan { border-color: var(--chipb); } .bx .kc.ghost.pan:hover { border-color: var(--outline); } .bx .kc.live, .bx .kc.ghost.pan.live { color: var(--fg); border-color: var(--fill); } diff --git a/ui/src/design/tokens.css b/ui/src/design/tokens.css index 0c41911e..b807e6e2 100644 --- a/ui/src/design/tokens.css +++ b/ui/src/design/tokens.css @@ -16,7 +16,7 @@ --bg: #000; --side: #000; --line: #151515; --line2: #1b1b1b; --outline: #5c5c5c; --rowline: #0f0f0f; - --hover: #121212; --card: #080808; --chip: #0e0e0e; --chipb: #212121; /* card #080808: Max 2026-10-02 */ + --hover: #121212; --card: #080808; --chip: #0e0e0e; --chipb: #212121; /* card #080808: owner decision */ --cardb: #0e0e0e; --cshadow: none; --fg: #f2f2f2; --fg2: #d0d0d0; --fg3: #8f8f8f; --glyph: #b0b0b0; --track: #303030; --accent: #fff; --onaccent: #000; @@ -25,7 +25,7 @@ --prog: #f2f2f2; --hlDim: #2e2e2e; --hlGrey: #313131; --fill: #2fbfa5; --thumb: #f2f2f2; --ctl: #8c8c8c; - --band: #080808; --bandimg-op: .17; --bandimg-from: 50%; /* banner = option colour, aurora .17: Max 2026-10-02 */ + --band: #080808; --bandimg-op: .17; --bandimg-from: 50%; /* banner = option colour, aurora .17: owner decision */ /* Tokens the built-in themes (themes.css) set on top of the ones above. Defaults here keep the grey look. */ --sel: var(--hlGrey); --selfg: var(--fg); --onfill: #04201b; --fillline: transparent; --focus: var(--fg); @@ -48,7 +48,7 @@ } .wnd button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; } .wnd :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } -/* Scrollbars (#322, Max 2026-10-02): a small rounded chip, no track and no arrow buttons. 10 px gutter, +/* Scrollbars (#322, owner decision): a small rounded chip, no track and no arrow buttons. 10 px gutter, 6 px thumb (2 px transparent border each side), a little stronger on hover and while dragging. */ .wnd ::-webkit-scrollbar { width: 10px; height: 10px; background: transparent; } .wnd ::-webkit-scrollbar-track, .wnd ::-webkit-scrollbar-corner { background: transparent; } diff --git a/ui/src/prefs/NewProfileDialog.svelte b/ui/src/prefs/NewProfileDialog.svelte index 6b914992..0b01035d 100644 --- a/ui/src/prefs/NewProfileDialog.svelte +++ b/ui/src/prefs/NewProfileDialog.svelte @@ -1,5 +1,5 @@