fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 2m8s
ci / rust-arm64 (pull_request) Successful in 4m2s
android / android (pull_request) Successful in 9m9s
ci / rust (pull_request) Successful in 11m18s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 2m8s
ci / rust-arm64 (pull_request) Successful in 4m2s
android / android (pull_request) Successful in 9m9s
ci / rust (pull_request) Successful in 11m18s
Four encoder faults, plus a note on a fifth that turned out not to be one. A centred stick did not encode as centre on the Y axes. The mapper inverted the already-quantised byte, and 0..255 has no exact midpoint: the forward map puts centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that DsState::neutral and the pad's own resting report use. Games idle-poll a centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by construction and keeps both extremes exact; the only cost is i16::MIN and -32767 sharing a code, one LSB at the very end of the travel. Force-feedback ignored replay.delay. It was decoded on upload and never read: an effect started the moment it was played 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. The delay now shifts the whole window, with length measured from the end of the delay rather than eaten into by it. Writing the test for that surfaced a second bug in the same path: a waiting effect was still a candidate for the abandoned-effect force-off, and since the play command is itself the last FF activity, an infinite effect with a delay longer than the idle window would be killed on its first contributing tick after sitting silent the whole time it waited. Being abandoned now requires the effect to have been audible for the window too. An empty serial panicked the service thread. The reply builder clamped the length to at least 1 and then sliced that many bytes out of the string, which asks a zero-byte slice for one byte. The kernel already has a graceful answer for a length it rejects, so report the true one and let it fall back. Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a declared 32767, leaving the last 127 counts unreachable, so no game could ever see the axis bottom out. One multiply gets both ends exact. The idle watchdog is left alone. It does cut finite multi-second effects that the uinput path exempts, but only the uinput path is handed an explicit duration; the protocols behind the watchdog are level-triggered with no duration field anywhere in a report, so there is nothing at that layer to exempt. The choice is between cutting a long effect and letting an abandoned one drone forever, and only the latter has field evidence behind it. Recorded at the constant so the next reader sees the cost rather than rediscovering it.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user