fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn #43

Merged
enricobuehler merged 1 commits from worktree-haptics-m8-proto into main 2026-08-04 21:03:36 +00:00
4 changed files with 287 additions and 30 deletions
+188 -20
View File
@@ -254,13 +254,45 @@ fn ioctl_ptr<T>(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<Instant>,
}
/// 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<Option<Instant>>,
/// `Some(window)` while playing.
playing: Option<Playback>,
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<Duration>) -> 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<Playback> {
Some(Playback {
starts: at,
ends: None,
})
}
/// Playing from `at`, no delay, for `len`.
fn playing_for(at: Instant, len: Duration) -> Option<Playback> {
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)));
@@ -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]
@@ -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).
@@ -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