Skip to content
Merged
5 changes: 3 additions & 2 deletions docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ Device-lost recovery lives in the main loop: restore the cursor, clean Inspect s
churny backstop if a transform game session was live in the last 30 s, rebuild on a 500 ms backoff.
While a transform session runs the idle render engine cannot notice a TDR itself, so the loop polls
`GetDeviceRemovedReason` (`RenderEngine::pollDeviceRemoved`). A render session that loses its device
ends at 1x and hands over to the transform engine instead of freezing the loop; picks go to
Transform while the device stays lost (`EnginePickInputs::renderLost`).
is torn down (`TeardownSession`, a 1x Inspect session included) and the view goes to the transform
engine at 1x instead of freezing the loop; the next active tick enters a fresh transform session.
Picks go to Transform while the device stays lost (`EnginePickInputs::renderLost`).

## Idle: the loop sleeps at 1x

Expand Down
22 changes: 17 additions & 5 deletions docs/architecture/03-engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,20 @@ and only then does an engine change relaunch Wind (Settings' Restart Wind button
If the transform half fails to initialize, Auto logs a warning and runs render only; every pick
site checks `t.mTransform`.

**Transform is primary-only.** Its visual offsets are monitor-local while its input transform, edge
clip and sprite add the monitor origin, so on another monitor the picture and the input mapping
would describe different rects (review 2026-10-16 #11). Every pick off the primary returns Render,
except while the render device is lost. A session moves to a non-primary monitor only when the
render engine took the retarget (`MayRetargetSession`): a render engine on another adapter, or a
transform-only process, keeps the session where it was.

## The pick

`wind::ShouldPickTransform` (`src/engine/engine_pick.h`) is header-only, has no `<windows.h>` and is
doctested. It runs at the zoom-in edge and every zoomed tick, with the same `EnginePickInputs`.
In order:

0. **A lost render device forces Transform; otherwise a non-primary monitor forces Render.**
1. **Protected content forces Transform.** `captureProtected` (the foreground or a child window
has display-affinity capture protection: Netflix, Apple TV, PlayReady) or an exe on
`renderExclude`. On Render such a window is a black rectangle, so this beats every other rule.
Expand All @@ -64,8 +72,8 @@ In order:
2. **The per-category preference applies next.** `ClassifyWindow` sorts the foreground into Game,
Acrylic, Desktop or Other, and `engineGame`, `engineAcrylic`, `engineDesktop` and `engineOther`
(each Auto, Transform or Render) choose for it. Render is honoured outright; Transform still
needs the primary monitor and an exe not on `transformExclude`.
3. **Auto rule.** Transform when the session is on the primary monitor, the exe is not on
needs an exe not on `transformExclude`.
3. **Auto rule.** Transform when the exe is not on
`transformExclude` or in `churny_apps.txt` (unless `tdrTest` is on), and either:
- **game path**: the foreground covers the monitor, is borderless and is not the shell desktop
(Win+D reads as a borderless cover, issue #172); or
Expand All @@ -78,16 +86,20 @@ In order:

```mermaid
flowchart TD
A[Zoom-in edge or foreground change while zoomed] --> D{capture-protected, renderExclude or rotated output?}
A[Zoom-in edge or foreground change while zoomed] --> L{render device lost?}
L -- yes --> T
L -- no --> M{primary monitor?}
M -- no --> R
M -- yes --> D{capture-protected, renderExclude or rotated output?}
D -- yes --> T[TRANSFORM]
D -- no --> PR{category preference}
PR -- Render --> R[RENDER]
PR -- Transform --> PX{primary and not transformExclude?}
PR -- Transform --> PX{not transformExclude?}
PX -- yes --> T
PX -- no --> R
PR -- Auto --> B{game path or desktop path?}
B -- no --> R
B -- yes --> P{primary, not excluded, not churny?}
B -- yes --> P{not excluded, not churny?}
P -- yes --> T
P -- no --> R
```
Expand Down
17 changes: 14 additions & 3 deletions docs/architecture/04-render-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,24 @@ flowchart TD
because the cursor comes from `GetCursorInfo`.
- **Fresh grabs** (zoom-in, `invalidateCapture()`) drain to the latest frame, not the first: the
first frame after recreating a duplication can be a transitional composite, which flashed the
window underneath. Bounded at ~3 ms per attempt and 100 ms in total.
window underneath. Bounded at 100 ms until the first frame is copied, then ~3 ms per attempt and
12 ms of draining (`FreshDrainContinue`, `src/render/capture_policy.h`): a 360-500 Hz panel
delivers a frame inside every wait, which once ran all 40 attempts.
- **A duplication that cannot be created** (secure desktop, recorders holding the output's
duplication slots) is retried lazily by `capture()`, 250 ms doubling to 2 s (`DuplRetry`), logging
only the 1st, 2nd, 4th... failure; the cached frame stays on screen. A zoom-in, retarget or device
rebuild resets the pace. It is not fatal at `initialize` either: only a target output missing from
the adapter is.
- **Dirty rects.** `copyChangedRegions` patches only the rects DDA reports and falls back to a full
`CopyResource` whenever a partial update is not provably safe (no previous frame, move rects,
missing metadata, out-of-range rect).
- **Crop to the view.** On a near-full repaint the copy can be cropped to the magnified view.
`cropCapture=0` by default (a desktop window switch would leave stale pixels outside the view);
`gameCrop=1` (default) crops while the foreground covers the monitor, where every pixel is dirty
again next frame.
again next frame. That stops holding once the screen settles (a page turn in a fullscreen
viewer, Win+D), so a crop leaves a debt (`CropDebt`): the next uncropped frame is copied in full,
and a screen quiet for 250 ms with the debt owed gets a fresh grab (the duplication is re-created;
its first frame is the whole desktop).
- Rotated outputs are not supported: `recreateDupl` logs them, the engine pick sends them to
Transform, and a captured surface whose size differs from the desktop is never copied (the last
good frame stays, logged once).
Expand Down Expand Up @@ -245,7 +255,8 @@ engage by default on the desktop.
- `DXGI_ERROR_DEVICE_REMOVED/RESET` latches `deviceLost()`. `recoverDeviceLost()` rebuilds through
the same `buildDeviceResources` as `initialize`, so the paths cannot drift.
- **Always restore the OS cursor.** `shutdown` and the shared crash filter (`wind::CursorCrashFilter`, `mag_host.cpp`: report first, then restore) restore
the pointer and release any `ClipCursor`. Cursor hiding goes through
the pointer and release any `ClipCursor`; on the tick thread the filter also puts an input
transform Wind enabled back to identity. Cursor hiding goes through
`MagApiAcquire`/`MagApiRelease`; see [05](05-transform-engine.md) for why the pairing must be
shared.

Expand Down
12 changes: 9 additions & 3 deletions docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,11 +362,17 @@ leaves the caret to `followCaret` (sources in `findCaret`: xterm input, Win32, U
(`src/track/java_bridge.*`). UIPI drops the JVM's handshake to a UIAccess process, so the bridge's
hidden windows get a narrow `ChangeWindowMessageFilterEx` allowance. Never poll the bridge (each
read runs on the Java app's UI thread); read only after bridge callbacks. Load only
Authenticode-signed bridge DLLs. Read the caret with `getCaretLocation`, scaled by the monitor
DPI (Java answers in its user space, device px / scale). The older character bounds
Authenticode-signed bridge DLLs, and only when every DLL the loader would take from the bridge's
folder is signed too: its import and delay-import tables are read (`PeImportNames`,
`src/track/pe_imports.h`) and walked through the folder (API sets and names with no file there
resolve from the system). Read the caret with `getCaretLocation`, scaled by the monitor
DPI (Java answers in its user space, device px / scale). Modern JDKs anchor each monitor's user
space at its device origin; older ones scale around (0,0). The two agree only on a monitor at
(0,0), so both are computed and the one inside the Java window wins (`JavaPickCaretPx`). The older character bounds
(`getAccessibleTextRect`) gave x=2 for every Swing caret and stay only as a fallback, scaled the
same way (`JavaSpanRectPx`). A bridge probe that fails backs off per process (2 s doubling to
60 s), and the retry after a failed read backs off 250 ms doubling to 4 s; a bridge event or a
60 s) and per app folder (5 s doubling to 5 min; a load failure can be transient, so a folder is
never written off), and the retry after a failed read backs off 250 ms doubling to 4 s; a bridge event or a
window switch resets it. The MSAA caret (`MsaaCaret`) skips windows Windows reports as hung and
sits out 2 s after a call over 250 ms.
- **Mouse edge mode** (`mouseAlign=1`, free-pointer sessions only): the pointer is unwelded and
Expand Down
23 changes: 18 additions & 5 deletions src/core/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1486,13 +1486,20 @@ static void RetargetSessionMonitor(TickState& t, bool zoomed, bool inspect) {
if (zoomed || inspect) {
MonitorTarget nt = t.cfg.multiMonitor ? MonitorUnderCursor() : PrimaryMonitor();
IMagnifierModel* rt = t.mRender ? t.mRender : t.model;
bool ok = SameMonitor(nt, t.mon) ? false : rt->retarget(nt);
const bool renderOk = ok;
// The transform engine magnifies the primary only (engine_pick.h): a transform-only
// process (rt is the transform) never moves a session to another monitor.
const bool ntPrimary = nt.x == 0 && nt.y == 0;
const bool rtIsRender = dynamic_cast<RenderModel*>(rt) != nullptr;
bool ok = SameMonitor(nt, t.mon) ? false
: (wind::MayRetargetSession(ntPrimary, rtIsRender) && rt->retarget(nt));
const bool renderOk = ok && rtIsRender;
const bool hadTransformHalf = t.mTransform && t.mTransform != rt;
// The transform half owns its own bounds and must follow even when the render
// half refuses (retarget returns false across adapters, where the overlay cannot
// move but the transform is still valid on the new geometry).
if (!SameMonitor(nt, t.mon) && t.mTransform && t.mTransform != rt) {
// move but the transform is still valid on the new geometry), but only onto the
// primary: on any other monitor it would magnify the wrong region (review 2026-10-16 #11).
if (!SameMonitor(nt, t.mon) && t.mTransform && t.mTransform != rt &&
wind::MayRetargetSession(ntPrimary, renderOk)) {
if (t.mTransform->retarget(nt)) ok = true;
}
// Hybrid with a render half that refused while the transform half took it: the
Expand Down Expand Up @@ -3709,7 +3716,13 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
// transform engine all ride RunTick): end the dead render session, hand the view
// to the transform engine at a clean 1x and keep ticking. Picks stay on transform
// while the device is lost (EnginePickInputs::renderLost); recovery below retries.
if (ts.prevLvl > 1.0) ts.model->setActive(false); // parks the overlay; safe when lost
// Any active render session ends here, a 1x Inspect one included (gating on the
// level left its overlay up, frozen), and the next active tick starts a fresh
// transform session through EnterSession (a held zoom bind otherwise ramped on
// without the transform's enter: review 2026-10-16 #8). Teardown hides the overlay
// by layer alpha, which is safe while the device is lost.
if (ts.prevActive) TeardownSession(ts);
ts.prevActive = false; ts.prevInspect = false;
ts.model = ts.mTransform;
ts.prevLvl = 1.0; ts.zoom = ZoomController(1.0, ts.cfg.maxLevel);
}
Expand Down
41 changes: 32 additions & 9 deletions src/engine/engine_pick.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@
// predicate in the app is unit-tested (issues #148 exclusion, #172 shell desktop) instead of
// living inline in two RunTick sites that had to stay identical by hand.
//
// Order of the rules (see ShouldPickTransform): capture-protected, renderExclude or rotated output
// -> transform always; then the per-window-category preference; then the Auto rule.
// Order of the rules (see ShouldPickTransform): a lost render device -> transform; then a non-primary
// monitor -> render; then capture-protected, renderExclude or rotated output -> transform; then the
// per-window-category preference; then the Auto rule.
//
// The transform engine magnifies the PRIMARY monitor only. Its visual offsets are monitor-local
// (ComputeMagTransform gets the mapper's source rect without the monitor origin) while its input
// transform, edge clip and sprite add the origin, so on another monitor the picture and the input
// mapping describe two different rects (review 2026-10-16 #11). Nothing in the repo pins down DWM's
// coordinate convention for MagSetFullscreenTransform off the primary, so no offset is added there:
// a non-primary session gets render (a rotated or protected secondary shows its known render limits
// instead of a wrong region), and a session only moves to a non-primary monitor when the render
// engine took the retarget (MayRetargetSession).
//
// Auto picks transform on the PRIMARY target monitor for (a) a borderless foreground that covers
// the monitor (games, F11 video: compositor-internal magnification survives a heavy game's present
Expand Down Expand Up @@ -75,23 +85,26 @@ inline bool ShouldPickTransform(const EnginePickInputs& in) {
// already mitigate. Netflix inside a browser is both at once, and this is the case that
// ordering resolves: black video every time beats an occasional crash risk.
// A lost render device is the same hard limit: it cannot draw anything, so transform it is.
if (in.renderLost || in.captureProtected || in.renderExcluded || in.rotatedOutput) return true;
// The one exception to the primary-only rule: a lost device cannot draw at all, and the
// device-lost failover hands every session to transform until recovery.
if (in.renderLost) return true;
if (!in.primaryMonitor) return false; // transform is primary-only (header note)
if (in.captureProtected || in.renderExcluded || in.rotatedOutput) return true;
// 2. An explicit user preference for this window category. Transform is still refused off the
// primary monitor (no cross-adapter transform chase) and on an excluded exe, because those
// are correctness limits rather than taste. The churny list and the input-transform check
// are deliberately NOT applied here: they only keep the AUTO pick conservative, and an
// explicit per-category Transform choice is the user accepting that trade (review
// 2026-10-09 #44).
if (in.pref == EnginePref::Render) return false;
if (in.pref == EnginePref::Transform) return in.primaryMonitor && !in.excluded;
if (in.pref == EnginePref::Transform) return !in.excluded;
// 3. Auto: the historical behaviour, unchanged.
// The GAME path: a borderless cover that is not the shell desktop (issue #172 - Win+D is
// not a game). The DESKTOP path: explicit opt-in + verified input transform; the shell
// desktop is fine there (that IS the desktop). Exclusions and the churny list veto both.
const bool game = in.coversMonitor && in.borderless && !in.shellDesktop;
const bool desktop = in.desktopTransformOptIn && in.inputTransformOk;
return (game || desktop) && in.primaryMonitor &&
!in.excluded && (in.tdrHarness || !in.churny);
return (game || desktop) && !in.excluded && (in.tdrHarness || !in.churny);
}

// Which category a foreground falls into, from signals the caller has already read. Order is
Expand All @@ -107,12 +120,22 @@ inline WindowCategory ClassifyWindow(bool coversMonitor, bool borderless, bool s

// THE ENGINE SETTING (`model`), applied live. Both engines are built at startup whatever `model`
// says, so a change takes effect on the next pick without a restart. Auto: the automatic rule above.
// A pinned engine: Transform always; Render unless the render engine cannot draw at all (device
// lost) or its overlay sits on the wrong monitor (renderStale), where showing nothing is worse.
// A pinned engine: Transform on the primary monitor (render elsewhere, see the header note); Render
// unless the render engine cannot draw at all (device lost) or its overlay sits on the wrong monitor
// (renderStale), where showing nothing is worse. renderStale only happens on the primary
// (MayRetargetSession keeps a session off a non-primary monitor the render engine refused).
inline bool PickTransformFor(const std::string& model, const EnginePickInputs& in, bool renderStale) {
if (model == "transform") return true;
if (model == "transform") return in.primaryMonitor || in.renderLost;
if (model == "render") return in.renderLost || renderStale;
return ShouldPickTransform(in) || renderStale;
}

// Whether a zoom session may move to another monitor (multiMonitor=1). The primary is always fine:
// either engine can magnify it. A non-primary monitor needs the render engine to have taken the
// retarget; a render engine on another adapter refuses it, and a transform-only process (the render
// engine failed to start) has none, so the session stays where it is.
inline bool MayRetargetSession(bool targetPrimary, bool renderTookIt) {
return targetPrimary || renderTookIt;
}

} // namespace wind
Loading
Loading