fix(host/inject,drivers): rumble root fixes A-C — lossless report ring + rumble-keyed idle watchdogs

B: PadFeedback.game_drove -> rumble_drove, keyed on vibration-asserting reports — an
LED/adaptive-trigger stream can no longer feed the abandoned-rumble force-off while a
coalesced stop never re-asserts (the confirmed unbounded stuck-ON path). C: Linux parity —
every UHID backend now arms the shared watchdog (Steam Input drives these pads over hidraw
with Windows abandonment semantics) and the uinput mixer force-stops abandoned
infinite-replay FF effects (FfState, unit-tested). Shared PUNKTFUNK_RUMBLE_IDLE_MS hatch
(0 = off; non-zero floored above SDL's ~2 s rumble resend).

A: PadShm v2.1 — a 1024 B tail extension carrying an 8-slot lossless output-report ring,
feature-negotiated via zeroed reserved fields (out_ring_ver; deliberately NO
GAMEPAD_PROTO_VERSION bump — mixed generations degrade to the legacy latest-report slot
instead of failing closed). The pf-dualsense driver dual-writes both planes
(publish_output); the host's shared OutputDrain drains oldest->newest with a torn-read
recheck and an overflow->resync path (PadFeedback.resync force-stops + re-arms dedups).
pf-umdf-util grows a min_data_size map fallback. Ds*Feedback.fresh removed (dead).

design/rumble-root-fix.md par. A-C. Verified: pf-inject tests+clippy Linux+Windows (53/53
on winbox incl. the stop-coalesce repro); drivers ws check+clippy on the CI runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 14:07:32 +02:00
parent 570ff504ad
commit 9e6fc6e071
20 changed files with 877 additions and 212 deletions
@@ -35,6 +35,13 @@ pub struct ChannelConfig {
pub data_magic: u32,
/// The DATA section's size (`size_of::<XusbShm>()` / `size_of::<PadShm>()`).
pub data_size: usize,
/// Fallback map length when the full `data_size` map is refused — the legacy section size of a
/// layout that grew by tail extension (`PAD_SHM_LEGACY_SIZE` for the pad channel). Sections are
/// pagefile-backed and page-granular, so the full-size map is expected to succeed against
/// either host generation; this exists so a refused map can never fail the pad closed. Set
/// equal to `data_size` for layouts that never grew. A caller gates tail-extension features on
/// `MappedView::mapped_len()` (plus the layout's own capability field), never on assumption.
pub min_data_size: usize,
/// `offset_of!(…Shm, pad_index)` in the DATA section.
pub pad_index_off: usize,
/// The driver's logger (each driver tees to its own debug file).
@@ -159,7 +166,9 @@ impl ChannelClient {
/// close it — the view keeps the section alive. On validation failure the handle is
/// deliberately NOT closed: a tampered value could name an unrelated handle in our own table.
fn adopt(&self, cfg: &ChannelConfig, value: u64) {
let Some(view) = MappedView::from_handle_value(value, cfg.data_size) else {
let Some(view) = MappedView::from_handle_value(value, cfg.data_size)
.or_else(|| MappedView::from_handle_value(value, cfg.min_data_size))
else {
if value != 0 {
(cfg.log)(&format!(
"[{}] delivered DATA handle 0x{value:x} did not map — ignoring",
@@ -80,6 +80,12 @@ impl MappedView {
Some(MappedView { base, len })
}
/// How many bytes this view maps — the gate for tail-extension features (a caller may only
/// touch offsets `< mapped_len()`; see `ChannelConfig::min_data_size`).
pub fn mapped_len(&self) -> usize {
self.len
}
/// Assert `off..off+n` is inside the view and, for atomics, `align`-aligned. The view base is
/// page-aligned (`MapViewOfFile`), so field alignment reduces to offset alignment.
#[inline]