Compare commits

..
Author SHA1 Message Date
enricobuehler 31b5f90b12 fix(host/windows): two virtual pads stop tearing each other's reports
apple / swift (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 2m52s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m20s
windows-drivers / probe-and-proto (pull_request) Successful in 29s
windows-drivers / driver-build (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 25m42s
Three faults on the Windows pad path, two of them races that only bite when a
game drives a pad hard enough for two callbacks to overlap.

pf-gamepad's output ring could hand the host a torn report. Publishing is a
read-modify-write — read the cursor, write the slot it names, advance it — and
the framework dispatches output callbacks in parallel, so two could be inside
it at once: both read the same head, both wrote the SAME slot, and both stored
head+1, so the cursor moved once for two reports and the host read a single
entry with two reports mixed into it. An atomic fetch_add does not fix this. It
hands each writer its own slot but advances the cursor before the bytes exist,
so the host is then invited to read a slot still being filled. Serializing the
publish is what makes the cursor bump mean "the slot below is complete". The
ring exists to stop a rumble STOP being coalesced away, and a torn slot can eat
that STOP with no idle watchdog behind it.

Both drivers also promised the host an ordering they never established. The
host loads out_seq and rumble_seq with Acquire and says so in its own comments
— "Acquire pairs with the driver's publish-then-bump store order" — but the
drivers bumped both with plain writes, and an Acquire load pairs with a Release
store and nothing else. On a weakly-ordered core the host could see a fresh seq
against stale bytes. pf-xusb's rumble seq was racy in the same way as the ring:
two SET_STATE calls could both read one value and both write back value+1, so
the host saw one bump for two writes and skipped a level. A skipped stop is the
one that hurts — the pad buzzes until the ~2.5 s idle force-off notices the
game went quiet, which is what bounds the damage.

Diagnosing an unattached driver stalled the session. The pad service thread —
the one feeding input and rumble — waited up to two seconds for a pnputil
enumeration, per unattached pad, at exactly the moment a session was already
going wrong. The diagnosis now runs on its own thread. Off the hot path the
wait no longer has to be a compromise, so it is generous enough to report what
it actually found instead of giving up with "still enumerating" — which, given
pnputil routinely takes longer than the old budget, is what it usually did.
2026-08-04 19:20:01 +02:00
7 changed files with 133 additions and 202 deletions
@@ -21,12 +21,8 @@ import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
///
/// A caller that owns a particular pad passes the location id it wants (see
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
/// lowest location id wins an arbitrary but *stable* choice, where `Set.first` was neither.
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
final class DualSenseHID {
private let manager: IOHIDManager
private var device: IOHIDDevice?
@@ -47,57 +43,9 @@ final class DualSenseHID {
deinit { close() }
/// The IOKit location id of the device this instance opened the handle a caller correlates
/// with its `GCController`. `nil` until a successful `open`.
private(set) var locationID: UInt32?
/// A device's location id, or `nil` if IOKit does not report one.
static func locationID(of dev: IOHIDDevice) -> UInt32? {
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
}
/// Every connected DualSense/Edge, by location id what a caller pairs against its controllers.
static func attachedLocationIDs() -> [UInt32] {
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
let matches = productIDs.map { pid in
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
return []
}
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
return devices.compactMap(locationID(of:)).sorted()
}
/// Which attached device to drive, as an index into `ids` the whole selection rule, pure so
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
///
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
/// merely arbitrary it can differ between two calls in one process. With two DualSenses that
/// made each renderer's paddevice binding a coin flip: both could land on the same device
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
/// place never displaces one it can.
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
}
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
let ordered = Array(devices)
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
return nil
}
return ordered[i]
}
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
/// Find and open the first connected DualSense. Returns false if none is present or it can't
/// be opened (caller then falls back to CoreHaptics).
func open() -> Bool {
let matches = Self.productIDs.map { pid in
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
@@ -107,21 +55,13 @@ final class DualSenseHID {
return false
}
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
let dev = Self.pick(devices, preferring: preferred)
let dev = devices.first
else {
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
return false
}
device = dev
locationID = Self.locationID(of: dev)
if let preferred, locationID != preferred {
// Not fatal one pad still gets rumble but with two pads attached it means this
// renderer is driving the wrong one, and it is invisible without the log line.
log.error(
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
)
}
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
@@ -130,16 +70,8 @@ final class DualSenseHID {
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
/// each 0...255. (0, 0) stops.
///
/// Returns whether the write reached the device. The caller needs this: it used to be logged
/// and swallowed, so a failed write still counted as a successful render. That matters most
/// for a **stop**, which has nothing behind it the renderer stamps its write clock even on
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
/// stop left the motors running with nothing scheduled to try again.
@discardableResult
func rumble(low: UInt8, high: UInt8) -> Bool {
guard let dev = device else { return false }
func rumble(low: UInt8, high: UInt8) {
guard let dev = device else { return }
let report = bluetooth
? Self.bluetoothReport(low: low, high: high)
: Self.usbReport(low: low, high: high)
@@ -149,9 +81,7 @@ final class DualSenseHID {
}
if rc != kIOReturnSuccess {
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
return false
}
return true
}
func close() {
@@ -117,15 +117,7 @@ public final class GamepadFeedback {
reset(slot.controller)
slots[pad] = nil
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
// per-motor `CHHapticEngine.stop()` an XPC round trip to gamecontrollerd, which the
// renderer's own notes record as able to hang plus `DualSenseHID.close()`, whose
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
// change, and the main thread is what drives the presenter's CADisplayLink, so
// blocking here hitches the picture mid-stream. The renderer is already detached from
// routing above, so nothing observes it after this point.
if let renderer { Task.detached { renderer.stop() } }
renderer?.stop()
}
for (pad, controller) in want {
if let slot = slots[pad] {
@@ -290,12 +282,6 @@ public final class GamepadFeedback {
private func reset(_ controller: GCController?) {
guard let c = controller else { return }
c.playerIndex = .indexUnset
// Put the lightbar out too. This class is what turned it on (see the `Led` and
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
// set stays lit in firmware after the stream ends back at the launcher, or for a pad
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
// indicator IS the lightbar; a DualSense is not.
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
ds.leftTrigger.setModeOff()
ds.rightTrigger.setModeOff()
@@ -459,18 +459,6 @@ final class RumbleRenderer: @unchecked Sendable {
if split {
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
// which handle died lose `high` and `render` falls to the combined branch (selected
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
// which at least renders both motors somewhere.
if low == nil || high == nil {
log.warning("rumble: only one split-handle engine came up — falling back to combined")
teardown() // disarms handlers, stops the survivor's players + engine, nils both
}
} else {
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
}
@@ -599,9 +587,7 @@ final class RumbleRenderer: @unchecked Sendable {
#if os(macOS)
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
let hid = DualSenseHID()
// Ask for the device this renderer's controller actually is, so two attached DualSenses
// do not both get driven through whichever one an unordered Set happened to yield first.
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
guard hid.open() else { return false }
dualSenseHID = hid
return true
#else
@@ -609,24 +595,6 @@ final class RumbleRenderer: @unchecked Sendable {
#endif
}
#if os(macOS)
/// Correlate a `GCController` with an IOKit location id.
///
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
/// missing. With one pad attached this is the same device it always was.
static func hidLocationID(for c: GCController) -> UInt32? {
let ids = DualSenseHID.attachedLocationIDs()
guard ids.count > 1 else { return ids.first }
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
return ids.first
}
return ids[rank]
}
#endif
/// Write the target to the DualSense over HID if that's the active backend; false not a
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
@@ -637,20 +605,8 @@ final class RumbleRenderer: @unchecked Sendable {
let keepalive = levels != (0, 0)
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
if levels != lastHidWrite.levels || keepalive {
if hid.rumble(low: levels.0, high: levels.1) {
lastHidWrite = (levels, .now())
} else {
// The write did not reach the device. Do NOT stamp the clock that would claim a
// render that never happened, and for a stop there is nothing behind it: the
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
// target is (0, 0), so the motors would keep running with nothing scheduled.
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
// rebuilds it. Health is reported so the state is visible rather than silent.
log.error("rumble: HID write failed — dropping the handle, falling back")
closeHID()
reportHealth("Lost the direct connection to this DualSense; using the system path.")
return false
}
hid.rumble(low: levels.0, high: levels.1)
lastHidWrite = (levels, .now())
}
return true
#else
@@ -43,33 +43,5 @@ final class DualSenseHIDTests: XCTestCase {
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
XCTAssertEqual(crc, 0xCBF4_3926)
}
// MARK: - Device selection (B14)
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
/// `Set.first` from an unordered set, so the paddevice binding was a coin flip that could
/// point both renderers at the same pad.
func testPreferredIndexHonoursAnExplicitLocation() {
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
}
/// No preference (or one the pad no longer has): fall back to the LOWEST id arbitrary, but
/// stable across calls, which `Set.first` was not.
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
// open it degrades to the same stable fallback.
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
}
/// A device IOKit reports no location for must never displace one it can place.
func testPreferredIndexSortsUnplaceableDevicesLast() {
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
}
}
#endif
@@ -819,46 +819,77 @@ impl DriverAttach {
/// 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.
///
/// 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) {
let store = match driver_store_has(self.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 &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)"
);
let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log);
let shm_name = self.shm_name.clone();
let instance_id = self.instance_id.clone();
std::thread::Builder::new()
.name("pf-driver-diagnose".into())
.spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id))
.ok();
}
}
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
/// draining pad slots even when the driver store is wedged.
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into
/// the closure so the blocking calls stay visible as blocking.
fn diagnose_blocking(
driver: &'static str,
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
/// 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,
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query
/// still running past [`INVENTORY_WAIT`]) or
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
fn driver_store_inventory() -> Option<&'static str> {
static INV: OnceLock<String> = OnceLock::new();
@@ -360,9 +360,32 @@ 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
/// confirmed stuck-rumble path).
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);
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
view.write_u32(OFF_OUT_SEQ, seq);
// Release, not a plain write: the host loads `out_seq` with Acquire specifically to order its
// 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);
if len != 0 {
let head = view.read_u32(OFF_RING_HEAD);
@@ -375,6 +398,11 @@ 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
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
static CHANNEL: ChannelClient = ChannelClient::new();
+29 -1
View File
@@ -358,20 +358,48 @@ 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
/// the game-visible polling path advance.
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);
let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
}
/// 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) {
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_SMALL, small);
let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
v.write_u32(OFF_RUMBLE_SEQ, seq);
v.store_u32(OFF_RUMBLE_SEQ, seq, Ordering::Release);
}
/// 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).
fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);