Compare commits

..
Author SHA1 Message Date
enricobuehler 76832a5b86 fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying
apple / swift (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / rust (pull_request) Successful in 7m11s
Five faults in the Apple client's feedback path.

With two DualSenses attached, each pad's renderer opened "the first connected
DualSense" — taken from an unordered Set, so the choice could differ between
two calls in one process. Both renderers could land on the same device, one
pad's rumble coming out of the other while their per-instance write dedupes
fought over it, or they could split by luck. Each renderer now asks for the
device its own controller is, correlating GameController's stable ordering with
IOKit's location ids; the selection rule is a pure function so it can be tested
without an IOHIDDevice, which cannot be constructed. Without a preference the
lowest location id wins — still arbitrary, but stable, which Set.first was not.

A failed HID write was logged and swallowed, so a write that never reached the
device still counted as a successful render. That matters most for a stop,
which has nothing behind it: the renderer stamped its write clock even on
failure, the keepalive only re-writes non-zero levels, the ticker is cancelled
once the target is zero, and on USB there is no firmware timeout. A swallowed
stop therefore left the motors running with nothing scheduled to try again.
The write result now reaches the caller, which drops the handle and falls back
to CoreHaptics rather than claiming success.

A half-failed split-handle setup reported HEALTHY. Only the all-nil case
counted as failure, so one surviving handle passed silently while rendering
something wrong in a direction that depended on which handle died: lose the
right one and render falls to the combined branch, playing max(low, high) on
the LEFT handle; lose the left and the split branch discards the heavy motor
outright. A half-open split now tears the survivor down and takes the combined
path, which at least renders both motors somewhere.

Session end never put the lightbar out. This class is what turned it on, and
every DS write is valid-flag-selective, so a game's last colour stayed lit in
firmware after the stream ended — a DS4 was cleared incidentally because its
player indicator IS the lightbar, a DualSense was not.

And the renderer's stop() ran on the main actor. It is a queue.sync whose body
is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own
notes record as able to hang — plus a blocking HID write to a device that has
just departed, and it queues behind any in-flight setup(). It runs on every
unplug and every pin change, and the main thread drives the presenter's
CADisplayLink, so it hitched the picture mid-stream. It is detached now; the
renderer is already off routing by then, so nothing observes it.

Verified: swift build clean, 188 tests pass (185 before), and the three new
device-selection tests fail if the deterministic fallback is reverted.

Note for anyone rebuilding here: the checked-in xcframework was stale (it
predates punktfunk_connection_report_phase) and build-xcframework.sh still dies
on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand
from `cargo build --target aarch64-apple-darwin` is enough to typecheck.

From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20).
2026-08-04 08:20:12 +02:00
6 changed files with 172 additions and 156 deletions
@@ -21,8 +21,12 @@ import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// 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.
/// 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.
final class DualSenseHID {
private let manager: IOHIDManager
private var device: IOHIDDevice?
@@ -43,9 +47,57 @@ final class DualSenseHID {
deinit { close() }
/// 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 {
/// 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 {
let matches = Self.productIDs.map { pid in
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
@@ -55,13 +107,21 @@ final class DualSenseHID {
return false
}
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
let dev = devices.first
let dev = Self.pick(devices, preferring: preferred)
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))")
@@ -70,8 +130,16 @@ final class DualSenseHID {
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
/// each 0...255. (0, 0) stops.
func rumble(low: UInt8, high: UInt8) {
guard let dev = device else { return }
///
/// 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 }
let report = bluetooth
? Self.bluetoothReport(low: low, high: high)
: Self.usbReport(low: low, high: high)
@@ -81,7 +149,9 @@ 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,7 +117,15 @@ public final class GamepadFeedback {
reset(slot.controller)
slots[pad] = nil
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
renderer?.stop()
// 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() } }
}
for (pad, controller) in want {
if let slot = slots[pad] {
@@ -282,6 +290,12 @@ 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,6 +459,18 @@ 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)
}
@@ -587,7 +599,9 @@ final class RumbleRenderer: @unchecked Sendable {
#if os(macOS)
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
let hid = DualSenseHID()
guard hid.open() else { return false }
// 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 }
dualSenseHID = hid
return true
#else
@@ -595,6 +609,24 @@ 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).
@@ -605,8 +637,20 @@ final class RumbleRenderer: @unchecked Sendable {
let keepalive = levels != (0, 0)
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
if levels != lastHidWrite.levels || keepalive {
hid.rumble(low: levels.0, high: levels.1)
lastHidWrite = (levels, .now())
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
}
}
return true
#else
@@ -43,5 +43,33 @@ 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
+4 -130
View File
@@ -285,21 +285,6 @@ fn set_valve_hidapi(enabled: bool) {
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
}
/// Disable the Valve HIDAPI drivers **before SDL exists** — call this alongside the other
/// pre-`SDL_Init` hints, not after a subsystem is up.
///
/// The damage these drivers do happens at *enumeration*, which is part of initialising the
/// joystick/gamepad subsystem. Setting the hint afterwards does detach the driver, but only after
/// it has already sent the Deck its `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` — so the
/// built-in trackpad-mouse dies system-wide and stays dead until the firmware watchdog restores
/// lizard mode seconds later. The threaded worker ([`run`]) has always done this in the right
/// order; the caller-pumped path could not, because by the time it receives a
/// [`sdl3::GamepadSubsystem`] the enumeration has already happened. Hence a separate entry point
/// its callers can put in the right place.
pub fn preinit_disable_valve_hidapi() {
set_valve_hidapi(false);
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T;
@@ -408,12 +393,9 @@ impl GamepadService {
/// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's
/// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback).
///
/// The Valve HIDAPI drivers are held off here too, but this is **too late to be the only
/// place it happens**: the `subsystem` argument means enumeration is already done, and that
/// is when the Deck driver kills the trackpad-mouse. The caller must also call
/// [`preinit_disable_valve_hidapi`] with its other pre-`SDL_Init` hints. This call still
/// earns its place — it re-asserts "off" for a process that ran a session earlier — but on
/// its own it only detaches a driver that has already done the damage.
/// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their
/// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled
/// for the duration of an attached session only.
pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) {
set_valve_hidapi(false);
let pads = Arc::new(Mutex::new(Vec::new()));
@@ -574,38 +556,6 @@ impl GamepadPump {
self.worker.menu_poll();
self.worker.render_feedback();
}
/// Close every forwarded slot — flush its held wire state, tell the host to remove the pad,
/// and physically silence it. Call once on the way out of the caller's event loop.
///
/// [`GamepadService::detach`] only *posts* `Ctl::Detach`; the close — the flush, the host-side
/// `GamepadRemove`, and the explicit `set_rumble(0, 0)` backstop in `close_slot_at` — happens
/// when the pump next drains it. An exit path that detached and then left the loop without
/// another [`tick`](Self::tick) therefore skipped all of it, and nothing else would: the slots
/// hold no `Drop` that silences them. A pad left mid-buzz stayed buzzing.
///
/// This closes the slots directly rather than draining the queued `Ctl::Detach` that would
/// have done it. Same physical outcome by a shorter path, and deliberately so: this also runs
/// from `Drop`, and `drain_ctl` reaches `Mutex::lock().unwrap()`, which on a poisoned lock
/// would panic — during an unwind that aborts the process. Closing a slot touches no lock.
///
/// Idempotent, and safe with nothing attached.
pub fn shutdown(&mut self) {
self.worker.close_all_slots();
}
}
/// The silence backstop of last resort. A caller's loop can also leave by `?` on a fatal overlay
/// or present error — several paths do — and those would skip an explicit
/// [`shutdown`](GamepadPump::shutdown) entirely, leaving a forwarded pad buzzing on the way out.
///
/// Callers should still call `shutdown` at their normal exit rather than lean on this: the pad
/// wants to go quiet *before* a long teardown (session join, `vkDeviceWaitIdle`), not after it.
/// Doing both is free — `shutdown` is idempotent.
impl Drop for GamepadPump {
fn drop(&mut self) {
self.shutdown();
}
}
/// The lowest wire pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)) not already held
@@ -1676,11 +1626,6 @@ impl Worker {
HidOutput::PlayerLeds { bits, .. } if is_ds => {
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(bits));
}
// Every other pad with player LEDs gets them through SDL, which owns the
// per-device pattern. This used to fall through and do nothing at all.
HidOutput::PlayerLeds { bits, .. } => {
let _ = set_player_leds(&slot.pad, bits);
}
HidOutput::Trigger {
which, ref effect, ..
} if is_ds => {
@@ -1688,43 +1633,12 @@ impl Worker {
.pad
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
// Deliberately unhandled, listed rather than left to a bare `_` so a new
// variant cannot join them silently: adaptive triggers exist only on a
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
// and carried by `send_effect` above when the pad is one.
HidOutput::Trigger { .. }
| HidOutput::TrackpadHaptic { .. }
| HidOutput::HidRaw { .. } => {}
_ => {}
}
}
}
}
/// The SDL player index for the wire's positional player-LED `bits`, or `None` for "no player".
///
/// The wire carries a bitmask — one bit per LED, low 5 — while SDL wants a player *index* and owns
/// the per-device pattern. The count bridges them: every convention that reaches this wire spells
/// "player N" as N lit LEDs, both the DualSense patterns (`0x04`, `0x0A`, `0x15`, `0x1B`, `0x1F`)
/// and the Switch/XInput run of low bits (`0x01`, `0x03`, `0x07`, `0x0F`). SDL's index is 0-based,
/// so player 1 is index 0; no lit LED means *no* player rather than player 0.
///
/// Split out from [`set_player_leds`] so the mapping is testable — an `sdl3::Gamepad` needs a real
/// device, so nothing that takes one can be.
fn player_index_from_bits(bits: u8) -> Option<u16> {
match (bits & 0x1F).count_ones() {
0 => None,
n => Some((n - 1) as u16),
}
}
/// Drive a non-DualSense pad's player LEDs from the wire's positional `bits`.
fn set_player_leds(pad: &sdl3::gamepad::Gamepad, bits: u8) -> Result<(), sdl3::Error> {
match player_index_from_bits(bits) {
None => pad.unset_player_index(),
Some(i) => pad.set_player_index(i),
}
}
/// The wire pad index a [`HidOutput`] is addressed to (every variant carries `pad`).
fn hidout_pad(h: &HidOutput) -> u8 {
match h {
@@ -2094,43 +2008,3 @@ mod slot_tests {
);
}
}
#[cfg(test)]
mod player_led_tests {
use super::*;
/// Both conventions that reach this wire spell "player N" as N lit LEDs, so the count is the
/// player number regardless of WHICH bits a given pad lights. Pinned because the mapping is
/// otherwise only obvious once you have seen both patterns side by side.
#[test]
fn player_index_counts_lit_leds_for_both_conventions() {
// DualSense / hid-playstation patterns — non-contiguous, symmetric about the centre LED.
assert_eq!(player_index_from_bits(0x04), Some(0)); // player 1
assert_eq!(player_index_from_bits(0x0A), Some(1)); // player 2
assert_eq!(player_index_from_bits(0x15), Some(2)); // player 3
assert_eq!(player_index_from_bits(0x1B), Some(3)); // player 4
assert_eq!(player_index_from_bits(0x1F), Some(4)); // player 5
// Switch/XInput style — a contiguous run of low bits, the same count each time.
assert_eq!(player_index_from_bits(0x01), Some(0));
assert_eq!(player_index_from_bits(0x03), Some(1));
assert_eq!(player_index_from_bits(0x07), Some(2));
assert_eq!(player_index_from_bits(0x0F), Some(3));
}
/// No lit LED is "no player", NOT player 0 — the difference between LEDs off and player 1 lit.
#[test]
fn no_lit_led_is_no_player() {
assert_eq!(player_index_from_bits(0x00), None);
// Only the low 5 bits are player LEDs; junk above them must not invent a player.
assert_eq!(player_index_from_bits(0xE0), None);
}
/// The mask is applied before counting, so out-of-range bits cannot inflate the index past
/// the 5 real LEDs.
#[test]
fn high_bits_are_masked_off_before_counting() {
assert_eq!(player_index_from_bits(0xFF), Some(4)); // 0x1F worth of LEDs, not 8
assert_eq!(player_index_from_bits(0xE4), Some(0)); // 0x04 with junk on top
}
}
-14
View File
@@ -466,13 +466,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
#[cfg(windows)]
crate::win32::set_app_user_model_id();
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// Hold SDL's Valve HIDAPI drivers off BEFORE SDL_Init: the Deck driver clears the pad's
// digital mappings at *enumeration*, which is part of bringing the gamepad subsystem up, so a
// hint set after `sdl.gamepad()` — where this used to live, inside GamepadService::pumped —
// only detached a driver that had already killed the built-in trackpad-mouse system-wide. The
// symptom was the Deck losing its trackpad cursor at the start of every session until the
// firmware watchdog restored lizard mode. They are still enabled for an attached session.
pf_client_core::gamepad::preinit_disable_valve_hidapi();
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
@@ -1902,13 +1895,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
};
// Every exit from the loop above converges here, which is why the gamepad teardown belongs
// here and not on the individual `break`s. `gamepad.detach()` only queues the detach; the
// close — flush, host-side GamepadRemove, and the explicit rumble-stop backstop — runs when
// the pump drains it. Single mode broke out of the loop immediately after detaching and
// Event::Quit never detached at all, so both left forwarded pads unflushed and, if the game
// was rumbling at the time, still buzzing.
pump.shutdown();
// Join the pump BEFORE the device-wide idle: its decode submissions on the shared
// device would race vkDeviceWaitIdle otherwise.
if let Some(st) = stream.take() {