Merge pull request 'fix(host/input): rumble comes back when a controller does' (#25) from worktree-haptics-m1-rumble-seq into main
apple / swift (push) Successful in 1m18s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 2m9s
ci / docs-site (push) Successful in 1m43s
deb / build-publish-client-arm64 (push) Successful in 1m47s
android / android (push) Successful in 5m55s
deb / build-publish-host (push) Successful in 4m49s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 8m33s
deb / build-publish (push) Successful in 4m53s
ci / rust (push) Successful in 6m22s
windows-host / package (push) Successful in 12m32s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / deploy-docs (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 19m43s
apple / swift (push) Successful in 1m18s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 2m9s
ci / docs-site (push) Successful in 1m43s
deb / build-publish-client-arm64 (push) Successful in 1m47s
android / android (push) Successful in 5m55s
deb / build-publish-host (push) Successful in 4m49s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 8m33s
deb / build-publish (push) Successful in 4m53s
ci / rust (push) Successful in 6m22s
windows-host / package (push) Successful in 12m32s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / deploy-docs (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 19m43s
Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
@@ -629,6 +629,20 @@ const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
|
||||
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
|
||||
const RUMBLE_STOP_BURST: u8 = 2;
|
||||
|
||||
/// Clear a removed pad's rumble bookkeeping — the level, the "we have seen a level" flag, and any
|
||||
/// stop re-sends still owed. Together these end the pad's lease, so a re-plug on the same wire
|
||||
/// index inherits nothing that could buzz the new device.
|
||||
///
|
||||
/// The per-pad rumble **sequence is deliberately not a parameter**: it must stay monotonic for the
|
||||
/// life of the connection because the client gates on it with a wrapping half-space compare and
|
||||
/// never resets its side (`punktfunk-core/src/client/pump/datagram_task.rs`). Resetting it here is
|
||||
/// the bug pinned by [`tests::rumble_seq_survives_a_removal_so_the_client_gate_accepts`].
|
||||
fn clear_pad_feedback(state: &mut (u16, u16), seen: &mut bool, stop_burst: &mut u8) {
|
||||
*state = (0, 0);
|
||||
*seen = false;
|
||||
*stop_burst = 0;
|
||||
}
|
||||
|
||||
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
|
||||
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
|
||||
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
|
||||
@@ -824,11 +838,22 @@ pub(super) fn input_thread(
|
||||
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
|
||||
}
|
||||
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
|
||||
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
|
||||
rumble_state[idx] = (0, 0);
|
||||
rumble_seen[idx] = false;
|
||||
rumble_seq[idx] = 0;
|
||||
rumble_stop_burst[idx] = 0;
|
||||
// stale rumble lease (a lease still ticking would buzz the new pad).
|
||||
//
|
||||
// `rumble_seq` deliberately SURVIVES — do not reset it here. The client's
|
||||
// rumble reorder gate (`client/pump/datagram_task.rs`) is per-CONNECTION
|
||||
// and has no reset path, so restarting this counter strands every later
|
||||
// envelope for the re-plugged pad until the host climbs back past the
|
||||
// value the client already stored (up to 128 sends ≈ 15 s of continuous
|
||||
// rumble, or dozens of separate rumble events). The three clears below are
|
||||
// what actually kill a stale lease; the sibling `pad_seq` gate keeps its
|
||||
// value across a removal for exactly the same reason (see the comment at
|
||||
// the top of this arm).
|
||||
clear_pad_feedback(
|
||||
&mut rumble_state[idx],
|
||||
&mut rumble_seen[idx],
|
||||
&mut rumble_stop_burst[idx],
|
||||
);
|
||||
}
|
||||
}
|
||||
InputKind::GamepadArrival => {
|
||||
@@ -1071,6 +1096,72 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pad re-plug must not strand the client's rumble reorder gate.
|
||||
///
|
||||
/// The client's `rumble_last_seq` lives for the whole QUIC connection and has no reset path
|
||||
/// (`punktfunk-core/src/client/pump/datagram_task.rs`), so this host's per-pad rumble counter
|
||||
/// has to stay monotonic across a `GamepadRemove`. Regression: the removal arm used to do
|
||||
/// `rumble_seq[idx] = 0`, which made every envelope after a re-plug fail `seq_newer` until the
|
||||
/// counter climbed back past the value the client had already stored — up to 128 sends.
|
||||
///
|
||||
/// Drives the real wire encoder and the real gate, so it fails if either side's rule moves.
|
||||
#[test]
|
||||
fn rumble_seq_survives_a_removal_so_the_client_gate_accepts() {
|
||||
use punktfunk_core::input::GamepadSnapshot;
|
||||
use punktfunk_core::quic::{decode_rumble_envelope, encode_rumble_datagram_v2};
|
||||
|
||||
// The client half: one per-pad slot, per connection, never reset.
|
||||
let deliver = |seq: u8, gate: &mut Option<u8>| {
|
||||
let d = encode_rumble_datagram_v2(0, 0x4000, 0x8000, seq, 400);
|
||||
let env = decode_rumble_envelope(&d)
|
||||
.expect("v2 envelope decodes")
|
||||
.envelope
|
||||
.expect("v2 tail present");
|
||||
if GamepadSnapshot::seq_newer(env.seq, *gate) {
|
||||
*gate = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// The host half: one wrapping counter, bumped on every change and every renewal.
|
||||
let mut gate: Option<u8> = None;
|
||||
let mut seq = 0u8;
|
||||
|
||||
// A long rumble before the unplug pushes the client's stored seq well past zero.
|
||||
for _ in 0..100 {
|
||||
seq = seq.wrapping_add(1);
|
||||
assert!(deliver(seq, &mut gate));
|
||||
}
|
||||
assert_eq!(gate, Some(100));
|
||||
|
||||
// The pad is unplugged mid-buzz: the lease is cleared, the counter is not.
|
||||
let (mut state, mut seen, mut burst) = ((0x1234u16, 0x5678u16), true, RUMBLE_STOP_BURST);
|
||||
clear_pad_feedback(&mut state, &mut seen, &mut burst);
|
||||
assert_eq!(
|
||||
(state, seen, burst),
|
||||
((0, 0), false, 0),
|
||||
"lease not cleared"
|
||||
);
|
||||
|
||||
// It returns on the same wire index and the game rumbles again: the very first envelope
|
||||
// has to reach the actuator.
|
||||
seq = seq.wrapping_add(1);
|
||||
assert!(
|
||||
deliver(seq, &mut gate),
|
||||
"first envelope after a re-plug was dropped by the client's reorder gate"
|
||||
);
|
||||
|
||||
// Non-vacuity: the pre-fix behaviour (counter restarted at 0) really is rejected, and
|
||||
// stays rejected for the whole forward window — this is the bug, reproduced.
|
||||
let mut stranded = Some(100u8);
|
||||
assert!(
|
||||
(1..=100).all(|s| !deliver(s, &mut stranded)),
|
||||
"test is vacuous — a restarted counter should have been gated out"
|
||||
);
|
||||
}
|
||||
|
||||
/// Incremental wire events accumulate into the full pad frame the virtual xpad applies.
|
||||
#[test]
|
||||
fn gamepad_accumulator() {
|
||||
|
||||
Reference in New Issue
Block a user