Skip to content

feat(pointing): declare scroll resolution in the report descriptor - #3483

Open
alex-shattu wants to merge 1 commit into
zmkfirmware:mainfrom
alex-shattu:fix/scroll-resolution
Open

feat(pointing): declare scroll resolution in the report descriptor#3483
alex-shattu wants to merge 1 commit into
zmkfirmware:mainfrom
alex-shattu:fix/scroll-resolution

Conversation

@alex-shattu

@alex-shattu alex-shattu commented Aug 23, 2026

Copy link
Copy Markdown

PR check-list

  • Branch has a clean commit history. One commit, conventional message.
  • Additional tests are included, if changing behaviors/core code that is testable. Nothing under app/tests covers the HID report descriptor, so there was no harness to extend. The arithmetic was checked against Apple's own algorithm instead, see the collapsed section. Happy to add a test if you can say what shape you want.
  • Proper Copyright + License headers added to applicable files. Only existing files touched, headers unchanged.
  • Pre-commit used to check formatting of files, commit messages, etc. clang-format was run with the repo's config and reports the touched files clean. The markdown table was aligned by hand the way prettier would. The full suite was not available locally, so please say if CI disagrees.
  • Includes any necessary documentation changes. docs/docs/config/pointing.md.

What this fixes

A trackball mapped to scroll is close to unusable on macOS today.

Move the ball slowly and nothing happens for a while, then the page jumps by a big
chunk. There is no small scroll step to be had. Move it at a normal speed and the page
flies much further than the movement should give.

The only knob for this right now is the divider on zip_xy_scaler. A bigger divider
slows scrolling down, but it also makes the smallest possible step bigger, because it
rounds the stream to whole counts and drops the rest. So you can have slow scrolling
or fine scrolling, but not both.

With the resolution declared, slow movement scrolls a small amount, normal movement
scrolls in proportion, and the speed can be set without losing the small steps.

This only matters for devices that scroll from a sensor, so trackballs and trackpads.
A rotary encoder is a notched wheel, and the host's guess is fine for one. The default
of 0 leaves those unchanged.

What the patch does

Adds CONFIG_ZMK_POINTING_SCROLL_RESOLUTION, the number of counts the device sends
per inch of travel, and puts it in the report descriptor as a physical range on the
Wheel and AC Pan axes.

Those axes currently declare a physical range of zero, which tells the host nothing.
The host then falls back to guessing, and macOS guesses 9 counts per inch, the figure
for a notched wheel. That guess is what produces the behaviour above. Details of how
the guess turns into coarse jumps are in the collapsed section.

The default of 0 declares nothing and leaves the descriptor byte for byte as it is
today, so nothing changes unless you set it.

CONFIG_ZMK_POINTING_SMOOTH_SCROLLING does not help here, because macOS does not
implement HID Resolution Multipliers at all. The new option is independent of it and
works with it turned off.

Usage

Set it to what the device sends per inch, after the input processors have run. A 1600
CPI sensor feeding &zip_xy_to_scroll_mapper directly sends 1600. With
&zip_xy_scaler 1 10 in front of it, 160. Bigger numbers scroll slower.

Testing

Tested on hardware, a Charybdis with a PMW3360 trackball on a dongle, with
CONFIG_ZMK_POINTING_SCROLL_RESOLUTION=1600 and no scaler on the scroll listener.
Slow scrolling works, and normal speed no longer runs away.

macOS only. Windows and Linux drive high resolution scrolling from the Resolution
Multiplier instead of the physical range, so a declaration they do not read should not
affect them. I cannot test either, so a check would be welcome.

Why the host's guess produces coarse jumps, and how the numbers were checked

Where the guess comes from

No descriptor field states resolution directly. The host works it out from the ranges,
in IOHIDEventService::determineResolution:

resolution = (logical range * 10^-unit_exponent) / physical range

A physical range of zero gives nothing, so IOHIDEventService.cpp falls back to
kDefaultScrollFixedResolution = (9 << 16). The pointer equivalent is
kDefaultFixedResolution = (400 << 16), which is why cursor movement has always felt
right while scrolling did not.

This patch declares the physical range with a unit exponent of -1, split evenly either
side of zero, since these are relative axes.

What 9 counts per inch does

In IOHIPointing.cpp:

isHighResScroll = res > (IOFixed)(SCROLL_DEFAULT_RESOLUTION * 2);   // 9 * 2
consumeClearThreshold = (IOFixedDivide(res, SCROLL_CONSUME_RESOLUTION) >> 16) * 2;
consumeCountThreshold = consumeClearThreshold * SCROLL_CONSUME_COUNT_MULTIPLIER;

At res = 9 the first line is false, so the high resolution path stays off. The
second works out to 0, which switches off the throttle as well. Apple's comment on
that throttle describes the symptom:

RY: throttle the tranlation of scroll based on the resolution threshold. This allows
us to not generated traditional scroll wheel (line) events for high res devices at
really low (fine granularity) speeds. This prevents a succession of single scroll
events that can make scrolling slowly actually seem faster.

The newer userland path has a second problem. IOHIDAccelerationAlgorithm:

double deviceScale = resolution_/rate_;
value /= deviceScale;

At resolution = 9 and rate = 60 the scale is 0.15, so the speed handed to the
acceleration curve is about 6.7 times too high. That is the runaway at normal speed.

From ioreg on the dongle before the change: HIDScrollResolution = 589824, which is
9 in 16.16 fixed point. The MacBook trackpad next to it reports 26214400, or 400.

Resolution Multipliers on macOS

ResolutionMultiplier appears once in the whole IOHIDFamily tree, as
kHIDUsage_GD_ResolutionMultiplier = 0x48 in IOHIDUsageTables.h. Nothing reads it
and nothing writes the feature report, so the multipliers keep their defaults and
CONFIG_ZMK_POINTING_SMOOTH_SCROLLING has no effect there. The Kconfig help says so
now.

Checking the numbers

determineResolution was transcribed and run over the descriptor bytes the macros
produce. The bytes decode back correctly, and the computed resolution is exact up to
about 800 counts per inch and within a fraction of a percent above that.

The build assert rejects values the descriptor cannot express. One of those is worth
calling out: if the physical range reaches the end of the logical range, the host
throws the declaration away and goes back to guessing. It does not round, it reverts,
and silently. The bound was checked over the whole input range.

On the hardware result, two things changed at once, the declaration and dropping the
scaler. Dropping the scaler on its own would have made scrolling ten times faster
against the 9 counts per inch guess, so clearly worse. The declaration is what did the
work.

Unrelated, spotted while reading this code

apply_resolution_scaling() in input_listener.c divides by 16 - multiplier. The
descriptor maps logical 0..15 onto physical 1..16, so logical value L means a
multiplier of L+1, and the divisor should be 16 / (L + 1). The two agree at the ends
only: L=15 gives 1 both ways, L=0 gives 16 both ways. In between they differ, so L=7
should divide by 2 and divides by 9 instead. Left alone here, since it only affects
hosts that write the feature report and I cannot test those. Happy to open a separate
issue.

The report descriptor left the physical range of the wheel and AC Pan
axes at zero, so it claimed no scroll resolution at all. Hosts then fall
back to a default that assumes a notched wheel, and a device scrolling
from a sensor is misread badly.

On macOS the default is 9 counts per inch
(kDefaultScrollFixedResolution, IOHIDEventService.cpp) and it has two
effects. The high resolution scroll path is gated on the declared
resolution exceeding twice that default, so it stays off, along with the
consume threshold whose purpose is to stop fine movement turning into a
succession of single line events. And the scroll acceleration curve
normalises velocity by resolution/report_rate, so at 9 counts per inch
against a 60 Hz rate the velocity fed to the curve is inflated more than
sixfold. A trackball emitting 1600 counts per inch is therefore treated
as a wheel moving far faster than it is: scrolling arrives in coarse
steps and accelerates away, no matter how fine the stream from the
device is.

Add CONFIG_ZMK_POINTING_SCROLL_RESOLUTION, in counts per inch, and
express it as a physical range with a unit exponent of -1, which is how
a host derives resolution:

    resolution = (logical range * 10^-unit_exponent) / physical range

The default of 0 declares nothing and keeps the previous descriptor byte
for byte, so nothing changes for anyone who does not set it.

Note that this is the only lever available on macOS, which does not
implement HID Resolution Multipliers at all: the usage appears nowhere in
IOHIDFamily outside its usage tables, so the feature report ZMK relies
on for CONFIG_ZMK_POINTING_SMOOTH_SCROLLING is never written there and
the multipliers keep their default. The Kconfig help now says so.

Verified against the algorithm in IOHIDEventService::determineResolution:
the emitted descriptor bytes decode back to the intended resolution
exactly up to about 800 counts per inch and within a fraction of a
percent above it. The build assert rejects the values the descriptor
cannot express, including the case where the physical range reaches the
end of the logical range, which would silently revert the host to its
notched wheel default rather than merely round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alex-shattu
alex-shattu requested review from a team as code owners August 23, 2026 15:03
@caksoylar

Copy link
Copy Markdown
Contributor

Can you clarify what this is actually useful for? I can only tell from the pages of description above that it prevents smooth scrolling from acting badly on macOS (but it still doesn't do smooth scroll)?

@alex-shattu

Copy link
Copy Markdown
Author

Short answer: it is for any ZMK device that scrolls from a sensor — a trackball or a trackpad — on macOS. It is independent of CONFIG_ZMK_POINTING_SMOOTH_SCROLLING and works with that off; the two only happen to touch the same axes.

On "it still doesn't do smooth scroll" — on macOS this is the thing that turns smooth scrolling on. macOS never reads the Resolution Multiplier, so SMOOTH_SCROLLING cannot reach it at all. It decides from the declared resolution instead, in IOHIPointing.cpp:

isHighResScroll = res > (IOFixed)(SCROLL_DEFAULT_RESOLUTION * 2);   // 9 * 2

With nothing declared res is 9, so that is false and the high-resolution path stays off — together with the consume threshold whose job is to stop slow movement becoming a run of single line events. Declaring anything above 18 flips it. So for macOS this is not damage control around SMOOTH_SCROLLING; it is the only route to the same feature.

There is a second use even where none of that applies: it is a scroll speed control that costs no precision. Today the only knob is a zip_xy_scaler divider, which rounds the stream to whole counts and discards the remainder. Declaring the resolution changes what one count is worth instead, so every count still reaches the host.

Scope, plainly: verified on macOS only, with a PMW3360 trackball. I changed two things at once on hardware — declared 1600 and dropped the scaler — so on attribution: dropping the scaler by itself would have made scrolling ten times faster against the assumed 9 counts per inch, i.e. clearly worse, so the declaration is what is doing the work.

And you are right that the description is far too long for what this is. Happy to cut it to roughly the above and fold the source-diving into a collapsed section if that makes review easier.

@Genteure

Copy link
Copy Markdown
Contributor

The "Why" section talks about technical implementation details after Claude already decided this is the fix, trying to justify the changes after the fact. It doesn't explain the actual "why is this needed" at all. The descriptor, code or math is not why. What's the scroll behavior before and after this change?

To put it plainly — en-x-claude is hard to read and understand, please use one of en-US, en-GB, or other en- variants.

I see you added a 👍 to #3383, is this related?

@alex-shattu

Copy link
Copy Markdown
Author

Fair on all three.

Language. This was written with Claude's help and it shows. Rewritten here and in the PR body.

What changes for the user. Take a keyboard with a trackball mapped to scroll, on macOS.

Before:

  • Move the ball slowly and nothing happens for a while, then the page jumps by a big chunk. There is no small scroll step available at all.
  • Move it at a normal speed and the page flies much further than the movement should give.
  • The only tuning knob today is the divider on zip_xy_scaler. A bigger divider slows things down, but it also makes the smallest possible step bigger. So you can have slow scrolling or fine scrolling, but not both.

After, with the resolution declared:

  • Slow movement scrolls a small amount.
  • Normal movement scrolls in proportion to it, with no runaway.
  • Speed can be set without giving up the small steps.

That is the point of the change. The descriptor and the arithmetic are only how the host gets told, and you are right that they do not answer "why".

This only affects devices that scroll from a sensor, so trackballs and trackpads. A rotary encoder really is a notched wheel, and 9 counts per inch is a reasonable guess for one. The default of 0 leaves those alone.

#3383. Related, but it does not replace this one.

It fixes a real bug. I hit the same symptom, built that patch, and it made no difference on my machine. Its divisor is 16 - multiplier, and on macOS the multiplier stays at its default of 15, so the divisor is already 1 and the corrected line does nothing. macOS never writes that feature report.

So #3383 matters for hosts that negotiate multipliers, and this PR matters for macOS, which does not negotiate them. The two do not overlap. That also answers the "requested testing" note in #3383, and I am happy to post it over there if it helps.

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.

3 participants