fix(client/apple): the phone-gyro mirror was left on the old motion convention
ci / docs-site (pull_request) Successful in 1m3s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 1m30s
windows-drivers / driver-build (pull_request) Successful in 1m39s
ci / web (pull_request) Successful in 2m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 3m21s
android / android (pull_request) Successful in 4m54s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m6s
ci / rust (pull_request) Successful in 9m4s
windows-drivers / probe-and-proto (pull_request) Failing after 10m10s

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)`.
This commit is contained in:
2026-08-07 19:34:51 +02:00
parent 5a4305c072
commit 1eab4b6626
@@ -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