fix(client/apple): acceleration was upside down — measured on glass

G16, first result. A DualSense paired to an iPhone, streaming to a Linux host,
lying flat and face up: hid-playstation decoded z = −0.99 g where a DualSense
owes +1.00. Vector magnitude was 1.006 g, so the scale was already correct —
this is purely direction, and it was wrong for every accelerometer sample the
Apple client has ever sent.

The cause is a convention mismatch, not a sign typo. Apple reports acceleration
as the gravity VECTOR, which points down: a device face-up on a table reads
z = −1. An accelerometer physically measures proper acceleration, and at rest
that is the +1 g normal force pushing UP — which is what a DualSense's report,
and therefore our wire, carries. The two are exact negatives. Both branches were
affected, because `m.acceleration` follows the same Apple convention as the
gravity/userAcceleration split, so reading the "raw vector" was not an escape
from it.

`rotationRate` is a true angular rate and needs no flip. The same session
confirmed that independently: rotating the pad clockwise seen from above
produced a negative yaw, which is correct under the right-hand rule about an
up-pointing Z. That asymmetry — accel wrong, gyro right — is itself evidence for
this diagnosis rather than a blanket frame error, and it is why the fix is three
negations at one site instead of a remap.

The sweep predicted this ("Apple accel plausibly INVERTED — CoreMotion gravity
-1 g vs DS +1 g up at rest") but could not confirm it without hardware. It is
now measured, and the mechanism is confirmed in the code rather than inferred
from the number.

Method, for whoever repeats it: the readout is python-evdev on the host reading
the virtual pad's own motion node, dividing by the axis `resolution` the kernel
publishes, so it prints deg/s and g. That is downstream of the calibration blob
— the same layer a game reads — which is what makes a sign error visible to a
human at all.

Two things this does NOT establish. The host was a KVM guest, so the DualSense
could not be attached natively for a side-by-side reference reading; the test
stands on the DualSense convention being a fixed property of the hardware, which
is decisive for the at-rest sign but weaker for the gyro axis ORDER. And the fix
itself is unverified on glass: confirming it needs a rebuilt client on the
device, so someone should re-run the same at-rest reading and see +1.00.

Gate: `swiftc -parse` clean. A full typecheck needs the gitignored
PunktfunkCore.xcframework assembled first and has not been run.
This commit is contained in:
2026-08-07 14:56:27 +02:00
parent cbfa03b7ad
commit 9e9bb9f466
@@ -564,18 +564,32 @@ public final class GamepadCapture {
let now = DispatchTime.now().uptimeNanoseconds
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
slot.lastMotionNs = now
// Total acceleration in g: gravity + user when split, else the raw vector.
// Total acceleration in g: gravity + user when split, else the raw vector then NEGATED
// into the wire's convention.
//
// Apple reports acceleration as the gravity VECTOR: a device lying flat face-up reads
// z = 1, because gravity points down. An accelerometer physically measures proper
// acceleration, which at rest is the +1 g normal force pushing UP, and that is what a
// DualSense's report the wire's convention carries. The two are exact negatives, so
// every sample we sent was upside down, on both branches (`m.acceleration` follows the
// same Apple convention as the gravity/user split).
//
// Measured on glass 2026-08-07 (G16): a DualSense flat and face-up, streamed from an
// iPhone to a Linux host, arrived at hid-playstation as z = 0.99 g where +1.00 was owed.
// Magnitude was 1.006 g, so the SCALE was already right this is purely direction.
// `rotationRate` is a true angular rate and needs no flip; the same session confirmed yaw
// came through with the correct sign.
let ax: Float
let ay: Float
let az: Float
if m.hasGravityAndUserAcceleration {
ax = Float(m.gravity.x + m.userAcceleration.x)
ay = Float(m.gravity.y + m.userAcceleration.y)
az = Float(m.gravity.z + m.userAcceleration.z)
ax = -Float(m.gravity.x + m.userAcceleration.x)
ay = -Float(m.gravity.y + m.userAcceleration.y)
az = -Float(m.gravity.z + m.userAcceleration.z)
} else {
ax = Float(m.acceleration.x)
ay = Float(m.acceleration.y)
az = Float(m.acceleration.z)
ax = -Float(m.acceleration.x)
ay = -Float(m.acceleration.y)
az = -Float(m.acceleration.z)
}
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG