Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ are refresh-rate dependent**, so every tick-tuned constant derives from the dete

Device-lost recovery lives in the main loop: restore the cursor, clean Inspect state, mark the
churny backstop if a transform game session was live in the last 30 s, rebuild on a 500 ms backoff.
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`).

## Idle: the loop sleeps at 1x

Expand Down
8 changes: 5 additions & 3 deletions docs/architecture/05-transform-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,9 @@ Defences (wall arming in `RunTick`, write clamp in `TransformModel::present`):
| On | Smooth | Lifted while the MPO ghost (`mpoBuster=1`) is shown and settled; fail-closed |
| Off | Either | None |

- The registry is read once at startup; the boot state governs until reboot, because DWM reads it
only at boot (`src/mpo_boot.h`).
- The registry is read once at startup; the recorded state governs until dwm.exe restarts, because
DWM reads it only when it starts. The record (`src/mpo_boot.h`, pure half `mpo_boot_logic.h`) is
keyed on dwm.exe's creation time, read without opening the process; OS boot time is the fallback.
- The ghost is a fullscreen alpha-1 click-through window that demotes surfaces off the overlay
plane.
- A write-site clamp backs the walls up when the session is exposed and the ghost is not settled,
Expand Down Expand Up @@ -268,7 +269,8 @@ welded cursor has hover dead zones. Identity or no publish both produce dead zon
- **It needs UIAccess.** Availability is read from the process token's `TokenUIAccess` bit at
`initialize`, with zero Magnification calls; an acquire/release probe would run an identity
write. A verified-failed publish clears `inputTransformAvailable_`, which stops Auto picking
Transform for the desktop.
Transform for the desktop. Only `ERROR_ACCESS_DENIED` is permanent; any other failure is re-armed
from the token probe when the session ends.
- `ixDecimate` (default 4) publishes every Nth changed tick during motion, and always when motion
rests. Clicks ride the real pointer and never consult the transform.

Expand Down
8 changes: 7 additions & 1 deletion src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,13 @@ Config ParseConfig(const std::string& text) {
else if (key == "trackLog") c.trackLog = std::stoi(val);
else if (key == "ixDecimate") c.ixDecimate = std::stoi(val);
else if (key == "mpoBuster") c.mpoBuster = std::stoi(val);
else if (key == "txSamplingMode") c.txSamplingMode = std::stoi(val);
else if (key == "txSamplingMode") {
// Only 0 (nearest), 1 (smooth) and -1 (leave DWM alone) are meaningful. 2..4 are
// kernel aliases of nearest that would slip past the MPO sampling guard; anything
// else folds to nearest so EffectiveSamplingMode guards it (review 2026-10-09 #23).
const int v = std::stoi(val);
c.txSamplingMode = (v == 1 || v == -1) ? v : 0;
}
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);
Expand Down
3 changes: 2 additions & 1 deletion src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ struct Config {
// 2..4 = undocumented modes the kernel accepts and round-trips. FIELD-TESTED 2026-08-13:
// all three render IDENTICALLY to nearest, i.e. they are aliases, not cheaper filters.
// Mode 1 is the only real smooth path. Do not re-test these hoping for a middle
// ground - there isn't one on this Windows build.
// ground - there isn't one on this Windows build. ParseConfig folds 2..4 (and any
// other value) to 0, so the MPO sampling guard sees them as nearest.
// -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
Expand Down
6 changes: 5 additions & 1 deletion src/engine_pick.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ struct EnginePickInputs {
// image (review 2026-10-09 M3); DWM's own magnification handles rotation. Same effect as
// captureProtected.
bool rotatedOutput = false;
// The render engine's D3D device is lost and not yet recovered (GPU TDR). Nothing can be drawn
// by it, so every pick goes to the transform engine until recovery (review 2026-10-09 #13).
bool renderLost = false;
};

inline bool ShouldPickTransform(const EnginePickInputs& in) {
Expand All @@ -65,7 +68,8 @@ inline bool ShouldPickTransform(const EnginePickInputs& in) {
// exists to avoid a RARE dwm crash at high zoom that the pan wall and the MPO buster
// 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.
if (in.captureProtected || in.renderExcluded || in.rotatedOutput) return true;
// 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;
// 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.
Expand Down
55 changes: 46 additions & 9 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,12 @@ static void RefreshFgCache(TickState& t, HWND fgw) {
// instant switch) must agree exactly - that is why the pure decision was extracted in the first
// place - and the category/preference/override plumbing is now big enough that duplicating it by
// hand would be the obvious place for the two to drift apart.
// The render engine's device is lost and not yet recovered: picks must go to the transform engine.
static bool RenderDeviceLost(const TickState& t) {
auto* rm = dynamic_cast<RenderModel*>(t.mRender);
return rm && rm->deviceLost();
}

static void FillCategoryInputs(const TickState& t, wind::EnginePickInputs& pin) {
const wind::WindowCategory cat = wind::ClassifyWindow(
pin.coversMonitor, pin.borderless, pin.shellDesktop, t.fgCacheBackdrop);
Expand Down Expand Up @@ -1509,6 +1515,7 @@ static void RunTick(TickState& t) {
pin.tdrHarness = t.cfg.tdrTest > 0;
pin.desktopTransformOptIn = t.cfg.desktopTransform != 0;
pin.inputTransformOk = tAvail && tAvail->inputTransformAvailable();
pin.renderLost = RenderDeviceLost(t);
FillCategoryInputs(t, pin);
IMagnifierModel* pick = ShouldPickTransform(pin) ? t.mTransform : t.mRender;
if (pick && pick != t.model) t.model = pick;
Expand Down Expand Up @@ -2136,6 +2143,7 @@ static void RunTick(TickState& t) {
pin.tdrHarness = t.cfg.tdrTest > 0;
pin.desktopTransformOptIn = t.cfg.desktopTransform != 0;
pin.inputTransformOk = tAvail && tAvail->inputTransformAvailable();
pin.renderLost = RenderDeviceLost(t);
FillCategoryInputs(t, pin);
IMagnifierModel* want = ShouldPickTransform(pin) ? t.mTransform : t.mRender;
// STICKY (field: the engine flapped render<->transform inside one zoom session, and
Expand Down Expand Up @@ -3267,6 +3275,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {

bool running = true;
unsigned long long nextRecoverMs = 0; // device-lost recovery backoff gate (GetTickCount64)
bool lostSeen = false; // this device loss already attributed (backstop runs once)
// The transform model does no blocking present, so it can never self-pace via Present(1,0) or
// DwmFlush the way the render model does. It must always be timer-paced (like the idle/1x path),
// or the zoomed loop spins flat out and floods MagSetFullscreenTransform, backing up DWM's
Expand All @@ -3288,16 +3297,31 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
// D3D device was removed; rebuild it on a backoff so we don't spin (the driver may take a
// moment to return). Crucially, un-hide the OS cursor first so the user is never left without
// a pointer while we are unable to draw the magnified one. Skip the normal tick this iteration.
if (auto* rm = dynamic_cast<RenderModel*>(ts.mRender); rm && rm->deviceLost()) {
auto* rm = dynamic_cast<RenderModel*>(ts.mRender);
// The render engine is idle while a transform session runs, so its own Present/Acquire
// never sees a TDR then; ask the device directly (review 2026-10-09 #12). Without this the
// loss surfaced only after the session, often past the 30 s attribution window below.
if (rm && ts.mTransform && ts.model == ts.mTransform) rm->pollDeviceRemoved();
if (rm && !rm->deviceLost()) lostSeen = false;
if (rm && rm->deviceLost()) {
// TDR backstop (issue #148): if a transform GAME session was live within the last
// 30s, this device-lost almost certainly IS the driver reset that session caused
// (e.g. an animated cursor churning invisibly to the handle poll). Remember the app
// so it never gets the transform path again - one crash ever, then render.
if (!ts.transformExe.empty() &&
GetTickCount64() - ts.lastTransformGameMs < 30000) {
MarkChurnyApp(ts.transformExe, "device-lost backstop");
ts.fgCacheHwnd = nullptr; // the game usually keeps its HWND: re-read churny (M1)
if (!lostSeen) {
lostSeen = true;
if (!ts.transformExe.empty() &&
GetTickCount64() - ts.lastTransformGameMs < 30000) {
MarkChurnyApp(ts.transformExe, "device-lost backstop");
ts.fgCacheHwnd = nullptr; // the game usually keeps its HWND: re-read churny (M1)
}
}
// The render engine owns the view (or idles at 1x) vs a transform session in flight:
// the render-side cleanup below must not touch a live transform session (its Inspect
// clip, cursor and lock state are valid).
const bool renderOwnsView = ts.model == ts.mRender;
const bool canFailOver = ts.mTransform != nullptr;
if (renderOwnsView) {
// Restore through the ACTIVE model: in a transform session the transform half (not
// the render engine) hid the cursor, and only it restores its blanker state too.
SetSystemCursorHidden(ts, ts.model, false);
Expand All @@ -3314,13 +3338,26 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
g_input.state().commitRight.exchange(0);
}
EndGameInspect(ts); // device-lost must not strand the game backgrounded
if (canFailOver) {
// A render device that stays lost must not freeze the app (hotkeys, reload, the
// 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
ts.model = ts.mTransform;
ts.prevLvl = 1.0; ts.zoom = ZoomController(1.0, ts.cfg.maxLevel);
}
}
unsigned long long now = GetTickCount64();
if (now >= nextRecoverMs) {
// A transform session in flight keeps the loop; rebuilding the device waits for 1x.
if (now >= nextRecoverMs && (renderOwnsView || ts.prevLvl <= 1.0)) {
if (!rm->recoverDeviceLost()) nextRecoverMs = now + 500; // retry in 0.5s
else { ts.prevLvl = 1.0; ts.zoom = ZoomController(1.0, ts.cfg.maxLevel); } // back to 1x, clean
else if (renderOwnsView) { ts.prevLvl = 1.0; ts.zoom = ZoomController(1.0, ts.cfg.maxLevel); } // back to 1x, clean
}
if (renderOwnsView && !canFailOver) {
Sleep(50);
continue;
}
Sleep(50);
continue;
}

// Pacing while zoomed:
Expand Down
Loading
Loading