From 384f8e00aaac3cd2c6fa2436f916deeb7bf85e62 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 21:56:39 +0200 Subject: [PATCH] refactor(host/W4): extract inject keymap tables + rehome HidoutDedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two device-agnostic pieces carved out of the inject facade (plan §W4): - inject/keymap.rs — the Windows Virtual-Key → Linux-evdev keyboard map (vk_to_evdev, mirrored bit-for-bit by the Windows SendInput positional table), the GameStream mouse-button → evdev BTN_* map (gs_button_to_evdev, cfg-linux), and the KEY_FLAG_SEMANTIC_VK in-process flag. - inject/hidout_dedup.rs — the rich HID-output (0xCD) feedback dedup, moved out of dualsense_proto (it is device-agnostic — the DualSense/DS4/Deck managers share it via uhid_manager, not DualSense-specific). Its unit test moves with it. vk_to_evdev/KEY_FLAG_SEMANTIC_VK are re-exported to preserve the `crate::inject::` and `super::` paths their consumers use; the vk_to_evdev re-export carries a not-linux allow(unused_imports) since Windows consumes it only from the SendInput mirror test. uhid_manager's import repointed to the new home. Pure move; no behavior change. Linux clippy+tests + Windows host clippy (nvenc,amf-qsv) both green; fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/inject.rs | 172 ++---------------- .../punktfunk-host/src/inject/hidout_dedup.rs | 126 +++++++++++++ crates/punktfunk-host/src/inject/keymap.rs | 162 +++++++++++++++++ .../src/inject/proto/dualsense_proto.rs | 114 ------------ .../punktfunk-host/src/inject/uhid_manager.rs | 2 +- 5 files changed, 304 insertions(+), 272 deletions(-) create mode 100644 crates/punktfunk-host/src/inject/hidout_dedup.rs create mode 100644 crates/punktfunk-host/src/inject/keymap.rs diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 6a5054de..a3998edc 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -12,15 +12,21 @@ use anyhow::Result; use punktfunk_core::input::{InputEvent, InputKind}; -/// In-process tag on a key event's `flags`: the VK in `code` is **layout-semantic** (already -/// resolved under the sending client's keyboard layout — the GameStream/Moonlight convention) -/// rather than the punktfunk-native **US-positional** convention (the physical key's US-layout VK, -/// which every first-party client sends — the client's local layout never touches the wire). -/// The Windows injector maps semantic VKs through the foreground app's layout and positional VKs -/// through a fixed table; conflating the two is exactly the German y↔z / ö→ü scramble. -/// Set ONLY by `gamestream::input::decode`; the punktfunk/1 ingest strips it from wire events, so -/// a network client can never flip the host's key-decoding convention. -pub const KEY_FLAG_SEMANTIC_VK: u32 = 0x8000_0000; +#[path = "inject/keymap.rs"] +mod keymap; +#[cfg(target_os = "linux")] +pub(crate) use keymap::gs_button_to_evdev; +pub use keymap::KEY_FLAG_SEMANTIC_VK; +// vk_to_evdev is consumed by the Linux injectors (kwin/libei/wlr) and — on Windows — only by the +// SendInput mirror test; keep the shared `crate::inject::vk_to_evdev` re-export unconditionally. +#[cfg_attr(not(target_os = "linux"), allow(unused_imports))] +pub use keymap::vk_to_evdev; + +/// Device-agnostic dedup for the rich HID-output feedback plane (0xCD), shared by the virtual-pad +/// managers ([`uhid_manager`]). +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[path = "inject/hidout_dedup.rs"] +pub mod hidout_dedup; /// Injects input events into the host session. Not `Send`: an injector owns compositor /// resources (a Wayland connection, an xkb state) and lives entirely on the control thread @@ -319,154 +325,6 @@ fn libei_ei_source() -> libei::EiSource { } } -/// Map a Windows Virtual-Key code (as sent by Moonlight/GameStream) to a Linux evdev key code. -pub fn vk_to_evdev(vk: u8) -> Option { - match vk { - // --- Navigation / editing / whitespace --- - 0x08 => Some(14), // VK_BACK -> KEY_BACKSPACE - 0x09 => Some(15), // VK_TAB -> KEY_TAB - 0x0D => Some(28), // VK_RETURN -> KEY_ENTER - 0x13 => Some(119), // VK_PAUSE -> KEY_PAUSE - 0x14 => Some(58), // VK_CAPITAL -> KEY_CAPSLOCK - 0x1B => Some(1), // VK_ESCAPE -> KEY_ESC - 0x20 => Some(57), // VK_SPACE -> KEY_SPACE - 0x21 => Some(104), // VK_PRIOR -> KEY_PAGEUP - 0x22 => Some(109), // VK_NEXT -> KEY_PAGEDOWN - 0x23 => Some(107), // VK_END -> KEY_END - 0x24 => Some(102), // VK_HOME -> KEY_HOME - 0x25 => Some(105), // VK_LEFT -> KEY_LEFT - 0x26 => Some(103), // VK_UP -> KEY_UP - 0x27 => Some(106), // VK_RIGHT -> KEY_RIGHT - 0x28 => Some(108), // VK_DOWN -> KEY_DOWN - 0x2C => Some(99), // VK_SNAPSHOT -> KEY_SYSRQ - 0x2D => Some(110), // VK_INSERT -> KEY_INSERT - 0x2E => Some(111), // VK_DELETE -> KEY_DELETE - - // --- Generic modifiers --- - 0x10 => Some(42), // VK_SHIFT -> KEY_LEFTSHIFT - 0x11 => Some(29), // VK_CONTROL -> KEY_LEFTCTRL - 0x12 => Some(56), // VK_MENU -> KEY_LEFTALT - - // --- Digit row (KEY_0 is 11, KEY_1..KEY_9 are 2..10) --- - 0x30 => Some(11), // VK_0 - 0x31 => Some(2), // VK_1 - 0x32 => Some(3), // VK_2 - 0x33 => Some(4), // VK_3 - 0x34 => Some(5), // VK_4 - 0x35 => Some(6), // VK_5 - 0x36 => Some(7), // VK_6 - 0x37 => Some(8), // VK_7 - 0x38 => Some(9), // VK_8 - 0x39 => Some(10), // VK_9 - - // --- Letters A-Z (NOT sequential in evdev) --- - 0x41 => Some(30), // A - 0x42 => Some(48), // B - 0x43 => Some(46), // C - 0x44 => Some(32), // D - 0x45 => Some(18), // E - 0x46 => Some(33), // F - 0x47 => Some(34), // G - 0x48 => Some(35), // H - 0x49 => Some(23), // I - 0x4A => Some(36), // J - 0x4B => Some(37), // K - 0x4C => Some(38), // L - 0x4D => Some(50), // M - 0x4E => Some(49), // N - 0x4F => Some(24), // O - 0x50 => Some(25), // P - 0x51 => Some(16), // Q - 0x52 => Some(19), // R - 0x53 => Some(31), // S - 0x54 => Some(20), // T - 0x55 => Some(22), // U - 0x56 => Some(47), // V - 0x57 => Some(17), // W - 0x58 => Some(45), // X - 0x59 => Some(21), // Y - 0x5A => Some(44), // Z - - // --- Meta / context-menu --- - 0x5B => Some(125), // VK_LWIN -> KEY_LEFTMETA - 0x5C => Some(126), // VK_RWIN -> KEY_RIGHTMETA - 0x5D => Some(127), // VK_APPS -> KEY_COMPOSE - - // --- Numpad --- - 0x60 => Some(82), // KP0 - 0x61 => Some(79), // KP1 - 0x62 => Some(80), // KP2 - 0x63 => Some(81), // KP3 - 0x64 => Some(75), // KP4 - 0x65 => Some(76), // KP5 - 0x66 => Some(77), // KP6 - 0x67 => Some(71), // KP7 - 0x68 => Some(72), // KP8 - 0x69 => Some(73), // KP9 - 0x6A => Some(55), // VK_MULTIPLY -> KEY_KPASTERISK - 0x6B => Some(78), // VK_ADD -> KEY_KPPLUS - 0x6C => Some(96), // VK_SEPARATOR -> KEY_KPENTER - 0x6D => Some(74), // VK_SUBTRACT -> KEY_KPMINUS - 0x6E => Some(83), // VK_DECIMAL -> KEY_KPDOT - 0x6F => Some(98), // VK_DIVIDE -> KEY_KPSLASH - - // --- Function keys (F1..F10 = 59..68, F11/F12 = 87/88) --- - 0x70 => Some(59), - 0x71 => Some(60), - 0x72 => Some(61), - 0x73 => Some(62), - 0x74 => Some(63), - 0x75 => Some(64), - 0x76 => Some(65), - 0x77 => Some(66), - 0x78 => Some(67), - 0x79 => Some(68), - 0x7A => Some(87), - 0x7B => Some(88), - - // --- Locks --- - 0x90 => Some(69), // VK_NUMLOCK -> KEY_NUMLOCK - 0x91 => Some(70), // VK_SCROLL -> KEY_SCROLLLOCK - - // --- Left/right modifiers --- - 0xA0 => Some(42), // VK_LSHIFT -> KEY_LEFTSHIFT - 0xA1 => Some(54), // VK_RSHIFT -> KEY_RIGHTSHIFT - 0xA2 => Some(29), // VK_LCONTROL -> KEY_LEFTCTRL - 0xA3 => Some(97), // VK_RCONTROL -> KEY_RIGHTCTRL - 0xA4 => Some(56), // VK_LMENU -> KEY_LEFTALT - 0xA5 => Some(100), // VK_RMENU -> KEY_RIGHTALT - - // --- OEM punctuation (US layout) --- - 0xBA => Some(39), // VK_OEM_1 -> KEY_SEMICOLON - 0xBB => Some(13), // VK_OEM_PLUS -> KEY_EQUAL - 0xBC => Some(51), // VK_OEM_COMMA -> KEY_COMMA - 0xBD => Some(12), // VK_OEM_MINUS -> KEY_MINUS - 0xBE => Some(52), // VK_OEM_PERIOD -> KEY_DOT - 0xBF => Some(53), // VK_OEM_2 -> KEY_SLASH - 0xC0 => Some(41), // VK_OEM_3 -> KEY_GRAVE - 0xDB => Some(26), // VK_OEM_4 -> KEY_LEFTBRACE - 0xDC => Some(43), // VK_OEM_5 -> KEY_BACKSLASH - 0xDD => Some(27), // VK_OEM_6 -> KEY_RIGHTBRACE - 0xDE => Some(40), // VK_OEM_7 -> KEY_APOSTROPHE - 0xE2 => Some(86), // VK_OEM_102 -> KEY_102ND - - _ => None, - } -} - -/// Map a GameStream mouse button id (1=left … 5=X2) to a Linux evdev `BTN_*` code. -#[cfg(target_os = "linux")] -fn gs_button_to_evdev(b: u32) -> Option { - Some(match b { - 1 => 0x110, // BTN_LEFT - 2 => 0x112, // BTN_MIDDLE - 3 => 0x111, // BTN_RIGHT - 4 => 0x113, // BTN_SIDE (X1) - 5 => 0x114, // BTN_EXTRA (X2) - _ => return None, - }) -} - // Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput // backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`; // `#[path]` keeps every `crate::inject::*` module name flat. diff --git a/crates/punktfunk-host/src/inject/hidout_dedup.rs b/crates/punktfunk-host/src/inject/hidout_dedup.rs new file mode 100644 index 00000000..11dbe080 --- /dev/null +++ b/crates/punktfunk-host/src/inject/hidout_dedup.rs @@ -0,0 +1,126 @@ +//! Per-pad dedup for the rich HID-output feedback plane (0xCD), carved out of `dualsense_proto` +//! (plan §W4 — it is device-agnostic, shared by the DualSense/DS4/Deck managers via +//! [`crate::inject::uhid_manager`], not DualSense-specific). A game bundles rumble + lightbar + +//! LEDs + adaptive triggers into one output report, so a merely-rumbling pad re-sends unchanged +//! rich state every report; this forwards only genuine changes (one-shot pulses always fire). + +use punktfunk_core::quic::HidOutput; + +/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report +/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is +/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report. +/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the +/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by +/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire). +#[derive(Clone, Default)] +pub struct HidoutDedup { + led: Option<(u8, u8, u8)>, + player_leds: Option, + /// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2. + trigger: [Option>; 2], +} + +impl HidoutDedup { + /// Forget all remembered state — call when a pad is created or unplugged so the first feedback + /// after a (re)connect is always forwarded. + pub fn clear(&mut self) { + *self = HidoutDedup::default(); + } + + /// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a + /// one-shot pulse; `false` if it repeats the last-forwarded value for its kind. + pub fn should_forward(&mut self, h: &HidOutput) -> bool { + match h { + HidOutput::Led { r, g, b, .. } => { + let v = Some((*r, *g, *b)); + if self.led == v { + false + } else { + self.led = v; + true + } + } + HidOutput::PlayerLeds { bits, .. } => { + let v = Some(*bits); + if self.player_leds == v { + false + } else { + self.player_leds = v; + true + } + } + HidOutput::Trigger { which, effect, .. } => { + let slot = (*which as usize).min(1); + if self.trigger[slot].as_deref() == Some(effect.as_slice()) { + false + } else { + self.trigger[slot] = Some(effect.clone()); + true + } + } + // One-shot haptic pulse (Steam voice-coil) — state-less, always fires. + HidOutput::TrackpadHaptic { .. } => true, + // Raw as-is passthrough reports must NEVER dedup: the physical device's firmware + // watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms + // against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would + // silence the motors / re-enable lizard mode on the real controller. + HidOutput::HidRaw { .. } => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two + /// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`. + #[test] + fn hidout_dedup_forwards_only_changes() { + let mut d = HidoutDedup::default(); + let led = |r| HidOutput::Led { + pad: 0, + r, + g: 0, + b: 0, + }; + // First value forwards; an exact repeat is dropped; a change forwards again. + assert!(d.should_forward(&led(10))); + assert!(!d.should_forward(&led(10))); + assert!(d.should_forward(&led(20))); + + // Player LEDs dedup on their own field, independent of the lightbar. + let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits }; + assert!(d.should_forward(&pl(0b101))); + assert!(!d.should_forward(&pl(0b101))); + assert!(!d.should_forward(&led(20))); // lightbar still unchanged + + // The two adaptive triggers (L2=0, R2=1) are tracked separately. + let trig = |which, byte| HidOutput::Trigger { + pad: 0, + which, + effect: vec![byte, 0, 0], + }; + assert!(d.should_forward(&trig(0, 1))); + assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards + assert!(!d.should_forward(&trig(0, 1))); + assert!(d.should_forward(&trig(0, 2))); // L2 effect changed + + // One-shot haptic pulses are never deduped. + let haptic = HidOutput::TrackpadHaptic { + pad: 0, + side: 0, + amplitude: 1, + period: 2, + count: 3, + }; + assert!(d.should_forward(&haptic)); + assert!(d.should_forward(&haptic)); + + // `clear` re-arms every kind. + d.clear(); + assert!(d.should_forward(&led(20))); + assert!(d.should_forward(&pl(0b101))); + assert!(d.should_forward(&trig(0, 2))); + } +} diff --git a/crates/punktfunk-host/src/inject/keymap.rs b/crates/punktfunk-host/src/inject/keymap.rs new file mode 100644 index 00000000..401a54e3 --- /dev/null +++ b/crates/punktfunk-host/src/inject/keymap.rs @@ -0,0 +1,162 @@ +//! Key/button mapping tables (plan §W4, carved out of the inject facade): the Windows Virtual-Key +//! → Linux-evdev keyboard map (mirrored bit-for-bit by the Windows SendInput positional table), the +//! GameStream mouse-button → evdev `BTN_*` map, and the in-process semantic-VK flag. Pure lookup +//! tables — no state, no OS handles. + +/// In-process tag on a key event's `flags`: the VK in `code` is **layout-semantic** (already +/// resolved under the sending client's keyboard layout — the GameStream/Moonlight convention) +/// rather than the punktfunk-native **US-positional** convention (the physical key's US-layout VK, +/// which every first-party client sends — the client's local layout never touches the wire). +/// The Windows injector maps semantic VKs through the foreground app's layout and positional VKs +/// through a fixed table; conflating the two is exactly the German y↔z / ö→ü scramble. +/// Set ONLY by `gamestream::input::decode`; the punktfunk/1 ingest strips it from wire events, so +/// a network client can never flip the host's key-decoding convention. +pub const KEY_FLAG_SEMANTIC_VK: u32 = 0x8000_0000; + +/// Map a Windows Virtual-Key code (as sent by Moonlight/GameStream) to a Linux evdev key code. +pub fn vk_to_evdev(vk: u8) -> Option { + match vk { + // --- Navigation / editing / whitespace --- + 0x08 => Some(14), // VK_BACK -> KEY_BACKSPACE + 0x09 => Some(15), // VK_TAB -> KEY_TAB + 0x0D => Some(28), // VK_RETURN -> KEY_ENTER + 0x13 => Some(119), // VK_PAUSE -> KEY_PAUSE + 0x14 => Some(58), // VK_CAPITAL -> KEY_CAPSLOCK + 0x1B => Some(1), // VK_ESCAPE -> KEY_ESC + 0x20 => Some(57), // VK_SPACE -> KEY_SPACE + 0x21 => Some(104), // VK_PRIOR -> KEY_PAGEUP + 0x22 => Some(109), // VK_NEXT -> KEY_PAGEDOWN + 0x23 => Some(107), // VK_END -> KEY_END + 0x24 => Some(102), // VK_HOME -> KEY_HOME + 0x25 => Some(105), // VK_LEFT -> KEY_LEFT + 0x26 => Some(103), // VK_UP -> KEY_UP + 0x27 => Some(106), // VK_RIGHT -> KEY_RIGHT + 0x28 => Some(108), // VK_DOWN -> KEY_DOWN + 0x2C => Some(99), // VK_SNAPSHOT -> KEY_SYSRQ + 0x2D => Some(110), // VK_INSERT -> KEY_INSERT + 0x2E => Some(111), // VK_DELETE -> KEY_DELETE + + // --- Generic modifiers --- + 0x10 => Some(42), // VK_SHIFT -> KEY_LEFTSHIFT + 0x11 => Some(29), // VK_CONTROL -> KEY_LEFTCTRL + 0x12 => Some(56), // VK_MENU -> KEY_LEFTALT + + // --- Digit row (KEY_0 is 11, KEY_1..KEY_9 are 2..10) --- + 0x30 => Some(11), // VK_0 + 0x31 => Some(2), // VK_1 + 0x32 => Some(3), // VK_2 + 0x33 => Some(4), // VK_3 + 0x34 => Some(5), // VK_4 + 0x35 => Some(6), // VK_5 + 0x36 => Some(7), // VK_6 + 0x37 => Some(8), // VK_7 + 0x38 => Some(9), // VK_8 + 0x39 => Some(10), // VK_9 + + // --- Letters A-Z (NOT sequential in evdev) --- + 0x41 => Some(30), // A + 0x42 => Some(48), // B + 0x43 => Some(46), // C + 0x44 => Some(32), // D + 0x45 => Some(18), // E + 0x46 => Some(33), // F + 0x47 => Some(34), // G + 0x48 => Some(35), // H + 0x49 => Some(23), // I + 0x4A => Some(36), // J + 0x4B => Some(37), // K + 0x4C => Some(38), // L + 0x4D => Some(50), // M + 0x4E => Some(49), // N + 0x4F => Some(24), // O + 0x50 => Some(25), // P + 0x51 => Some(16), // Q + 0x52 => Some(19), // R + 0x53 => Some(31), // S + 0x54 => Some(20), // T + 0x55 => Some(22), // U + 0x56 => Some(47), // V + 0x57 => Some(17), // W + 0x58 => Some(45), // X + 0x59 => Some(21), // Y + 0x5A => Some(44), // Z + + // --- Meta / context-menu --- + 0x5B => Some(125), // VK_LWIN -> KEY_LEFTMETA + 0x5C => Some(126), // VK_RWIN -> KEY_RIGHTMETA + 0x5D => Some(127), // VK_APPS -> KEY_COMPOSE + + // --- Numpad --- + 0x60 => Some(82), // KP0 + 0x61 => Some(79), // KP1 + 0x62 => Some(80), // KP2 + 0x63 => Some(81), // KP3 + 0x64 => Some(75), // KP4 + 0x65 => Some(76), // KP5 + 0x66 => Some(77), // KP6 + 0x67 => Some(71), // KP7 + 0x68 => Some(72), // KP8 + 0x69 => Some(73), // KP9 + 0x6A => Some(55), // VK_MULTIPLY -> KEY_KPASTERISK + 0x6B => Some(78), // VK_ADD -> KEY_KPPLUS + 0x6C => Some(96), // VK_SEPARATOR -> KEY_KPENTER + 0x6D => Some(74), // VK_SUBTRACT -> KEY_KPMINUS + 0x6E => Some(83), // VK_DECIMAL -> KEY_KPDOT + 0x6F => Some(98), // VK_DIVIDE -> KEY_KPSLASH + + // --- Function keys (F1..F10 = 59..68, F11/F12 = 87/88) --- + 0x70 => Some(59), + 0x71 => Some(60), + 0x72 => Some(61), + 0x73 => Some(62), + 0x74 => Some(63), + 0x75 => Some(64), + 0x76 => Some(65), + 0x77 => Some(66), + 0x78 => Some(67), + 0x79 => Some(68), + 0x7A => Some(87), + 0x7B => Some(88), + + // --- Locks --- + 0x90 => Some(69), // VK_NUMLOCK -> KEY_NUMLOCK + 0x91 => Some(70), // VK_SCROLL -> KEY_SCROLLLOCK + + // --- Left/right modifiers --- + 0xA0 => Some(42), // VK_LSHIFT -> KEY_LEFTSHIFT + 0xA1 => Some(54), // VK_RSHIFT -> KEY_RIGHTSHIFT + 0xA2 => Some(29), // VK_LCONTROL -> KEY_LEFTCTRL + 0xA3 => Some(97), // VK_RCONTROL -> KEY_RIGHTCTRL + 0xA4 => Some(56), // VK_LMENU -> KEY_LEFTALT + 0xA5 => Some(100), // VK_RMENU -> KEY_RIGHTALT + + // --- OEM punctuation (US layout) --- + 0xBA => Some(39), // VK_OEM_1 -> KEY_SEMICOLON + 0xBB => Some(13), // VK_OEM_PLUS -> KEY_EQUAL + 0xBC => Some(51), // VK_OEM_COMMA -> KEY_COMMA + 0xBD => Some(12), // VK_OEM_MINUS -> KEY_MINUS + 0xBE => Some(52), // VK_OEM_PERIOD -> KEY_DOT + 0xBF => Some(53), // VK_OEM_2 -> KEY_SLASH + 0xC0 => Some(41), // VK_OEM_3 -> KEY_GRAVE + 0xDB => Some(26), // VK_OEM_4 -> KEY_LEFTBRACE + 0xDC => Some(43), // VK_OEM_5 -> KEY_BACKSLASH + 0xDD => Some(27), // VK_OEM_6 -> KEY_RIGHTBRACE + 0xDE => Some(40), // VK_OEM_7 -> KEY_APOSTROPHE + 0xE2 => Some(86), // VK_OEM_102 -> KEY_102ND + + _ => None, + } +} + +/// Map a GameStream mouse button id (1=left … 5=X2) to a Linux evdev `BTN_*` code. +#[cfg(target_os = "linux")] +pub(crate) fn gs_button_to_evdev(b: u32) -> Option { + Some(match b { + 1 => 0x110, // BTN_LEFT + 2 => 0x112, // BTN_MIDDLE + 3 => 0x111, // BTN_RIGHT + 4 => 0x113, // BTN_SIDE (X1) + 5 => 0x114, // BTN_EXTRA (X2) + _ => return None, + }) +} diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index ef143cd9..d632ecd9 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -535,124 +535,10 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) { } } -/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report -/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is -/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report. -/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the -/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by -/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire). -#[derive(Clone, Default)] -pub struct HidoutDedup { - led: Option<(u8, u8, u8)>, - player_leds: Option, - /// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2. - trigger: [Option>; 2], -} - -impl HidoutDedup { - /// Forget all remembered state — call when a pad is created or unplugged so the first feedback - /// after a (re)connect is always forwarded. - pub fn clear(&mut self) { - *self = HidoutDedup::default(); - } - - /// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a - /// one-shot pulse; `false` if it repeats the last-forwarded value for its kind. - pub fn should_forward(&mut self, h: &HidOutput) -> bool { - match h { - HidOutput::Led { r, g, b, .. } => { - let v = Some((*r, *g, *b)); - if self.led == v { - false - } else { - self.led = v; - true - } - } - HidOutput::PlayerLeds { bits, .. } => { - let v = Some(*bits); - if self.player_leds == v { - false - } else { - self.player_leds = v; - true - } - } - HidOutput::Trigger { which, effect, .. } => { - let slot = (*which as usize).min(1); - if self.trigger[slot].as_deref() == Some(effect.as_slice()) { - false - } else { - self.trigger[slot] = Some(effect.clone()); - true - } - } - // One-shot haptic pulse (Steam voice-coil) — state-less, always fires. - HidOutput::TrackpadHaptic { .. } => true, - // Raw as-is passthrough reports must NEVER dedup: the physical device's firmware - // watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms - // against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would - // silence the motors / re-enable lizard mode on the real controller. - HidOutput::HidRaw { .. } => true, - } - } -} - #[cfg(test)] mod tests { use super::*; - /// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two - /// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`. - #[test] - fn hidout_dedup_forwards_only_changes() { - let mut d = HidoutDedup::default(); - let led = |r| HidOutput::Led { - pad: 0, - r, - g: 0, - b: 0, - }; - // First value forwards; an exact repeat is dropped; a change forwards again. - assert!(d.should_forward(&led(10))); - assert!(!d.should_forward(&led(10))); - assert!(d.should_forward(&led(20))); - - // Player LEDs dedup on their own field, independent of the lightbar. - let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits }; - assert!(d.should_forward(&pl(0b101))); - assert!(!d.should_forward(&pl(0b101))); - assert!(!d.should_forward(&led(20))); // lightbar still unchanged - - // The two adaptive triggers (L2=0, R2=1) are tracked separately. - let trig = |which, byte| HidOutput::Trigger { - pad: 0, - which, - effect: vec![byte, 0, 0], - }; - assert!(d.should_forward(&trig(0, 1))); - assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards - assert!(!d.should_forward(&trig(0, 1))); - assert!(d.should_forward(&trig(0, 2))); // L2 effect changed - - // One-shot haptic pulses are never deduped. - let haptic = HidOutput::TrackpadHaptic { - pad: 0, - side: 0, - amplitude: 1, - period: 2, - count: 3, - }; - assert!(d.should_forward(&haptic)); - assert!(d.should_forward(&haptic)); - - // `clear` re-arms every kind. - d.clear(); - assert!(d.should_forward(&led(20))); - assert!(d.should_forward(&pl(0b101))); - assert!(d.should_forward(&trig(0, 2))); - } - /// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0 /// on the left half, right pad (surface 2) contact 1 on the right half; y follows the /// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click diff --git a/crates/punktfunk-host/src/inject/uhid_manager.rs b/crates/punktfunk-host/src/inject/uhid_manager.rs index 29e7a05a..20a68a98 100644 --- a/crates/punktfunk-host/src/inject/uhid_manager.rs +++ b/crates/punktfunk-host/src/inject/uhid_manager.rs @@ -7,7 +7,7 @@ //! use [`PadSlots`] directly instead. use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame, MAX_PADS}; -use crate::inject::dualsense_proto::HidoutDedup; +use crate::inject::hidout_dedup::HidoutDedup; use crate::inject::pad_slots::PadSlots; use anyhow::Result; use punktfunk_core::quic::{HidOutput, RichInput};