fix(clients/pads): the phone mirror never needed the controller path's frame change
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m38s
ci / rust-arm64 (pull_request) Successful in 2m12s
windows-drivers / probe-and-proto (pull_request) Successful in 21s
android / android (pull_request) Successful in 3m39s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m11s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m14s
ci / rust (pull_request) Successful in 6m32s

Reverts half of 1eab4b66 and closes G10's open frame question, both settled by the same
measurement.

1eab4b66 made two corrections to the Apple phone-gyro mirror. The negation was right and
stays: Apple reports the gravity VECTOR, pointing down, while an accelerometer measures
proper acceleration, pointing up at rest, and the wire carries the latter. The frame
change was wrong, and this removes it.

The mistake was a name collision. Two different frames are both called "the controller
frame". GCMotion reports a CONTROLLER in (Right, Forward, Up) — measured on a real
DualSense — which is not the wire's frame, which is why `GamepadCapture.forwardMotion`
converts. The mirror's orientation remap resolves THIS DEVICE into the frame its header
describes, x right, y up, z out of the screen. For the pose that mirror exists to serve —
a phone clipped upright with the screen facing the player — "out of the screen" points at
the player, so that frame is (Right, Up, Backward), which IS the wire's. It was already
correct. Applying the controller path's conversion on top rotated it out of true: a phone
sitting still would have reported gravity as −1 g on the roll axis rather than +1 g up,
i.e. claimed to be lying on its edge.

Reasoning by analogy is what produced it — "the mirror says controller frame, the capture
path says controller frame, so the same fix applies". Both files say it; they mean
different things.

What caught it was measuring the Android twin, which does the same thing straight through.
On glass: a DualSense on Bluetooth to a phone, streaming to a Linux host, reads +1 g on the
up axis end to end. Had the Apple mirror needed a conversion, the Android one would have
needed the same one and would have been visibly wrong. It is not.

The same run settles G10's frame, which shipped straight-through and explicitly unverified
because nobody had put a Bluetooth pad in front of the platform sensor framework. Now
somebody has. `PadSensors`' own first-sample log read `accel 0, 10000, 0` — exactly 1 g on
slot 1 — and at the far end hid-playstation published gravity as +0.991 g on ABS_Y, with
every rotation driving its correctly-named axis and the signs agreeing with gravity's
independent witness on 95 of 100 rotating samples. Android hands a controller's sensors
over in the pad's own frame, as documented. No remap, and the comment now says measured
instead of assumed.

Worth recording why the earlier suspicion was wrong, since it is the same trap in the other
direction: Android's DEVICE sensor frame really does put +z out of the screen, so a flat
phone puts gravity on z — but a CONTROLLER's sensors are reported in the controller's
frame, not the phone's. One platform, two conventions, chosen by what the sensor is
attached to.

Gate: Apple macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the
iOS-triple typecheck, which is what actually compiles `DeviceGyro.swift`; Android
`:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`. Green.

Still owed: `DeviceGyroRemap`'s four orientation matrices remain derived — this run used a
controller's own sensors, not the mirror, so it says nothing about them. They need a
gyro-less pad on wire index 0 and a phone turned through all four orientations.
This commit is contained in:
2026-08-07 21:28:13 +02:00
parent 7a4cdac5b7
commit ee61e8c9ba
2 changed files with 40 additions and 36 deletions
@@ -211,25 +211,23 @@ class PadSensors(private val router: GamepadRouter) {
/**
* One gyroscope sample (Android: rad/s) → the wire's three signed-16 components, in place.
*
* ⚠ **THE AXIS FRAME IS NOT VERIFIED ON THIS PATH.** The wire is a unit passthrough into a
* virtual DualSense report, and that report's frame was MEASURED on 2026-08-07 over raw
* HID: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward, toward the player
* (roll), right-handed. Android documents its sensor frame for a handheld device as +x
* right, +y up, +z out of the face — the same frame once "the face" is read as the one the
* player looks at, which is what the platform means by a controller's own sensor. So
* straight through is the mapping the documentation implies, and it is what this does.
* What nobody has DONE is put a Bluetooth DualSense in front of the platform sensor
* framework and compare: those numbers come through a HID driver and InputFlinger's sensor
* mapper, either of which could permute or negate without saying so.
* The axis frame is straight through, and that is now MEASURED rather than assumed.
*
* The measurement that settles it is the DualSense one repeated on this path — pad flat
* and still, then three labelled rotations:
* - at rest, gravity must land as +1 g on ACCEL slot 1 (not 0, not 2);
* - yaw clockwise seen from above ⇒ GYRO slot 1 negative;
* - pitch the far edge down ⇒ slot 0 negative;
* - roll right-side down ⇒ slot 2 negative.
* Any disagreement is a remap, and it belongs HERE with its own expectations in
* `PadSensorsTest` — not spread across callers, and not guessed at in advance.
* The wire is a unit passthrough into a virtual DualSense report, whose frame was measured
* over raw HID on 2026-08-07: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward
* toward the player (roll), right-handed. Android hands a controller's own sensors over in
* that same frame — which was the documented expectation, but the numbers pass through a
* HID driver and InputFlinger's sensor mapper, either of which could have permuted or
* negated without saying so.
*
* Verified 2026-08-07 end to end: a DualSense on Bluetooth to an Android phone, streaming
* to a Linux host. This path's own first-sample log read `accel 0, 10000, 0` — exactly 1 g
* on slot 1 — and at the far end `hid-playstation` published gravity as +0.991 g on ABS_Y
* with every rotation driving its correctly-named axis (yaw→RY, pitch→RX, roll→RZ) and the
* signs agreeing with gravity's independent witness on 95 of 100 rotating samples.
*
* So: no remap. If a future device disagrees, the remap belongs HERE with its own
* expectations in `PadSensorsTest` — not spread across callers.
*/
fun gyroToWire(values: FloatArray, out: IntArray) {
for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f })
@@ -237,9 +235,10 @@ class PadSensors(private val router: GamepadRouter) {
/**
* One accelerometer sample (Android: m/s², specific force) → the wire's three signed-16
* components, in place. Same unverified frame as [gyroToWire] and the same straight-through
* components, in place. Same measured frame as [gyroToWire] and the same straight-through
* mapping; the sign needs no flip, because Android and the DualSense report agree that the
* axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]).
* axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]) — which is precisely
* what the on-glass run read back, `accel 0, 10000, 0` with the pad lying flat.
*/
fun accelToWire(values: FloatArray, out: IntArray) {
for (i in 0..2) out[i] = Gamepad.motionAccelWire(values.getOrElse(i) { 0f })
@@ -183,28 +183,33 @@ public final class DeviceGyro {
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`.
// NO frame conversion here, and that is not an oversight `GamepadCapture.forwardMotion`
// applies `GamepadWire.appleMotionToWire` and this deliberately does not.
//
// 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))
// The trap is that two different frames are both called "the controller frame". GCMotion
// reports a CONTROLLER in (Right, Forward, Up) measured on a real DualSense which is
// not the wire's frame, hence the conversion over there. `r` above resolves THIS DEVICE
// into the frame the header describes: x right, y up, z out of the screen. For the pose
// this mirror exists to serve a phone clipped upright, screen facing the player "out of
// the screen" points AT the player, so that frame is (Right, Up, Backward), which IS the
// wire's frame. Straight through is already correct.
//
// Applying the controller path's conversion here was tried and was WRONG: a phone at rest
// would have reported gravity as 1 g on the roll axis instead of +1 g up, i.e. lying on
// its edge. Caught by measuring the Android twin, which does the same thing straight
// through and reads +1 g on the up axis end to end. If a future capture path needs a
// conversion, decide it from that source's OWN measured frame rather than by analogy.
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
let gyro = (
GamepadWire.motionRaw(g.0, scale: gs),
GamepadWire.motionRaw(g.1, scale: gs),
GamepadWire.motionRaw(g.2, scale: gs)
GamepadWire.motionRaw(rot.x, scale: gs),
GamepadWire.motionRaw(rot.y, scale: gs),
GamepadWire.motionRaw(rot.z, scale: gs)
)
let accel = (
GamepadWire.motionRaw(a.0, scale: as_),
GamepadWire.motionRaw(a.1, scale: as_),
GamepadWire.motionRaw(a.2, scale: as_)
GamepadWire.motionRaw(acc.x, scale: as_),
GamepadWire.motionRaw(acc.y, scale: as_),
GamepadWire.motionRaw(acc.z, scale: as_)
)
state.lock.lock()
state.lastAccel = accel