Skip to content

fix(smartshift): stop runaway free-spin scroll and SmartShift control snap-back#333

Open
AprilNEA wants to merge 1 commit into
masterfrom
fix/smartshift-scroll-runaway
Open

fix(smartshift): stop runaway free-spin scroll and SmartShift control snap-back#333
AprilNEA wants to merge 1 commit into
masterfrom
fix/smartshift-scroll-runaway

Conversation

@AprilNEA

Copy link
Copy Markdown
Owner

Summary

Three independent defects made the wheel scroll "insanely fast / unusable" and the SmartShift GUI controls (Permanent Ratchet, sensitivity slider) snap back to defaults on MX Master 3 / 3s and MX Anywhere 3s.

Root causes & fixes

1. SmartShift writes rejected the 0 "do not change" sentinel → silent failure / snap-back

set_status validated auto_disengage / tunable_torque as non-zero and bailed out before the HID++ call. Per the x2110 / x2111 setRatchetControlMode spec, 0 means "do not change" (real values are 0x01..=0xFF). On a device that reports tunable_torque == 0 (no tunable-torque hardware, e.g. MX Anywhere 3s) every SmartShift write failed silently — the panel showed the change optimistically, then the confirm read snapped it back. Now 0 is sent as preserve; the redundant set_mode_preserving_status / nonzero_smartshift_value helpers are removed and the toggle shares set_status.

2. A 0 readback clamped to auto_disengage = 1 (≈0.25 turn/s) → constant free-spin

That threshold releases the ratchet into free-spin on the gentlest scroll, which reads as "insanely fast." The sensitivity slider floor is raised from 1 to a usable 8 (≈2 turn/s); sub-floor / sentinel values normalise to the 16 default. The floor and default are single-sourced in openlogi-core, and a deserialize-time heal repairs already-persisted low thresholds on load — so a config.toml that already holds a runaway value is fixed automatically on the next agent start (the values live in volatile RAM and the agent re-applies config on reconnect, #189).

3. Native scroll inversion (0x2121) rewritten on every config reload

reload_config fires on every DPI / SmartShift / binding edit, and each one re-wrote the wheel mode — needless HID++ traffic that could race other writes. The write now short-circuits when the wheel already holds the desired (native, invert) state.

Notes

  • User-visible: the sensitivity slider minimum moves from 1 to 8; persisted thresholds below 8 are healed to 16 on load. 0xFF (permanent ratchet) and real thresholds ≥ 8 are untouched.
  • An adversarial multi-agent review flagged that gating the inversion re-apply on "changed only" would drop the incidental recovery when a wake-time write times out; since the 0x2121 write is now idempotent, the re-apply stays unconditional and just skips at the HID++ layer.

Testing

  • Unit tests added: the config heal (low_auto_disengage_heals_to_default_on_load) and the panel threshold clamp.
  • cargo test and cargo clippy --all-targets -- -D warnings pass across the changed crates.
  • Hardware verification still recommended on MX Master 3/3s (0x2111, tunable torque) and MX Anywhere 3s (no torque) over Bluetooth/Bolt: confirm controls no longer snap back, the wheel ratchets normally at every slider position, and a hand-edited auto_disengage = 1 config heals to 16 on restart.

Fixes #317

…317)

Three independent defects made the wheel scroll "insanely fast" and the
SmartShift GUI controls snap back to defaults on MX Master 3s / 3 and
MX Anywhere 3s.

- set_status rejected a 0-valued auto_disengage / tunable_torque instead of
  treating 0 as the firmware's documented "do not change" sentinel (x2110 /
  x2111). On a device that reports tunable_torque == 0 (no tunable-torque
  hardware) every SmartShift write failed silently, so the panel optimistically
  showed the change then snapped back. 0 is now sent as preserve; the redundant
  set_mode_preserving_status / nonzero_smartshift_value helpers are gone and the
  toggle shares set_status.

- The panel clamped a 0 readback to auto_disengage = 1 (~0.25 turn/s), which
  releases the ratchet into free-spin on the gentlest scroll. The friendly
  slider floor is raised to a usable 8 (~2 turn/s) and sub-floor / sentinel
  values normalise to the 16 default; the floor and default are single-sourced
  in openlogi-core, and a deserialize heal repairs already-persisted low
  thresholds on load so reapply pushes the good value on reconnect.

- Native scroll inversion (0x2121) was re-written on every config reload,
  needless traffic that could race other writes. The write now short-circuits
  when the wheel already holds the desired (native, invert) state.

Adds unit tests for the threshold clamp and the config heal.
@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes three independent bugs that caused runaway free-spin scroll and SmartShift panel snap-back on MX Master 3/3s and MX Anywhere 3s. The root causes were: (1) set_status rejecting the 0 "do not change" HID++ sentinel instead of treating it as "preserve", causing every write to silently fail on devices without tunable-torque hardware; (2) a 0 device readback seeding the slider at auto_disengage = 1 (≈0.25 turn/s), releasing the ratchet on any scroll; and (3) 0x2121 scroll-inversion being rewritten unconditionally on every config reload.

  • SmartShift write path unified: nonzero_smartshift_value rejection removed; set_mode_preserving_status collapsed into set_status, which now maps 0 to NonZeroU8::None (Enhanced) / Some(0) (Legacy) — both encode the firmware "do not change" sentinel, unblocking writes on devices with tunable_torque == 0.
  • Auto-disengage floor raised to 8: THRESHOLD_MIN and SMARTSHIFT_MIN_AUTO_DISENGAGE single-sourced across the GUI slider and a new #[serde(deserialize_with)] deserialize-time heal in openlogi-core; persisted thresholds below 8 are repaired to the default (16) on load.
  • Scroll-inversion write made idempotent: set_scroll_inversion_on_channel now reads the current wheel mode and short-circuits when it already matches the desired (Native, inverted) state, eliminating redundant HID++ traffic on every DPI/SmartShift/binding change.

Confidence Score: 4/5

Safe to merge; the three bug fixes are correctly implemented, tests cover the new floor/heal boundary, and no regressions were identified in the changed paths.

The write-path unification (merging set_mode_preserving_status into set_status) is the riskiest change, but the semantics are identical for the toggle path and the Enhanced/Legacy zero-handling is correctly wired through NonZeroU8::new and Some(0) respectively. The config heal and GUI floor are well-tested. The only open items are documentation gaps in the public set_smartshift API and no tracing when the config heal fires.

smartshift.rs — the merged write path and the Legacy Some(0)/Some(auto_disengage) encoding relies on an assertion about SmartShiftFeature::set_ratchet_control_mode that is documented in comments but not enforced at the type level; hardware verification on a Legacy (0x2110) device is recommended.

Important Files Changed

Filename Overview
crates/openlogi-hid/src/write/smartshift.rs Removes nonzero_smartshift_value rejection and set_mode_preserving_status helper; unifies them into set_status where 0 → NonZeroU8::new(0) = None (Enhanced) / Some(0) (Legacy), both encoding the HID++ "do not change" sentinel. Logic is correct; public set_smartshift docs don't yet mention the 0=preserve semantics.
crates/openlogi-core/src/config.rs Adds SMARTSHIFT_AUTO_DISENGAGE_DEFAULT / SMARTSHIFT_MIN_AUTO_DISENGAGE constants and a #[serde(deserialize_with)] heal on SmartShift::auto_disengage; 0-7 → 16 on load. Unit test covers floor boundary and 0xFF pass-through correctly.
crates/openlogi-gui/src/components/smartshift_panel.rs Raises THRESHOLD_MIN from 1 to SMARTSHIFT_MIN_AUTO_DISENGAGE (8), single-sources constants with openlogi-core, rewrites clamp_threshold to normalise sub-floor values to DEFAULT_THRESHOLD. Module-level tests added; logic matches config-heal intent.
crates/openlogi-hid/src/hires_wheel.rs Adds idempotent check before the 0x2121 setWheelMode write — skips if wheel already has Native target with the desired inversion flag; guards correctly require both inverted and target == Native.
crates/openlogi-agent-core/src/orchestrator.rs Doc-comment update only — explains that apply_native_scroll_inversions is still called on every config reload but the HID++ write short-circuits at the 0x2121 layer when the state is already correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User action / config reload] --> B{Source}
    B -->|Slider release / mode pill / permanent toggle| C[commit_smartshift GUI]
    B -->|Device reconnect| D[set_smartshift_on_channel]
    B -->|Toggle hotkey| E[toggle_smartshift_on_channel]
    B -->|Config reload| F[apply_native_scroll_inversions]

    C --> G[set_smartshift_on_channel]
    D --> G
    E --> H[SmartShift::status read]
    H --> I[set_status mode=flipped rest=status]

    G --> J[SmartShift::set_status]
    I --> J

    J --> K{Feature}
    K -->|Enhanced 0x2111| L["NonZeroU8::new(auto_disengage) — 0 becomes None preserve"]
    K -->|Legacy 0x2110| M["Some(auto_disengage) — 0 becomes 0x00 preserve"]

    L --> N[set_ratchet_control_mode Enhanced]
    M --> O[set_ratchet_control_mode Legacy]

    F --> P[set_scroll_inversion_on_channel]
    P --> Q[get_wheel_mode]
    Q --> R{Already Native and correct invert?}
    R -->|Yes| S[skip return Ok]
    R -->|No| T[set_wheel_mode Native + inverted]

    subgraph Config load heal
        U[TOML deserialize SmartShift] --> V{auto_disengage < 8?}
        V -->|Yes| W[heal to DEFAULT 16]
        V -->|No| X[pass through unchanged]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[User action / config reload] --> B{Source}
    B -->|Slider release / mode pill / permanent toggle| C[commit_smartshift GUI]
    B -->|Device reconnect| D[set_smartshift_on_channel]
    B -->|Toggle hotkey| E[toggle_smartshift_on_channel]
    B -->|Config reload| F[apply_native_scroll_inversions]

    C --> G[set_smartshift_on_channel]
    D --> G
    E --> H[SmartShift::status read]
    H --> I[set_status mode=flipped rest=status]

    G --> J[SmartShift::set_status]
    I --> J

    J --> K{Feature}
    K -->|Enhanced 0x2111| L["NonZeroU8::new(auto_disengage) — 0 becomes None preserve"]
    K -->|Legacy 0x2110| M["Some(auto_disengage) — 0 becomes 0x00 preserve"]

    L --> N[set_ratchet_control_mode Enhanced]
    M --> O[set_ratchet_control_mode Legacy]

    F --> P[set_scroll_inversion_on_channel]
    P --> Q[get_wheel_mode]
    Q --> R{Already Native and correct invert?}
    R -->|Yes| S[skip return Ok]
    R -->|No| T[set_wheel_mode Native + inverted]

    subgraph Config load heal
        U[TOML deserialize SmartShift] --> V{auto_disengage < 8?}
        V -->|Yes| W[heal to DEFAULT 16]
        V -->|No| X[pass through unchanged]
    end
Loading

Comments Outside Diff (1)

  1. crates/openlogi-hid/src/write/smartshift.rs, line 283-294 (link)

    P2 Public API silent "do not change" semantics undocumented

    set_smartshift now silently treats auto_disengage = 0 and tunable_torque = 0 as "do not change" (firmware sentinel) rather than writing the given values, but the function-level doc says it "Write[s] a full SmartShift configuration" with no mention of this. Any future caller who passes 0 will not get the write they might expect. The inner set_status comments explain this clearly; surfacing it in the public doc would close the gap.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(smartshift): stop runaway free-spin ..." | Re-trigger Greptile

Comment on lines +291 to +301
fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<u8, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = u8::deserialize(deserializer)?;
Ok(if value < SMARTSHIFT_MIN_AUTO_DISENGAGE {
SMARTSHIFT_AUTO_DISENGAGE_DEFAULT
} else {
value
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent config heal — no diagnostic on altered value

deserialize_auto_disengage silently replaces any value below SMARTSHIFT_MIN_AUTO_DISENGAGE with the default. A user who hand-edits config.toml to, say, auto_disengage = 3 will see no indication that the value was changed to 16 on the next agent start. Adding a tracing::warn! when the branch fires would let users (and future developers) diagnose this substitution without having to diff the file.

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]/[Bug]: Modify scrolling speed, unusable now!!

1 participant