{"id":552,"date":"2026-09-26T21:21:42","date_gmt":"2026-09-26T20:21:42","guid":{"rendered":"https:\/\/www.fabidouille.com\/?p=552"},"modified":"2026-10-25T19:48:33","modified_gmt":"2026-10-25T18:48:33","slug":"saitek-ff","status":"publish","type":"post","link":"https:\/\/www.fabidouille.com\/?p=552","title":{"rendered":"Retour de Force: a 2007 Saitek R660GT vs. Linux Force Feedback"},"content":{"rendered":"<pre class=\"wp-block-code\"><code>hid-saitek-pidff 0003:06A3:FF32.0001: Error initialising force feedback (quirks=0x1f)<\/code><\/pre>\n<p>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.<\/p>\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" src=\"https:\/\/www.fabidouille.com\/wp-content\/uploads\/2026\/09\/wheel.jpg\" alt=\"The Saitek R660 GT force feedback wheel with its gear lever, on a white stool, with the pedals on the shelf below\"\/><figcaption>My setup. Entry level in 2007, still entry level now.<\/figcaption><\/figure>\n<h2 class=\"wp-block-heading\">First try: a kernel crash<\/h2>\n<p>The kernel has a generic force feedback driver for HID wheels, <code>hid-universal-pidff<\/code>. 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 <code>hid_pidff_init_with_quirks()<\/code>.<\/p>\n<p>Result: a kernel oops. Two times, for science. Just after dmesg printed \u00ab\u00a0Error initialising force feedback\u00a0\u00bb: a NULL pointer dereference at address <code>0xa8<\/code>, in <code>hid_hw_open<\/code> \/ <code>hid_hw_power<\/code>.<\/p>\n<p>The first time, <code>systemd-udevd<\/code> also got stuck in D state and I had to reboot.<\/p>\n<p>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 <code>hid_hw_stop()<\/code>. The hidraw node stays, attached to a half-removed device. The next process that opens it (here udevd) reads freed memory.<\/p>\n<p>This is a known bug. There is a patch on the kernel mailing list with a very clear title: \u00ab\u00a0HID: universal-pidff: stop the device when force-feedback init fails\u00a0\u00bb. 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).<\/p>\n<p>The fix is simple (simplified here):<\/p>\n<pre class=\"wp-block-code\"><code>error = hid_pidff_init_with_quirks(hdev, quirks);\nif (error) {\n        hid_hw_stop(hdev);   \/* this line was missing *\/\n        return error;\n}<\/code><\/pre>\n<p>No more crash. But still no force:<\/p>\n<pre class=\"wp-block-code\"><code>hid-saitek-pidff 0003:06A3:FF32.0001: Error initialising force feedback (quirks=0x1f)<\/code><\/pre>\n<p>Even with all 5 existing quirks enabled.<\/p>\n<h2 class=\"wp-block-heading\">Surprise: it speaks I-Force<\/h2>\n<p>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 <code>usbmon<\/code> on the Linux side.<\/p>\n<p>The bytes start with <code>41<\/code>, <code>42<\/code>, <code>01<\/code>, <code>02<\/code>, <code>03<\/code>, <code>04<\/code>&#8230; They match nothing in the HID descriptor. But they match exactly the Immersion I-Force commands described in the kernel file <code>Documentation\/input\/devices\/iforce-protocol.rst<\/code>:<\/p>\n<ul class=\"wp-block-list\">\n<li><code>0x01<\/code>: FF_CMD_EFFECT<\/li>\n<li><code>0x02<\/code>: FF_CMD_ENVELOPE<\/li>\n<li><code>0x05<\/code>: FF_CMD_CONDITION<\/li>\n<\/ul>\n<p>So the real protocol of this wheel is I-Force, not generic HID PID. The descriptor was already a clue: the PID \u00ab\u00a0Block Load\u00a0\u00bb, \u00ab\u00a0Block Free\u00a0\u00bb and \u00ab\u00a0Create New Effect\u00a0\u00bb items are missing.<\/p>\n<p>The <code>iforce<\/code> driver in the mainline kernel knows a similar wheel from the same family: the Saitek R440 (<code>06a3:ff04<\/code>). So no compilation, just a dynamic bind:<\/p>\n<pre class=\"wp-block-code\"><code>echo \"06a3 ff32\" | sudo tee \/sys\/bus\/usb\/drivers\/iforce\/new_id<\/code><\/pre>\n<p>The wheel shows up as \u00ab\u00a0Saitek R440 Force Wheel\u00a0\u00bb, 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.<\/p>\n<p>One problem: with <code>iforce<\/code>, the buttons do not work (it expects the R440 button layout).<\/p>\n<h2 class=\"wp-block-heading\">The final driver<\/h2>\n<p>So for daily use I went back to the patched PID driver, built with DKMS, with an entry for <code>06a3:ff32<\/code> and a few new quirks for this firmware:<\/p>\n<ul class=\"wp-block-list\">\n<li><code>MISSING_BLOCK_REPORTS<\/code>: the firmware does not really do Block Load, so the driver creates the effect index itself, always the same slot<\/li>\n<li><code>MISSING_GAIN<\/code>: the Set Effect report has no Gain field<\/li>\n<li><code>MISSING_EBI<\/code>: the Envelope and Condition reports use Parameter Block Offset (<code>0x23<\/code>) where the driver expects Effect Block Index (<code>0x22<\/code>)<\/li>\n<li>a fix for the Direction field, which this firmware does not put in its own collection<\/li>\n<li>skip the \u00ab\u00a0device managed pool\u00a0\u00bb check, the wheel says itself it cannot do it<\/li>\n<\/ul>\n<p>Result: force, buttons, axes. It survives unplug and reboot.<\/p>\n<h2 class=\"wp-block-heading\">Why no constant force: three proofs<\/h2>\n<p>Constant force and periodic effects (sine, square&#8230;) do nothing on this wheel. With the PID driver, with <code>iforce<\/code>, 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.<\/p>\n<p>Proof 1, replay. I wrote the exact bytes captured from Windows directly to <code>\/dev\/hidraw0<\/code> with a small Python script (<code>os.write()<\/code>), with no kernel driver at all. One burst, with an internal type byte <code>0x0a<\/code>: the wheel pulls to the left. So the method works. The burst tagged <code>0x26<\/code> (the standard HID PID usage for Constant Force), byte for byte, init line included: nothing. Two times.<\/p>\n<p>Proof 2, the official panel. The Force tab of the Windows driver (Immersion TouchSense, the real signed driver from Saitek) has exactly four settings:<\/p>\n<ul class=\"wp-block-list\">\n<li>Default Gain<\/li>\n<li>Spring Gain<\/li>\n<li>Damper Gain<\/li>\n<li>Default Spring (always on)<\/li>\n<\/ul>\n<p>No constant force, no periodic. The people who made the wheel don&rsquo;t offer it.<\/p>\n<p>Proof 3, the disassembly. This is my favourite part.<\/p>\n<p>First, get the driver without booting Windows: the VM disk mounted read-only with <code>guestmount<\/code> (libguestfs), and the Saitek package copied out:<\/p>\n<ul class=\"wp-block-list\">\n<li><code>saiIFF32.sys<\/code>: the Immersion USB\/HID minidriver<\/li>\n<li><code>saiHFF32.sys<\/code>: the kernel driver<\/li>\n<li><code>sai2FF32.dll<\/code> \/ <code>saiQFF32.dll<\/code>: the \u00ab\u00a0Immersion Physical Interface Device Driver\u00a0\u00bb<\/li>\n<li><code>saicFF32.dll<\/code>: the 2 MB control panel<\/li>\n<li><code>SaiFF32.inf<\/code><\/li>\n<\/ul>\n<p>The <code>.inf<\/code> already tells a story: for <code>VID_06A3&amp;PID_FF32<\/code> it registers the full DirectInput effect list (Constant, Ramp, Square, Sine, Triangle, Sawtooth, Spring, Damper, Inertia, Friction, Custom). So any Windows game that asks \u00ab\u00a0can you do constant force?\u00a0\u00bb gets \u00ab\u00a0yes\u00a0\u00bb.<\/p>\n<p>Then the code. A first try with <code>pefile<\/code> + <code>capstone<\/code> scripts found the <code>WriteFile<\/code> call that sends the effects, but the real logic is behind indirect calls that linear disassembly cannot follow. So: Ghidra 12, headless (<code>analyzeHeadless<\/code>, OpenJDK 21), with a small Java GhidraScript for the analysis:<\/p>\n<ol class=\"wp-block-list\">\n<li><code>FUN_1000ad90<\/code>: a generic \u00ab\u00a0send a raw HID report\u00a0\u00bb function. Critical section, <code>memcpy<\/code>, <code>WriteFile<\/code> (overlapped). It knows nothing about effect types.<\/li>\n<li>Ghidra&rsquo;s call graph (<code>getCallingFunctions()<\/code>): 10 functions call it, one per report type.<\/li>\n<li>Two of them, fully decompiled:<\/li>\n<\/ol>\n<ul class=\"wp-block-list\">\n<li><code>FUN_1000bb30<\/code>, Set Constant Force: usage <code>0x22<\/code> (Effect Block Index), then <code>0x70<\/code> (Magnitude)<\/li>\n<li><code>FUN_1000b0d0<\/code>, Set Condition (Spring, Damper, the effects that work): usages <code>0x22<\/code>, <code>0x23<\/code>, <code>0x60<\/code> to <code>0x65<\/code><\/li>\n<\/ul>\n<p>Both are built exactly the same way:<\/p>\n<ol class=\"wp-block-list\">\n<li>fill the report with <code>HidP_SetUsageValue<\/code> \/ <code>HidP_SetScaledUsageValue<\/code> (Usage Page <code>0x0F<\/code>, PID)<\/li>\n<li><code>memcmp<\/code> with the last report sent, stop if nothing changed<\/li>\n<li>send it with <code>FUN_1000ad90<\/code><\/li>\n<\/ol>\n<p>No special case, no empty function, no early return. The Windows driver sends constant force exactly like the spring.<\/p>\n<p>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.<\/p>\n<h2 class=\"wp-block-heading\">Today<\/h2>\n<ul class=\"wp-block-list\">\n<li>Spring, Damper, gain and autocenter: OK, with two different drivers<\/li>\n<li>SuperTuxKart: force feedback on, and its log says \u00ab\u00a0buttons: 7, axes: 3, hats: 0\u00a0\u00bb<\/li>\n<li>Speed Dreams: autocenter works (it uses Spring), the constant force effects are turned off<\/li>\n<li>Constant and periodic effects: never<\/li>\n<\/ul>\n<h2 class=\"wp-block-heading\">How AI was used<\/h2>\n<p>This was a real team effort with an AI coding agent (Claude Code), running on the laptop:<\/p>\n<ul class=\"wp-block-list\">\n<li>the agent wrote the 60-line module, the HID descriptor parser, the replay scripts and the DKMS quirks<\/li>\n<li>the agent set up Ghidra headless by itself and wrote the GhidraScript that found and decompiled the functions above<\/li>\n<li>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<\/li>\n<li>the first diagnosis (\u00ab\u00a0a kernel bug, nothing to do\u00a0\u00bb) came from the agent too. A second review challenged it and found the missing <code>hid_hw_stop()<\/code>. So: useful, fast, but check its conclusions.<\/li>\n<\/ul>\n<p>Still to do: add <code>06a3:ff32<\/code> to the <code>iforce<\/code> table and to the <code>hid-universal-pidff<\/code> list, and send the patches upstream. The next owner of this wheel would get force feedback out of the box.<\/p>\n<p>Note: I don&rsquo;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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A 2007 Saitek wheel on Linux: one kernel crash, the I-Force protocol, and three proofs that some effects were never in the hardware.<\/p>\n","protected":false},"author":0,"featured_media":551,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-552","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-non-classe"],"_links":{"self":[{"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=\/wp\/v2\/posts\/552","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=552"}],"version-history":[{"count":5,"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=\/wp\/v2\/posts\/552\/revisions"}],"predecessor-version":[{"id":626,"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=\/wp\/v2\/posts\/552\/revisions\/626"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=\/wp\/v2\/media\/551"}],"wp:attachment":[{"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=552"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=552"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.fabidouille.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=552"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}