diff --git a/CHANGELOG.md b/CHANGELOG.md index 412ddcb..f2954b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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-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))); } 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");