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
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 of1eab4b66and closes G10's open frame question, both settled by the same measurement.1eab4b66made 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user