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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Loading