diff --git a/crates/pf-inject/src/inject/linux/gamepad.rs b/crates/pf-inject/src/inject/linux/gamepad.rs index 435cf87d..551ccef6 100644 --- a/crates/pf-inject/src/inject/linux/gamepad.rs +++ b/crates/pf-inject/src/inject/linux/gamepad.rs @@ -254,13 +254,45 @@ fn ioctl_ptr(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result< Ok(()) } +/// The window a played effect occupies: `replay.delay` of silence, then `replay.length` of rumble. +#[derive(Clone, Copy)] +struct Playback { + /// When the effect starts contributing — `play + replay.delay`. Until then it is armed but + /// silent, which is the whole point of the delay. + starts: Instant, + /// When it stops, or `None` for replay length 0 (until explicitly stopped). + ends: Option, +} + /// One FF effect a game uploaded: rumble magnitudes + playback state. struct Effect { strong: u16, weak: u16, - /// `Some(deadline)` while playing (replay length 0 = until stopped). - playing: Option>, + /// `Some(window)` while playing. + playing: Option, replay_ms: u16, + /// `replay.delay` — how long after the play command the effect stays silent. Decoded from the + /// upload since forever and, until now, never acted on: the effect started immediately and + /// ended `replay.length` later, so anything scheduling a delayed effect (DirectInput under + /// Wine does this routinely) fired early AND finished early by the same amount. + delay_ms: u16, +} + +impl Effect { + /// The window a play command at `at` opens: silent for `replay.delay`, then `replay.length` of + /// rumble (or until stopped, when the length is 0). + /// + /// `replay.length` is measured from the END of the delay, not from the play command, so the + /// delay shifts the whole window instead of eating into it. Split out from the `EV_FF` handler + /// purely so this is testable — the handler itself needs a live uinput fd. + fn window(&self, at: Instant) -> Playback { + let starts = at + Duration::from_millis(self.delay_ms as u64); + Playback { + starts, + ends: (self.replay_ms > 0) + .then(|| starts + Duration::from_millis(self.replay_ms as u64)), + } + } } /// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy @@ -299,17 +331,29 @@ impl FfState { /// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones), /// scale by gain. Returns the new `(low, high)` only when it changed since the last call. fn mix(&mut self, now: Instant, idle: Option) -> Option<(u16, u16)> { - let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t); + let quiet_since = |t: Instant| idle.is_some_and(|d| now.duration_since(t) >= d); + let plane_stale = quiet_since(self.last_activity); let (mut strong, mut weak) = (0u32, 0u32); for e in self.effects.values_mut() { - let Some(deadline) = e.playing else { continue }; - match deadline { + let Some(p) = e.playing else { continue }; + // Still inside `replay.delay`: armed, silent, and NOT a candidate for expiry or the + // abandoned-effect force-off — it has not had its turn yet. + if now < p.starts { + continue; + } + match p.ends { Some(d) if now >= d => e.playing = None, // An infinite-replay effect the game stopped driving (no FF traffic for the whole // idle window) — the alive-but-abandoned case the kernel's close-time auto-erase // cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the // clock). Mirrors the XUSB/UHID abandoned-rumble force-off. - None if stale => { + // + // "Abandoned" needs the effect to have been AUDIBLE for the window too, not just + // the plane quiet: the play command is itself the last activity, so an effect with + // a `replay.delay` longer than the window would otherwise be force-stopped the + // instant it finally started — silent the whole time it waited, then killed on its + // first contributing tick. + None if plane_stale && quiet_since(p.starts) => { tracing::info!( strong = e.strong, weak = e.weak, @@ -544,10 +588,12 @@ impl VirtualPad { weak: 0, playing: None, replay_ms: 0, + delay_ms: 0, }); slot.strong = strong; slot.weak = weak; slot.replay_ms = e.replay_length; + slot.delay_ms = e.replay_delay; } up.effect.id = e.id; // hand the assigned slot back to the kernel up.retval = 0; @@ -574,14 +620,7 @@ impl VirtualPad { (EV_FF, code) => { self.ff.note_activity(); if let Some(e) = self.ff.effects.get_mut(&(code as i16)) { - e.playing = if ev.value != 0 { - Some((e.replay_ms > 0).then(|| { - Instant::now() - + std::time::Duration::from_millis(e.replay_ms as u64) - })) - } else { - None - }; + e.playing = (ev.value != 0).then(|| e.window(Instant::now())); } } _ => {} @@ -802,15 +841,34 @@ mod ff_state_tests { ff } + /// Playing from `at`, no delay, until explicitly stopped. + fn playing(at: Instant) -> Option { + Some(Playback { + starts: at, + ends: None, + }) + } + + /// Playing from `at`, no delay, for `len`. + fn playing_for(at: Instant, len: Duration) -> Option { + Some(Playback { + starts: at, + ends: Some(at + len), + }) + } + #[test] fn abandoned_infinite_effect_is_forced_off_after_idle_window() { + let now = Instant::now(); let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, - playing: Some(None), + // Playing since before the window: "abandoned" means audible AND unattended, so an + // effect that only just started is not a candidate however stale the plane is. + playing: playing(now - Duration::from_millis(2600)), replay_ms: 0, + delay_ms: 0, }); - let now = Instant::now(); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing // The game goes silent on the FF plane past the idle window: cut, exactly once. @@ -825,8 +883,9 @@ mod ff_state_tests { let mut ff = ff_with(Effect { strong: 0x4000, weak: 0, - playing: Some(Some(now + Duration::from_secs(10))), + playing: playing_for(now, Duration::from_secs(10)), replay_ms: 10_000, + delay_ms: 0, }); // FF plane long stale, but the effect declared a finite replay — the declared duration is // the contract (a real pad honors it too), so it keeps playing… @@ -842,26 +901,135 @@ mod ff_state_tests { let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, - playing: Some(None), + playing: playing(now - Duration::from_millis(3000)), replay_ms: 0, + delay_ms: 0, }); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); ff.last_activity = now - Duration::from_millis(3000); assert_eq!(ff.mix(now, IDLE), Some((0, 0))); // The game plays the effect again — an FF event refreshes the clock and re-arms playback. ff.last_activity = now; - ff.effects.get_mut(&0).unwrap().playing = Some(None); + ff.effects.get_mut(&0).unwrap().playing = playing(now); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); } + /// `replay.delay` shifts the whole window: silent until it elapses, then the FULL + /// `replay.length`. Before this the delay was decoded and dropped, so a delayed effect both + /// started early and finished early — DirectInput under Wine schedules these routinely. + #[test] + fn replay_delay_holds_the_effect_off_then_gives_it_its_full_length() { + let now = Instant::now(); + let starts = now + Duration::from_millis(500); + let mut ff = ff_with(Effect { + strong: 0x8000, + weak: 0, + playing: Some(Playback { + starts, + ends: Some(starts + Duration::from_secs(1)), + }), + replay_ms: 1000, + delay_ms: 500, + }); + // Inside the delay: armed but silent. + assert_eq!(ff.mix(now, IDLE), None); + assert_eq!(ff.mix(now + Duration::from_millis(499), IDLE), None); + // Delay elapsed: it plays. + assert_eq!( + ff.mix(now + Duration::from_millis(501), IDLE), + Some((scaled(0x8000), 0)) + ); + // Still playing at 1400 ms — it gets its full second FROM the delay, not from the play. + assert_eq!(ff.mix(now + Duration::from_millis(1400), IDLE), None); + // And ends at delay + length, not at length. + assert_eq!( + ff.mix(now + Duration::from_millis(1600), IDLE), + Some((0, 0)) + ); + } + + /// The window a play opens, straight from the uploaded fields — this is the half that reads + /// `replay.delay` at all. Pinned separately because the `EV_FF` handler that calls it needs a + /// live uinput fd, so a test driving `mix` alone would pass with the delay ignored entirely. + #[test] + fn window_offsets_the_whole_playback_by_replay_delay() { + let at = Instant::now(); + + let delayed = Effect { + strong: 0, + weak: 0, + playing: None, + replay_ms: 1000, + delay_ms: 500, + }; + let w = delayed.window(at); + assert_eq!( + w.starts, + at + Duration::from_millis(500), + "delay defers the start" + ); + assert_eq!( + w.ends, + Some(at + Duration::from_millis(1500)), + "length runs from the END of the delay, so the effect keeps its full second" + ); + + // No delay: starts immediately, unchanged from before. + let plain = Effect { + strong: 0, + weak: 0, + playing: None, + replay_ms: 1000, + delay_ms: 0, + }; + let w = plain.window(at); + assert_eq!(w.starts, at); + assert_eq!(w.ends, Some(at + Duration::from_millis(1000))); + + // Length 0 = until stopped, but the delay still applies. + let infinite = Effect { + strong: 0, + weak: 0, + playing: None, + replay_ms: 0, + delay_ms: 250, + }; + let w = infinite.window(at); + assert_eq!(w.starts, at + Duration::from_millis(250)); + assert_eq!(w.ends, None); + } + + /// A delayed effect must not be force-stopped as "abandoned" while it is still waiting: it has + /// not had its turn, and the idle window is shorter than a delay can legitimately be. + #[test] + fn a_waiting_effect_is_not_cut_by_the_idle_watchdog() { + let now = Instant::now(); + let starts = now + Duration::from_secs(5); + let mut ff = ff_with(Effect { + strong: 0x8000, + weak: 0, + playing: Some(Playback { starts, ends: None }), + replay_ms: 0, + delay_ms: 5000, + }); + ff.last_activity = now - Duration::from_secs(60); // long stale + assert_eq!(ff.mix(now, IDLE), None); // silent, but NOT cut + // It still plays when its delay elapses. + assert_eq!( + ff.mix(now + Duration::from_millis(5001), IDLE), + Some((scaled(0x8000), 0)) + ); + } + #[test] fn disabled_watchdog_never_cuts() { let now = Instant::now(); let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, - playing: Some(None), + playing: playing(now), replay_ms: 0, + delay_ms: 0, }); ff.last_activity = now - Duration::from_secs(600); assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0))); diff --git a/crates/pf-inject/src/inject/proto/dualsense_proto.rs b/crates/pf-inject/src/inject/proto/dualsense_proto.rs index 32852914..1b294804 100644 --- a/crates/pf-inject/src/inject/proto/dualsense_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualsense_proto.rs @@ -250,11 +250,19 @@ impl DsState { use punktfunk_core::input::gamepad as gs; let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8; let on = |bit: u32| buttons & bit != 0; + // Invert in i16 space, BEFORE the quantisation, rather than as `255 - to_u8(v)`. + // 0..=255 has no exact midpoint: `to_u8` puts centre at 0x80, which leaves 128 codes below + // it and 127 above, so mirroring the *output* (`255 - 0x80` = 0x7F) lands a centred stick + // one LSB off the 0x80 that `DsState::neutral` — and the pad's own resting report — use. + // Games idle-poll a centred stick constantly, so that off-by-one showed up as a permanent + // sub-deadzone tilt on the Y axes only. Negating first maps centre to centre by + // construction and keeps both extremes exact (+32767 → 0, -32768 → 255); the only cost is + // that i16::MIN and -32767 share the 255 code, one LSB at the very end of the travel. let mut s = DsState { lx: to_u8(lx), - ly: 255 - to_u8(ly), + ly: to_u8(ly.saturating_neg()), rx: to_u8(rx), - ry: 255 - to_u8(ry), + ry: to_u8(ry.saturating_neg()), l2: lt, r2: rt, ..DsState::neutral() @@ -783,6 +791,29 @@ mod tests { assert_eq!(r[53], 0x0A); } + /// A centred stick must encode as the pad's own neutral on BOTH axes. Inverting the quantised + /// byte (`255 - v`) put Y one LSB below it, which games idle-poll constantly — a permanent + /// sub-deadzone tilt. Extremes must stay exact either way. + #[test] + fn centred_sticks_encode_as_neutral_on_every_axis() { + let n = DsState::neutral(); + let s = DsState::from_gamepad(0, 0, 0, 0, 0, 0, 0); + assert_eq!((s.lx, s.ly), (n.lx, n.ly), "left stick centre"); + assert_eq!((s.rx, s.ry), (n.rx, n.ry), "right stick centre"); + + // Y is still inverted (XInput +y = up, DualSense 0 = up) and both ends stay exact. + let up = DsState::from_gamepad(0, 0, i16::MAX, 0, i16::MAX, 0, 0); + assert_eq!((up.ly, up.ry), (0, 0), "full up = 0"); + let down = DsState::from_gamepad(0, 0, i16::MIN, 0, i16::MIN, 0, 0); + assert_eq!((down.ly, down.ry), (255, 255), "full down = 255"); + + // X keeps its existing mapping. + let right = DsState::from_gamepad(0, i16::MAX, 0, i16::MAX, 0, 0, 0); + assert_eq!((right.lx, right.rx), (255, 255)); + let left = DsState::from_gamepad(0, i16::MIN, 0, i16::MIN, 0, 0, 0); + assert_eq!((left.lx, left.rx), (0, 0)); + } + /// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in /// `buttons[2]`. #[test] diff --git a/crates/pf-inject/src/inject/proto/steam_proto.rs b/crates/pf-inject/src/inject/proto/steam_proto.rs index c03c574f..125894c7 100644 --- a/crates/pf-inject/src/inject/proto/steam_proto.rs +++ b/crates/pf-inject/src/inject/proto/steam_proto.rs @@ -183,8 +183,9 @@ impl SteamState { /// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck /// state. Sticks pass through (the kernel negates Y, which yields the conventional direction — - /// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when - /// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire). + /// validated on-box); triggers scale u8 0..255 → u16 0..32767 ([`trigger_u16`]) and set the + /// full-pull bit when pressed. Trackpad + motion + the back grips arrive separately + /// ([`apply_rich`], the M3 wire). pub fn from_gamepad( buttons: u32, lx: i16, @@ -200,8 +201,8 @@ impl SteamState { ly, rx, ry, - lt: (lt as u16) * 128, - rt: (rt as u16) * 128, + lt: trigger_u16(lt), + rt: trigger_u16(rt), ..SteamState::neutral() }; let mut b = 0u64; @@ -375,8 +376,8 @@ pub fn sc_from_gamepad( ly, rx: 0, ry: 0, - lt: (lt as u16) * 128, - rt: (rt as u16) * 128, + lt: trigger_u16(lt), + rt: trigger_u16(rt), // The wire right stick becomes a right-pad contact (see the doc above). rpad_x: rx, rpad_y: ry, @@ -466,6 +467,18 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes()); } +/// Scale a wire trigger (u8 `0..=255`) onto the Deck's full axis (u16 `0..=32767`). +/// +/// This was `v * 128`, which tops out at 32640 — a fully-pulled trigger reported 99.6% and the top +/// 127 counts of the declared range were unreachable, so a game reading the axis could never see a +/// true full pull. One multiply gets both ends exact (`0 → 0`, `255 → 32767`) and stays monotonic. +/// +/// `serialize_report`'s inverse (`>> 7`, for the legacy u8 trigger bytes) still round-trips both +/// ends against this: `32767 >> 7 == 255`. +fn trigger_u16(v: u8) -> u16 { + ((v as u32 * 32767) / 255) as u16 +} + /// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a /// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so /// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`, @@ -473,7 +486,12 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] { let mut buf = [0u8; STEAM_REPORT_LEN]; let bytes = serial.as_bytes(); - let len = bytes.len().clamp(1, 21); + // `min`, not `clamp(1, 21)`. Clamping the LOW end to 1 and then slicing `bytes[..len]` asks a + // zero-byte slice for one byte, which panics — on the service thread, for an input the kernel + // already has a graceful answer to. Reporting the true length lets its own validation + // (`1 <= reply[1] <= 21`) reject an empty serial and fall back to "XXXXXXXXXX", which is the + // documented behaviour for a reply it does not like. + let len = bytes.len().min(21); buf[0] = 0x00; // report id 0 — stripped by steam_recv_report buf[1] = ID_GET_STRING_ATTRIBUTE; buf[2] = len as u8; @@ -704,7 +722,7 @@ mod tests { assert_ne!(s.buttons & btn::STEAM, 0); assert_ne!(s.buttons & btn::LB, 0); assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit - assert_eq!(s.lt, 255 * 128); + assert_eq!(s.lt, 32767); // full pull reaches the TOP of the declared range assert_eq!(s.lx, 1000); assert_eq!(s.ly, -2000); @@ -730,6 +748,30 @@ mod tests { assert_eq!(s.accel, [16384, -8192, 0]); } + /// An empty serial must not panic. `clamp(1, 21)` asked a zero-byte slice for one byte, which + /// is an out-of-range slice index — on the service thread. The kernel rejects a zero length by + /// its own rule (`1 <= reply[1] <= 21`) and falls back, which is the graceful answer. + #[test] + fn empty_serial_reply_does_not_panic() { + let r = serial_reply(""); + assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE); + assert_eq!( + r[2], 0, + "length the kernel will reject, rather than a panic" + ); + + // Normal and over-long serials still behave. + let r = serial_reply("ABC123"); + assert_eq!(r[2], 6); + assert_eq!(&r[4..10], b"ABC123"); + let long = "X".repeat(40); + assert_eq!( + serial_reply(&long)[2], + 21, + "clamped to the protocol maximum" + ); + } + /// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the /// left / right surfaces to the matching pad (x passes straight through; y flips from the /// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction). diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 8f91f0a9..b392a25f 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -159,6 +159,22 @@ impl OverflowWarn { /// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive /// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a /// rumble write, so its any-activity keying is already rumble-keyed by construction). +/// +/// KNOWN COST, deliberately accepted. That invariant only covers writers that re-assert. A game +/// driving the pad through the kernel's *evdev* FF interface does not: `ff-memless` sends one +/// output report when an effect starts and one when it stops, with nothing in between, so a finite +/// effect longer than this window is cut in half here. The uinput path +/// (`linux/gamepad.rs`) exempts exactly that case — but it can, because evdev FF hands it an +/// explicit `replay.length`. Nothing equivalent reaches this layer: [`PadFeedback`] carries motor +/// levels, and the protocols it speaks (DualSense / DS4 / Deck / Switch Pro) are all +/// level-triggered with no duration field anywhere in a report. So the choice is between cutting a +/// long finite effect and letting an abandoned residual drone forever, and the residual is the one +/// with field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is +/// not affected either way — `hid-nintendo` re-sends rumble continuously, and a physical Pro's +/// HD-rumble decays faster than this window regardless. +/// +/// Do not "fix" this by widening or disabling the window without evidence about which failure real +/// titles actually hit; the hatch below exists for exactly that experiment. const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500); /// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides