Windows fullscreen magnifier (C++17, MSVC, DXGI/D3D11, DWM). Developer docs: docs/architecture/
(start at its README). Keep them in step with the code; the code wins when they disagree.
New plans and brainstorm output are not committed. docs/specs, docs/design and docs/history
are frozen history cited from code, not kept in step with it.
- All build output goes to
build\(exes, PDBs,ui\distcopy; objects inbuild\obj\<target>\), never the repo root. A devbuild\Wind.exekeeps its ini, profiles and logs inbuild\. See 11. build.bat:Wind.exe+WindTray.exe(uiAccess=false). Wind refuses to start without UIAccess; run a plain build withWIND_ALLOW_NO_UIACCESS=1(orWIND_SELFTEST=1).build.bat test: builds and runs the doctest binary; exit 0 = pass.build.bat check: compile-only pass over Wind.exe's sources, the tray sources and WindConfig's host.build.bat uiaccess: the uiAccess=true build; works only signed and run fromC:\Program Files\Wind.build.bat config: builds the Svelte UI (ui/->ui/dist/) andWindConfig.exe.build.bat tray:WindTray.exealone. It always uses the plain manifest; never make it uiAccess.build.bat installer: NSIS setup (needswinget install NSIS.NSIS), thentools\installer_check.ps1.pwsh -File tools\release.ps1: release installer intodist\(what CI runs).- UI tests:
npx playwright testinui/(run when anything underui/changes).
- Three binaries:
Wind.exe(core, UIAccess when installed),WindConfig.exe(WebView2 settings),WindTray.exe(tray flyout, never UIAccess). Settings travel only throughmagnifier.ini. See 01. src/is grouped by area (core,engine,render,transform,color,input,cursor,track,zoom,config,tray,platform,tray_app,config_ui; map in 01). Include a Wind header by bare name ("config.h", never a path);build.bat'sWIND_INCputs every folder on the include path, and a new folder goes there (and inCORE_SRCif Wind.exe compiles it).version.h,resource.handwind.rcstay insrc/itself. See 11.- Engines behind
IMagnifierModel, chosen bymodel=(applies live: both engines are always built):hybrid(Auto, default) picksrenderortransformper session via the puresrc/engine/engine_pick.h.model=magnifyis parsed ashybrid; the magnify engine is retired, do not revive it. See 03, 04, 05. - Profiles:
profiles\<Name>.ininext to the live ini; the live ini is the session, the profile file the saved state. See 08. - Settings UI: schema-driven Svelte (
ui/src/settings-schema.js);HandleWebMessageinsrc/config_ui/main.cppis the authoritative bridge message set; extend the Playwright mocks when adding one. See 09. - Defaults live in
config.hand the template, and are copied by the schema:tests/fixtures/config_defaults.txtlinks them. After changing a default or clamp, runset WIND_UPDATE_FIXTURES=1 && build.bat test, thennpm run test:rulesinui/. See 11. - Logic the tests cover stays free of
<windows.h>. The test build compiles only the.cppfiles listed inbuild.bat :test, withWIND_TESTSdefined; a file that straddles puts its OS half under#ifndef WIND_TESTS(config.cpp). Thin Win32 I/O headers may be tested from a test file that includes<windows.h>itself (test_profiles_io.cpp). See 11.
Chapter numbers refer to docs/architecture/NN-*.md.
Magnification runtime (05)
- Never call
MagInitialize/MagUninitializedirectly; usewind::MagApiAcquire()/MagApiRelease()(src/transform/mag_host.*). Independent pairs break each other: two cursors, or transform writes returning FALSE. Keep every hold symmetric. - The COMPOSED pointer (DWM drawing it into the magnified frame) taxes every cursor change any app makes; a context alone does not (measured 2026-10-07). The transform engine builds the context + cursor lens at idle and keeps them warm, lens style ON only while zoomed; no warm-up write at launch. See 05 and 07.
- Colour filters (warmth/brightness) hold the runtime at 1x and pay that tax. Measure a cursor-toggling game with colour on before blaming anything else. See 04.
- Magnification calls are thread-affine: only the owning thread's writes take effect.
Idle and the loop (02)
- The 1x loop sleeps. Anything that must react at 1x needs a wake: an LL-hook edge calls
WakeMain()after publishing state, a window message in the wait mask, orIdleNowkeeping the loop awake. Never addQS_RAWINPUT/QS_INPUTto the mask. - Tick counts depend on the refresh rate; derive new ones through
TicksAtHz/setTickRate. - A config reload rebuilds
ZoomController; UI-only keys are stripped byStripUiOnlyKeysso they never reload. Add new UI-only keys tokUiOnlyKeys(src/config/profiles.h) andtests/fixtures/global_keys.txt(the UI'sGLOBAL_KEYSis tested against it).
Cursor (07)
- In a transform session the lock (
lockApps, tells) applies only while the pointer is hidden (LockApplies); gates readt.lockEff, nott.detector.locked(). See 07. - Native cursor (Mouse tracking = Native, both sampling modes;
src/transform/native_cursor.h): DWM draws the real pointer via Wind's cursor lens (a hiddenWC_MAGNIFIERwindow, built on the tick thread at idle) and centres the view itself (SetFullscreenMagnifierOffsetsDWMUpdated). While DWM centres, never write a same-level transform or warm pulse. Never use a public write to get the composed pointer: it blocks 200-260 ms.MagGetFullscreenTransformcannot see DWM's centring. In a native session the only window Wind draws is the Inspect crosshair. Mouse tracking = Wind (txNativeCursor=0, the DEFAULT) is the pre-#369 sprite session (07): gate native-only paths onnativeSession_. - The cursor grows with the zoom in every engine (#253). Do not restore constant size;
cursorConstantSize=1is the render-only opt-in. Test defaults on a wiped%LOCALAPPDATA%\Wind: the dev ini differs from a clean install. - The view must keep moving when a game locks the cursor: locked sessions pan from Raw Input deltas,
not
GetCursorPos. Forced locks go throught.detector.seedLock(). - The oracle baseline is measured, never assumed (
parkedLastFrame/weldedLastFrame), and never a post-present read. Never weld while a mouse button is held (ShouldDragFollow). - Click point, drawn cursor and view all come from the smoothed centre; do not move clicks to the unsmoothed target.
- A clip is a lock signal only when under 90% of the monitor (
ClipRectConfines); work-area clips are common. - Tracking never moves the pointer: detached views (
t.viewDetached); the pointer comes to the view on a mouse move. Never poll the Java Access Bridge at a fixed rate (reads follow bridge events, a window switch or a backed-off retry); load only Authenticode-signed bridge DLLs. - Shell input panels (emoji picker, Win+V) need nothing in a Native session: DWM draws its pointer above them. A Wind session shows the real pointer through the cursor lens while one is open, frozen by a 1 px clip that Wind moves with the view (#492); keep that freeze hidden from the lock tell. Never use hook-thread view writes for it.
Input (06)
- Bound keys are swallowed by LL hooks with balanced down/up; release swallowed keys on teardown.
- The keyboard hook is the authority for bound-key state; only the mouse hook skips Wind's own
injections (
kWindInjectTag), the keyboard hook counts the Alt/Win mask key. - Bind rules live in
src/input/keybind_rules.handui/src/lib/keybindRules.js, both tested againsttests/fixtures/keybind_cases.txt: change both. - LL hooks cannot block Raw Input, so bound keys still reach raw-input games. No driver-based fix.
Transform engine (05)
- Apply the level every tick and write every changed tick; do not quantize ramps or throttle writes (write-rate and step gates were field-rejected and removed).
- Keep the 2 px right/bottom clamp and the 1-texel left/top floor in
ComputeMagTransform(TDR and grey-edge classes). - MPO on + nearest sampling overflows a 16-bit driver field above ~9.3x at the far right unless the
MPO guard (
src/transform/mpo_guard.h, default on) keeps apps off planes while zoomed. Never turn the guard off with nearest on an MPO boot; walls come back only when it is off. txWarmMode/txWarmHz: every warm write is a full DWM re-render; composition-rate metrics miss the pan-start hitch. Field-verify in a game.- Publish the source-rect input transform on every change (needs UIAccess); identity or none gives hover dead zones. Judge publishes by read-back, not the return value.
- The bitmap smoothing flag is DWM-global and outlives Wind; a stale state can make a build look smooth. First suspect if dwm.exe crashes return.
Render engine (04)
- The overlay must use
WDA_EXCLUDEFROMCAPTURE, or it magnifies its own output (black feedback loop). - Never cache the SDR white level; re-read it (
refreshSdrWhite). - Click-through needs
WS_EX_LAYERED | WS_EX_TRANSPARENT. - No DirectComposition flip-model path: it tears on VRR. Blt only.
- Show and hide by layer alpha, never
SW_HIDE; park by moving, never resizing. Two park moves per session is the floor. - The reveal is gated on the present fence (and composite evidence over fullscreen apps).
- Always restore the OS cursor and release
ClipCursoron every exit path, including the crash filter. - Verify the overlay only from inside:
WIND_SELFTEST=1 build\Wind.exewriteswind_selftest.pngto its working directory (the repo root when started from there). zorderBandships 0: band 16 covers the shell but loses the cursor under the Snipping Tool. Re-test both before changing it. Never let a refused band be silent (CreateBandedWindow).
DPI and monitors
- Declare Per-Monitor-V2 DPI awareness (
Wind.manifest), or offset maths is wrong on scaled displays. multiMonitor=1retargets per zoom-in and keeps the session on one monitor; no cross-adapter chase.
Installer and paths (11, installer/README.md)
- The installer runs elevated, so HKCU and
%LOCALAPPDATA%can belong to another user: autostart in HKLM, launch Wind throughexplorer.exe, stop it by settingLocal\Wind_QuitRequestand waiting for the process, nottaskkill. - Program Files is read-only for the runtime: resolve the ini with
wind::ResolveIniPath(), logs withResolveLogDir, and keep the explicit WebView2 user-data folder. Never write next to the exe. - An unreadable ini is not a missing one (another process may be mid-replace). Read the live ini for
a read-modify-write with
wind::ReadLiveIniand stop when it fails; never write defaults over an existing file. See 08.
- Visual Studio is a prerelease channel here;
build.batcalls vswhere with-all -prerelease. - Issue, branch, PR for every change. Remote:
github.com/Maxaubert/Wind.
- Every push to
mainthat can change the binary rebuilds and republishes the installer (.github/workflows/release.yml). Bumpingsrc/version.h(the only version declaration) cuts a new release; otherwise the current release's assets are refreshed. Never hand-upload an asset. - CI signs nothing. Setup signs the UIAccess build per PC (
installer/local-sign.ps1). Never put the self-signed dev certificate in CI. alpha.yml(manual) builds any branch as pre-releasev<ver>-alpha.<sha>; it keeps the newest 5 and does not bump the version. Both workflows build through.github/actions/build-installer(version read, unit + UI test gates,release.ps1): change build steps there, not in one workflow.- Anything deployed locally that is not on
mainis one installer run from being lost. - Changes with a runtime surface are verified by deploying the signed UIAccess build. Deploy
(elevated; give it a 2 minute timeout):
$p = Start-Process pwsh -Verb RunAs -PassThru -WorkingDirectory '<repo>' -ArgumentList '-ExecutionPolicy','Bypass','-File','<repo>\tools\uiaccess_setup.ps1'; $p.WaitForExit(110000)Use an absolute-Filepath (the elevated process starts in System32) and not-Wait(it waits for leftover build processes). Checktools\uiaccess_setup.logforstatus=ValidandDONE. Then launch from a normal shell:Start-Process "C:\Program Files\Wind\Wind.exe".
- Never use em-dashes (U+2014) anywhere: code, comments, docs, commit messages, UI copy (also not
—). Use en-dashes, commas or rephrase.