hid-saitek-pidff 0003:06A3:FF32.0001: Error initialising force feedback (quirks=0x1f)
I saw this line a lot. It comes from my Saitek R660GT, a cheap racing wheel from 2007. On Linux the steering and the pedals work out of the box, but the motor stays quiet. I wanted my force feedback back, so I went looking: in the kernel, in the USB traffic, and finally inside the Windows driver itself.

First try: a kernel crash
The kernel has a generic force feedback driver for HID wheels, hid-universal-pidff. But it only accepts a fixed list of recent wheels. So I wrote a 60-line module that does the same probe and calls the exported function hid_pidff_init_with_quirks().
Result: a kernel oops. Two times, for science. Just after dmesg printed « Error initialising force feedback »: a NULL pointer dereference at address 0xa8, in hid_hw_open / hid_hw_power.
The first time, systemd-udevd also got stuck in D state and I had to reboot.
The cause: my module had copied a bug from the code I used as a base, for free. When force feedback init fails, the probe returns without calling hid_hw_stop(). The hidraw node stays, attached to a half-removed device. The next process that opens it (here udevd) reads freed memory.
This is a known bug. There is a patch on the kernel mailing list with a very clear title: « HID: universal-pidff: stop the device when force-feedback init fails ». There are also two CVEs in the same code (CVE-2025-37862 and CVE-2026-23349, the second one for kernels 6.18 to 7.0-rc7, mine was 6.18).
The fix is simple (simplified here):
error = hid_pidff_init_with_quirks(hdev, quirks);
if (error) {
hid_hw_stop(hdev); /* this line was missing */
return error;
}
No more crash. But still no force:
hid-saitek-pidff 0003:06A3:FF32.0001: Error initialising force feedback (quirks=0x1f)
Even with all 5 existing quirks enabled.
Surprise: it speaks I-Force
Next idea: look at what the official Windows driver sends. I ran it in a Windows 7 VM with the wheel passed through, and captured the USB traffic with usbmon on the Linux side.
The bytes start with 41, 42, 01, 02, 03, 04… They match nothing in the HID descriptor. But they match exactly the Immersion I-Force commands described in the kernel file Documentation/input/devices/iforce-protocol.rst:
0x01: FF_CMD_EFFECT0x02: FF_CMD_ENVELOPE0x05: FF_CMD_CONDITION
So the real protocol of this wheel is I-Force, not generic HID PID. The descriptor was already a clue: the PID « Block Load », « Block Free » and « Create New Effect » items are missing.
The iforce driver in the mainline kernel knows a similar wheel from the same family: the Saitek R440 (06a3:ff04). So no compilation, just a dynamic bind:
echo "06a3 ff32" | sudo tee /sys/bus/usb/drivers/iforce/new_id
The wheel shows up as « Saitek R440 Force Wheel », with all the force feedback capabilities. I start a Spring effect: the wheel goes back to the center by itself. Damper adds resistance when I turn. Ouf.
One problem: with iforce, the buttons do not work (it expects the R440 button layout).
The final driver
So for daily use I went back to the patched PID driver, built with DKMS, with an entry for 06a3:ff32 and a few new quirks for this firmware:
MISSING_BLOCK_REPORTS: the firmware does not really do Block Load, so the driver creates the effect index itself, always the same slotMISSING_GAIN: the Set Effect report has no Gain fieldMISSING_EBI: the Envelope and Condition reports use Parameter Block Offset (0x23) where the driver expects Effect Block Index (0x22)- a fix for the Direction field, which this firmware does not put in its own collection
- skip the « device managed pool » check, the wheel says itself it cannot do it
Result: force, buttons, axes. It survives unplug and reboot.
Why no constant force: three proofs
Constant force and periodic effects (sine, square…) do nothing on this wheel. With the PID driver, with iforce, always. Before blaming the wheel, I tested the usual suspects: fixed effect slots, magnitude up to 100 %, slow periodic effects (60 ms, 300 ms). Nothing.
Proof 1, replay. I wrote the exact bytes captured from Windows directly to /dev/hidraw0 with a small Python script (os.write()), with no kernel driver at all. One burst, with an internal type byte 0x0a: the wheel pulls to the left. So the method works. The burst tagged 0x26 (the standard HID PID usage for Constant Force), byte for byte, init line included: nothing. Two times.
Proof 2, the official panel. The Force tab of the Windows driver (Immersion TouchSense, the real signed driver from Saitek) has exactly four settings:
- Default Gain
- Spring Gain
- Damper Gain
- Default Spring (always on)
No constant force, no periodic. The people who made the wheel don’t offer it.
Proof 3, the disassembly. This is my favourite part.
First, get the driver without booting Windows: the VM disk mounted read-only with guestmount (libguestfs), and the Saitek package copied out:
saiIFF32.sys: the Immersion USB/HID minidriversaiHFF32.sys: the kernel driversai2FF32.dll/saiQFF32.dll: the « Immersion Physical Interface Device Driver »saicFF32.dll: the 2 MB control panelSaiFF32.inf
The .inf already tells a story: for VID_06A3&PID_FF32 it registers the full DirectInput effect list (Constant, Ramp, Square, Sine, Triangle, Sawtooth, Spring, Damper, Inertia, Friction, Custom). So any Windows game that asks « can you do constant force? » gets « yes ».
Then the code. A first try with pefile + capstone scripts found the WriteFile call that sends the effects, but the real logic is behind indirect calls that linear disassembly cannot follow. So: Ghidra 12, headless (analyzeHeadless, OpenJDK 21), with a small Java GhidraScript for the analysis:
1. FUN_1000ad90: a generic « send a raw HID report » function. Critical section, memcpy, WriteFile (overlapped). It knows nothing about effect types. 2. Ghidra’s call graph (getCallingFunctions()): 10 functions call it, one per report type. 3. Two of them, fully decompiled: – FUN_1000bb30, Set Constant Force: usage 0x22 (Effect Block Index), then 0x70 (Magnitude) – FUN_1000b0d0, Set Condition (Spring, Damper, the effects that work): usages 0x22, 0x23, 0x60 to 0x65
Both are built exactly the same way:
1. fill the report with HidP_SetUsageValue / HidP_SetScaledUsageValue (Usage Page 0x0F, PID) 2. memcmp with the last report sent, stop if nothing changed 3. send it with FUN_1000ad90
No special case, no empty function, no early return. The Windows driver sends constant force exactly like the spring.
The driver sends it. The wheel fait la sourde oreille. The limit is in the firmware: an entry-level TouchSense 2 wheel whose descriptor promises more than the firmware does.
Today
- Spring, Damper, gain and autocenter: OK, with two different drivers
- SuperTuxKart: force feedback on, and its log says « buttons: 7, axes: 3, hats: 0 »
- Speed Dreams: autocenter works (it uses Spring), the constant force effects are turned off
- Constant and periodic effects: never
How AI was used
This was a real team effort with an AI coding agent (Claude Code), running on the laptop:
- the agent wrote the 60-line module, the HID descriptor parser, the replay scripts and the DKMS quirks
- the agent set up Ghidra headless by itself and wrote the GhidraScript that found and decompiled the functions above
- I drove the Windows VM, pressed the buttons, and above all felt (or not) the forces in my hands: the wheel was the only real test
- the first diagnosis (« a kernel bug, nothing to do ») came from the agent too. A second review challenged it and found the missing
hid_hw_stop(). So: useful, fast, but check its conclusions.
Still to do: add 06a3:ff32 to the iforce table and to the hid-universal-pidff list, and send the patches upstream. The next owner of this wheel would get force feedback out of the box.
Note: I don’t have much time for the blog these days, so this post was written with the help of an AI, from my own notes, then read and corrected by me. Better to share the content this way than not at all.