From a370642af6d77214228ff0425d30879f369c4764 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:27:30 +0200 Subject: [PATCH 1/2] feat(transform): optional full release glide with the smooth-zoom ladder (#425) txGlideTail=1 lets the high-res release glide run out to rest like nearest sampling: the slow tail that EaseOutShouldStop used to cut is written unsnapped (GlideTailUnsnapped), since snapping a step smaller than the clean-level spacing can only hold and hop. Default 0 keeps today's early stop until it is field-tested. Docs record the measured ladder trade-off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 7 +++++++ src/config.cpp | 1 + src/config.h | 4 ++++ src/main.cpp | 1 + src/transform_model.cpp | 8 +++++++- src/zoom_ladder.h | 10 ++++++++++ tests/test_zoom_ladder.cpp | 7 +++++++ 7 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 2d0514ac..49684dc3 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -236,6 +236,13 @@ DWM magnifies with nearest neighbour unless something calls resulting shift per level. While smooth, the applied level snaps to the nearest level predicting under 1 px (never backwards in a ramp; held once the zoom settles). Measured standalone at 3840x2160: 10-25x jitter 11 px -> 0.7 px p95, worst jump 42 px -> 2 px; 2-10x 4.7 px -> 0.8 px. +- The ladder's cost is uneven speed: above ~6x clean levels are ~0.9 % apart while a tick moves ~3 %, + so single ticks run 15-40 % fast or slow (measured 2026-10-10: tick-to-tick rate change 8-21 % + median above 6x, against 1-5 % with the ladder off or nearest). Capped or rate-keeping snaps were + simulated against the trace and all brought most of the image jitter back, so the ladder stays. +- Release glide: by default the ease-out stops once it moves slower than clean levels are apart + (`EaseOutShouldStop`, about 10-20 % of full speed). `txGlideTail=1` glides it out to rest like + nearest, the slow tail unsnapped (`GlideTailUnsnapped`, a few small image jumps at the end). - Nearest while the level moves and smooth at rest was tried: steady, but the switch from pixel to smooth is plainly visible, so it was rejected (2026-10-07). The older "swap shifted the image 1-2 px" verdict predates the working setter and is void. diff --git a/src/config.cpp b/src/config.cpp index 8ebc1468..4846f9a0 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -227,6 +227,7 @@ Config ParseConfig(const std::string& text) { else if (key == "txWarmHz") c.txWarmHz = std::stoi(val); else if (key == "mpoNearestGuard") c.mpoNearestGuard = std::stoi(val); else if (key == "txSmoothLadder") c.txSmoothLadder = std::stoi(val); + else if (key == "txGlideTail") c.txGlideTail = std::stoi(val); else if (key == "mpoGuardTest") c.mpoGuardTest = std::stoi(val); else if (key == "mpoGuard") c.mpoGuard = std::stoi(val); else if (key == "mpoGuardLiftWall") c.mpoGuardLiftWall = std::stoi(val); diff --git a/src/config.h b/src/config.h index 46fe5ed8..27ef11fc 100644 --- a/src/config.h +++ b/src/config.h @@ -302,6 +302,10 @@ struct Config { // levels where DWM's per-frame scratch rounding predicts under 1 px of movement (the zoom shake: // 11 px p95 -> 0.7 px measured at 10-25x). 0 = off (hot). int txSmoothLadder = 1; + // Release glide with the ladder (issue #425, src/zoom_ladder.h GlideTailUnsnapped): 0 (default) + // stops the ease-out once it moves slower than clean levels are apart; 1 glides it out to rest + // like nearest sampling, its slow tail unsnapped (a few small image jumps at the very end). Hot. + int txGlideTail = 0; int mpoGuardTest = 0; // diagnostic: apply the MPO guard effect even on an MPO-off boot (hot) int mpoGuard = 1; // diagnostic: 0 = never apply the guard effect (A/B on an MPO boot; hot) int mpoGuardLiftWall = 1; // no pan walls / write clamp / ghost while the session is plane-free (hot) diff --git a/src/main.cpp b/src/main.cpp index 66e31876..cdd83889 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1338,6 +1338,7 @@ static void RunTick(TickState& t) { // AFTER the controller ticked: before it, level() still equals last frame's level, so the // "still moving" test never fired and the ease-out always ran its slow tail (#375). if (!held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && t.cfg.txSmoothLadder != 0 && + t.cfg.txGlideTail == 0 && // full glide (#425): the tail runs unsnapped instead t.zoom.level() != t.prevLvl && wind::EaseOutShouldStop(t.zoom.level(), t.prevLvl)) { if (auto* tmStop = dynamic_cast(t.model)) { const double shown = tmStop->writtenLevel(); diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 5ea52a63..579d7277 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -560,9 +560,15 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg ladderReq_ = level; ladderOut_ = lastLevel_; applyLevel = lastLevel_; + } else if (GlideTailUnsnapped(cfg.txGlideTail != 0, ex.zoomDriven, applyLevel, lastLevel_)) { + // Full release glide (#425): the slow tail is written as requested, so it decelerates + // to rest instead of holding and hopping between clean levels. + ladderReq_ = level; + ladderOut_ = applyLevel; } else { const int dir = applyLevel > lastLevel_ ? 1 : (applyLevel < lastLevel_ ? -1 : 0); - // (The slow tail of an ease-out never reaches here: RunTick stops the glide first.) + // (With txGlideTail=0 the slow tail of an ease-out never reaches here: RunTick stops the + // glide first.) const double snapped = SnapSmoothLevel(applyLevel, r.centerX, r.centerY, mon_.w, mon_.h, lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir); ladderReq_ = level; diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index c34f16c4..6cd8caac 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -142,4 +142,14 @@ inline bool EaseOutShouldStop(double level, double prevLevel) { return step < 0.6 * SnapWindow(level); } +// FULL RELEASE GLIDE (issue #425, txGlideTail=1). The truncated ease-out above made a high-res +// release stop at 10-20 % of its speed while nearest sampling glides to rest (field 2026-10-10: +// "drives 50, then from 15 straight to 0"). With the option on, the glide is not cut; its slow tail +// (the part EaseOutShouldStop would cut) is written UNSNAPPED, because snapping a step smaller than +// the clean-level spacing can only hold and hop. The cost is the few DWM rounding steps the tail +// crosses, each a small image jump. Snapping still applies while a zoom key is held. +inline bool GlideTailUnsnapped(bool fullGlide, bool zoomDriven, double level, double prevLevel) { + return fullGlide && !zoomDriven && EaseOutShouldStop(level, prevLevel); +} + } // namespace wind diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 440e874a..1761d370 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -92,3 +92,10 @@ TEST_CASE("the ease-out runs while it moves faster than the clean-level spacing, CHECK(EaseOutShouldStop(25.0, 25.0 / 1.004) == true); // 0.4 % at 25x (window 1.2 %) CHECK(EaseOutShouldStop(1.0, 1.0) == false); } + +TEST_CASE("full release glide: only the released slow tail goes unsnapped (#425)") { + CHECK(GlideTailUnsnapped(true, false, 5.0, 5.0 / 1.001) == true); // released, in the tail + CHECK(GlideTailUnsnapped(true, false, 5.0, 5.0 / 1.02) == false); // released, still fast: snap + CHECK(GlideTailUnsnapped(true, true, 5.0, 5.0 / 1.001) == false); // key held: always snap + CHECK(GlideTailUnsnapped(false, false, 5.0, 5.0 / 1.001) == false); // option off: unchanged +} From 4152d2272421b7438a992daa840ce68a727d2cc7 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:32:53 +0200 Subject: [PATCH 2/2] fix(transform): the full-glide tail eases into its rounding cell instead of crossing it (#425) Field: the unsnapped tail shook the cursor where it crossed DWM's rounding steps. Inside one rounding cell (same scratch size and rounded origin) the image moves continuously, so the tail's progress now eases into the cell edge (RoundingCellEdge + SoftApproach: slope 1 at the hand-over, never reaching the edge) and the controller takes the level on screen once the glide has finished. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/main.cpp | 11 +++++++++++ src/transform_model.cpp | 20 +++++++++++++++++--- src/transform_model.h | 4 ++++ src/zoom_ladder.h | 35 +++++++++++++++++++++++++++++++++++ tests/test_zoom_ladder.cpp | 22 ++++++++++++++++++++++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index cdd83889..ebe1e251 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1345,6 +1345,17 @@ static void RunTick(TickState& t) { if (shown > 1.001) { t.zoom.setLevel(shown); t.zoom.stopGlide(); } } } + // Full glide (#425): the tail eases into a rounding-cell edge and can rest short of where the + // controller's glide ended. Once the controller is still, it takes the level on screen, so + // the next zoom starts from what is shown rather than jumping to the controller's level. + if (!held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && t.cfg.txSmoothLadder != 0 && + t.cfg.txGlideTail != 0 && t.zoom.level() == t.prevLvl && t.zoom.level() > 1.001) { + if (auto* tmRest = dynamic_cast(t.model)) { + const double shown = tmRest->writtenLevel(); + if (shown > 1.001 && std::fabs(shown - t.zoom.level()) > t.zoom.level() * 1e-7) + t.zoom.setLevel(shown); + } + } } // Recenter on a recenterVk key press (rising edge). bool recenter = false; diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 579d7277..10c6edc9 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -66,6 +66,7 @@ void TransformModel::resetTransformState() { forceWrite_ = false; // lastLevel_ = 0 already forces the next write ixPubLevel_ = 0.0; ladderReq_ = 0.0; ladderOut_ = 0.0; + tailActive_ = false; tailDir_ = 0; lastPreLadder_ = 0.0; // Everything the write path caches must be forgotten across a teardown, or the next session // compares against values DWM no longer holds and skips the writes that would re-apply them. lastLevel_ = 0.0; lastRequestedLevel_ = 0.0; @@ -554,15 +555,26 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg ladderOut_ = lastLevel_; applyLevel = lastLevel_; } else if (rampStopped && applyLevel == level && lastLevel_ > 1.001 && - std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5)) { + (tailActive_ || std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5))) { + // (A full-glide tail stopped short of its cell edge on purpose: keep it there too.) // The zoom just stopped: keep the level already on screen rather than re-snapping, so // releasing the key never nudges the zoom in or out (field 2026-10-07). ladderReq_ = level; ladderOut_ = lastLevel_; applyLevel = lastLevel_; } else if (GlideTailUnsnapped(cfg.txGlideTail != 0, ex.zoomDriven, applyLevel, lastLevel_)) { - // Full release glide (#425): the slow tail is written as requested, so it decelerates - // to rest instead of holding and hopping between clean levels. + // Full release glide (#425): the slow tail is not snapped (a step smaller than the clean + // levels are apart can only hold and hop), and it never leaves the rounding cell it is + // in, where the image moves continuously: its progress eases into the cell edge. + const int dir = applyLevel > lastLevel_ ? 1 : -1; + if (!tailActive_ || dir != tailDir_) { + tailActive_ = true; + tailDir_ = dir; + tailStart_ = lastLevel_; + tailReqStart_ = lastPreLadder_ > 1.001 ? lastPreLadder_ : lastLevel_; + tailEdge_ = RoundingCellEdge(tailStart_, dir, r.centerX, r.centerY, mon_.w, mon_.h); + } + applyLevel = SoftApproach(tailStart_, tailEdge_, std::fabs(applyLevel - tailReqStart_)); ladderReq_ = level; ladderOut_ = applyLevel; } else { @@ -576,6 +588,8 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg applyLevel = snapped; } } + if (!(cfg.txGlideTail != 0 && !ex.zoomDriven)) tailActive_ = false; // a held zoom ends any tail + lastPreLadder_ = preLadderLevel; double srcL = r.srcLeft, srcT = r.srcTop; if (applyLevel != level) { OffsetF o = ComputeOffsetF(r.centerX, r.centerY, applyLevel, mon_.w, mon_.h); diff --git a/src/transform_model.h b/src/transform_model.h index 7456a776..3592de33 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -95,6 +95,10 @@ class TransformModel : public IMagnifierModel { unsigned long long lastGhostAssertMs_ = 0; // 500ms assert cadence int appliedSampling_ = -2; // sampling mode DWM currently holds (-2 = unknown) int sampleTryMode_ = -2; // sampling mode being attempted (#274) + // Full release glide tail (#425): eases into the rounding-cell edge instead of crossing it. + bool tailActive_ = false; + int tailDir_ = 0; + double tailStart_ = 0.0, tailEdge_ = 0.0, tailReqStart_ = 0.0, lastPreLadder_ = 0.0; unsigned long dwmGenSeen_ = 0; // DwmGeneration() last acted on (#396) int sampleTries_ = 0; // attempts so far for it (bounded at 3) unsigned long long sampleLastTryMs_ = 0; // when the last attempt ran diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 6cd8caac..4f286e4f 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -152,4 +152,39 @@ inline bool GlideTailUnsnapped(bool fullGlide, bool zoomDriven, double level, do return fullGlide && !zoomDriven && EaseOutShouldStop(level, prevLevel); } +// The last level reachable from z, moving in `dir`, without leaving z's rounding cell (z itself when +// dir is 0). Field 2026-10-10: the unsnapped tail shook the cursor exactly where it crossed DWM's +// rounding steps; inside the cell the image moves continuously. Searched within maxRel of z. +inline double RoundingCellEdge(double z, int dir, double centreX, double centreY, int w, int h, + double maxRel = 0.03) { + if (dir == 0 || z <= 1.001) return z; + const double step = z * 2e-4; + double prev = z; + for (double t = step; t <= z * maxRel; t += step) { + const double q = z + dir * t; + if (q <= 1.001) return prev; + if (!SameRoundingCell(z, q, centreX, centreY, w, h)) { + double a = prev, b = q; // a inside, b outside: bisect the step + for (int i = 0; i < 40; ++i) { + const double m = 0.5 * (a + b); + if (SameRoundingCell(z, m, centreX, centreY, w, h)) a = m; else b = m; + } + return a; + } + prev = q; + } + return z + dir * z * maxRel; +} + +// Map the tail's requested progress (level units travelled since the tail began, >= 0) onto a path +// that eases into `edge` and never reaches it: start + (edge - start) * (1 - e^(-progress / gap)). +// Its slope at the start is 1, so the hand-over from the glide keeps its speed; it slows smoothly as +// it nears the edge, so the glide decelerates to rest instead of stopping or crossing a step. +inline double SoftApproach(double start, double edge, double progress) { + const double gap = std::fabs(edge - start); + if (gap <= 0.0 || progress <= 0.0) return start; + const double s = edge > start ? 1.0 : -1.0; + return start + s * gap * (1.0 - std::exp(-progress / gap)); +} + } // namespace wind diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 1761d370..d28c32a0 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -99,3 +99,25 @@ TEST_CASE("full release glide: only the released slow tail goes unsnapped (#425) CHECK(GlideTailUnsnapped(true, true, 5.0, 5.0 / 1.001) == false); // key held: always snap CHECK(GlideTailUnsnapped(false, false, 5.0, 5.0 / 1.001) == false); // option off: unchanged } + +TEST_CASE("the glide tail eases into its rounding cell's edge and never crosses it (#425)") { + const int W = 3840, H = 2160; + const double cx = 1920.0, cy = 1080.0; + for (double z = 2.0; z < 30.0; z *= 1.137) { + for (int dir = -1; dir <= 1; dir += 2) { + const double edge = RoundingCellEdge(z, dir, cx, cy, W, H); + CHECK(SameRoundingCell(z, edge, cx, cy, W, H)); // edge is inside + CHECK_FALSE(SameRoundingCell(z, edge + dir * z * 1e-6, cx, cy, W, H)); // a hair further is not + double prev = z; + for (double p = 0.0; p <= z * 0.05; p += z * 0.0005) { + const double q = SoftApproach(z, edge, p); + CHECK(SameRoundingCell(z, q, cx, cy, W, H)); // never leaves the cell + CHECK((q - prev) * dir >= 0.0); // monotonic + prev = q; + } + } + } + // Hand-over keeps the glide's speed: slope 1 at the start. + CHECK(SoftApproach(5.0, 5.1, 1e-6) == doctest::Approx(5.0 + 1e-6).epsilon(1e-9)); + CHECK(SoftApproach(5.0, 4.9, 0.0) == 5.0); +}