Skip to content

hid-tmff2: add T500RS wheel support (constant force) - #221

Draft
cazzoo wants to merge 2 commits into
Kimplul:masterfrom
cazzoo:t500rs/constant-force
Draft

cazzoo wants to merge 2 commits into
Kimplul:masterfrom
cazzoo:t500rs/constant-force

Conversation

@cazzoo

@cazzoo cazzoo commented Sep 10, 2026

Copy link
Copy Markdown

Stacked on #220 (defer gain/autocenter to the workqueue).

Adds a T500RS driver on top of the core hid-tmff2 framework:

  • HID-mode init sequence (0x42 sync, slot STOP sweep, 0x40 autocenter teardown, 0x43 gain default); the b65d bootloader -> b65e mode switch is handled by hid-tminit.
  • Constant force effects only at this stage: hardware slot 0 with fixed subtypes (param 0x0e / env 0x1c), 0x03 level updates, mandatory zero-envelope 0x02, and a software expiry timer sending per-slot 0x41 STOPs (the wheel never auto-stops on its own).
  • Gain and range via the core parameters; autocentering routed through the core workqueue (T500RS callbacks do blocking HID I/O and must not run from atomic context).
  • Protocol reference: docs/T500RS_FFBEFFECTS.md (constant force sections). The shared docs/FFBEFFECTS.md is renamed to docs/T300RS_FFBEFFECTS.md since it documents the T300RS protocol.
  • udev rule for the SteamDeck variant.

Follow-up stages in this stack add periodic/ramp effects and passive (condition) effects.

@Kimplul

Kimplul commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Gain and range via the core parameters; autocentering routed through the core workqueue (T500RS callbacks do blocking HID I/O and must not run from atomic context).

This bit is the preparatory PR, and shouldn't really be included in this PR. You should be able to change the target of this PR to #220 instead of master, which splits off the commits that are not about the T500 constant force changes. That's the idea of stacked PRs anyway.

Comment thread docs/T500RS_FFBEFFECTS.md
`1, 2, 3, ...` assigned sequentially -> their `0x01` uploads and `0x41`
START/STOP packets carry that slot number (`41 01 41 ff` starts slot 1).

### Why the wheel never stops on its own

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was discussed previously, packet uploads have a duration field which presumably tells the wheel how long an effect should run. This contradicts that, no?

-1 };

/*
* Resolve the hardware effect slot index for a given effect.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is new, didn't we last time conclude that effect_id + 1 was the way to go?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We did, indeed, but after the captures I got from community members, I figured that it's really true. ID 1 is reserved for constant, and the following ones for the effects

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what happens if you try to upload two constant force effects on Windows?

Comment thread docs/T500RS_FFBEFFECTS.md
until stopped, so the driver sends `0xffff` ("infinite") and relies on its own
timer (paragraph 3).

### 5.2 Envelope - `0x02` (9 bytes)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you'd like, envelopes can be split off into a separate PR as well, since they're shared across several different effects. Documenting them here and then just saying "they're not used" feels a bit weird, especially since they're apparently a separate packet?

}

/* Validate effect parameters based on type */
/* Per-type range checks. We only reject values that are genuinely

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no range checks?

(struct t500rs_pkt_r40_config *)buf;
config->id = 0x40;
config->subcmd = 0x11;
config->data1 = range_value &

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cpu_to_le16() would be more clear here.

Comment thread README.md
> meaning that the wheels have to be initialized with `tmdrv`. Please see
> https://github.com/Kimplul/hid-tmff2/issues/48.

> **NOTE:** The T500 RS (like other modern Thrustmaster wheels) first enumerates

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the T500 is the same as other wheels, why add this note?

Comment thread README.md
```shell
echo 'blacklist hid_thrustmaster' | sudo tee /etc/modprobe.d/hid_thrustmaster.conf
```
**Only do this if this repo's `hid-tminit` is installed** (it is built and

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not strictly related to the T500, I would suggest removing. Updating docs is something I've been thinking of, that can be handled separately.

};

/* Supported effects */
extern const signed short t500rs_effects[];

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this needs to be exposed in the header.

* - b11-b12: envelope packet subtype (LE) - determines 0x02 code
* - b13-b14: reserved (0x0000)
*/
struct t500rs_pkt_r01_main {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, these can probably just fine be in hid-tmt500rs.c. Generally, a header should only expose what's required to use the corresponding *.c file, which should be very little in this case. hid-tmff2.c doesn't need to know the packet structure of the wheels, just that a t500rs_populate_api() function exists :)

hid_warn(t500rs->hdev,
"Autocenter-disable (0x40 0x04) failed: %d\n", ret);

/* Report 0x43 - Set global gain (2 bytes). Advisory: seed the device

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory?

hid_warn(t500rs->hdev,
"Initial gain set (0x43) failed: %d\n", ret);

hid_info(t500rs->hdev, "T500RS initialized successfully (HID mode)\n");

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there other modes?

(struct t500rs_pkt_r40_config *)init_buf;
config->id = 0x40;
config->subcmd = 0x04;
// Keep explicit zeros even though memset() clears them.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

}
ret = t500rs_send_hid(t500rs, init_buf, 4);
if (ret)
hid_warn(t500rs->hdev,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, why doesn't this just call t500rs_set_autocenter() directly?

hid_warn(t500rs->hdev, "Init command 0x42 0x00 failed: %d\n",
ret);

/* Defensive state reset: a module reload (rmmod/insmod) does not

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Man, this comment is verbose. Just say you're stopping any effects that may be lying around or something more to the point, this is rather tedious to try and parse.

* Windows sends these at startup: 0x42 0x04, 0x42 0x05, 0x42 0x00
* These appear to initialize the FFB subsystem state.
*
* The opening sync (0x42 0x04) is mandatory: if the device cannot

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not the open command that another comment claims the T500 doesn't use?

return -EINVAL;
}

hid_dbg(tmff2->hdev, "T500RS: Initializing HID mode\n");

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not using T500RS_DBG here for whatever reason. For what it's worth, I don't really see the benefit of the macro anyway, and would maybe prefer just using hid_dbg directly, but eh.

goto out;
}

/* Apply settings with Report 0x42 0x05 */

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applying settings seems to be used a few times, would it make sense to have a separate function for it?

@cazzoo

cazzoo commented Sep 10, 2026

Copy link
Copy Markdown
Author

Gain and range via the core parameters; autocentering routed through the core workqueue (T500RS callbacks do blocking HID I/O and must not run from atomic context).

This bit is the preparatory PR, and shouldn't really be included in this PR. You should be able to change the target of this PR to #220 instead of master, which splits off the commits that are not about the T500 constant force changes. That's the idea of stacked PRs anyway.

I am not sure about the stack. I tried to link them (it's linked locally on my PC using gh CLI) but when I attempt to push, I am having 422 errors:
⚠ failed to update base branch for PR #221: HTTP 422: Validation Failed (https://api.github.com/repos/Kimplul/hid-tmff2/pulls/221) PullRequest.base is invalid

In the WebUI, If I edit the PR target, I cannot select the initial PR (with the defer), the reason being that my code resides in a fork, and this new PR cannot target the other PR itself. Either i'm doing wrong, either I just missed something.

@Kimplul

Kimplul commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Huh, weird. I've only used the GitLab version of stacked PRs, which worked as expected, and you could select the PR from the dropdown of target branches.

Anyway, no big deal if we can't figure this out right now, we can just focus on one PR at a time and when the lowest one gets merged, the commits should disappear from the others PR and the discussion hopefully stays rather more focused.

@Kimplul

Kimplul commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Darn, read through the GitHub docs about stacked PRs and noticed

Stacked pull requests require all branches to be in the same repository. Cross-fork stacks are not supported.

That's annoying, I guess they're currently targeting more enterprise-y situations. Sorry, didn't realize earlier, but let's keep this informatl stack going, I suspect it's still better than one huge PR.

@kakra

kakra commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Maybe just keep a local queue branch, then push only a reviewable small commit series. Wait for feedback. Create fixup commits, fold them into the PR commits, then rebase. If the PR is ready and merged, rebase your remaining work, then create a new PR.

The input core invokes the FF gain and autocenter callbacks from
event context, which may run with IRQs disabled, and the sysfs gain
store calls them from process context. Model drivers issue blocking
HID output in these callbacks, which is illegal in atomic context
and races the effect worker on shared transfer buffers.

Route both through the existing effect workqueue instead: mark the
request pending in the device entry and apply it in the worker,
serialized with effect processing.

Gain follows the master x game model (as lg4ff): the sysfs gain
parameter acts as the master gain, the value written through the
input API is the game gain, and the worker sends their product to
the wheel via tmff2_scale_gain(). Both sources only mark the gain
pending; the worker recomputes the product from the live values on
each flush, so a sysfs change takes effect immediately without
having to replay the last in-game request.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@cazzoo
cazzoo force-pushed the t500rs/constant-force branch from b1b7784 to 78340e4 Compare September 10, 2026 20:43
Add a T500RS driver on top of the core hid-tmff2 framework:

- HID-mode init sequence (0x42 sync, slot STOP sweep, 0x40 autocenter
  teardown, 0x43 gain default); the b65d bootloader -> b65e mode switch
  is handled by hid-tminit.
- Constant force effects only at this stage: hardware slot 0 with fixed
  subtypes (param 0x0e / env 0x1c), 0x03 level updates, mandatory
  zero-envelope 0x02, and a software expiry timer sending per-slot 0x41
  STOPs (the wheel never auto-stops on its own).
- Gain and range via the core parameters; autocentering routed through
  the core workqueue (T500RS callbacks do blocking HID I/O and must not
  run from atomic context).
- Protocol reference: docs/T500RS_FFBEFFECTS.md (constant force
  sections). The shared docs/FFBEFFECTS.md is renamed to
  docs/T300RS_FFBEFFECTS.md since it documents the T300RS protocol.
- udev rule for the SteamDeck variant.
@cazzoo
cazzoo force-pushed the t500rs/constant-force branch from 78340e4 to cdba043 Compare September 10, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants