fix(client/apple): stop dropping rotation, and stop inventing it
ci / bun-nix (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m3s
ci / docs-site (pull_request) Successful in 1m38s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m19s
windows-drivers / probe-and-proto (pull_request) Successful in 33s
android / android (pull_request) Successful in 3m33s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m15s
ci / rust (pull_request) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m9s

G13 — the three capture-fidelity findings from the gyro sweep, two fixed and one
argued.

**The 4 ms floor was a DROP, and it was shedding real rotation.** 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, so a dropped sample is rotation that happened and can never
be recovered. GameController's delivery jitters 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 — aim drifting short, which reads
as bad sensitivity rather than as a bug.

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 idle watchdog is a 100 ms
timeout this cannot outpace. The throttle's two fields went with it: `lastMotionNs` was
left set-but-never-read once the guard was gone, and `motionIntervalNs` had no other
consumer. (Notes elsewhere say `flush` parks motion and reads it — that is PR #88's
branch, not this one. Checked rather than assumed.)

**An X-Box pad was streaming gyro it does not have.** Capture attached to any `GCMotion`,
and an X-Box controller exposes one that reports gravity and NOTHING else. So the client
sent a permanently-zero `rotationRate` to the host as authoritative gyro, under a
declaration saying this pad has one. That is worse than having no motion plane at all: a
game sees a controller being held perfectly still forever, and there is nothing to fall
back to and nothing to notice. Now gated on `hasRotationRate`, which is GameController's
own answer to the question we actually mean.

The settings badge had the same bug from the same cause — `hasMotion` was
`motion != nil`, so an X-Box pad got a gyroscope icon. It now reads `hasRotationRate`
too. One wrong predicate was driving both the UI promise and the wire behaviour, which is
why they were wrong together.

That also simplifies G8's "your gyro can't reach this session" notice, which had to test
`hasRotationRate` itself to avoid nagging about a gyro the pad never had. With the attach
gated on it, the notice is just the else-branch.

**Motion stays on the main queue, and this is the argument for why.** GameController's
`handlerQueue` is a property of the CONTROLLER, not of an element, so moving motion off
main moves buttons, sticks, the touchpad and the escape chord with it. This class is
`@MainActor` throughout — eight `assumeIsolated` sites, the slot table, the gesture
timers — so that is a rewrite of the isolation model rather than a queue assignment, and
it would put the tvOS escape chord (the only controller way out of a stream there) on a
background queue. That is a real risk for a speculative gain. The comment says so at the
call site, and names the measurement to make first if it ever does bite: the host's
per-pad motion inter-arrival histogram already reports exactly this and would say whether
the delay is client-side or on the wire.

Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) and the
iOS-triple typecheck green. No test pins the throttle removal or the capability gate:
both are properties of live `GCMotion` delivery, which this module cannot fake — there is
no injectable seam, and inventing one to assert "we called sendMotion twice" would test
the mock. They are argued at the call sites instead, in the same spirit as the parts of
`DsCapture` that are not unit-testable in their module either. On-glass verification is
owed with the two already outstanding on that rig.
This commit is contained in:
2026-08-07 19:12:02 +02:00
parent d996449a82
commit 0170da2a5f
3 changed files with 66 additions and 13 deletions
@@ -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-Selectguide 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.
//
@@ -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,
+19
View File
@@ -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, }