From 1eab4b6626c091389a4fe98a2acb9bd76620a198 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 19:34:51 +0200 Subject: [PATCH] fix(client/apple): the phone-gyro mirror was left on the old motion convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by the merge. `DeviceGyro`'s header states the contract plainly — "units and axis semantics match `GamepadCapture.forwardMotion` exactly … the same convention, so a future sign/scale correction lands in one place for both sources" — and this branch made two such corrections in only one of the two places. That is a promise the code stopped keeping the moment the controller path was fixed. Both were true parity when #88 was written; both broke here. **The negation.** `GamepadCapture` sends `-(gravity + userAcceleration)` because Apple reports the gravity VECTOR, pointing down, while an accelerometer measures proper acceleration, pointing up at rest — and the wire carries the latter. The mirror sent it un-negated, so a phone lying still told the host it was accelerating downward at 1 g. The comment above that line even claimed the convention matched. **The frame.** The mirror's remap targets the controller frame its own header describes — x right, y up, z out of the screen — which is exactly GameController's frame, and that is not the DualSense report frame the wire is defined in. So the same change of basis the controller path now takes applies here, after the orientation remap rather than instead of it: the remap resolves which way the phone is being held, and the basis change translates the result into the pad's language. Two different jobs that happen to compose. Order matters for the closing sample too. `stop` replays `lastAccel` beside a zero gyro so "rotation stopped" does not also read as free fall; `lastAccel` is recorded after both conversions, so what gets parked is what was actually sent. Left alone deliberately: `DeviceGyroRemap` itself and `DeviceGyroRemapTests`. The orientation matrices answer a different question — which way is the phone being held — and nothing measured this evening bears on them. They remain derived-not-verified, as their own doc says, and the on-glass pass that owes the controller path a check owes them one too, in all four orientations. Gate: macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the iOS-triple typecheck green — the latter is what actually compiles this file, since the whole thing is `#if os(iOS)`. --- .../PunktfunkKit/Gamepad/DeviceGyro.swift | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift index 442c6542..53a09354 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift @@ -174,22 +174,37 @@ public final class DeviceGyro { state.lock.unlock() let rot = r.apply( x: Float(m.rotationRate.x), y: Float(m.rotationRate.y), z: Float(m.rotationRate.z)) - // Same total-acceleration convention as GamepadCapture.forwardMotion. + // Total acceleration, NEGATED — the same convention as GamepadCapture.forwardMotion, which + // this file's header promises to track. Apple reports the gravity VECTOR (pointing down); + // an accelerometer measures proper acceleration (pointing up at rest), and the wire carries + // the latter. Without the minus a still phone told the host it was accelerating downward at + // 1 g. let acc = r.apply( - x: Float(m.gravity.x + m.userAcceleration.x), - y: Float(m.gravity.y + m.userAcceleration.y), - z: Float(m.gravity.z + m.userAcceleration.z)) + x: -Float(m.gravity.x + m.userAcceleration.x), + y: -Float(m.gravity.y + m.userAcceleration.y), + z: -Float(m.gravity.z + m.userAcceleration.z)) + // Then the SAME change of basis the controller path takes. `r` puts the sample in the + // controller frame this file's header describes — x right, y up, z out of the screen — + // which is exactly GameController's frame, and that is not the DualSense report frame the + // wire is defined in. Measured 2026-08-07; see `GamepadWire.appleMotionToWire`. + // + // Both corrections are here because the header states the intent plainly: units and axis + // semantics match `GamepadCapture.forwardMotion` so a sign/scale fix lands in one place for + // both sources. Fixing only the controller path would have left this one silently on the + // old convention — a mirror that disagrees with the thing it mirrors. + let g = GamepadWire.appleMotionToWire((rot.x, rot.y, rot.z)) + let a = GamepadWire.appleMotionToWire((acc.x, acc.y, acc.z)) let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG let gyro = ( - GamepadWire.motionRaw(rot.x, scale: gs), - GamepadWire.motionRaw(rot.y, scale: gs), - GamepadWire.motionRaw(rot.z, scale: gs) + GamepadWire.motionRaw(g.0, scale: gs), + GamepadWire.motionRaw(g.1, scale: gs), + GamepadWire.motionRaw(g.2, scale: gs) ) let accel = ( - GamepadWire.motionRaw(acc.x, scale: as_), - GamepadWire.motionRaw(acc.y, scale: as_), - GamepadWire.motionRaw(acc.z, scale: as_) + GamepadWire.motionRaw(a.0, scale: as_), + GamepadWire.motionRaw(a.1, scale: as_), + GamepadWire.motionRaw(a.2, scale: as_) ) state.lock.lock() state.lastAccel = accel