diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 9907a5cc..50f0dc1d 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -66,7 +66,6 @@ public final class GamepadCapture { var buttons: UInt32 = 0 var axes: [Int32] = [0, 0, 0, 0, 0, 0] var fingerActive: [Bool] = [false, false] - var lastMotionNs: UInt64 = 0 // Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to // this class's mask-diff model): a Select pressed ALONE is held out of the mask // until it resolves into a tap (delivered on release) or — past `guideHold` — a @@ -89,9 +88,6 @@ public final class GamepadCapture { /// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`). private var slots: [Slot] = [] - /// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate). - private static let motionIntervalNs: UInt64 = 4_000_000 - /// The cross-client controller escape chord (pf-client-core's `ESCAPE_CHORD`): /// L1+R1+Start+Select held together — four simultaneous buttons no game uses, so normal /// play can't trip it. Held for `disconnectHold` it ends the session via @@ -314,17 +310,36 @@ public final class GamepadCapture { // pad off what this slot declared, not off the session echo — under "Automatic" a couch // with an X-Box pad on 0 and a DualSense on 1 echoes X-Box 360 while the host builds pad 1 // a DualSense whose gyro works. + // + // Gated on `hasRotationRate`, not on `motion != nil`. An X-Box controller exposes a + // `GCMotion` that reports gravity and NOTHING else — attaching to it streamed a + // permanently-zero `rotationRate` to the host as authoritative gyro, under a declaration + // that says this pad has one. A game reading it sees a controller being held perfectly + // still forever, which is worse than seeing no motion plane at all: there is nothing to + // fall back to and nothing to notice. let motionCanReach = connection.motionReaches(declared: slot.pref) - if forwarding, let motion = c.motion { + if forwarding, let motion = c.motion, motion.hasRotationRate { if motionCanReach { if motion.sensorsRequireManualActivation { motion.sensorsActive = true } + // Delivered on the MAIN queue, like every other handler here, and deliberately so + // even though ~250 Hz of samples on main is not free. + // + // GameController's `handlerQueue` is a property of the CONTROLLER, not of an + // element, so there is no way to move motion off main without moving buttons, + // sticks, the touchpad and the escape chord with it. This whole class is + // `@MainActor` — eight `assumeIsolated` sites, the slot table, the gesture timers + // — so that is a rewrite of the isolation model, not a queue assignment. It would + // also put the tvOS escape chord (the ONLY controller way out of a stream there) + // on a background queue, which is a real risk taken for a speculative gain. + // + // If main-queue contention ever shows up as motion jitter, the measurement to make + // first is `motion_cadence`'s per-pad inter-arrival histogram on the host — it + // already reports exactly this, and would say whether the delay is here or on the + // wire before anyone restructures the class for it. motion.valueChangedHandler = { [weak self, weak slot] m in MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } } } - } else if motion.hasRotationRate { - // Only for a pad that really has a gyro. A gravity-only pad (an X-Box controller's - // GCMotion) has nothing the player could expect to reach the game, so telling them - // it didn't would be a nag about a feature they never had. + } else { onMotionUnreachable?(slot.pref) } } @@ -584,9 +599,21 @@ public final class GamepadCapture { private func forwardMotion(_ slot: Slot, _ m: GCMotion) { guard !suspended else { return } - let now = DispatchTime.now().uptimeNanoseconds - guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return } - slot.lastMotionNs = now + // Every sample goes out. There used to be a 4 ms floor here, and it was a DROP: a sample + // arriving 3.9 ms after the last one was discarded outright. + // + // That is the wrong shape for this signal. Buttons and sticks are absolute state, so a + // dropped frame costs nothing — the next one says everything it would have. Angular + // velocity is a RATE, and a consumer integrates it into an angle; a dropped sample is + // rotation that happened and can never be recovered. GameController's delivery is jittery + // around the pad's own ~250 Hz, so a floor set AT that rate does not shed a rare extra + // sample, it sheds a steady fraction of every turn — and the error is one-signed, so it + // accumulates into aim drifting short rather than into noise. + // + // Nothing needed the ceiling: GC delivers at the sensor's rate rather than faster, the SDL + // client has always forwarded every sample, and the host's own idle watchdog runs on a + // 100 ms timeout this cannot outpace. The throttle's `lastMotionNs`/`motionIntervalNs` went + // with it rather than being left set-but-unread — nothing else consumed either. // Total acceleration in g: gravity + user when split, else the raw vector — then NEGATED // into the wire's convention. // diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift index 0e13eb96..3d5c1687 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift @@ -41,6 +41,10 @@ public final class GamepadManager: ObservableObject { public let kind: PunktfunkConnection.GamepadType public let hasLight: Bool public let hasHaptics: Bool + /// This controller has a GYROSCOPE — not merely a `GCMotion`. The distinction is the whole + /// point: an X-Box pad exposes a `GCMotion` that reports gravity and nothing else, so + /// `motion != nil` is true for a controller with no angular rate to give. Read + /// `hasRotationRate`, which is GameController's own answer to the question we mean. public let hasMotion: Bool public let hasAdaptiveTriggers: Bool /// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the @@ -265,7 +269,10 @@ public final class GamepadManager: ObservableObject { kind: kind, hasLight: c.light != nil, hasHaptics: c.haptics != nil, - hasMotion: c.motion != nil, + // `hasRotationRate`, not `motion != nil` — see the property. The settings row shows a + // gyroscope badge off this, and promising a gyro an X-Box pad does not have is the + // same lie as streaming its non-existent rotation to the host. + hasMotion: c.motion?.hasRotationRate ?? false, // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the // Edge included); the DualShock 4 has none. hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge, diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 8c25b9b9..079fccd8 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -2133,6 +2133,25 @@ typedef struct { +// What a controller sitting still, face up, actually puts on the wire: **1 g along the UP +// axis** — which is index 1 — and nothing on the other two. +// +// This is a measured fact, not a convention we chose. On 2026-08-07 a real DualSense was read +// over raw HID: at rest it reports `+0.997 g` on report axis 1, and the same session pinned +// the frame as (Right, Up, Backward) — axis 0 carries pitch, 1 yaw, 2 roll. The wire is a unit +// passthrough into that report, so the wire's up axis is the pad's. +// +// It exists because the alternative is worse than imprecise. A virtual pad that has never +// received a motion sample used to report `[0, 0, 0]`, and zero acceleration is not "no +// information" — it is a controller in **free fall**, which is a claim about the physical +// world that is never true of a pad on a desk. A game deriving orientation from it gets a +// definite wrong answer instead of a boring right one. `switch_proto`'s neutral has always +// done this correctly (1 g on its own up axis); the DualSense family and the Deck did not. +// +// Backends whose units differ rescale this like any other sample rather than hard-coding +// their own version of 1 g — see `steam_remap::motion_wire_to_deck`. +#define MOTION_NEUTRAL_ACCEL { 0, (int16_t)MOTION_ACCEL_LSB_PER_G, 0, } +