From ff7a19fe38590c089dab7add6bd4a7a572f99e4c Mon Sep 17 00:00:00 2001 From: mescon <5875228+mescon@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:20:36 +0200 Subject: [PATCH 1/2] fix(ffb): a 64-button virtual wheel, and the debug trace in the log The virtual wheel exposed 32 buttons and dropped the kernel's codes for buttons 17 to 21 (BTN_TRIGGER_HAPPY to +4) as unused, because the RS50 never emits them. A G PRO with a shifter does, and its higher gears could not be bound in DiRT Rally 2.0 (#105). The input report now carries 64 buttons. The layout keeps every bit the RS50's DirectInput users have already bound: the joystick range on bits 0-15, the RS50's extended block on 16-31 as before, the five recovered buttons on 32-36, and anything past the RS50 block's sixteen codes from 37 on; a code past 64 is dropped, not wrapped. The proxy's report-level trace (LOGI_FFB_DEBUG) went to stderr only, which under Steam is a console nobody reads; it now goes through the same log mirror as the proxy's other messages, so one opted-in run shows whether the game sends any force at all. --- CHANGELOG.md | 13 ++++++ .../crates/logi-ffb/src/descriptor.rs | 8 ++-- .../logi-wheel/crates/logi-ffb/src/proxy.rs | 18 ++++---- .../logi-wheel/crates/logi-ffb/src/source.rs | 46 +++++++++++++------ 4 files changed, 57 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 412ddcb..f70ba93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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 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 diff --git a/userspace/logi-wheel/crates/logi-ffb/src/descriptor.rs b/userspace/logi-wheel/crates/logi-ffb/src/descriptor.rs index ca700ab..705da20 100644 --- a/userspace/logi-wheel/crates/logi-ffb/src/descriptor.rs +++ b/userspace/logi-wheel/crates/logi-ffb/src/descriptor.rs @@ -15,7 +15,7 @@ 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 { @@ -23,7 +23,7 @@ pub struct InputReport { 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. @@ -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) diff --git a/userspace/logi-wheel/crates/logi-ffb/src/proxy.rs b/userspace/logi-wheel/crates/logi-ffb/src/proxy.rs index 18a4329..18fa5bf 100644 --- a/userspace/logi-wheel/crates/logi-ffb/src/proxy.rs +++ b/userspace/logi-wheel/crates/logi-ffb/src/proxy.rs @@ -381,11 +381,11 @@ impl Proxy { if debug { let report_id = bytes.first().copied().unwrap_or(0); let hex: Vec = 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); @@ -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); @@ -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); @@ -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) { @@ -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) { @@ -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); @@ -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"); } } diff --git a/userspace/logi-wheel/crates/logi-ffb/src/source.rs b/userspace/logi-wheel/crates/logi-ffb/src/source.rs index f40a7f4..cc4bb50 100644 --- a/userspace/logi-wheel/crates/logi-ffb/src/source.rs +++ b/userspace/logi-wheel/crates/logi-ffb/src/source.rs @@ -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 { @@ -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))); } From b072921542fb9dbedacb2b0883f10983b8decb7d Mon Sep 17 00:00:00 2001 From: mescon <5875228+mescon@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:26:52 +0200 Subject: [PATCH 2/2] fix(launch,doctor): say when the module lags the apps, no unearned teardown, no false module failure Four small things found while working #105, none needing a reporter: The launcher's first log line showed "module v0.41.0, logi-tf-sim 0.42.0" through a day of tests before anyone read it as what it was: a package update had installed the new module but the kernel kept running the old one, because a plugged-in wheel keeps it busy. The launcher now says that 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) also got the TrueForce teardown pair at exit. That pair is for sessions that could have left the wheel's engine started: raw HID, the texture merge, the bridge, an in-prefix helper. It is now sent only for those. The doctor failed the kernel-module check whenever no wheel was attached, although the module binds on plug-in and a bare desk is the machine doing what it should. It now warns in that case and fails only when a wheel is on the bus and the module still is not loaded. logi-wheel --launch-plan printed "wheel=none" and stopped when no wheel was attached, even with a class named by --wheel. It now prints the plan for the named class, with a note, so the recipe can be read and tested without a wheel; a raw-HID scope still needs the attached wheel. Also the one shellcheck finding the launcher carried (SC1087). --- CHANGELOG.md | 17 ++++++++++ tools/logi-launch.sh | 31 +++++++++++++++++-- tools/setup.sh | 12 ++++++- .../crates/logi-wheel-tui/src/main.rs | 20 ++++++++---- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f70ba93..f2954b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ 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 diff --git a/tools/logi-launch.sh b/tools/logi-launch.sh index 66a5ef8..69844ea 100755 --- a/tools/logi-launch.sh +++ b/tools/logi-launch.sh @@ -120,6 +120,24 @@ native_marker_id() { printf '%s' "$1" | tr -c 'A-Za-z0-9._\n-' '-'; } # 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 +v*) + mod_plain=${mod_ver#v}; mod_plain=${mod_plain%%-*} + if [ -n "$app_ver" ] && [ "$mod_plain" != "$app_ver" ]; then + 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 %command%` names the title explicitly, for when # the appid cannot identify it: a non-Steam shortcut (whose id Steam @@ -209,7 +227,7 @@ fi 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 @@ -1183,7 +1201,16 @@ if [ -n "$rpm_bridge_pid" ] || [ -n "$merge_attrs" ] || \ $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" ] || \ + [ -n "$rpm_bridge_pid" ] || [ -n "$helper_group_pid" ]; then + send_teardown_pair + fi } trap session_cleanup EXIT # Signal hardening: a bare "$@" would make SIGTERM/SIGINT hit only diff --git a/tools/setup.sh b/tools/setup.sh index bcea289..afbb295 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -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 diff --git a/userspace/logi-wheel/crates/logi-wheel-tui/src/main.rs b/userspace/logi-wheel/crates/logi-wheel-tui/src/main.rs index e7eb62b..393aa25 100644 --- a/userspace/logi-wheel/crates/logi-wheel-tui/src/main.rs +++ b/userspace/logi-wheel/crates/logi-wheel-tui/src/main.rs @@ -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 = 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()), @@ -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 = 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");