Skip to content

Trackpad Architecture

Internal implementation notes for makima-deckery's trackpad emulation (merged to main, not yet in a release — see Trackpad for the user-facing config reference).

Why hidraw, not evdev

The kernel driver (hid-steam) exposes trackpad position on evdev as ABS_HAT0X/Y (left) / ABS_HAT1X/Y (right), with clicks on BTN_THUMB/BTN_THUMB2. Earlier versions of this feature read position from evdev. That turned out to be the wrong source: evdev and hidraw are two different HID interfaces of the same physical device, read via two independent kernel/userspace paths, and they can arrive out of order relative to each other — combining "position from evdev" with "touch from hidraw" caused large cursor jumps on reposition.

Makima instead parses the raw 64-byte hidraw report directly (pad_hidraw.rs) and reads position, touch state, and click for both pads out of that single read. Because all of it comes from one read(), it's atomic by construction — there is no ordering bug possible, and no evdev fallback: if no hidraw sibling device is found for a controller, trackpad position/touch is simply unavailable (state.json will show pads as untouched, and MT-trackpad mode gets no input).

Data flow

hidraw report (64 bytes, both pads atomically)
        │
        ▼
pad_hidraw.rs ◄──────────────── HapticCommand (feature report 0x8F back to device)
        │  PadFrame{lx,ly,ltouch,lclick,rx,ry,rtouch,rclick}
        ▼
trackpad_router.rs                                    always ──► PadState ──► state.json
        │  Core routing + gesture-session state machine (pad-order-independent)
        │
        ├─ left touching, no gesture session  ──► SinglePadFrame  ──► mt_trackpad::run_single  ──► "Deckery Left Trackpad"
        ├─ right touching, no gesture session  ──► SinglePadFrame  ──► mt_trackpad::run_single  ──► "Deckery Right Trackpad"
        └─ both touching (gesture session)     ──► CombinedPadFrame ──► gesture_pad::run        ──► "Deckery Combined Trackpad"

Three layers, each independently testable and swappable:

pad_hidraw.rs        Raw producer. Parses hidraw reports into PadFrame per pad
                      and exposes a HapticCommand sink for feature-report
                      writes back to the device. No knowledge of modes,
                      gestures, or haptic policy — pure HID wire-format
                      decode/encode.

trackpad_router.rs    Core routing. Owned by EventReader, not by any handler.
                      Always mirrors raw position/touch/click into state.json
                      regardless of mode, tracks combined two-finger gesture-
                      session entry/exit, and routes each frame to whichever
                      per-channel handler input is attached.

handler modules       Interpreters downstream of the router. Each turns one
(mt_trackpad.rs,      channel's frame stream into virtual device events plus
gesture_pad.rs, ...)  its own haptic policy. Selected per pad via `mode` in
                      the config; a handler owns everything about "what this
                      mode feels like" and self-parses its own config shape.

This split exists so a new interpretation of the same raw pad data — a trackball-style relative-mouse mode, a radial/zone mode, different haptic timing — is a new handler module consuming the same SinglePadFrame/CombinedPadFrame stream, without touching the raw parser or the router.

Layer 1 — pad_hidraw.rs: raw producer

Reads the controller's raw hidraw interface (the .0005 sibling of the evdev gamepad node — found via sysfs, see find_hidraw_for_evdev) and, on every report, emits a PadFrame:

pub struct PadFrame {
    pub lx: i32, pub ly: i32, pub ltouch: bool, pub lclick: bool,
    pub rx: i32, pub ry: i32, pub rtouch: bool, pub rclick: bool,
}

Byte offsets were determined empirically by recording evdev and hidraw simultaneously against a shared monotonic clock. Position lives at fixed i16 offsets in the report; touch and click for both pads are separate bits within the same status byte (byte[10]) — confirmed against the upstream hid-steam kernel driver's own bit layout.

This layer also owns the write side: haptic pulses ("click ticks", gesture feedback) are sent back to the device as HID feature reports (HIDIOCSFEATURE, report ID 0x8F) via a HapticCommand channel. HapticPad::{Left, Right, Both} selects which actuator fires — its wire encoding was fixed empirically against real hardware (not read from kernel source alone, which described the opposite mapping).

Trackpad position/touch/click data and the Lizard Mode suppression heartbeat share this same raw hidraw file descriptor.

Layer 2 — trackpad_router.rs: Core routing

Not spawned as its own task — called from EventReader::run inside the same tokio::join! as everything else, so it can borrow shared state (PadState, gesture-session flag) for its whole lifetime.

Two responsibilities, always active regardless of config:

  1. State export. Every frame updates PadState (position, hardware touch, pressed) for both pads unconditionally — state.json reflects raw hardware truth even when a pad's mode = "disabled", so the HUD can visualize trackpad input with no handler attached.
  2. Gesture-session tracking. A pure, pad-order-independent state machine (decide_gesture_transition) decides whether a combined two-finger gesture session is active: it starts the instant both pads are simultaneously touching, and ends the instant either lifts — regardless of which pad touched down or lifted first. On exit, whichever pad is still touching gets a synthetic touch-down resume on its own individual channel, so a two-finger gesture that becomes one finger seamlessly continues as single-pad input instead of just stopping.

Beyond that, the router's only job is dispatch: forward each frame to whichever of left_tx / right_tx / combined_tx channels are Some (i.e. have a handler attached for that mode). A None channel is a disabled channel — the router skips sending into it entirely rather than blocking on a channel nobody is reading, so a pad set to "disabled" can never stall routing for the other pad.

state.json writes are rate-limited to ~60 Hz for analog movement, but bypass the limit entirely for digital transitions (touch lift/down, gesture enter/exit) so those are never delayed.

Layer 3 — handler modules

A handler consumes one channel's frame stream (SinglePadFrame for an individual pad, CombinedPadFrame for the gesture channel) and decides what it means: which virtual device to emit MT events on, and what haptic feedback (if any) to fire and when. Selected per pad via mode in [trackpad.left] / [trackpad.right].

mode Handler Status
"disabled" none Default. No virtual device, no events forwarded. Position/touch/click still tracked into state.json by the router.
"mt-trackpad" mt_trackpad.rs Implemented. Emits standard MT touchpad events (Deckery Left/Right Trackpad), plus haptic click-ticks on both the press and release edges.
"trackball" trackball.rs Stub only. Accepted as a config value without warning, but no handler is actually wired into the router's tokio::join! — behaves identically to "disabled" today. Only its (currently empty) config struct exists, as a home for the shape once real relative-mouse behaviour lands.
"scroll" scroll_pad.rs Stub only, same status as "trackball" — module exists, nothing dispatches to it yet.

The combined gesture channel ([trackpad.gestures], enabled via combined_gesture_device = true) is handled separately by gesture_pad.rs — see below.

Config ownership split

Config for a trackpad side is split between Core (config.rs) and the handler that mode selects, and each side only ever knows its own half:

  • Core owns mode (which handler gets spawned), click_pressure (a HID feature-report threshold that lives on the physical sensor itself — independent of whichever handler is active, since two handlers could never sensibly want different firmware thresholds at the same time), kde (the [trackpad.*.kde] sub-table, consumed by kde_input_defaults before any device is created — see KDE/libinput defaults below), and router-level settings like combined_gesture_device.
  • Everything else in a [trackpad.left] / [trackpad.right] / [trackpad.gestures] table is handler-specific (haptics policy, movement algorithm, gesture semantics) and is handed to the handler as a raw, unparsed toml::Value — Core never learns or validates the shape. Each handler module defines its own #[derive(Deserialize)] struct and parses itself, falling back to defaults (with a logged warning) on any shape mismatch, so a typo in a handler-owned field can never crash makima or block input on the pad.

This is why mt_trackpad::MtTrackpadConfig and gesture_pad::GesturePadConfig exist as self-contained structs living in their own handler files instead of centralized in config.rs.

The combined gesture channel — gesture_pad.rs

The combined device isn't a distinct physical sensor — it has no mode or click_pressure of its own, so it doesn't get a TrackpadSideConfig like left/right do. Instead it's its own handler module with its own config shape, self-parsed from the raw [trackpad.gestures] table.

A click during a two-finger gesture has no established touchpad semantics (unlike a single-finger tap/click on an individual pad) — so unlike mt_trackpad's on_press/on_release, there is deliberately no click-based haptic on the gesture channel. The physical click bit is still forwarded to the virtual device's BTN_LEFT unconditionally (same as a real multi-touch pad would report it), just with no haptic tied to it.

What is meaningful for a gesture is its lifecycle — start, ongoing movement, end — so GestureHapticConfig is keyed on that instead (on_gesture_start/on_gesture_move/on_gesture_end).

gesture_pad::run currently only sees a flat stream of CombinedPadFrames, not session-transition events — recognising "session just started/ended" needs a signal from trackpad_router's gesture-session tracking that isn't plumbed through to the handler yet. The config shape is settled and parses today; wiring the actual pulses is a follow-up.

Haptic feedback mechanism

Both mt_trackpad (individual pads) and gesture_pad (combined channel) share the same underlying pulse mechanism (mt_trackpad::pulse) and wire format (pad_hidraw::HapticCommand → HID feature report 0x8F), but each owns its own policy for when to fire:

  • mt_trackpad: fires two independent pulses per pad — on_press on the press edge and on_release on the release edge of a physical click. A click is really two separately feelable edges, not one event, so each gets its own configurable pulse. Both default to the same tuned three-pulse burst (8ms on / 8ms off, duration_us=8000, interval_us=8000, count=3, gain_db=0) if unset — validated on real Steam Deck hardware against the Lizard Mode click buzz as a reference feel. gain_db is deliberately left at 0 by default: on-hardware A/B testing showed no perceptible effect from raising it, so it isn't a real lever the way the timing fields are (tracked in makima-deckery#20).
  • gesture_pad: fires on gesture lifecycle events (start/move/end) instead of click — not wired up yet, see above.

Haptic parameters (duration_us, interval_us, count, gain_db) are identical across both — same HapticPulse struct — only the trigger condition differs per handler.

Virtual device geometry

Position is Y-corrected to libinput convention (hardware reports up as negative; the virtual device flips this). On the combined device, left/right pads are split into left/right halves of a shared X axis so a pinch gesture tracks correctly across both MT slots. libinput derives everything it needs — two-finger scroll/pan, pinch-zoom — purely from the two ABS_MT_POSITION slots; there is no separate gesture-type event makima produces.

Lizard Mode

Full Steam independence requires suppressing the hid-steam kernel driver's built-in mouse/scroll fallback ("Lizard Mode"), which otherwise emits mouse/scroll events directly from the trackpads, bypassing makima entirely. Controlled via SUPPRESS_LIZARD_MODE — see Lizard Mode for the user-facing setting. Implementation-wise it shares the same raw hidraw file descriptor that pad_hidraw.rs uses for trackpad data: a heartbeat sends suppression feature reports every 4 s, and if makima crashes or exits, the fd closes and Lizard Mode re-activates automatically within ~8 s.

KDE/libinput defaults

When a virtual MT device first appears, KWin reads ~/.config/kcminputrc for a [Libinput][vendor][product][name] section and applies those settings. Without such a section, KDE defaults apply — which include tap-to-click enabled and adaptive acceleration, neither of which is right for a Steam Deck pad.

kde_input_defaults.rs solves this by writing the relevant sections to kcminputrc before the uinput nodes are created, so KWin always finds them on first device discovery. It runs every time device_session.rs sets up a trackpad session (i.e. on every makima start and on every config-file reload that triggers a session restart), and always overwrites the Deckery sections — making the [trackpad.*.kde] TOML block the single source of truth rather than KWin's settings UI.

All three virtual devices share the same stable uinput IDs (vendor 0x1234, product 0x5678, bus BUS_VIRTUAL), so KWin's per-device settings always key on the same identifiers across reboots.

Default values written (all overridable via [trackpad.*.kde] / [trackpad.gestures.kde]):

Setting Single pad default Gesture pad default
TapToClick false false
DisableWhileTyping false false
PointerAcceleration 0.200 —
PointerAccelerationProfile 2 (flat) 2 (flat)
NaturalScroll false true
ScrollFactor — 0.5