Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ changes to the sysfs surface, minor versions add supported wheels or
new attributes, patch versions are bug fixes and documentation. Pre-1.0
the contract is "it works on RS50 and G Pro as listed here".

## Unreleased

**The launcher says when the running module is older than the apps, and
sends nothing a session did not earn.** A package update installs the new
module but cannot swap the one the kernel is running while a wheel is
plugged in, so the apps move on and the module stays behind until a reload
or a reboot; the log showed "module v0.41.0, logi-tf-sim 0.42.0" for a day
of tests before anyone read it that way ([#105](../../issues/105)). The
launcher now says so in words, with the reload command. A session that ran
as a child of the launcher only because it had to start the daemon itself
(no user service manager) no longer gets the TrueForce teardown pair at
exit, which is for sessions that could have left the wheel's engine
started. The doctor no longer fails the kernel-module check when no wheel
is attached: the module loads when one is plugged in, and the check said
"not loaded" as a failure on a machine doing exactly what it should. And
`logi-wheel --launch-plan` prints the plan for a wheel class named with
`--wheel` even with no wheel attached, so the recipe can be read and
tested without one.

**The DirectInput proxy's virtual wheel has 64 buttons, and its debug
trace reaches the log.** The virtual wheel exposed 32 buttons and dropped
the kernel's codes for buttons 17 to 21, which the RS50 never emits but a
G PRO with a shifter does: the higher gears could not be bound in DiRT Rally
2.0 ([#105](../../issues/105)). The report now carries 64 buttons; the
bits the RS50's DirectInput users have already bound stay where they were,
the five recovered buttons follow them, and anything beyond continues from
there. With `LOGI_FFB_DEBUG` set, the proxy's report-level trace now lands
in the launcher log as well as on stderr, which under Steam is the only
place it can be read.

## 0.42.2 - 2026-09-21

**The proxy can open `/dev/uhid` on a machine where nothing else loads
Expand Down
31 changes: 29 additions & 2 deletions tools/logi-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,24 @@
# First line of every run: which builds are in play. A daemon older than
# the module reads like a driver fault in every log after this one.
say "versions: module $(cat /sys/module/hid_logitech_dd/version 2>/dev/null || echo 'not loaded'), $(logi-tf-sim --version 2>/dev/null || echo 'logi-tf-sim not on PATH'), $(logi-ffb --version 2>/dev/null || echo 'logi-ffb not on PATH')"
# A package update installs the new module but cannot swap the one the
# kernel is running while a wheel is plugged in, so the apps move on and
# the module stays behind until a reload or a reboot. That log line above
# showed "module v0.41.0, logi-tf-sim 0.42.0" for a whole day of tests
# before anyone noticed (#105). Say it in words.
mod_ver=$(cat /sys/module/hid_logitech_dd/version 2>/dev/null || true)
app_ver=$(logi-tf-sim --version 2>/dev/null | awk '{print $2}')
case "$mod_ver" in

Check failure on line 130 in tools/logi-launch.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a default case (*) to handle unexpected values.

See more on https://sonarcloud.io/project/issues?id=mescon_logitech-rs50-linux-driver&issues=AaDGFMXkWi1m4JDiVZ9A&open=AaDGFMXkWi1m4JDiVZ9A&pullRequest=116
v*)
mod_plain=${mod_ver#v}; mod_plain=${mod_plain%%-*}
if [ -n "$app_ver" ] && [ "$mod_plain" != "$app_ver" ]; then

Check failure on line 133 in tools/logi-launch.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=mescon_logitech-rs50-linux-driver&issues=AaDGFMXkWi1m4JDiVZ9B&open=AaDGFMXkWi1m4JDiVZ9B&pullRequest=116

Check failure on line 133 in tools/logi-launch.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=mescon_logitech-rs50-linux-driver&issues=AaDGFMXkWi1m4JDiVZ9C&open=AaDGFMXkWi1m4JDiVZ9C&pullRequest=116
say "note: the loaded module is $mod_ver but the apps are $app_ver. The new module"
say "note: is installed but not running: unplug the wheel and run"
say "note: sudo modprobe -r hid_logitech_dd && sudo modprobe hid_logitech_dd"
say "note: or reboot. Until then this log describes the old driver."
fi
;;
esac

# `logi-launch --game <name> %command%` names the title explicitly, for when
# the appid cannot identify it: a non-Steam shortcut (whose id Steam
Expand Down Expand Up @@ -209,7 +227,7 @@
user_conf="${XDG_CONFIG_HOME:-$HOME/.config}/logi-wheel/games.conf"
this_app="${SteamAppId:-${SteamGameId:-0}}"
if [ -r "$user_conf" ]; then
user_line=$(sed -n "s/^[[:space:]]*$this_app[[:space:]]\+//p" "$user_conf" | head -1)
user_line=$(sed -n "s/^[[:space:]]*${this_app}[[:space:]]\+//p" "$user_conf" | head -1)
if [ -n "$user_line" ]; then
say "using your games.conf entry for appid $this_app"
# plan_get below takes the FIRST match for a key, so the user's
Expand Down Expand Up @@ -1183,7 +1201,16 @@
$merge_attrs
MERGEATTRS
fi
send_teardown_pair
# The pair is for sessions that could have left the wheel's
# TrueForce engine started: raw HID, the texture merge, the
# bridge or an in-prefix helper. A session that ran as a child
# only because it had to start the daemon (no user service
# manager) had none of those, and gets no packets it never asked
# for.
if [ -n "$hidraw_granted" ] || [ -n "$merge_attrs" ] || \

Check failure on line 1210 in tools/logi-launch.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=mescon_logitech-rs50-linux-driver&issues=AaDGFMXkWi1m4JDiVZ9D&open=AaDGFMXkWi1m4JDiVZ9D&pullRequest=116

Check failure on line 1210 in tools/logi-launch.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=mescon_logitech-rs50-linux-driver&issues=AaDGFMXkWi1m4JDiVZ9E&open=AaDGFMXkWi1m4JDiVZ9E&pullRequest=116
[ -n "$rpm_bridge_pid" ] || [ -n "$helper_group_pid" ]; then

Check failure on line 1211 in tools/logi-launch.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=mescon_logitech-rs50-linux-driver&issues=AaDGFMXkWi1m4JDiVZ9F&open=AaDGFMXkWi1m4JDiVZ9F&pullRequest=116

Check failure on line 1211 in tools/logi-launch.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=mescon_logitech-rs50-linux-driver&issues=AaDGFMXkWi1m4JDiVZ9G&open=AaDGFMXkWi1m4JDiVZ9G&pullRequest=116
send_teardown_pair
fi
}
trap session_cleanup EXIT
# Signal hardening: a bare "$@" would make SIGTERM/SIGINT hit only
Expand Down
12 changes: 11 additions & 1 deletion tools/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,17 @@ doctor() {
fi
fi
else
bad "hid_logitech_dd is not loaded (run: sudo ./tools/setup.sh)"
# The module binds on plug-in, so with no wheel on the bus "not
# loaded" is the machine doing exactly what it should, not a fault.
# The same check used to fail every doctor run on a desk without
# the wheel attached.
local pid_re_now
pid_re_now="$(echo "$WHEEL_PIDS $WHEEL_PIDS_CONSOLE" | tr ' ' '|')"
if lsusb 2>/dev/null | grep -qiE "046d:($pid_re_now)"; then
bad "hid_logitech_dd is not loaded although a wheel is on the bus (run: sudo ./tools/setup.sh)"
else
wrn "hid_logitech_dd is not loaded; it loads when a wheel is plugged in (none on the bus now)"
fi
fi
# App versions, and their absence. Reporting only the ones that are
# present used to hide the case that actually bites: a from-source
Expand Down
8 changes: 4 additions & 4 deletions userspace/logi-wheel/crates/logi-ffb/src/descriptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ pub const PRODUCT: u16 = 0xc276;
pub const VIRTUAL_PRODUCT: u16 = 0xc2dd;
pub const VIRTUAL_NAME: &str = "logi-ffb Virtual Wheel";
pub const INPUT_REPORT_ID: u8 = 0x01;
pub const INPUT_REPORT_LEN: usize = 14;
pub const INPUT_REPORT_LEN: usize = 18;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputReport {
pub steering: u16,
pub throttle: u16,
pub brake: u16,
pub clutch: u16,
pub buttons: u32,
pub buttons: u64,
pub hat: u8,
/// Raw hat axis states (-1/0/1), tracked so either axis event can
/// re-derive the 8-way POV value in `hat`; not serialized.
Expand Down Expand Up @@ -80,9 +80,9 @@ const JOYSTICK_PREFIX: &[u8] = &[
0xC0, // End Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Min (Button 1)
0x29, 0x20, // Usage Max (Button 32)
0x29, 0x40, // Usage Max (Button 64)
0x15, 0x00, 0x25, 0x01, // Logical 0..1
0x75, 0x01, 0x95, 0x20, // 1 bit x 32
0x75, 0x01, 0x95, 0x40, // 1 bit x 64
0x81, 0x02, // Input (buttons)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x39, // Usage (Hat switch)
Expand Down
18 changes: 9 additions & 9 deletions userspace/logi-wheel/crates/logi-ffb/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,11 @@ impl Proxy {
if debug {
let report_id = bytes.first().copied().unwrap_or(0);
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02x}")).collect();
eprintln!(
crate::note(&format!(
"logi-ffb: debug: uhid Output len={} report_id={report_id:#04x} bytes={}",
bytes.len(),
hex.join(" ")
);
));
}
if let Some(op) = pidff::decode(&bytes) {
self.dispatch(op);
Expand All @@ -404,7 +404,7 @@ impl Proxy {
// we simply skip creating a block for it.
Ok(uhid::Event::SetReport { rnum: 0x54, rtype, data, id }) => {
if debug {
eprintln!("logi-ffb: debug: uhid SetReport rnum=0x54 rtype={rtype}");
crate::note(&format!("logi-ffb: debug: uhid SetReport rnum=0x54 rtype={rtype}"));
}
if let Some(kind) = data.get(1).and_then(|&b| pidff::effect_kind_from_type_byte(b)) {
let block = assign_block(&mut self.next_block);
Expand All @@ -430,7 +430,7 @@ impl Proxy {
// ack regardless so the kernel is never left waiting.
Ok(uhid::Event::SetReport { rnum, rtype, data, id }) => {
if debug {
eprintln!("logi-ffb: debug: uhid SetReport rnum={rnum:#04x} rtype={rtype}");
crate::note(&format!("logi-ffb: debug: uhid SetReport rnum={rnum:#04x} rtype={rtype}"));
}
if let Some(op) = pidff::decode(&data) {
self.dispatch(op);
Expand All @@ -445,7 +445,7 @@ impl Proxy {
// RAM pool so the host does not treat us as full.
Ok(uhid::Event::GetReport { rnum: 0x56, rtype, id }) => {
if debug {
eprintln!("logi-ffb: debug: uhid GetReport rnum=0x56 rtype={rtype}");
crate::note(&format!("logi-ffb: debug: uhid GetReport rnum=0x56 rtype={rtype}"));
}
let reply = pidff::pid_block_load_reply(self.last_created_block);
if let Err(e) = self.device.send_get_report_reply(id, 0, &reply) {
Expand All @@ -459,7 +459,7 @@ impl Proxy {
// was derived and what is still unconfirmed).
Ok(uhid::Event::GetReport { rnum: 0x57, rtype, id }) => {
if debug {
eprintln!("logi-ffb: debug: uhid GetReport rnum=0x57 rtype={rtype}");
crate::note(&format!("logi-ffb: debug: uhid GetReport rnum=0x57 rtype={rtype}"));
}
let reply = pidff::pid_pool_reply();
if let Err(e) = self.device.send_get_report_reply(id, 0, &reply) {
Expand All @@ -474,7 +474,7 @@ impl Proxy {
// shifted into the report-id position.
Ok(uhid::Event::GetReport { rnum, rtype, id }) => {
if debug {
eprintln!("logi-ffb: debug: uhid GetReport rnum={rnum:#04x} rtype={rtype}");
crate::note(&format!("logi-ffb: debug: uhid GetReport rnum={rnum:#04x} rtype={rtype}"));
}
if let Err(e) = self.device.send_get_report_reply(id, 0, &[rnum]) {
break Err(e);
Expand All @@ -483,13 +483,13 @@ impl Proxy {

Ok(uhid::Event::Open) => {
if debug {
eprintln!("logi-ffb: debug: uhid Open");
crate::note("logi-ffb: debug: uhid Open");
}
}

Ok(uhid::Event::Close) => {
if debug {
eprintln!("logi-ffb: debug: uhid Close");
crate::note("logi-ffb: debug: uhid Close");
}
}

Expand Down
46 changes: 31 additions & 15 deletions userspace/logi-wheel/crates/logi-ffb/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,22 +131,27 @@ pub fn map_event(report: &mut InputReport, ev: &input_event) -> bool {
}
}
EV_KEY if ev.code >= BTN_TRIGGER => {
// Two button blocks: the joystick range (BTN_TRIGGER..) maps
// to bits 0-15, and the wheel's extended controls (encoders,
// G1, GL, GR - BTN_TRIGGER_HAPPY range) map to bits 16-31,
// so DirectInput sims can bind them too (issue #50).
// The wheel's extended block starts at BTN_TRIGGER_HAPPY6
// (0x2c5, the first code it actually uses); 0x2c0-0x2c4 are
// unused and dropped to avoid an underflow.
// The kernel numbers a joystick's buttons BTN_TRIGGER+0..15 and
// then BTN_TRIGGER_HAPPY+0.. for the seventeenth onward. The
// layout here keeps every bit the RS50's DirectInput users have
// already bound (issue #50): the joystick range on bits 0-15,
// the RS50's extended block from BTN_TRIGGER_HAPPY6 (0x2c5, its
// first used code: encoders, G1, GL, GR) on bits 16-31. The
// five codes below it, buttons 17-21, were dropped as "unused"
// because the RS50 never emits them; a G PRO with a shifter
// does, and its higher gears could not be bound (#105). They
// now take bits 32-36, and anything past the RS50 block's
// sixteen bits continues from 37, in a 64-button report.
const EXT_FIRST: u16 = BTN_TRIGGER_HAPPY + 5;
let bit = if ev.code >= EXT_FIRST {
16 + (ev.code - EXT_FIRST)
} else if ev.code < BTN_TRIGGER_HAPPY {
ev.code - BTN_TRIGGER
let bit: u32 = if ev.code < BTN_TRIGGER_HAPPY {
(ev.code - BTN_TRIGGER) as u32
} else if ev.code >= EXT_FIRST {
let n = (ev.code - EXT_FIRST) as u32;
if n < 16 { 16 + n } else { 37 + (n - 16) }
} else {
32 // unused 0x2c0-0x2c4: outside both blocks
32 + (ev.code - BTN_TRIGGER_HAPPY) as u32
};
if bit < 32 {
if bit < 64 {
if ev.value != 0 {
report.buttons |= 1 << bit;
} else {
Expand Down Expand Up @@ -242,9 +247,20 @@ mod tests {
assert!(r.buttons & (1 << 16) != 0, "R Encoder CW is bit 16");
assert!(!map_event(&mut r, &ev(EV_KEY, 0x2cd, 1)));
assert!(r.buttons & (1 << 24) != 0, "GR is bit 24");
// Buttons 17-21 (0x2c0-0x2c4), which the RS50 never emits but a
// G PRO with a shifter does (#105), land above the RS50 block so
// nothing already bound moves.
assert!(!map_event(&mut r, &ev(EV_KEY, 0x2c0, 1)));
assert_eq!(r.buttons & 0xFFFF_0000, (1 << 16) | (1 << 24),
"unused 0x2c0-0x2c4 codes map to nothing");
assert!(r.buttons & (1 << 32) != 0, "button 17 (0x2c0) is bit 32");
assert!(!map_event(&mut r, &ev(EV_KEY, 0x2c4, 1)));
assert!(r.buttons & (1 << 36) != 0, "button 21 (0x2c4) is bit 36");
assert_eq!(r.buttons & 0xFFFF_0000, (1 << 16) | (1 << 24), "the RS50 block is unchanged");
// Past the RS50 block's sixteen codes the numbering continues after
// those five, and the report's 64 bits are the ceiling.
assert!(!map_event(&mut r, &ev(EV_KEY, 0x2d5, 1)));
assert!(r.buttons & (1 << 37) != 0, "0x2d5 is bit 37");
assert!(!map_event(&mut r, &ev(EV_KEY, 0x2f0, 1)));
assert_eq!(r.buttons >> 38, 0, "a code past 64 buttons is dropped, not wrapped");
assert_eq!(r.steering, 0x4000);
assert!(map_event(&mut r, &ev(EV_SYN, SYN_REPORT, 0)));
}
Expand Down
20 changes: 14 additions & 6 deletions userspace/logi-wheel/crates/logi-wheel-tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,6 @@ fn launch_plan(
// when it is not and the answer is ambiguous, the harmful half is
// withheld rather than guessed.
let wheels = logi_wheel_core::Device::discover_all();
if wheels.is_empty() {
println!("wheel=none");
return Ok(());
}
let mut kinds: Vec<games::WheelCaps> = wheels.iter().map(|d| d.wheel_caps()).collect();
kinds.dedup();
let forced = match wheel_arg.as_deref() {
Some("dd") | Some("direct-drive") | Some("rs50") | Some("gpro") => Some(games::WheelCaps::direct_drive()),
Some("xbox") | Some("g923-xbox") => Some(games::WheelCaps::xbox_sdk()),
Expand All @@ -360,8 +354,22 @@ fn launch_plan(
}
None => None,
};
// With no wheel attached and none named there is nothing honest to
// say (see above). Named, the class is enough: the recipe is per class,
// and printing it lets the docs, the tests and a wrapper started before
// the wheel is plugged in see what a session would get; the one thing
// it cannot carry is a raw-HID scope, which needs the attached wheel.
if wheels.is_empty() && forced.is_none() {
println!("wheel=none");
return Ok(());
}
let mut kinds: Vec<games::WheelCaps> = wheels.iter().map(|d| d.wheel_caps()).collect();
kinds.dedup();
let ambiguous = forced.is_none() && kinds.len() > 1;
let caps = forced.unwrap_or_else(|| wheels[0].wheel_caps());
if wheels.is_empty() {
println!("note=no wheel attached; this is the plan for the class you named");
}
if ambiguous {
println!("wheel=mixed");
println!("note=several kinds of wheel attached and the game picks one, not us");
Expand Down
Loading