Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4bf75a6e |
@@ -819,77 +819,46 @@ impl DriverAttach {
|
|||||||
|
|
||||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||||
///
|
|
||||||
/// Runs on its own thread and returns immediately. The caller is the session's pad service
|
|
||||||
/// thread — the one feeding input and rumble — and everything below is slow: the driver-store
|
|
||||||
/// check waits up to [`INVENTORY_WAIT`] for a `pnputil` enumeration that can take tens of
|
|
||||||
/// seconds, and the devnode lookup is a synchronous PnP call. Blocking there stalled input for
|
|
||||||
/// up to two seconds *per unattached pad* (the wait is a deadline, not a one-off: while the
|
|
||||||
/// enumeration is still outstanding every pad pays it again), at exactly the moment a session
|
|
||||||
/// is already going wrong. Diagnostics must never be able to hurt the thing they diagnose.
|
|
||||||
///
|
|
||||||
/// Off the hot path the wait also stops being a compromise — it can afford to be patient and
|
|
||||||
/// report what it actually found rather than "still enumerating".
|
|
||||||
fn diagnose(&self) {
|
fn diagnose(&self) {
|
||||||
let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log);
|
let store = match driver_store_has(self.inf) {
|
||||||
let shm_name = self.shm_name.clone();
|
Some(true) => "driver package present in the driver store",
|
||||||
let instance_id = self.instance_id.clone();
|
Some(false) => {
|
||||||
std::thread::Builder::new()
|
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||||
.name("pf-driver-diagnose".into())
|
}
|
||||||
.spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id))
|
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||||
.ok();
|
};
|
||||||
|
let devnode = match &self.instance_id {
|
||||||
|
Some(id) => devnode_status_line(id),
|
||||||
|
None => {
|
||||||
|
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::warn!(
|
||||||
|
driver = self.driver,
|
||||||
|
shm = %self.shm_name,
|
||||||
|
grace_secs = ATTACH_GRACE.as_secs(),
|
||||||
|
store,
|
||||||
|
devnode = %devnode,
|
||||||
|
driver_log = self.driver_log,
|
||||||
|
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||||
|
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||||
|
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||||
|
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||||
|
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into
|
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
|
||||||
/// the closure so the blocking calls stay visible as blocking.
|
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
|
||||||
fn diagnose_blocking(
|
/// draining pad slots even when the driver store is wedged.
|
||||||
driver: &'static str,
|
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
|
||||||
inf: &'static str,
|
|
||||||
driver_log: &'static str,
|
|
||||||
shm_name: &str,
|
|
||||||
instance_id: Option<String>,
|
|
||||||
) {
|
|
||||||
let store = match driver_store_has(inf) {
|
|
||||||
Some(true) => "driver package present in the driver store",
|
|
||||||
Some(false) => {
|
|
||||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
|
||||||
}
|
|
||||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
|
||||||
};
|
|
||||||
let devnode = match &instance_id {
|
|
||||||
Some(id) => devnode_status_line(id),
|
|
||||||
None => "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
|
||||||
.to_string(),
|
|
||||||
};
|
|
||||||
tracing::warn!(
|
|
||||||
driver,
|
|
||||||
shm = %shm_name,
|
|
||||||
grace_secs = ATTACH_GRACE.as_secs(),
|
|
||||||
store,
|
|
||||||
devnode = %devnode,
|
|
||||||
driver_log,
|
|
||||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
|
||||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
|
||||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
|
||||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
|
||||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long [`driver_store_inventory`] waits for the background pnputil query before reporting
|
|
||||||
/// without it. Only [`diagnose_blocking`] waits, and that has a thread to itself, so this is
|
|
||||||
/// generous: pnputil routinely takes longer than a couple of seconds on a busy driver store, and
|
|
||||||
/// the old two-second budget — chosen to limit the damage while this ran on the pad service thread
|
|
||||||
/// — meant the diagnosis usually gave up and printed "still enumerating", which is the one answer
|
|
||||||
/// that helps nobody. Nothing waits on this thread, so patience costs only a late log line.
|
|
||||||
const INVENTORY_WAIT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||||
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
|
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
|
||||||
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
|
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
|
||||||
/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query
|
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
|
||||||
/// still running past [`INVENTORY_WAIT`]) or
|
|
||||||
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
|
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
|
||||||
fn driver_store_inventory() -> Option<&'static str> {
|
fn driver_store_inventory() -> Option<&'static str> {
|
||||||
static INV: OnceLock<String> = OnceLock::new();
|
static INV: OnceLock<String> = OnceLock::new();
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ pub const LEGACY_STALE_MS: u64 = 1000;
|
|||||||
/// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall).
|
/// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall).
|
||||||
const BACKSTOP_LEGACY_MS: u32 = 2000;
|
const BACKSTOP_LEGACY_MS: u32 = 2000;
|
||||||
|
|
||||||
|
/// The longest lease the engine honours, whatever the envelope claims — the receiver-side mirror of
|
||||||
|
/// the host's own `RUMBLE_TTL_CEIL_MS`.
|
||||||
|
///
|
||||||
|
/// No host built from this tree can exceed it (the `PUNKTFUNK_RUMBLE_TTL_MS` hatch is clamped to
|
||||||
|
/// `[150, 5000]` before it reaches the wire), so this is defence in depth against a third-party or
|
||||||
|
/// modified sender that stamps a long TTL and then wedges its renewal pump while the connection
|
||||||
|
/// stays up. It matters on exactly the platforms that sustain a level for the whole lease: Apple,
|
||||||
|
/// whose renderer deliberately keeps no staleness policy of its own, and a Deck slot, whose
|
||||||
|
/// keepalive re-kicks the actuator until the lease ends. Duration-parameterized embedders (SDL,
|
||||||
|
/// Android) already self-terminate at the clamped backstop.
|
||||||
|
///
|
||||||
|
/// Deliberately NOT `pub`: an embedder has no use for it, and every `pub` const in this crate is
|
||||||
|
/// emitted into `include/punktfunk_core.h` as an UNPREFIXED `#define` — a collision hazard the
|
||||||
|
/// header already has ~170 instances of, and one this has no reason to add to.
|
||||||
|
const MAX_LEASE_MS: u16 = 5_000;
|
||||||
|
|
||||||
/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net
|
/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net
|
||||||
/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits
|
/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits
|
||||||
/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself
|
/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself
|
||||||
@@ -75,8 +91,11 @@ struct PadState {
|
|||||||
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
|
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
next_keepalive: Option<Instant>,
|
next_keepalive: Option<Instant>,
|
||||||
/// Current jitter phase (see [`ActuatorQuirks::dedup_jitter`]).
|
/// The exact value last handed to an embedder. `(0, 0)` ⇔ the engine believes this actuator is
|
||||||
jitter: bool,
|
/// silent. It replaces a free-running jitter phase because one field answers all three live
|
||||||
|
/// questions: would re-sending this be a no-op device write (the dedupe nudge), is a stop
|
||||||
|
/// redundant, and would the nudge synthesize the reserved stop.
|
||||||
|
last_emit: (u16, u16),
|
||||||
quirks: ActuatorQuirks,
|
quirks: ActuatorQuirks,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +107,7 @@ impl PadState {
|
|||||||
legacy_wire: None,
|
legacy_wire: None,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
next_keepalive: None,
|
next_keepalive: None,
|
||||||
jitter: false,
|
last_emit: (0, 0),
|
||||||
quirks: ActuatorQuirks {
|
quirks: ActuatorQuirks {
|
||||||
keepalive_ms: 0,
|
keepalive_ms: 0,
|
||||||
min_pulse_ms: 0,
|
min_pulse_ms: 0,
|
||||||
@@ -112,6 +131,7 @@ impl PadState {
|
|||||||
self.legacy_wire = None;
|
self.legacy_wire = None;
|
||||||
self.next_keepalive = None;
|
self.next_keepalive = None;
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
|
self.last_emit = (0, 0);
|
||||||
RumbleCommand {
|
RumbleCommand {
|
||||||
pad,
|
pad,
|
||||||
low: 0,
|
low: 0,
|
||||||
@@ -119,6 +139,40 @@ impl PadState {
|
|||||||
backstop_ms: 0,
|
backstop_ms: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the command for the pad's current level, and record what we handed out.
|
||||||
|
///
|
||||||
|
/// On a `dedup_jitter` actuator, re-emitting the value the device last took is a no-op write on
|
||||||
|
/// an SDL-class layer, so the low motor's LSB is nudged. Keying that on `last_emit` rather than
|
||||||
|
/// on a free-running phase is what makes it work on EVERY emit path. Previously the nudge lived
|
||||||
|
/// only in the keepalive branch, so a host renewal — which arrives every `ttl*3/10` ms, 120 ms
|
||||||
|
/// at the 400 ms default and 60 ms at the hatch floor — re-emitted the raw level, collided with
|
||||||
|
/// the last jittered write, was swallowed, AND re-anchored the keepalive. That stretched the
|
||||||
|
/// gap between *distinct* device writes to 80 ms at the default cadence and 100 ms at the
|
||||||
|
/// floor, on an actuator whose quirk declares 40.
|
||||||
|
///
|
||||||
|
/// The nudge is refused when it would synthesize the reserved `(0, 0)` stop. That is level
|
||||||
|
/// `(1, 0)` and only that: `high` must already be 0, and `low ^ 1 == 0` implies `low == 1`.
|
||||||
|
/// There the LSB steps up instead, so the phase still alternates (1 ↔ 3, two parts in 65535)
|
||||||
|
/// and the pad never receives a stop the policy did not order.
|
||||||
|
fn emit(&mut self, pad: u16) -> RumbleCommand {
|
||||||
|
let (mut low, high) = self.level;
|
||||||
|
if self.quirks.dedup_jitter && (low, high) == self.last_emit {
|
||||||
|
let alt = low ^ 1;
|
||||||
|
low = if (alt, high) == (0, 0) {
|
||||||
|
low | 0b10
|
||||||
|
} else {
|
||||||
|
alt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
self.last_emit = (low, high);
|
||||||
|
RumbleCommand {
|
||||||
|
pad,
|
||||||
|
low,
|
||||||
|
high,
|
||||||
|
backstop_ms: self.backstop(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy
|
/// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy
|
||||||
@@ -156,6 +210,8 @@ impl RumbleEngine {
|
|||||||
p.dirty = true;
|
p.dirty = true;
|
||||||
match ttl_ms {
|
match ttl_ms {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
|
// Never honour a lease longer than [`MAX_LEASE_MS`], whatever the sender claims.
|
||||||
|
let t = t.min(MAX_LEASE_MS);
|
||||||
p.ttl_ms = t;
|
p.ttl_ms = t;
|
||||||
p.legacy_wire = None;
|
p.legacy_wire = None;
|
||||||
p.deadline = if (low, high) != (0, 0) {
|
p.deadline = if (low, high) != (0, 0) {
|
||||||
@@ -214,22 +270,25 @@ impl RumbleEngine {
|
|||||||
if p.dirty {
|
if p.dirty {
|
||||||
p.dirty = false;
|
p.dirty = false;
|
||||||
if p.level == (0, 0) {
|
if p.level == (0, 0) {
|
||||||
return (Some(p.silence(pad)), None);
|
// Relay a stop only if the actuator is, as far as the engine knows, still
|
||||||
|
// buzzing. A zero on an already-silent pad heals nothing and costs every
|
||||||
|
// embedder a command — Android an unconditional log line plus a binder
|
||||||
|
// `cancel()`. Two senders produce them: the host's deliberate
|
||||||
|
// `RUMBLE_STOP_BURST` re-sends after the first stop already landed, and (behind
|
||||||
|
// `PUNKTFUNK_RUMBLE_ENVELOPE=0`) the legacy flat 500 ms refresh, which re-sends
|
||||||
|
// zeros for every latched pad for the rest of the session. The burst still
|
||||||
|
// heals the case it exists for: a LOST first stop leaves the pad buzzing, so
|
||||||
|
// `last_emit != (0, 0)` and the re-send does emit.
|
||||||
|
if p.last_emit != (0, 0) {
|
||||||
|
return (Some(p.silence(pad)), None);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if p.quirks.keepalive_ms > 0 {
|
if p.quirks.keepalive_ms > 0 {
|
||||||
p.next_keepalive =
|
p.next_keepalive =
|
||||||
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
|
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
|
||||||
}
|
}
|
||||||
let (low, high) = p.level;
|
return (Some(p.emit(pad)), None);
|
||||||
return (
|
|
||||||
Some(RumbleCommand {
|
|
||||||
pad,
|
|
||||||
low,
|
|
||||||
high,
|
|
||||||
backstop_ms: p.backstop(),
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired
|
// 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired
|
||||||
// or stale pad was silenced before reaching here, so a keepalive can never sustain a
|
// or stale pad was silenced before reaching here, so a keepalive can never sustain a
|
||||||
@@ -239,20 +298,7 @@ impl RumbleEngine {
|
|||||||
let due = *p.next_keepalive.get_or_insert(now + ka);
|
let due = *p.next_keepalive.get_or_insert(now + ka);
|
||||||
if now >= due {
|
if now >= due {
|
||||||
p.next_keepalive = Some(now + ka);
|
p.next_keepalive = Some(now + ka);
|
||||||
let (mut low, high) = p.level;
|
return (Some(p.emit(pad)), None);
|
||||||
if p.quirks.dedup_jitter {
|
|
||||||
p.jitter = !p.jitter;
|
|
||||||
low ^= p.jitter as u16;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
Some(RumbleCommand {
|
|
||||||
pad,
|
|
||||||
low,
|
|
||||||
high,
|
|
||||||
backstop_ms: p.backstop(),
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
merge_wake(&mut wake, due);
|
merge_wake(&mut wake, due);
|
||||||
}
|
}
|
||||||
@@ -357,6 +403,22 @@ pub(crate) struct Closed;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The Steam Deck's declared quirks — the only shipping actuator with `dedup_jitter`.
|
||||||
|
const DECK: ActuatorQuirks = ActuatorQuirks {
|
||||||
|
keepalive_ms: 40,
|
||||||
|
min_pulse_ms: 0,
|
||||||
|
dedup_jitter: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Drain the engine the way an embedder does: poll until nothing is due.
|
||||||
|
fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let (Some(c), _) = e.poll(t) {
|
||||||
|
out.push((c.low, c.high));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn ms(v: u64) -> Duration {
|
fn ms(v: u64) -> Duration {
|
||||||
Duration::from_millis(v)
|
Duration::from_millis(v)
|
||||||
}
|
}
|
||||||
@@ -527,4 +589,133 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(shared.next_command(ms(10)), Err(Closed));
|
assert_eq!(shared.next_command(ms(10)), Err(Closed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A host renewal must not repeat the value the device last took, or an SDL-class layer
|
||||||
|
/// swallows the write. Before the jitter moved onto every emit path it lived only in the
|
||||||
|
/// keepalive branch, so each renewal collided with the last jittered write and was deduped.
|
||||||
|
#[test]
|
||||||
|
fn renewal_keeps_the_dedupe_jitter_alternating() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
e.set_quirks(0, DECK);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||||
|
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
|
||||||
|
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(101, 200)]);
|
||||||
|
assert_eq!(drain(&mut e, t0 + ms(80)), vec![(100, 200)]);
|
||||||
|
// The renewal at the 120 ms default cadence: same level, must still be a distinct write.
|
||||||
|
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
|
||||||
|
assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]);
|
||||||
|
assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase-robust version of the same property, at the TTL hatch's 60 ms renewal floor: no two
|
||||||
|
/// consecutive DISTINCT device writes may be further apart than the declared 40 ms cadence.
|
||||||
|
#[test]
|
||||||
|
fn renewal_never_gaps_distinct_writes_at_the_60ms_floor() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
e.set_quirks(0, DECK);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let (mut last, mut last_write, mut worst) = ((0u16, 0u16), 0u64, 0u64);
|
||||||
|
for tick in 0..=360u64 {
|
||||||
|
let t = t0 + ms(tick);
|
||||||
|
if tick % 60 == 0 {
|
||||||
|
e.wire_update(t, 0, 100, 200, Some(400));
|
||||||
|
}
|
||||||
|
for v in drain(&mut e, t) {
|
||||||
|
assert_ne!(v, (0, 0), "a live lease must never emit the stop sentinel");
|
||||||
|
if v != last {
|
||||||
|
worst = worst.max(tick - last_write);
|
||||||
|
last_write = tick;
|
||||||
|
last = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
worst <= 41,
|
||||||
|
"worst distinct-write gap {worst} ms exceeds the 40 ms declared cadence"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nudge must stay behind `dedup_jitter`: an off-by-one amplitude on a default-quirks pad
|
||||||
|
/// would land in Apple's identical-target comparison and Android's one-shot amplitudes.
|
||||||
|
#[test]
|
||||||
|
fn default_quirks_pads_get_the_level_verbatim_on_every_renewal() {
|
||||||
|
let mut e = RumbleEngine::new(); // Apple / Android / plain SDL
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||||
|
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
|
||||||
|
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
|
||||||
|
assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Level `(1, 0)` is the one value whose LSB flip is the reserved stop. The nudge steps up
|
||||||
|
/// instead, so the phase still alternates and no stop is invented under a live lease.
|
||||||
|
#[test]
|
||||||
|
fn jitter_never_synthesizes_the_stop_sentinel() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
e.set_quirks(0, DECK);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 1, 0, Some(400));
|
||||||
|
assert_eq!(e.poll(t0).0, Some(cmd(0, 1, 0, 800)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 3, 0, 800)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 1, 0, 800)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero for a pad the engine already believes is silent is dropped: it heals nothing and
|
||||||
|
/// costs every embedder a command. The deliberate stop-burst heal is unaffected, because a
|
||||||
|
/// LOST stop leaves the pad buzzing and the re-send therefore does emit.
|
||||||
|
#[test]
|
||||||
|
fn a_redundant_stop_is_dropped_but_the_burst_still_heals_a_lost_one() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||||
|
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
|
||||||
|
// First stop reaches the embedder…
|
||||||
|
e.wire_update(t0 + ms(10), 0, 0, 0, Some(0));
|
||||||
|
assert_eq!(drain(&mut e, t0 + ms(10)), vec![(0, 0)]);
|
||||||
|
// …and the burst re-sends behind it are now silent.
|
||||||
|
e.wire_update(t0 + ms(20), 0, 0, 0, Some(0));
|
||||||
|
e.wire_update(t0 + ms(30), 0, 0, 0, Some(0));
|
||||||
|
assert_eq!(drain(&mut e, t0 + ms(30)), Vec::new());
|
||||||
|
|
||||||
|
// But if the pad is buzzing (the stop that mattered was lost), a re-send still emits.
|
||||||
|
e.wire_update(t0 + ms(40), 0, 100, 200, Some(400));
|
||||||
|
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]);
|
||||||
|
e.wire_update(t0 + ms(50), 0, 0, 0, Some(0));
|
||||||
|
assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client bounds the host's lease. `RUMBLE_TTL_CEIL_MS` is sender-side only, so a modified
|
||||||
|
/// or third-party host could otherwise stamp a huge TTL and wedge its pump, leaving Apple and
|
||||||
|
/// the Deck buzzing for the whole of it.
|
||||||
|
#[test]
|
||||||
|
fn an_overlong_lease_is_clamped_to_the_ceiling() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 200, Some(u16::MAX));
|
||||||
|
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 5000)));
|
||||||
|
// Silenced at the ceiling, not at the 65 s the sender asked for.
|
||||||
|
assert!(e.poll(t0 + ms(MAX_LEASE_MS as u64 - 1)).0.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
e.poll(t0 + ms(MAX_LEASE_MS as u64)).0,
|
||||||
|
Some(cmd(0, 0, 0, 0)),
|
||||||
|
"the lease must end at the ceiling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A v2 envelope carrying `ttl_ms == 0` on a LIVE level. The audit suspected the zero would be
|
||||||
|
/// mistaken for the legacy sentinel in `backstop()`; it cannot, because the expiry check
|
||||||
|
/// preempts the relay branch — the pad silences on the same poll and never reaches a backstop.
|
||||||
|
/// Pinned so that ordering stays load-bearing rather than incidental.
|
||||||
|
#[test]
|
||||||
|
fn a_zero_ttl_envelope_silences_rather_than_taking_the_legacy_backstop() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 200, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
e.poll(t0).0,
|
||||||
|
Some(cmd(0, 0, 0, 0)),
|
||||||
|
"a zero-length lease must expire immediately, not emit with a legacy backstop"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,32 +360,9 @@ fn ring_len(view: &pf_umdf_util::section::MappedView) -> u32 {
|
|||||||
/// from being coalesced away by a following LED/trigger report inside one host poll window (the
|
/// from being coalesced away by a following LED/trigger report inside one host poll window (the
|
||||||
/// confirmed stuck-rumble path).
|
/// confirmed stuck-rumble path).
|
||||||
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||||
// Serialized: the whole publish is a read-modify-write (read the cursor, write the slot it
|
|
||||||
// names, then advance it) and the framework dispatches output callbacks in PARALLEL, so two
|
|
||||||
// can be inside this at once. Unsynchronized, both read the same `ring_head`, both write the
|
|
||||||
// SAME slot — tearing one report's bytes across the other's — and both store head+1, so the
|
|
||||||
// cursor advances once for two reports and the host sees a single torn entry.
|
|
||||||
//
|
|
||||||
// An atomic `fetch_add` on the head does not fix it. That hands each writer a distinct slot,
|
|
||||||
// but it advances the cursor BEFORE the slot bytes exist, so the host can read a slot that is
|
|
||||||
// still being filled — trading a torn slot for a torn slot the host is invited to read. Making
|
|
||||||
// the head-advance mean "the slot below is complete" is exactly what the lock buys.
|
|
||||||
//
|
|
||||||
// Poison-tolerant on purpose. Poison is sticky, so the repo's usual `if let Ok(g) = lock()`
|
|
||||||
// would skip the publish for the REST OF THE PROCESS after a single panic elsewhere — silently
|
|
||||||
// ending game output. Recovering the guard is safe here: the protected state is bytes in a
|
|
||||||
// shared section, not an invariant a panic could have broken.
|
|
||||||
let _publish = RING_PUBLISH
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
view.write_bytes(OFF_OUTPUT, bytes);
|
view.write_bytes(OFF_OUTPUT, bytes);
|
||||||
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||||
// Release, not a plain write: the host loads `out_seq` with Acquire specifically to order its
|
view.write_u32(OFF_OUT_SEQ, seq);
|
||||||
// copy of the report bytes after it (`dualsense_windows.rs`, "Acquire pairs with the driver's
|
|
||||||
// publish-then-bump store order"). An Acquire load pairs with a Release store and nothing
|
|
||||||
// else, so as a plain write this promised the host an ordering it never actually established —
|
|
||||||
// on a weakly-ordered core (ARM64) the fresh seq could arrive ahead of the bytes it announces.
|
|
||||||
view.store_u32(OFF_OUT_SEQ, seq, Ordering::Release);
|
|
||||||
let len = ring_len(view);
|
let len = ring_len(view);
|
||||||
if len != 0 {
|
if len != 0 {
|
||||||
let head = view.read_u32(OFF_RING_HEAD);
|
let head = view.read_u32(OFF_RING_HEAD);
|
||||||
@@ -398,11 +375,6 @@ fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializes [`publish_output`] against itself — see the note there for why an atomic cursor is
|
|
||||||
/// not enough. Uncontended in the common case: one output report at a time is the norm, and the
|
|
||||||
/// critical section is a few dozen bytes of memcpy into an already-mapped view.
|
|
||||||
static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
||||||
|
|
||||||
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
||||||
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||||
static CHANNEL: ChannelClient = ChannelClient::new();
|
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||||
|
|||||||
@@ -358,48 +358,20 @@ fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i1
|
|||||||
/// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see
|
/// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see
|
||||||
/// the game-visible polling path advance.
|
/// the game-visible polling path advance.
|
||||||
fn touch_driver_marks(data: &MappedView) {
|
fn touch_driver_marks(data: &MappedView) {
|
||||||
let _marks = SECTION_PUBLISH
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
||||||
let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
||||||
data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward.
|
/// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward.
|
||||||
///
|
|
||||||
/// Serialized and Release-published, because IOCTLs arrive concurrently and neither property held
|
|
||||||
/// before. `seq` was a read-modify-write across the two motor bytes: two `SET_STATE` calls could
|
|
||||||
/// both read the same value and both write back `seq + 1`, so the host — which treats an unchanged
|
|
||||||
/// seq as "nothing new" — saw one bump for two writes and skipped a level entirely. A skipped
|
|
||||||
/// **stop** is the one that hurts: the pad keeps buzzing until the host's ~2.5 s idle force-off
|
|
||||||
/// notices the game went quiet, which is where the bound on this bug comes from.
|
|
||||||
///
|
|
||||||
/// The seq store is Release for the same reason as `pf-gamepad`'s `out_seq`: the host loads it with
|
|
||||||
/// Acquire and documents that as ordering its read of the motor bytes ("the driver bumps
|
|
||||||
/// `rumble_seq` AFTER writing the rumble bytes", `gamepad_windows.rs`). A plain write gives that
|
|
||||||
/// Acquire nothing to pair with, so the guarantee the host's comment claims did not exist in either
|
|
||||||
/// direction — the host could read a fresh seq against stale motor levels on a weakly-ordered core.
|
|
||||||
fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) {
|
fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) {
|
||||||
let Some(v) = data else { return };
|
let Some(v) = data else { return };
|
||||||
let _publish = SECTION_PUBLISH
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
v.write_u8(OFF_RUMBLE_LARGE, large);
|
v.write_u8(OFF_RUMBLE_LARGE, large);
|
||||||
v.write_u8(OFF_RUMBLE_SMALL, small);
|
v.write_u8(OFF_RUMBLE_SMALL, small);
|
||||||
let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
|
let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
|
||||||
v.store_u32(OFF_RUMBLE_SEQ, seq, Ordering::Release);
|
v.write_u32(OFF_RUMBLE_SEQ, seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializes the section's read-modify-write publishes ([`publish_rumble`], [`touch_driver_marks`])
|
|
||||||
/// against each other. One lock rather than one per field: they are all short byte writes into the
|
|
||||||
/// same mapped view, and the contention is nil compared to the IOCTL round trip that reaches them.
|
|
||||||
///
|
|
||||||
/// Poison-tolerant deliberately — poison is sticky, so bailing out on it would silently stop
|
|
||||||
/// forwarding rumble for the rest of the process. The protected state is bytes in a shared section,
|
|
||||||
/// not an invariant a panic elsewhere could have violated.
|
|
||||||
static SECTION_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
||||||
|
|
||||||
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
|
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
|
||||||
fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
|
fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
|
||||||
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);
|
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);
|
||||||
|
|||||||
Reference in New Issue
Block a user