Skip to content

Protocol Force Feedback

mescon edited this page Aug 18, 2026 · 5 revisions

Protocol: Force Feedback

How force reaches the motor: the dedicated interface-2 endpoint stream, the packet encoding, and the driver's software effect engine. Part of the Protocol Specification; the audio-haptic layer that shares this endpoint is in TrueForce Protocol.

One packet family, two roles

Endpoint 0x03 OUT (interface 2) carries one 64-byte type-0x01 packet family. Byte 10, the "new samples this packet" count, and byte 11, its 0x0d marker, demultiplex it as a pair:

  • 0x00/0x00: a pure constant-force ("KF") update, documented here.
  • nonzero/0x0d: a unified force+audio ("TF") packet whose bytes 12-63 carry a rolling haptic sample window on top of the same force field; see TrueForce Protocol for the full framing.

The pair is strict: byte 10 = 0x04 with byte 11 = 0x00 never occurs on the wire, and the wheel silently discards such a packet's samples.

Constant-force report (64 bytes, byte 10 = 0)

Offset  Size  Type    Description
------  ----  ----    -----------
0       1     u8      Report ID (0x01)
1-3     3     -       Reserved (0x00 0x00 0x00)
4       1     u8      Command type (0x01 = stream/sample packet)
5       1     u8      Sequence counter (0x00-0xFF, wraps)
6-7     2     u16 LE  Force value / motor torque target ("cur")
8-9     2     u16 LE  Duplicate (must match bytes 6-7)
10      1     u8      New-sample count (0 here; nonzero = TF audio packet)
11      1     u8      Sample marker (0x00 here; 0x0d in a TF audio packet)
12-63   52    -       Zero for a constant-force packet

Bytes 6-9 are the motor torque target ("cur" in TrueForce terms) and are honoured whether or not the packet also carries audio: a constant-force packet is a TF stream packet with an empty sample window. The sequence counter is a single byte wrapping at 255 (unlike the two-byte HID++ sequence) and is shared across all interface-2 packet types.

Force encoding (offset binary)

Value Force
0x0000 maximum LEFT
0x8000 neutral
0xFFFF maximum RIGHT
uint16_t offset_binary = (int16_t)signed_value + 0x8000;

Rates

Games stream at 1000 Hz on Windows (median inter-packet gap ~1.0 ms in every gameplay capture), and the kernel driver's own force stream matches that, measured at 1000.2 Hz with a median period of 1.000 ms. It uses an hrtimer: a self-rearming jiffies timer cannot hold this rate, because the timer wheel never fires early, so every period comes back one jiffy long. There is no idle keepalive: the endpoint is silent when no effect plays. (An earlier revision documented an 05 07 "refresh" packet; capture re-analysis proved that was a DualShock 4 lightbar report to a different device that was plugged in at capture time, and the driver does not send it.)

One writer at a time

The endpoint is an interrupt OUT with a 1 ms interval, so it carries one packet per millisecond in total, not one per writer. Two senders at 1 kHz therefore take turns on it, one frame each, and since bytes 6-9 are a level the wheel holds rather than an event, the motor then alternates between their two targets every millisecond: a 500 Hz square wave, plus each side's samples at half rate. Measured on an RS50 with the driver and a userspace producer both streaming, with the before/after numbers and the audible signature, in the repo's docs/TRUEFORCE_PROTOCOL.md ("One writer at a time").

The rule that follows: take the stream, do not join it. The driver implements its half of that. It detects a userspace writer on interface 2 and stops sending packets of its own (stream_yield, on by default), and while yielding it does not go silent: when it has an effect of its own running it writes that net force into bytes 6-9 of the owner's packet on the way past, so one packet carries the owner's samples and the driver's force. With no effect running it leaves those bytes alone, because a native SDK session puts the game's force there and overwriting it would trade force feedback for dead centre. Two userspace programs get no such help from the endpoint and must arbitrate between themselves; the ones this project ships do it with a per-wheel lease (see Simulated TrueForce).

The driver's effect engine

The wheel firmware only understands raw constant forces on this endpoint, so the driver emulates the complete Linux FF effect set on top of it:

  • FF_CONSTANT, FF_RAMP, FF_PERIODIC (sine, square, triangle, saw up, saw down), and the four condition effects FF_SPRING, FF_DAMPER, FF_FRICTION, FF_INERTIA.
  • A 1 kHz hrtimer walks the active-effect slots, sums each effect's instantaneous contribution, applies FF_GAIN, and sends one net force value. The timer keeps running whenever any effect is playing, even if the current net force is zero (a spring at exact centre must push the moment the wheel moves).
  • Condition effects read the live wheel state (position from the interface-0 report, velocity and acceleration derived at the tick) and apply the standard ff_condition_effect formula; waveform and envelope semantics match Documentation/input/ff.rst.
  • Emulated springs get synthetic damping (wheel_spring_damping) because an undamped host-side spring on a low-friction direct-drive motor rings; see the Sysfs API Reference.
  • Vibration-class effects (rumble, periodics at 20 Hz or faster) can route to the TrueForce audio channel instead of the steering sum (wheel_texture_route), matching the Windows KF/TF split.
  • The driver can also merge a telemetry-driven engine texture into a game's own live TrueForce stream (wheel_tf_merge), stamping the byte-11 sample marker so the wheel renders it. This is the same merge G HUB's stack performs on Windows for Assetto Corsa EVO, which drives force through the SDK but produces no texture itself; see the Sysfs API Reference.
  • FFB must be sent from process context (workqueue); the driver uses hid_hw_output_report() on interface 2.

Architecture note: two force transports

The direct-drive wheels expose two independent force paths, and which one a host uses is a choice, not a hardware constraint:

  • HID++ feature 0x8123 fn2: the path Logitech's own Windows runtime uses for normal game FFB on these wheels. Report 0x11/0x12, device index 0xFF, signed int16 BE motor target at payload offset 10-11, sent at the game's rate (~140-333 Hz observed). Set-and-hold: the wheel maintains the last commanded force indefinitely with no keepalive.
  • Endpoint 0x03 (this page): the TrueForce session channel, used by SDK-native games and by this Linux driver for ALL force output. While a TrueForce session is active, the packet's "cur" field OVERRIDES the 0x8123 path, and the sample window plays additively on top.

The G920/G923 comparison in the Protocol Specification therefore describes defaults, not capabilities: the older wheels have only 0x8123, while the DD wheels have both. A minimal 0x8123-fn2 sender remains an untested alternative transport for this driver (potentially relevant if the wheel's FFB-filter smoothing turns out to apply only to that path); the G920-class slot-based 0x8123 engine, however, saturates the queue on these wheels and cannot be reused as-is.

Reference C structure

#define HIDPP_DD_FF_REPORT_ID       0x01
#define HIDPP_DD_FF_REPORT_SIZE     64

struct hidpp_dd_ff_report {
    u8 report_id;       /* 0x01 */
    u8 reserved[3];     /* 0x00, 0x00, 0x00 */
    u8 effect_type;     /* 0x01 = constant force */
    u8 sequence;        /* 0x00-0xFF, wraps */
    __le16 force;       /* 0x0000=left, 0x8000=center, 0xFFFF=right */
    __le16 force_dup;   /* duplicate of force value */
    u8 padding[54];     /* zeros */
} __packed;

Autocenter

These wheels have no hardware autocenter setting (G HUB exposes none); the driver provides a real host-side centring spring instead, driven by the autocenter sysfs attribute or the standard evdev FF_AUTOCENTER control, summed in the same 1 kHz loop.

Clone this wiki locally