Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs/architecture/05-transform-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1338,12 +1338,24 @@ 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<TransformModel*>(t.model)) {
const double shown = tmStop->writtenLevel();
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<TransformModel*>(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;
Expand Down
24 changes: 22 additions & 2 deletions src/transform_model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -554,22 +555,41 @@ 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 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 {
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;
ladderOut_ = snapped;
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);
Expand Down
4 changes: 4 additions & 0 deletions src/transform_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions src/zoom_ladder.h
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,49 @@ 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);
}

// 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
29 changes: 29 additions & 0 deletions tests/test_zoom_ladder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,32 @@ 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
}

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);
}
Loading