Scroll Wheel Accelerator: Building a Custom Input Daemon
From kernel drivers to logistic curves - building a custom scroll acceleration daemon

Smart mouse, dumb firmware
I recently bought an overpriced mouse for its "smart" scroll wheel. It's supposed to figure out if I need precise scrolling or quick navigation through lengthy documentation and code. My preferences and the firmware engineers' vision did not exactly line up. The acceleration was always either too aggressive or too conservative.
So instead of fighting the firmware, I decided to build my own scroll acceleration daemon.
The input pipeline
First I had to figure out how mouse input actually flows through Linux. On my setup it goes through:
- Mouse hardware
- Kernel driver (HID)
- Kernel subsystem (evdev)
- Userspace driver (libinput)
- Xorg server
- i3 window manager
- Focused application
And these are the actual devices on my system:
$ ls -l /dev/input/by-id/
total 0
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Keychron_Keychron_K2-event-if01 -> ../event9
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Keychron_Keychron_K2-event-kbd -> ../event8
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Razer_Razer_Basilisk_V3_Pro_000000000000-event-if01 -> ../event6
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Razer_Razer_Basilisk_V3_Pro_000000000000-event-mouse -> ../event3
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Razer_Razer_Basilisk_V3_Pro_000000000000-if01-event-kbd -> ../event4
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Razer_Razer_Basilisk_V3_Pro_000000000000-if01-event-mouse -> ../event5
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Razer_Razer_Basilisk_V3_Pro_000000000000-if01-mouse -> ../mouse1
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Razer_Razer_Basilisk_V3_Pro_000000000000-if02-event-kbd -> ../event7
lrwxrwxrwx 1 root root 9 2025-10-18 14:39 usb-Razer_Razer_Basilisk_V3_Pro_000000000000-mouse -> ../mouse0
My Razer mouse shows up as /dev/input/event3, so that's the one to poke at.
Raw kernel events
evtest shows what the kernel itself hands out:
$ sudo evtest /dev/input/event3
Event: time 1760892257.371629, type 2 (EV_REL), code 11 (REL_WHEEL_HI_RES), value -120
Event: time 1760892257.371629, -------------- SYN_REPORT ------------
Event: time 1760892257.375613, type 2 (EV_REL), code 8 (REL_WHEEL), value -1
Event: time 1760892257.375613, type 2 (EV_REL), code 11 (REL_WHEEL_HI_RES), value -120
Event: time 1760892257.375613, type 2 (EV_REL), code 1 (REL_Y), value -1
Event: time 1760892257.375613, -------------- SYN_REPORT ------------
Event: time 1760892257.376567, type 2 (EV_REL), code 0 (REL_X), value 1
Event: time 1760892257.376567, -------------- SYN_REPORT ------------
Event: time 1760892257.379603, type 2 (EV_REL), code 8 (REL_WHEEL), value -1
Event: time 1760892257.379603, type 2 (EV_REL), code 11 (REL_WHEEL_HI_RES), value -120
Event: time 1760892257.379603, type 2 (EV_REL), code 1 (REL_Y), value -1
Event: time 1760892257.379603, -------------- SYN_REPORT ------------
Event: time 1760892257.383656, type 2 (EV_REL), code 8 (REL_WHEEL), value -1
Event: time 1760892257.383656, type 2 (EV_REL), code 11 (REL_WHEEL_HI_RES), value -120
Event: time 1760892257.383656, type 2 (EV_REL), code 0 (REL_X), value 1
Event: time 1760892257.383656, -------------- SYN_REPORT ------------
Event: time 1760892257.386613, type 2 (EV_REL), code 1 (REL_Y), value -1
Event: time 1760892257.386613, -------------- SYN_REPORT ------------
Event: time 1760892257.387677, type 2 (EV_REL), code 8 (REL_WHEEL), value -1
Event: time 1760892257.387677, type 2 (EV_REL), code 11 (REL_WHEEL_HI_RES), value -120
The raw events from the kernel are very basic. libinput interprets them, handles device quirks, and adds things like pointer acceleration and scroll speed adjustments.
Processed events via libinput
The same device, after libinput is done with it:
$ sudo libinput debug-events /dev/input/event3
event3 POINTER_SCROLL_WHEEL +15.085s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
event3 POINTER_SCROLL_WHEEL +15.095s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
event3 POINTER_SCROLL_WHEEL +15.105s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
event3 POINTER_SCROLL_WHEEL +15.115s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
event3 POINTER_SCROLL_WHEEL +15.125s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
event3 POINTER_SCROLL_WHEEL +15.135s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
event3 POINTER_SCROLL_WHEEL +15.145s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
event3 POINTER_SCROLL_WHEEL +15.155s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
event3 POINTER_SCROLL_WHEEL +15.165s vert 15.00/120.0* horiz 0.00/0.0 (wheel)
[ 24.626] (II) Using input driver 'libinput' for 'Razer Razer Basilisk V3 Pro'
The Xorg server recognizes the mouse and assigns the xf86-input-libinput driver to handle it.
Where to hook in
Next question was where my code should sit, and I had a couple of options.
Hacking the kernel driver
I deal with kernel space and below for a living, so I know what's possible there, but it's most likely overkill. Plus, if something can be done at a higher exception level, I would prefer that any day.
The evdev/uinput userspace daemon
So I went with a userspace daemon instead:
evdev -> process -> uinput
This is the modern way to do input transformation. It's clean and doesn't care which display server you run. The daemon plays man-in-the-middle for mouse events.
How the daemon works
The plan looked like this:
Create a virtual mouse. Using the kernel's
uinputmodule, the daemon creates a new virtual mouse device (e.g.,/dev/input/eventX). This virtual device looks like real hardware to the rest of the system (libinput, Xorg, etc.).Grab the physical mouse. The daemon finds the real mouse device (
/dev/input/event3) and "grabs" it, which gives it exclusive access. That way libinput and Xorg never see the raw events directly, otherwise you get double-inputs.Run the main loop (read, process, write):
- Continuously read raw evdev events from the physical mouse
- Pass through mouse clicks and movements unchanged
- Apply custom acceleration logic to
REL_WHEELevents - Buffer events, track speed, and write modified events to the virtual mouse
Let the system use the virtual mouse. libinput and Xorg automatically detect the new virtual mouse and use it for cursor control, with no idea anything was intercepted or modified.
A basic pass-through daemon first
Before any acceleration logic, I used the Rust evdev crate to build a basic pass-through daemon, just to understand the flow.
Scroll events
The implementation needed events batched properly. Instead of emitting events one by one, I collect all events from a single fetch_events() call and emit them together as a batch. That way related events (like REL_WHEEL and REL_WHEEL_HI_RES) go out together.
With that working, this is what a single scroll looks like going through the daemon:
[2025-10-22T06:30:43Z INFO anxious_scroll_daemon] Event: InputEvent { time: SystemTime { tv_sec: 0, tv_nsec: 0 }, type: RELATIVE, code: REL_WHEEL, value: -1 }
[2025-10-22T06:30:43Z INFO anxious_scroll_daemon] Event: InputEvent { time: SystemTime { tv_sec: 0, tv_nsec: 0 }, type: RELATIVE, code: REL_WHEEL_HI_RES, value: -120 }
[2025-10-22T06:30:43Z INFO anxious_scroll_daemon] Event: InputEvent { time: SystemTime { tv_sec: 1761114643, tv_nsec: 974324000 }, type: SYNCHRONIZATION, code: SYN_REPORT, value: 0 }
[2025-10-22T06:30:43Z INFO anxious_scroll_daemon] --------------------------------
Both REL_WHEEL_HI_RES and REL_WHEEL are sent, followed by a SYN_REPORT. According to the Linux kernel documentation:
REL_WHEEL,REL_HWHEEL: These codes are used for vertical and horizontal scroll wheels, respectively. The value is the number of detents moved on the wheel, the physical size of which varies by device. For high-resolution wheels this may be an approximation based on the high-resolution scroll events. These event codes are legacy codes andREL_WHEEL_HI_RESandREL_HWHEEL_HI_RESshould be preferred where available.
REL_WHEEL_HI_RES,REL_HWHEEL_HI_RES: High-resolution scroll wheel data. The accumulated value 120 represents movement by one detent. For devices that do not provide high-resolution scrolling, the value is always a multiple of 120. For devices with high-resolution scrolling, the value may be a fraction of 120.If a vertical scroll wheel supports high-resolution scrolling, this code will be emitted in addition to
REL_WHEELorREL_HWHEEL. TheREL_WHEELandREL_HWHEELmay be an approximation based on the high-resolution scroll events.
SYN_REPORT: Used to synchronize and separate events into packets of input data changes occurring at the same moment in time. For example, motion of a mouse may set theREL_XandREL_Yvalues for one motion, then emit aSYN_REPORT.
Why both events?
I still wanted to know why the kernel sends both. My mouse is using the standard HID driver, so I went and read its scroll handling in the kernel source:
static void hidinput_handle_scroll(struct hid_usage *usage,
struct input_dev *input,
__s32 value)
{
int code;
int hi_res, lo_res;
if (value == 0)
return;
if (usage->code == REL_WHEEL_HI_RES)
code = REL_WHEEL;
else
code = REL_HWHEEL;
/*
* Windows reports one wheel click as value 120. Where a high-res
* scroll wheel is present, a fraction of 120 is reported instead.
* Our REL_WHEEL_HI_RES axis does the same because all HW must
* adhere to the 120 expectation.
*/
hi_res = value * 120/usage->resolution_multiplier;
usage->wheel_accumulated += hi_res;
lo_res = usage->wheel_accumulated/120;
if (lo_res)
usage->wheel_accumulated -= lo_res * 120;
input_event(input, EV_REL, code, lo_res);
input_event(input, EV_REL, usage->code, hi_res);
}
Huh. The driver accumulates high-resolution values in usage->wheel_accumulated and then calculates a low-resolution value (lo_res) by dividing the accumulated value by 120. So applications can pick low-res or hi-res depending on their use case, and the kernel sends both for maximum compatibility.
I went with the high-resolution values, for more precise acceleration control. At least all my applications were working fine capturing just the acceleration control, for now.
Designing the acceleration curve
Picking the transformation function was fun. I wanted:
- When scrolling slowly, sensitivity should be high (scroll should be slow) for precision
- As I scroll faster, sensitivity should drop so I can move through a file quickly
- The curve should be smooth and feel natural
I initially experimented with linear curves, but they felt too abrupt. I'm a sucker for smooth curves ("A curve is said to be smooth if it has no singular points, in other words if it has a (unique) tangent at all points.").
The logistic model
I chose the logistic model because it tapers off naturally and I can easily control the ramp-up. The basic idea is inspired by Quake Live acceleration, where faster scrolling leads to higher acceleration multipliers. Instead of piecewise ramp-up and ramp-down functions, I used a smooth sigmoid curve.

The daemon uses this logistic function for smooth, natural acceleration:
f(velocity) = max_sensitivity / (1 + C * e^(-ramp_rate * velocity))
To get the behavior I wanted at zero velocity, I set:
C = (max_sensitivity / base_sensitivity) - 1
The knobs and their defaults:
base_sensitivity: starting sensitivity (default: 1.0)max_sensitivity: maximum sensitivity (default: 15.0)ramp_rate: how quickly to accelerate (default: 0.3)
I quickly implemented this, and test it out and it felt great, and I was very satisfied with it. Scrolling feels natural and responsive now. Small movements stay precise, and I can still get through long documents fast.
There's more to do, though.
What's next
It works well for my needs, but there's somethings that need to done yet:
- Benchmarking. Measure the performance impact of the daemon, and optimize for minimal latency.
- Performance optimization. Hyperfocus on reducing the event processing overhead.
- Packaging. A proper systemd service, plus configuration management.
- Advanced features. Per-application acceleration profiles, and gesture recognition.
- Cross-platform support. Extend it to other input systems beyond Linux evdev.
Going from frustrated user to my own input daemon was educational and satisfying. Sometimes the best fix is the one you build yourself.