diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index f23847b6..fe7015ab 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -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 diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 2d0514ac..62222491 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -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, @@ -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. diff --git a/src/config.cpp b/src/config.cpp index 8ebc1468..86cd37a0 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -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); diff --git a/src/config.h b/src/config.h index 46fe5ed8..a2886a64 100644 --- a/src/config.h +++ b/src/config.h @@ -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 diff --git a/src/engine_pick.h b/src/engine_pick.h index 1178b00e..24e1859b 100644 --- a/src/engine_pick.h +++ b/src/engine_pick.h @@ -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) { @@ -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. diff --git a/src/main.cpp b/src/main.cpp index 66e31876..b1531118 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(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); @@ -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; @@ -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 @@ -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 @@ -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(ts.mRender); rm && rm->deviceLost()) { + auto* rm = dynamic_cast(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); @@ -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: diff --git a/src/mpo_boot.h b/src/mpo_boot.h index 5d0ed7c8..364f6592 100644 --- a/src/mpo_boot.h +++ b/src/mpo_boot.h @@ -4,26 +4,30 @@ #include #include #include "config_path.h" +#include "mpo_boot_logic.h" -// Remembers the MPO state that was in force at the last OS BOOT, which is the only thing a -// "requires restart" prompt can honestly be compared against (issue #164). +// Remembers the MPO state that was in force when the CURRENT dwm.exe started, which is the only +// thing a "requires restart" prompt can honestly be compared against (issue #164). // -// THE BUG THIS FIXES: DWM reads HKLM\...\Dwm\OverlayTestMode once, at boot. The Settings row used -// to compare the staged value against the CURRENT REGISTRY, so putting the value back to what DWM -// already loaded still demanded a restart - and, worse, a change that really did need one looked -// identical. Comparing against the boot state gets both right: -// staged == boot -> nothing to restart for, whatever the registry says right now -// staged != boot -> a restart is genuinely required for it to take effect +// THE BUG THIS FIXES: DWM reads HKLM\...\Dwm\OverlayTestMode once, when it starts. The Settings row +// used to compare the staged value against the CURRENT REGISTRY, so putting the value back to what +// DWM already loaded still demanded a restart - and, worse, a change that really did need one looked +// identical. Comparing against the state at DWM start gets both right: +// staged == recorded -> nothing to restart for, whatever the registry says right now +// staged != recorded -> a restart is genuinely required for it to take effect +// +// The record is keyed on dwm.exe's creation time (review 2026-10-09 #26; format and the currency +// test live in mpo_boot_logic.h): a dwm.exe restart without a reboot reloads the value, so an +// uptime-keyed record outlived the state it described. When the creation time cannot be read the +// OS boot time is used, as before. // // LIMITATION, deliberately not hidden: nothing can read back what DWM actually loaded, so this is -// the earliest reading Wind managed after the current boot. If Wind is first launched long after -// boot AND the value was changed in between, the record is that later value. Callers fall back to -// the live registry when there is no record for this boot, which is the old behaviour. +// the earliest reading Wind managed after that DWM start. If Wind is first launched long after it +// AND the value was changed in between, the record is that later value. Callers fall back to the +// live registry when there is no current record, which is the old behaviour. namespace wind { -// Approximate wall-clock boot time, in seconds. Used only to tell "same boot" from "new boot", so a -// few seconds of drift between two processes does not matter; kSameBootToleranceSec absorbs it -// (GetTickCount64's treatment of sleep/hibernate is the reason the tolerance is generous). +// Approximate wall-clock boot time, in seconds. Used only as the fallback stamp. inline long long ApproxBootTimeSeconds() { FILETIME ft{}; GetSystemTimeAsFileTime(&ft); @@ -32,7 +36,61 @@ inline long long ApproxBootTimeSeconds() { const long long nowSec = static_cast(now.QuadPart / 10000000ULL); return nowSec - static_cast(GetTickCount64() / 1000ULL); } -inline constexpr long long kSameBootToleranceSec = 300; + +// Creation time (FILETIME seconds) of THIS session's dwm.exe, or 0 when it cannot be read. Read +// from NtQuerySystemInformation(SystemProcessInformation), which carries every process's start +// time, so no handle to dwm.exe is ever opened (same rule as dwm_watch.cpp). +inline long long DwmStartSeconds() { +#if defined(_WIN64) + // Leading part of SYSTEM_PROCESS_INFORMATION (x64 layout, stable since Vista). + struct Spi { + ULONG NextEntryOffset; ULONG NumberOfThreads; + LONGLONG WorkingSetPrivateSize; ULONG HardFaultCount; ULONG NumberOfThreadsHighWatermark; + ULONGLONG CycleTime; LONGLONG CreateTime; LONGLONG UserTime; LONGLONG KernelTime; + USHORT ImageNameLength; USHORT ImageNameMaxLength; PWSTR ImageNameBuffer; + LONG BasePriority; HANDLE UniqueProcessId; HANDLE InheritedFromUniqueProcessId; + ULONG HandleCount; ULONG SessionId; + }; + static_assert(offsetof(Spi, CreateTime) == 32 && offsetof(Spi, ImageNameBuffer) == 64 && + offsetof(Spi, UniqueProcessId) == 80 && offsetof(Spi, SessionId) == 100, + "SYSTEM_PROCESS_INFORMATION layout"); + using NtQsiFn = LONG (NTAPI*)(ULONG, PVOID, ULONG, PULONG); + HMODULE nt = GetModuleHandleW(L"ntdll.dll"); + auto qsi = nt ? reinterpret_cast(GetProcAddress(nt, "NtQuerySystemInformation")) : nullptr; + if (!qsi) return 0; + DWORD session = 0; + if (!ProcessIdToSessionId(GetCurrentProcessId(), &session)) return 0; + std::string buf(1u << 20, '\0'); + for (int attempt = 0; attempt < 6; ++attempt) { + ULONG need = 0; + const LONG st = qsi(5 /*SystemProcessInformation*/, &buf[0], (ULONG)buf.size(), &need); + if (st == (LONG)0xC0000004L /*STATUS_INFO_LENGTH_MISMATCH*/) { + buf.assign((size_t)need + (64u << 10), '\0'); + continue; + } + if (st < 0) return 0; + const char* p = buf.data(); + for (;;) { + const Spi* e = reinterpret_cast(p); + if (e->ImageNameBuffer && e->ImageNameLength == 7 * sizeof(wchar_t) && + _wcsnicmp(e->ImageNameBuffer, L"dwm.exe", 7) == 0 && + e->SessionId == session && e->CreateTime > 0) + return e->CreateTime / 10000000LL; + if (!e->NextEntryOffset) break; + p += e->NextEntryOffset; + } + return 0; // dwm.exe not listed for this session + } +#endif + return 0; +} + +// The stamp that identifies "the compositor running now": dwm.exe's start, else the OS boot. +inline long long CurrentMpoStamp(bool& dwmKeyed) { + if (const long long d = DwmStartSeconds(); d > 0) { dwmKeyed = true; return d; } + dwmKeyed = false; + return ApproxBootTimeSeconds(); +} // Sits beside magnifier.ini so it lands in the same writable location in both dev and the // Program Files deploy (never next to the exe when that is read-only). @@ -43,40 +101,34 @@ inline std::wstring MpoBootRecordPath() { } // Read the record. Returns false when there is none, it is unreadable, or it belongs to an earlier -// boot - in every one of those cases the caller must NOT pretend to know the boot state. +// DWM/boot - in every one of those cases the caller must NOT pretend to know the boot state. inline bool MpoStateAtBoot(bool& disabledOut) { std::ifstream f(MpoBootRecordPath()); if (!f) return false; - long long recordedBoot = 0; int disabled = -1; - std::string line; - while (std::getline(f, line)) { - std::istringstream ls(line); - std::string key; - if (!std::getline(ls, key, '=')) continue; - std::string val; std::getline(ls, val); - try { - if (key == "boot") recordedBoot = std::stoll(val); - else if (key == "mpoDisabled") disabled = std::stoi(val); - } catch (...) { return false; } // corrupt record: fall back rather than guess - } - if (disabled < 0 || recordedBoot <= 0) return false; - const long long delta = ApproxBootTimeSeconds() - recordedBoot; - if (delta < -kSameBootToleranceSec || delta > kSameBootToleranceSec) return false; // older boot - disabledOut = (disabled != 0); + std::ostringstream text; + text << f.rdbuf(); + MpoBootRecord rec; + if (!ParseMpoBootRecord(text.str(), rec)) return false; // corrupt record: fall back rather than guess + bool dwmKeyed = false; + const long long now = CurrentMpoStamp(dwmKeyed); + if (!MpoRecordIsCurrent(rec, now, dwmKeyed)) return false; // older DWM start or boot + disabledOut = (rec.disabled != 0); return true; } // Called by the CORE at startup, right where it reads OverlayTestMode. Deliberately does NOT -// overwrite an existing record from the same boot: the first reading after a boot is the closest -// thing we have to what DWM loaded, and a later Wind restart could observe a value the user changed +// overwrite an existing record for the same DWM: the first reading after it started is the closest +// thing we have to what it loaded, and a later Wind restart could observe a value the user changed // in the meantime - which is exactly the state we must not mistake for the boot state. inline void RecordMpoBootState(bool disabledNow) { bool existing = false; - if (MpoStateAtBoot(existing)) return; // already recorded for this boot + if (MpoStateAtBoot(existing)) return; // already recorded for this DWM start std::ofstream f(MpoBootRecordPath(), std::ios::trunc); if (!f) return; // best-effort; callers fall back - f << "boot=" << ApproxBootTimeSeconds() << "\n" - << "mpoDisabled=" << (disabledNow ? 1 : 0) << "\n"; + MpoBootRecord rec; + rec.stamp = CurrentMpoStamp(rec.dwmKeyed); + rec.disabled = disabledNow ? 1 : 0; + f << FormatMpoBootRecord(rec); } } // namespace wind diff --git a/src/mpo_boot_logic.h b/src/mpo_boot_logic.h new file mode 100644 index 00000000..ba56ef36 --- /dev/null +++ b/src/mpo_boot_logic.h @@ -0,0 +1,67 @@ +#pragma once +// Pure half of the MPO boot record (no ): the record format and the "is this record +// still about the compositor that is running now" decision, so both are unit-tested +// (tests/test_mpo_boot.cpp). The Win32 stamp reads live in mpo_boot.h. +// +// WHY THE STAMP IS THE DWM START, NOT THE OS BOOT (review 2026-10-09 #26): DWM reads +// OverlayTestMode when dwm.exe STARTS, and dwm.exe restarts without a reboot (crash, driver reset, +// Ctrl+Win+Shift+B storms). A record keyed on uptime survived such a restart and kept claiming the +// old state even though the new DWM had loaded the registry value as it stood then. The record is +// therefore keyed on dwm.exe's creation time; when that cannot be read the OS boot time is the +// fallback (kind "boot"), which is the previous behaviour. +#include +#include +#include + +namespace wind { + +// Two stamps of the same dwm.exe are bit-identical (its FILETIME creation time), so this only +// absorbs rounding. An approximate boot time is derived from two clocks and drifts by seconds. +inline constexpr long long kSameDwmToleranceSec = 2; +inline constexpr long long kSameBootToleranceSec = 300; + +struct MpoBootRecord { + long long stamp = 0; // seconds: dwm.exe creation time, or approximate OS boot time + bool dwmKeyed = false; // true = stamp is the dwm.exe start ("dwmStart="), false = OS boot ("boot=") + int disabled = -1; // OverlayTestMode==5 at that moment; -1 = missing +}; + +inline std::string FormatMpoBootRecord(const MpoBootRecord& r) { + std::ostringstream o; + o << (r.dwmKeyed ? "dwmStart=" : "boot=") << r.stamp << "\n" + << "mpoDisabled=" << (r.disabled > 0 ? 1 : 0) << "\n"; + return o.str(); +} + +// False for an empty, partial or corrupt record: the caller must not pretend to know the state. +inline bool ParseMpoBootRecord(const std::string& text, MpoBootRecord& out) { + MpoBootRecord r; + std::istringstream in(text); + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + const size_t eq = line.find('='); + if (eq == std::string::npos) continue; + const std::string key = line.substr(0, eq), val = line.substr(eq + 1); + char* end = nullptr; + const long long v = std::strtoll(val.c_str(), &end, 10); + if (val.empty() || end == val.c_str() || *end != 0) return false; + if (key == "dwmStart") { r.stamp = v; r.dwmKeyed = true; } + else if (key == "boot") { if (!r.dwmKeyed) r.stamp = v; } // a dwmStart line wins + else if (key == "mpoDisabled") r.disabled = (int)v; + } + if (r.disabled < 0 || r.stamp <= 0) return false; + out = r; + return true; +} + +// Does the record describe the compositor/boot that is running now? A record of the other kind +// (e.g. an old uptime-keyed file, or the dwm.exe lookup failing now) is never trusted. +inline bool MpoRecordIsCurrent(const MpoBootRecord& rec, long long nowStamp, bool nowDwmKeyed) { + if (rec.dwmKeyed != nowDwmKeyed || nowStamp <= 0) return false; + const long long tol = rec.dwmKeyed ? kSameDwmToleranceSec : kSameBootToleranceSec; + const long long d = nowStamp - rec.stamp; + return d >= -tol && d <= tol; +} + +} // namespace wind diff --git a/src/render_engine.cpp b/src/render_engine.cpp index 832b1741..a7597048 100644 --- a/src/render_engine.cpp +++ b/src/render_engine.cpp @@ -480,6 +480,12 @@ RenderEngine::~RenderEngine() { shutdown(); delete s_; s_ = nullptr; } bool RenderEngine::ready() const { return s_ && s_->ready; } bool RenderEngine::deviceLost() const { return s_ && s_->deviceLost; } +void RenderEngine::pollDeviceRemoved() { + if (!s_ || !s_->ready || s_->deviceLost || !s_->device) return; + const HRESULT hr = s_->device->GetDeviceRemovedReason(); + if (FAILED(hr)) { s_->deviceLost = true; RLog("poll: device removed hr=0x%08lX", (unsigned long)hr); } +} + bool RenderEngine::parkedLastFrame() const { return s_ && s_->parkedLastFrame; } bool RenderEngine::recoverDeviceLost() { diff --git a/src/render_engine.h b/src/render_engine.h index 8f16a6b3..4658f30e 100644 --- a/src/render_engine.h +++ b/src/render_engine.h @@ -113,6 +113,10 @@ class RenderEngine { // update, adapter change). The caller should stop rendering and call recoverDeviceLost() (with // backoff). Until recovery succeeds, renderFrame() is a no-op. bool deviceLost() const; + // Ask the device whether it was removed (GetDeviceRemovedReason) and latch deviceLost if so. + // Present/AcquireNextFrame only notice a TDR while this engine draws, so during a transform + // session (this engine idle) the main loop polls here (review 2026-10-09 #12). Cheap. + void pollDeviceRemoved(); // Did the LAST renderFrame call actually SetCursorPos the pointer? (issue #169) The pan // oracle's baseline must be the park point when a park landed and the pointer's true position // when it did not (weld deduped on an unchanged pixel, drag-follow suppressed, device lost). diff --git a/src/render_model.cpp b/src/render_model.cpp index c4534550..777c6515 100644 --- a/src/render_model.cpp +++ b/src/render_model.cpp @@ -57,6 +57,7 @@ bool RenderModel::retarget(const MonitorTarget& m) { return engine_.retarget(m); bool RenderModel::coversShell() const { return true; } RenderEngine& RenderModel::engine() { return engine_; } bool RenderModel::deviceLost() const { return engine_.deviceLost(); } +void RenderModel::pollDeviceRemoved() { engine_.pollDeviceRemoved(); } bool RenderModel::recoverDeviceLost() { return engine_.recoverDeviceLost(); } void RenderModel::primeReveal() { engine_.primeReveal(); } bool RenderModel::frameCompositedSincePrime() const { return engine_.frameCompositedSincePrime(); } diff --git a/src/render_model.h b/src/render_model.h index 9f15da12..5df273de 100644 --- a/src/render_model.h +++ b/src/render_model.h @@ -30,6 +30,7 @@ class RenderModel : public IMagnifierModel { RenderEngine& engine(); // escape hatch for render-only main-loop code (device-lost, priming, selftest) bool deviceLost() const; // forwarded (main loop calls this) bool recoverDeviceLost(); + void pollDeviceRemoved(); // see RenderEngine::pollDeviceRemoved void primeReveal(); bool frameCompositedSincePrime() const; bool revealFrameDone(double spinBudgetMs = 0.0); diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 5ea52a63..64cf0513 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -191,7 +191,7 @@ bool TransformModel::initialize(const MonitorTarget& monitor) { if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) { DWORD uiAccess = 0, len = 0; if (GetTokenInformation(tok, TokenUIAccess, &uiAccess, sizeof(uiAccess), &len)) - inputTransformAvailable_ = uiAccess != 0; + inputTransformAvailable_ = uiAccessProbe_ = uiAccess != 0; CloseHandle(tok); } wind::Log(wind::LogLevel::Info, "transform", "input transform %s (token UIAccess=%d)", @@ -346,6 +346,7 @@ void TransformModel::edgeClipManage(bool wantActive) { void TransformModel::setActive(bool active) { active_ = active; + if (!active && !inputXformDenied_) inputTransformAvailable_ = uiAccessProbe_; // re-arm a transient failure if (active) { LARGE_INTEGER zf, z1, z2; // zoom timeline split (#310): a few QPC reads, always on QueryPerformanceFrequency(&zf); @@ -873,7 +874,12 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // Self-heal (spec constraint 1): a VERIFIED-failed ENABLED publish means this // build cannot fix the pointer-framework dead zones - the DESKTOP pick must stop // choosing the transform. Games are unaffected (legacy input surfaces). + // Only ERROR_ACCESS_DENIED (no UIAccess) is permanent. Any other single failed + // publish is treated as transient: the flag is re-armed from the token probe when + // the session ends (setActive(false)), so one hiccup no longer disables the + // desktop pick until restart (review 2026-10-09 #25). inputTransformAvailable_ = false; + if (setGle == ERROR_ACCESS_DENIED) inputXformDenied_ = true; if (!inputXformWarned_) { inputXformWarned_ = true; wind::Log(wind::LogLevel::Warn, "transform", diff --git a/src/transform_model.h b/src/transform_model.h index 7456a776..60c4521a 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -133,6 +133,8 @@ class TransformModel : public IMagnifierModel { int lastClickX_ = 0, lastClickY_ = 0; bool weldedLastFrame_ = false; // SetCursorPos ran in the last present() bool inputTransformAvailable_ = false; // MagSetInputTransform probe (UIAccess) + bool uiAccessProbe_ = false; // the token's UIAccess bit, the re-arm value + bool inputXformDenied_ = false; // publish failed ERROR_ACCESS_DENIED: stay off EnterSplit lastEnter_; double restLevel_ = 1.0; // cfg.txRestLevel (hot): >1 keeps DWM magnifying bool ensureMag(); diff --git a/src/version.h b/src/version.h index dcbea1ba..e2f8673a 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 24 -#define WIND_VER_PATCH 9 +#define WIND_VER_PATCH 10 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.24.9" +#define WIND_VERSION_STR "0.24.10" diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 33595917..34c9e6a4 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -675,3 +675,14 @@ TEST_CASE("the first-run ini carries the new keys and parses to the struct defau CHECK(t.panKeysOn == 1); CHECK(t.hideCursorOn == 1); CHECK(t.cursorLockOn == 1); CHECK(t.zoomWheelMods == 0); } + +TEST_CASE("txSamplingMode folds the nearest aliases 2..4 to 0 (review 2026-10-09 #23)") { + CHECK(ParseConfig("txSamplingMode=0\n").txSamplingMode == 0); + CHECK(ParseConfig("txSamplingMode=1\n").txSamplingMode == 1); + CHECK(ParseConfig("txSamplingMode=-1\n").txSamplingMode == -1); // leave DWM alone stays valid + CHECK(ParseConfig("txSamplingMode=2\n").txSamplingMode == 0); + CHECK(ParseConfig("txSamplingMode=4\n").txSamplingMode == 0); + CHECK(ParseConfig("txSamplingMode=99\n").txSamplingMode == 0); + // Folded to 0, the MPO guard sees an MPO-enabled boot's nearest and swaps in smooth. + CHECK(EffectiveSamplingMode(ParseConfig("txSamplingMode=3\n").txSamplingMode, false, false, 0) == 1); +} diff --git a/tests/test_engine_pick.cpp b/tests/test_engine_pick.cpp index 5d9536ea..15ef0efe 100644 --- a/tests/test_engine_pick.cpp +++ b/tests/test_engine_pick.cpp @@ -188,3 +188,12 @@ TEST_CASE("Netflix in a browser: protected beats transformExclude") { in.captureProtected = false; CHECK_FALSE(ShouldPickTransform(in)); } + +TEST_CASE("a lost render device sends every pick to the transform engine (review 2026-10-09 #13)") { + EnginePickInputs in; // a plain captioned desktop foreground: render as a rule + CHECK_FALSE(ShouldPickTransform(in)); + in.renderLost = true; + CHECK(ShouldPickTransform(in)); + in.pref = wind::EnginePref::Render; // even an explicit render pin cannot be honoured + CHECK(ShouldPickTransform(in)); +} diff --git a/tests/test_mpo_boot.cpp b/tests/test_mpo_boot.cpp new file mode 100644 index 00000000..638033bf --- /dev/null +++ b/tests/test_mpo_boot.cpp @@ -0,0 +1,56 @@ +#include "doctest.h" +#include "../src/mpo_boot_logic.h" + +using namespace wind; + +TEST_CASE("an MPO boot record round-trips, dwm-keyed and boot-keyed") { + MpoBootRecord a; a.stamp = 13400000000LL; a.dwmKeyed = true; a.disabled = 1; + MpoBootRecord out; + REQUIRE(ParseMpoBootRecord(FormatMpoBootRecord(a), out)); + CHECK(out.stamp == a.stamp); CHECK(out.dwmKeyed); CHECK(out.disabled == 1); + + MpoBootRecord b; b.stamp = 1760000000LL; b.dwmKeyed = false; b.disabled = 0; + REQUIRE(ParseMpoBootRecord(FormatMpoBootRecord(b), out)); + CHECK(out.stamp == b.stamp); CHECK_FALSE(out.dwmKeyed); CHECK(out.disabled == 0); +} + +TEST_CASE("a legacy record (boot= key, CRLF) still parses as boot-keyed") { + MpoBootRecord out; + REQUIRE(ParseMpoBootRecord("boot=1760000000\r\nmpoDisabled=1\r\n", out)); + CHECK_FALSE(out.dwmKeyed); + CHECK(out.stamp == 1760000000LL); + CHECK(out.disabled == 1); +} + +TEST_CASE("an empty, partial or corrupt record is rejected") { + MpoBootRecord out; + CHECK_FALSE(ParseMpoBootRecord("", out)); + CHECK_FALSE(ParseMpoBootRecord("mpoDisabled=1\n", out)); // no stamp + CHECK_FALSE(ParseMpoBootRecord("dwmStart=100\n", out)); // no state + CHECK_FALSE(ParseMpoBootRecord("dwmStart=abc\nmpoDisabled=1\n", out)); + CHECK_FALSE(ParseMpoBootRecord("dwmStart=0\nmpoDisabled=1\n", out)); +} + +TEST_CASE("a dwm-keyed record is current only for the same dwm.exe (review 2026-10-09 #26)") { + MpoBootRecord r; r.stamp = 1000000; r.dwmKeyed = true; r.disabled = 1; + CHECK(MpoRecordIsCurrent(r, 1000000, true)); + CHECK(MpoRecordIsCurrent(r, 1000001, true)); // rounding only + // dwm.exe restarted 90 s later with no reboot: the old reading no longer describes DWM. + CHECK_FALSE(MpoRecordIsCurrent(r, 1000090, true)); + CHECK_FALSE(MpoRecordIsCurrent(r, 999000, true)); +} + +TEST_CASE("a boot-keyed record keeps the generous boot tolerance") { + MpoBootRecord r; r.stamp = 1000000; r.dwmKeyed = false; r.disabled = 0; + CHECK(MpoRecordIsCurrent(r, 1000000 + 120, false)); + CHECK(MpoRecordIsCurrent(r, 1000000 - 120, false)); + CHECK_FALSE(MpoRecordIsCurrent(r, 1000000 + 3600, false)); // a later boot +} + +TEST_CASE("a record of the other stamp kind, or an unreadable stamp, is never trusted") { + MpoBootRecord dwm; dwm.stamp = 5000; dwm.dwmKeyed = true; dwm.disabled = 1; + MpoBootRecord boot; boot.stamp = 5000; boot.dwmKeyed = false; boot.disabled = 1; + CHECK_FALSE(MpoRecordIsCurrent(dwm, 5000, false)); // dwm.exe lookup failed this time + CHECK_FALSE(MpoRecordIsCurrent(boot, 5000, true)); // old uptime record, dwm start readable now + CHECK_FALSE(MpoRecordIsCurrent(dwm, 0, true)); +}