Live on this host since 2026-09-11 but never committed. The wheel firmware decodes the shifter's hall sensor itself and this unit's top row rests at Y 180-193, below the ~195 engage threshold, so 1st/3rd often did not register. g29_shifter.bpf.c re-decodes those two gates from the raw X/Y bytes with hysteresis (engage >= 160, release <= 140) and ORs the bit in before hid-logitech parses the report; it never clears a bit, so the firmware's working gates cannot regress. fix_g29_shifter.sh builds, attaches and installs it persistently (udev-hid-bpf rule + a systemd safety-net unit pulled in on every re-enumeration); g29_shifter_capture.py records and grades raw captures per gate. Two files were over the 250-line cap: the test file is split at the record/CLI boundary with the report builders moved into a conftest fixture (the naming hook allows only test_*.py in tests/), and the grader's docstrings are tightened. No behaviour change to the installed fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SS8M5wk88nJTXxDWz61KKk
5.1 KiB
Logitech G29 Driving Force Shifter: 1st/3rd not engaging (HID-BPF fix)
Files: fixes/g29_shifter.bpf.c (the fix), fixes/fix_g29_shifter.sh
(build/attach/install), fixes/g29_shifter_capture.py (grades a raw capture).
Force feedback (a separate problem, SDL-side): DOCS-g29-ffb.md.
Symptom
The stick physically locks into every gate, but 1st and 3rd often do not register in any game unless the stick is shoved hard forward. 2/4/6/R are fine.
Cause (measured 2026-09-11)
The wheel firmware decodes the shifter's hall sensor into gear buttons itself. The G29 input report (12 bytes, no report id) carries the raw values too:
| byte | content |
|---|---|
| 0–3 | hat (4 bits) + 25 buttons; gears 1–6, R = byte 2 bits 0–6 |
| 4–5 | wheel, 6–8 pedals |
| 9 | shifter X: left ≈47–90 (1/2), centre ≈104–139 (3/4), right ≈166–191 (5/6/R) |
| 10 | shifter Y: bottom row ≈30–60, neutral ≈105, top row ≈180–233 |
| 11 | flags; 0x40 = stick pushed down (reverse) |
Firmware thresholds, read off the on/off edges of a raw capture:
| row | engages | releases | where this stick actually rests |
|---|---|---|---|
| top (1/3/5) | Y ≥ ~195 | Y ≤ ~179 | 180–233, often 180–193 → no gear |
| bottom (2/4/6/R) | Y ≤ ~56 | Y ≥ ~72 | 30–60 |
The thresholds are symmetric around Y=125 but this unit's neutral is ≈105 (sensor/magnet offset ~20 rearward), so the top row has no margin. The bottom row keeps ~15–25 and occasionally drops for a second when the stick relaxes to Y 58–71 (seen once on 4th, once on 2nd in the baseline captures).
Fix
g29_shifter.bpf.c is a HID-BPF hid_device_event program attached by
udev-hid-bpf. It re-decodes 1st/3rd from bytes 9/10 with engage Y ≥ 160,
release Y ≤ 140 (hysteresis kept in a BPF global) and ORs the bit into byte
2 before hid-logitech parses the report. It never clears a bit, so the
firmware's own decode is untouched — the working gates cannot regress. It is
transparent to every game, Proton and Steam Input, and FFB is unaffected.
5th is left to the firmware: no capture data for the top-right gate.
The Arch udev-hid-bpf package ships no headers; the script shallow-clones the
matching upstream tag into ~/.cache/g29_shifter/ and compiles with the same
clang flags as upstream's meson.build.
Surviving reboots
Three layers, because a missed attach is silent — the wheel still works, just with the bad decode, which is indistinguishable from "the fix stopped working":
udev-hid-bpf install→/etc/udev-hid-bpf/g29_shifter.bpf.oplus/etc/udev/rules.d/99-hid-bpf-g29_shifter.rules, which attaches onadd.g29-shifter-bpf.service(systemd/), enabled atmulti-user.target, runsfix_g29_shifter.sh --ensure: a no-op when attached, re-attaches when not./etc/udev/rules.d/99-g29-shifter-ensure.rulespulls that unit in on every re-enumeration, so hotplug is covered as well as boot.
--status reports all of it. Verified 2026-09-12 by detaching and starting the
unit: it re-attached and exited cleanly. That test also caught the unit failing
with HOME: unbound variable — systemd has no $HOME, so the cache path now
falls back.
If the symptom returns after a boot
Check --status first. If it says attached and gears still misbehave, the
sensor's zero has drifted and the thresholds need recalibrating — the fixed
Y_TOP_RELEASE (140) assumes the stick rests near Y≈105. A drift upward of
~+40 would park the rest position above the release line, so a gear would
engage and never let go (the car stays in gear). Measure the rest value:
sudo ~/src/testsAndMisc/linux_configuration/fixes/g29_shifter_capture.py \
--record 20 ~/data/g29-captures/neutral.txt --runs
with hands off the stick, and compare the reported Y against 105.
Usage
~/src/testsAndMisc/linux_configuration/fixes/fix_g29_shifter.sh --test # attach until unplug
~/src/testsAndMisc/linux_configuration/fixes/fix_g29_shifter.sh # persistent (udev rule)
~/src/testsAndMisc/linux_configuration/fixes/fix_g29_shifter.sh --status
~/src/testsAndMisc/linux_configuration/fixes/fix_g29_shifter.sh --remove
Verifying / recalibrating
hidraw sees the report after the BPF program, so a raw capture shows the fix:
sudo timeout 60 xxd -c 12 /dev/hidraw$(basename "$(readlink -f /dev/input/by-id/usb-Logitech_G29_Driving_Force_Racing_Wheel-hidraw)" | tr -dc 0-9) > /tmp/g29.txt
~/src/testsAndMisc/linux_configuration/fixes/g29_shifter_capture.py /tmp/g29.txt --edges
Exit code 1 means some gate had a dwell ≥0.25 s with the bit clear. --edges
prints X/Y at every on/off transition — that is where the thresholds come
from. If the sensor drifts further, edit the #defines at the top of the
.bpf.c and re-run the script.