Status: the kernel driver leaves interface 2's hidraw node open for userspace. Two userspace paths consume that node:
- Proton sims (verified working):
tools/install-tf-shim.shcopies Logitech's own Authenticode-signed SDK DLLs (trueforce_sdk_x64.dll,logi_steering_wheel_x64.dll) into each Wine prefix and registers the CLSIDs. The unmodified DLLs run inside Wine and write to the wheel via Wine's HID stack, which reaches our kernel driver. No shim, no IAT hooks, no certificate spoofing. End-to-end verified against Assetto Corsa Competizione and Assetto Corsa EVO under Proton on RS50 in both modes: G PRO compatibility mode (046d:c272, 2026-04-26 / 2026-04-29) and native mode (046d:c276, AC EVO, 2026-07-08). The native run was usbmon-confirmed: Wine's HID backend opened interface 2 on the native PID and the SDK streamed ~2 kHz of type-0x01 packets (239k OUT over ~120 s) on ep 0x03. The SDK DLLs open and drive the wheel identically at either PID, so compat mode is not required for TrueForce.- Native Linux apps:
userspace/libtrueforce/is a native C reimplementation of the same protocol described below. Useful for Linux apps that want to drive TrueForce directly (telemetry-driven haptic generators, custom test rigs, etc.).Originally reverse-engineered from issue #5 captures (BeamNG.drive + G Pro, contributed by @SandSeppel) and re-verified 2026-04-21 against an RS50 + ACC capture on the same host. The two wheels use byte-for-byte identical init and streaming packets.
TRUEFORCE is a high-frequency audio-haptic stream that supplements traditional PID force feedback. Rather than low-rate constant-force updates (~50-100 Hz via HID SET_REPORT), TRUEFORCE sends a ~1000 Hz audio waveform directly to the wheel's DSP, which drives the motor with much higher fidelity.
The protocol runs entirely on USB Interface 2 (endpoints 0x03 OUT / 0x83 IN), which the kernel driver delegates to hidraw. No HID++ feature activation is required; userspace opens the hidraw node for interface 2 and starts writing.
Verified against three wheels:
| Wheel | PID | Capture / verification | Date |
|---|---|---|---|
| RS50 (PlayStation/PC) | 046d:c276 |
ACC gameplay | 2026-04-21 |
| G Pro Racing Wheel | 046d:c272 / 046d:c268 |
BeamNG.drive gameplay | 2026-04-19 |
| G923 (PlayStation/PC) | 046d:c266 |
logi-tf-sim driven tone on hardware |
2026-07-26 |
The 68-packet init sequence is identical across the RS50 and G Pro, byte-for-byte. The streaming packet layout (type 0x01) is also identical. Treat TRUEFORCE as a single protocol across the direct-drive wheel family.
The G923 Xbox edition (c26e) carries it too, confirmed 2026-08-10 from a
Windows capture of that wheel under Automobilista 2 (issue #27). Over roughly
40 seconds it received 17597 type-0x01 stream packets on its 0xFFFD
interface, against 24 HID++ 0x8123 commands which are setup only
(RESET_ALL, gains, effect create/play). The stream matches the documented
layout byte for byte: byte[5] a rolling sequence, bytes[6-9] cur duplicated,
byte[10] the new-sample count, 00 80 00 80 00 at idle and 04 samples while
driving. So its force arrives on the stream, not over HID++, and simulated
TrueForce should be able to drive that wheel the same way it drives a c266.
The gear-driven G923 carries the same interface-2 transport and stream protocol (first established by the TF4ALL project's Windows captures, issue #20; hardware-confirmed on a c266 by logi-tf-sim's synthetic sweep). One G923-specific caveat: while a type-0x01 stream runs, the wheel's motor follows the stream's cur field and stops reacting to its classic interface-0 force-feedback commands, so a G923 streamer must mirror the live FFB into cur for the stream's duration - logi-tf-sim reads the kernel driver's ffb_output sysfs attribute for exactly this (see SYSFS_API.md). Feel under real game telemetry is not yet verified on the G923.
| Metric | Without TRUEFORCE | With TRUEFORCE |
|---|---|---|
| Interface 2 data packets | 0 | tens of thousands per gameplay session |
| PID constant force updates (intf 1) | ongoing | small, init only |
Endpoint 0x03 packet rate |
idle | 1000 Hz, all senders: libtrueforce, the kernel's unified stream, and games (AC EVO already used 1000; 250-500 seen in older captures). 1000 is both Logitech's stated 1 ms TRUEFORCE interval and the USB interrupt-endpoint ceiling. |
| Samples per packet (new) | N/A | 4 new + 9 history = 13-slot rolling window |
| Effective audio sample rate | N/A | ~1000 Hz (250 pkt/s * 4 new samples) |
When TRUEFORCE is active, traditional PID FFB is used only for initial setup and occasional parameter changes. The high-frequency force data moves entirely to the audio stream.
Usage Page: 0xFFFD (vendor-defined)
Usage: 0xFD01
Report ID: 0x01
Size: 63 bytes IN + 63 bytes OUT (64 bytes total with report ID)
byte[0]: 0x01 Report ID
byte[1-3]: 0x00 0x00 0x00 Padding
byte[4]: COMMAND_TYPE See command table
byte[5]: SEQUENCE Rolling u8 counter (0x00-0xFF wrap), shared across all types
byte[6..]: PAYLOAD Type-specific
The sequence counter is rewritten at send time from a session-local counter. Each pass through the init sequence restarts the counter at 1; after init the stream continues from where init left off.
| Type | Purpose |
|---|---|
0x01 |
Audio data stream (dominant during gameplay) |
0x03 |
Start / play |
0x04 |
Stop / clear |
0x05 |
Parameter upload (48 floats, one per packet) |
0x06 |
Effect slot configuration (6 slots) |
0x07 |
Query / handshake |
0x09 |
Runtime parameter update |
0x0b |
Unknown (observed from AC EVO's session init with float 1.0) |
0x0e |
Operating range, IEEE 754 LE float degrees |
G Hub sends a 68-packet init sequence, then sends the same 68 packets a second time (sequence counter reset to 1 at the start of each pass) before the main per-sample stream begins. The init must be sent twice: a single pass is unreliable on cold boot. Both the 2026-04-19 G Pro + BeamNG and 2026-04-21 RS50 + ACC captures show the duplicate pass.
The 68 packets are stored verbatim in userspace/libtrueforce/src/tf_init_data.h. Breakdown:
| Packets | Type | Purpose |
|---|---|---|
| 1-48 | 0x05 |
48 parameters (indices 0x00-0x1d and 0x2b-0x3c) as IEEE 754 LE floats |
| 49 | 0x01 |
Neutral sample (primes the stream) |
| 50 | 0x0e |
Operating range = float 2700.0 (the wheel's max range). A pre-START no-op: 0x0e only takes effect between 0x03 START and 0x04 STOP, and this packet precedes the START at packet 68 (see the type-0x0e session-scoping notes below) |
| 51 | 0x01 |
Neutral sample |
| 52 | 0x07 |
Handshake / query |
| 53 | 0x01 |
Neutral sample |
| 54, 56, 58, 62, 64, 66 | 0x06 |
Effect slot configurations (slots 1-6) |
| 55, 57, 59, 61, 63, 65 | 0x01 |
Neutral sample between each slot config |
| 60 | 0x09 |
Runtime parameter update |
| 67 | 0x04 |
Stop / clear |
| 68 | 0x03 |
Start / play |
Key type-0x05 parameter values, for reference. Note: the committed
init we replay (userspace/libtrueforce/src/tf_init_data.h) sends every
type-0x05 packet with a zero value payload (only the index byte
varies), and TrueForce still works end-to-end, so these specific values
are not required for basic operation. The table below records the
non-zero values decoded from G Hub's own init capture, kept as a guide to
what each index means:
| Index | Value | Likely meaning |
|---|---|---|
0x00 |
2.0 | Channel count or mode |
0x02 |
32768.0 | Max amplitude (0x8000) |
0x03 |
65535.0 | Max range (0xFFFF) |
0x07 |
5.4054 | Damping coefficient? |
0x09 |
0.3 | Gain? |
0x0c |
47.1239 (15 pi) | Angular rate limit? |
0x0d |
1.5708 (pi/2) | Phase offset? |
0x0e |
-9.4248 (-3 pi) | Filter parameter? |
0x0f |
9.4248 (3 pi) | Filter parameter? |
0x10 |
13.0 | Samples per packet (matches the streaming window) |
0x12 |
4000.0 | Max frequency? |
0x14 |
2000.0 | Crossover frequency? |
0x1d |
4.0 | New samples per packet (matches the streaming 0x04 constant) |
0x33 |
350.0 | Crossover frequency? |
Read from a Windows capture of G HUB and iRacing on an RS50 (2026-07-27),
which carries both directions of interface 2 through a complete session
start; the mid-stream captures elsewhere in this project only show the
0x01/0x02 pair. Header 01 00 00 00 stripped, bytes after the payload
zero.
| Host sends | Wheel answers |
|---|---|
05 <seq> <index> |
05 <seq> <index> 09 <index> <float32 LE> <byte>: the wheel's current value for that index, not an echo. G HUB sent every 0x05 with a zero payload and still got 00 -> 2.0, 30 -> 3.0, 01 -> 1.0, 02 -> 32768.0, 03 -> 65535.0, the values in the table above. The last byte varies like byte 17 of a status report. |
07 <seq> 00 |
07 <seq> 06 then six triples <slot> 01 <flag> (01 01 01, 02 01 01, 03 01 01, 04 01 01, 05 01 00, 06 01 01) and a trailing byte: the six effect slots and their state. |
06 <seq> 01 01 |
a type 10 report with the same seq, laid out like a status report but with ff ff ff ff where the position pair sits. |
0e <seq> <float> |
a type 11 and a type 14 report, status layout, with the range float where the position pair sits. |
04 <seq>, 03 <seq> |
type 02 status reports carrying that seq, which then continue at the stream rate. |
09, 0c |
no reply of their own; the 02 stream continues. |
A set-up exchange from that capture, in order:
OUT 05 01 00 IN 05 01 00 09 00 00 00 00 40 b1 00
OUT 05 02 30 IN 05 02 30 09 30 00 00 40 40 10 00
OUT 05 03 01 IN 05 03 01 09 01 00 00 80 3f 2e 00
OUT 05 04 02 IN 05 04 02 09 02 00 00 00 47 a3 00
OUT 05 05 03 IN 05 05 03 09 03 00 ff 7f 47 22 00
OUT 07 34 00 IN 07 34 06 01 01 01 02 01 01 03 01 01 04 01 01 05 01 00 06 01 01 9f
OUT 06 36 01 01 IN 10 36 cd bf 03 00 80 00 80 82 b3 1d 0a ff ff ff ff 00 00 80 07 00 00 1d 95 00 00 1d 95 e8
OUT 04 43 00 IN 02 43 ca bf 03 00 80 00 80 b4 30 1e 0a 13 00 00 80 00 00 80 07 00 00 1d 95 00 00 1d 95 a5
OUT 03 44 00 IN 02 43 ca bf 03 00 80 00 80 52 38 1e 0a 81 00 00 80 00 00 80 07 00 00 1d 95 00 00 1d 95 91
Each 0x05 reply arrived about 2 ms after its question, and G HUB waited
for it before sending the next, so a host that expects the wheel to answer
every parameter (a PlayStation 5 does, per #131) is
expecting exactly this.
byte[0-3]: 01 00 00 00 Report header
byte[4]: 01 Command type
byte[5]: sequence Rolling counter
byte[6-7]: u16 LE Most-recent sample (newest-so-far)
byte[8-9]: u16 LE Duplicate of bytes 6-7
byte[10]: 0x04 Number of new samples in this packet (0x00 when none)
byte[11]: 0x0d Sample-window valid flag: 0x0d whenever byte[10] != 0,
0x00 when byte[10] = 0 (demux pair, see invariants below)
byte[12-15]: window[0] L, window[0] R (u16 LE each, mono duplicated)
byte[16-19]: window[1]
...
byte[60-63]: window[12]
Layout invariants observed across captures and replicated in src/stream.c:
- The 13-slot rolling window holds the most recent samples, oldest at
window[0], newest atwindow[12]. - Each packet advances the window by as many samples as byte 10 declares; that many fall off the front. G Hub sends 4 almost always and 5 in some captures, so the field is a count and not a constant.
libtrueforcesends 4 in the steady state, fewer when a drain came up short, and up to 12 (LOGITF_TF_CATCHUP_MAX) on a packet making up a coalesced timer expiry. 13 is the ceiling the window can express. - Every u16 sample is duplicated (L and R channels). The wheel is single-motor, the stereo duplication is ceremonial.
- Values are unsigned 16-bit little-endian, offset binary (centre
0x8000,0x0000= full left,0xFFFF= full right). - The preamble at bytes 6-9 ("cur") is the motor torque target, duplicated as
two u16 LE. While a TrueForce session is active the wheel steers by cur and
the window plays additively on top as audio; cur OVERRIDES the HID++ 0x8123
force path. In G Hub/SDK captures cur usually tracks the newest window
sample only because the games stream their FFB there. AC EVO carries its
game force in cur; the independent audio in the window of the same packet
is NOT the game's - AC EVO never calls the SDK's texture entry points
(
SetTorqueTF/SetStreamTF, never even probesGetTorqueTFRateBounds), so the window content in Windows captures is the driver stack's own synthesis, fed by the game's Escape telemetry and serialized into the same stream as the game's SDK force (which is why the sequence counter is continuous). The kernel driver's texture merge replicates that architecture. (cur semantics from the TF4ALL project's Windows captures; synthesis attribution established 2026-08-14.) - Bytes 10 and 11 are a demux pair, not independent constants
(hardware-proven 2026-08-14): every sample-carrying packet pairs
byte10 != 0withbyte11 = 0x0d, and every no-new-samples packet (menu keepalive, plain force) pairsbyte10 = 0x00withbyte11 = 0x00. The combinationbyte10 = 0x04+byte11 = 0x00never occurs in Windows captures, and the wheel silently discards the whole sample window of such a packet while still honouring its cur bytes - a wire-perfect texture stream that renders nothing. Any producer that adds samples to a packet must also stamp0x0d.
Packet cadence in libtrueforce is 1000 Hz (4 new samples * 1000 Hz = 4000 sample/s effective); the kernel driver's unified stream runs at the same 1000 Hz (4 kHz slot rate, 4 kHz unique content), driven by an hrtimer because a self-rearming jiffies timer cannot hold this rate. Games vary: ACC captures show 250-500 pkt/s, AC EVO up to ~1000 pkt/s (4 kHz audio) per TF4ALL measurements - the wheel accepts the whole range. If userspace can't keep up the thread holds the window for LOGITF_TF_STARVE_HOLD_TICKS and then flushes it toward centre while the held force unwinds (Windows does something similar under input starvation). If userspace overruns the transport, logitf_stream_push_s16() drops the OLDEST queued samples to hold the backlog to LOGITF_TF_MAX_PENDING_MS of audio, counting and reporting them; it does not block.
The stream has exactly one owner, and the endpoint enforces it whether
the software agrees or not. Endpoint 0x03 is an interrupt OUT with a
1 ms interval, so it carries one packet per millisecond in total, not
one per writer. Two programs each streaming at 1 kHz therefore do not
share it, they take turns on it: each gets every other frame.
What that does to the wheel is worse than halving a rate. Bytes 6-9 are a level, not an event: the wheel holds the last value it was given. So with two writers the torque target alternates between their two values every millisecond, which is a 500 Hz square wave on the motor. Measured on an RS50 with the kernel driver and a userspace producer both streaming:
| two writers | one writer | |
|---|---|---|
| packets carrying samples | 49% | 99% |
| gap between sample packets | 2.000 ms | 1.000 ms |
| samples delivered per second | 1934 | 3860 |
| 450-500 Hz content in the torque field | dominant | none |
(The one-writer column is the finished state. Making the driver yield took the rate from 1934/s to 3635/s on its own; the remaining gap was a producer discarding coalesced timer expirations, and closing that reached 3860/s against a 4000/s clock.)
Audibly this is a fixed buzz that does NOT move with the engine note, at exactly 500.00 Hz with a strong third harmonic and almost no second: the odd-harmonic signature of a level stepping every 2 ms rather than a resonance (issue #59). The samples also play at half rate, an octave low.
Consequences for anyone implementing this protocol:
- Take the stream, do not join it. Before streaming, establish that nothing else is: on Linux the kernel driver yields automatically (it detects a userspace writer on interface 2 and stops sending its own packets), but two userspace programs must arbitrate between themselves.
- The torque field belongs to whoever owns the stream. When the driver yields it does not go silent: if it has force of its own to apply it writes that 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. It leaves those bytes alone when it has no effect running, because a native SDK session puts the GAME's force there and overwriting it would replace force feedback with dead centre.
- A silent endpoint is the normal idle state. With nothing streaming
there is no traffic at all on
0x03; G HUB holds no idle session open, and neither should anything else (see the whine notes in the session-state section).
Decoded 2026-07-02 from a live usbmon capture of an AC EVO launch on
Linux: type-0x0e carries the wheel's operating range as an IEEE 754
LE float in degrees. Byte-exact layout:
byte[0]: 01 Report header
byte[4]: 0e Command type
byte[5]: sequence Rolling counter
byte[6-9]: f32 LE Operating range, degrees
Evidence:
- The canonical init's packet 50 carries
2700.0- exactly the wheel's maximum range, not a plausible sample rate. - AC EVO's SDK session init appends a second
0x0ewith90.0(01000000 0e <seq> 0000b442), and the wheel's physical range flips 900 -> 90 in the same 20-second window with ZERO HID++ traffic on interface 1 (confirmed: the only interface-1 range packets in the entire capture are the Linux driver's own polls, whose replies flip from 900 to 90). - Two captured frames, byte-for-byte, confirm bytes 6-9 and not any
neighbouring offset:
90.0=01 00 00 00 0e 46 00 00 b4 42,2700.0=01 00 00 00 0e 32 00 c0 28 45(2026-08-14 usbmon, AC EVO session init).
A session init sends multiple 0x0e pushes, not one: the
2026-08-14 capture shows 2700.0, then 90.0, then 2700.0 again in
the same init, which reads as the SDK negotiating its bounds around
the clamp rather than writing the final value once. Consumers of this
packet (the kernel driver's decode, any replay tooling) must not
assume a single push per session and must track the latest value seen,
not the first.
This is logiWheelSetOperatingRange*() on the wire, and it explains
why the launch-time range reset never produced a HID++ broadcast: it
does not go through the HID++ range feature at all. Games push their
configured steering rotation here at session start; a game whose
rotation setting is 90 (or defaulted) locks the wheel to 90 degrees.
The kernel driver's 20 s range poll detects the change and, by
default, restores the pre-reset range automatically (the
wheel_range_restore sysfs attribute; verified end-to-end with a
detection-to-restore latency of ~60 ms against a faithful replay of
the game traffic). Re-applying a range via HID++ sticks - the SDK
write is one-shot at session init.
Two firmware behaviours discovered while reproducing this (2026-07-03, live wheel):
- Type-
0x0eis session-scoped, and the scope is the started stream, not the init (sharpened 2026-08-14, live wheel + felt stops): a0x0epush is ignored on an idle interface AND after the init sequence alone - the canonical init's own2700.0pushes are no-ops because they precede the0x03START. The range write only takes effect between0x03START and0x04STOP. A mid-stream push applies immediately (900 -> 90 clamp felt at the rim; the wheel broadcasts the change over HID++). - Idle revert: if a TF session goes quiet (no stream packets, roughly a minute) the firmware reverts the session's range change on its own and broadcasts the restored value over HID++. A running game keeps its session alive, which is why real launch-time resets persist.
Hardware-proven across many sessions: logiWheelSetForceMode(1)
latches in the wheel itself, not just in the SDK, and stays latched
until a REAL power cycle. A USB reset is not enough, because the base
has its own PSU independent of the USB bus. Consequences, both
observed on real hardware:
- A second SDK session started without a power cycle comes up with a
dead haptic thread: status
0x80000008, no ep3 OUT stream at all. - A hard-killed session (game terminated rather than closed cleanly)
leaves the wheel's stream engine running on its own: the wheel keeps
streaming ep
0x83IN reports at 2 kHz indefinitely, with nothing on the host consuming them, until the base is power-cycled.
Refinement (2026-08-14): after a session dies without its teardown
reaching the wheel (hard kill, crash), the next session's
logiWheelSetForceMode(1) returns success yet there is zero ep3
traffic in both directions - and a healthy session also carries a
~1 kHz ep3 IN status stream, so its absence is the diagnostic, not
just missing OUT packets. Power-cycling the base before launch
recovers it. Running logi-tf-init between sessions is
untested/inconclusive as a recovery. An experimental pre-launch re-arm
exists: LOGI_TF_REARM=1 makes logi-launch send the 0x04+0x03
pair and then the 68-packet init twice from tools/tf-init.bin before
the game starts; it is off by default pending hardware validation.
Anyone reproducing SDK captures or writing session teardown code needs to power-cycle the base between sessions, not just close and reopen the handle.
LED / HID++ traffic during a live session (hardware, 2026-08-14):
the driver's old rev-LED form - an arm burst plus per-level fn3
activation - was fatal to a native TrueForce SDK session: sent at init
it killed the session, sent mid-session it killed wheel input. The
current form (bare SHORT fn2 GET_STATE + LONG fn6 level, nothing else)
coexists cleanly: base FFB + TrueForce texture + telemetry-driven rev
LEDs are hardware-validated running together. See
PROTOCOL_SPECIFICATION.md section 9 for the rev-LED wire protocol.
Which wheels honour it (2026-07-30):
| Wheel | Honours type-0x0e? |
|---|---|
| RS50 | yes, root-caused here |
G923 Xbox (c26e) |
no, see below |
G923 PlayStation (c266) |
no, tested on hardware |
The Xbox edition is disproved on hardware: in ACC's own config screen the rim turns its full 900 degrees and the input bar tracks the whole way, with the limit appearing only on track. A real operating-range change would soft-stop the rim everywhere, menus included. So the wheel is never reconfigured on that edition; the game clamps its own steering because it believes the wheel has 90 degrees of travel, which it gets from the TrueForce SDK falling back to the minimum of the legal range when it cannot reach G HUB. Nothing on the wheel is wrong, so there is nothing for a range restore to put back.
The PlayStation edition was tested directly: a live TrueForce session was
established (full init sequence sent, silent stream running, confirmed by the
daemon's own stream start), a type-0x0e carrying 90.0 was pushed on the
0xFFFD transport, and the rim then turned its full travel with no early soft
stop. Repeated with the session up rather than idle, since the first attempt
made exactly the mistake the session-scoping note above describes, and a bare
push on an idle interface is a no-op on every wheel including the RS50.
That bounds the operating-range restore work to the wheels whose force rides
HID++: the direct-drive wheels, which already heal it, and the G923 Xbox
edition, which has range_restore (see docs/SYSFS_API.md). The PlayStation
edition's classic engine exposes no range readback at all, so it is fortunate
rather than incidental that it needs none.
AC EVO's init also differs from the canonical G Hub init in two more
packets: a type-0x0b with float 1.0 (purpose unknown) and a
type-0x09 carrying floats 1.0 and 350.0.
byte[0-3]: 01 00 00 00 Report header
byte[4]: 02 Response type
byte[5]: sequence Echoes command sequence
byte[6-7]: u16 LE NOT current or temperature (see below)
byte[8]: 0x03 Status byte?
byte[9-10]: wheel_position (LE16) Matches joystick axis data
byte[11-12]: wheel_position2 Slightly delayed (~1 sample behind)
byte[13-16]: 32-bit counter Timestamp or sample counter
byte[17]: varying Checksum-like
byte[18-32]: status/counters
byte[33-63]: zeros
Bytes 6-7 are not a motor load reading. They were guessed to be current
or temperature, which would have made them the only route this project has to
answering "how hard is it safe to drive this motor". Tested on an RS50
(2026-08-08) with tests/motorlog, which drives a known amplitude and then
pushes exact silence while still sampling, because current collapses with the
force and temperature decays over tens of seconds:
- The value slides steadily downward at the same rate whether the wheel is being driven hard or sitting silent, which neither hypothesis allows.
- It wraps through zero to 0xFFFF and then goes flat exactly when the status byte changes 3 -> 2, so it is tied to stream state rather than to load.
- Across amplitudes 0.05, 0.30 and 0.60 its range overlaps almost completely (min 1105 to max 62794 at the lowest amplitude), so per-amplitude statistics are dominated by the wrapping.
Counter-like, and still undecoded. What matters for anyone arriving with the same idea: there is currently no known telemetry from these wheels that reports motor load, current or temperature, so amplitude limits have to be argued from what the motor is commanded to do, not measured from what it reports.
Responses arrive at the same cadence as the host's packet rate, giving real-time wheel-position feedback for synchronisation. libtrueforce's stream thread consumes them while a stream is active and exposes the latest snapshot via the Linux-native logitf_get_stream_feedback() API (wheel position, device counter, and the still-undecoded motor/status fields); the kernel driver ignores them.
Classic PID-style FFB is addressed by HID report ID (0x10/0x11),
distinct from TRUEFORCE's report ID 0x01; the wheel firmware
demultiplexes the two by report ID and they coexist (verified by playing
a sine on TRUEFORCE while holding a constant-torque KF effect).
Note on interfaces: on the G920-class HID++ path these PID reports are
addressed to interface 1, but the RS50 has no FFB OUT endpoint on
interface 1. The Linux driver actuates the RS50's constant force by
writing to interface 2 endpoint 0x03 (the dedicated hidpp_dd_ff_*
path), the same interface TRUEFORCE uses; the two are still separated by
report ID. See PROTOCOL_SPECIFICATION.md
section 4 for the constant-force (KF) subset of these same ep-0x03
packets and the wider HID++ / device reference.
Format: [10 FF <cmd> <param1> <param2> <param3>]
| Command | Description |
|---|---|
| ff 10 | Constant force update |
| ff 00 | Effect stop/reset |
| ff 01 | Effect start |
| ff 0f | Effect create/allocate |
| ff 17 | Set envelope |
| ff 08 | Set condition |
| ff 02 | Set effect type |
| ff 0d | Set periodic |
| ff 0a | Set constant force params |
| ff 09 | Set ramp |
Extended command format: [11 FF 10 2e 01 80 00 00 00 00 XX XX ...]
Used for constant force values with extended precision.
| Path | Purpose |
|---|---|
userspace/libtrueforce/src/tf_init_data.h |
68 canonical init packets, auto-generated from capture, sent twice at session bring-up |
userspace/libtrueforce/src/session.c |
logitf_session_ensure() opens the hidraw node and runs the two-pass init |
userspace/libtrueforce/src/stream.c |
250 Hz timerfd loop, 13-slot rolling window, logitf_stream_push_s16() / _clear() / _start/stop() |
userspace/libtrueforce/include/trueforce.h |
Mirrors the 62 exports of trueforce_sdk_x64.dll (Windows SDK) so a Linux app can call the same API surface |
- libtrueforce consumes type-
0x02device responses while a stream is active and exposes them vialogitf_get_stream_feedback()(2026-07-02). The motor field (bytes 6-7), status byte (8), and byte 17 checksum-like field are still undecoded; correlating the motor field against commanded torque on a live wheel would pin it down. The constant flag word at byte 11 (Resolved 2026-08-14: byte 11 is the sample-window valid flag, one half of the byte10/byte11 demux pair (see the stream layout invariants above); the wheel discards the window of any packet that carries samples without it. Value0x0d) is passed through verbatim; its exact meaning is still not decoded.0x05has been seen instead of0x04in byte 10 in some captures, corresponding to 5 new samples; libtrueforce sent the 4-new-samples variant exclusively until it began declaring the real count (4 in the steady state, fewer on a short drain, up to 12 when catching up a coalesced timer expiry).- Per-title parameter variation (are the 48 init floats game-specific or universal?) is unconfirmed. So far the same data produces audible TRUEFORCE across BeamNG and ACC.
The type-0x0e section above explains how a range reaches the wheel. This
explains why the number is so often 90, which was open for a long time and is
now settled.
trueforce_sdk_x64.dll contains a local IPC layer, logi::local_connection,
with a client, a server, and the pipe name logi.trueforce.connect compiled
in. On Windows the peer is G HUB. Under Proton nothing serves that pipe. From
a reporter's Wine log, repeating for as long as the game runs:
CreateFileW L"\\.\pipe\logi.trueforce.connect" ... creation 3
CreateFileW Unable to create file ... (status c0000034)
creation 3 is OPEN_EXISTING and c0000034 is STATUS_OBJECT_NAME_NOT_FOUND,
so the SDK is the client, nothing answers, and it retries forever, which
matches the library's own string Client failed to connect: polling again in %ld ms.
With no peer, a game asking how far the wheel turns gets 90: not a value anybody chose but the minimum of the legal 90-2700 range, which is why the symptom is always exactly 45 degrees each way.
Answering that pipe ourselves is not possible. On connect the SDK calls
GetNamedPipeServerProcessId, resolves the server to an executable with
K32GetModuleFileNameExA, runs WinVerifyTrust on it and reads the signer
with CertGetNameStringW. The string Logitech Inc is compiled in. A probe
serving that pipe is dropped in about a millisecond, in both message and byte
mode, which is what a rejected peer looks like rather than a rejected framing.
What is possible is answering the question the SDK cannot. A game loads the
SDK through a CLSID this project's shim installer writes, so the library it
loads is ours to choose: see tools/tf-range-proxy.c, which forwards every
other call to Logitech's own library and answers only the rotation getters.
Signatures for those are in docs/SDK_ABI_NOTES.md, taken from the library's
machine code rather than from any header.
Assetto Corsa Competizione resolves 56 symbols from this SDK, including all four rotation getters, and none at all from the older Steering Wheel SDK, so for that title this is the right library to answer through.