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] 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))); }