From efb7f99129e4a1509374fbf54a21c59a128e68db Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 18:41:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(client/apple):=20motion=20arrived=20in=20th?= =?UTF-8?q?e=20wrong=20frame=20=E2=80=94=20measured=20against=20a=20real?= =?UTF-8?q?=20pad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G16 step 1, and the second half of what 9e9bb9f4 started. That commit fixed the SIGN of acceleration (Apple reports the gravity vector, pointing down; a pad reports proper acceleration, pointing up). This fixes the FRAME, which is a separate defect and was never going to show up as an inverted axis — it shows up as roll where the game reads yaw. The wire is a unit passthrough. `dualsense_proto::write_report` puts gyro[0..3] and accel[0..3] straight into the virtual pad's report bytes 16.. and 22.., in order, with no permutation — the same slots a real DualSense fills. So the frame the wire is DEFINED in is the pad's own report frame, and forwarding GameController's x/y/z unconverted was speaking a different language with the same vocabulary. Both frames measured 2026-08-07 from ONE physical DualSense on one desk, read twice — over raw HID and through GameController — so this is two readings of the same controller in the same orientations rather than two documents: DualSense report frame: (Right, Up, Backward) axis 0 pitch, 1 yaw, 2 roll GameController frame: (Right, Forward, Up) Right is already slot 0; Up is GC's z and moves to slot 1; slot 2 wants Backward, which is GC's y negated. Hence (x, z, -y), applied to gyro AND acceleration because it is a change of basis and both live in that basis. Notable: the wire's documented naming was right all along — gyro[0]=pitch, [1]=yaw, [2]=roll is exactly what the hardware does. And Android needs no remap at all: it forwards the pad's own axis order un-remapped, which is correct. Its old reading was purely the scale bug f6de620f fixed. Only Apple was converting nothing. How the hardware frame was established, since a wrong frame here is invisible. Gravity at rest put +0.997 g on axis 1. Yaw clockwise-from-above drove axis 1 negative (98% of the rotation), pitch nose-down drove axis 0 negative (100%), roll right-side-down drove axis 2 negative (95%) — plain right-hand rule, and (a0 x a1 = a2) confirms the triad is right-handed. The accelerometer then corroborated the gyro's assignment independently: under pitch-down axis 2 rose 0.160 -> +0.339 (nose down raises the back, so world-up gains a Backward component) and under roll-right-down axis 0 went +0.021 -> -0.197, while yaw left acceleration untouched. Two different physical quantities agreeing on one triad. Apple's frame took four attempts, and the failures are worth recording because each was a different way to be confidently wrong: - peak |w| over a window containing BOTH the tip-down and the return stroke can record the return, with the opposite sign. Yaw (a continuous one-way spin) was unaffected; pitch and roll were exactly the two that disagreed with everything else. - reading `gravity + userAcceleration` when `hasGravityAndUserAcceleration` is FALSE yields a constant (0,0,1) in every orientation. It looks like data. The tell is that it never moves. The client's own else-branch on `m.acceleration` is the correct read and is what the instrument now mirrors. - `da/dt = -w x a` holds only for gravity, so testing it during vigorous waving — when `m.acceleration` carries inseparable linear acceleration — fits nothing. The frame that survived all of that: static poses, three of them, three repetitions each. Nose-down moved axis 1 by -0.635 (so axis 1 is Forward), right-side-down moved axis 0 by -0.686 (so axis 0 is Right), flat put +0.99 on axis 2 (Up). That conclusion holds whether or not the acceleration negation is right, because negating flips the measured vector and the physical direction it represents together. Confidence, stated honestly. The accelerometer half is solid: nine pose measurements, and mapping the flat pose through gives (+0.005, +0.992, +0.192) against the hardware's own (+0.021, +0.997, +0.160) — all three components, including the small tilt term that is what distinguishes this mapping from the five other permutations that also put gravity on slot 1. That the gyro shares the frame unmodified rests on a weaker measurement: a gravity-dominated consistency test that preferred (+x,+y,+z) by 1.22x, which is a margin, not a landslide. It is corroborated by the yaw reading (the one rotation measured without the return-stroke ambiguity) agreeing with right-hand rule in that frame, and by the peak-vs-return mechanism explaining the two that did not. A device-side confirmation is still owed and is listed below. The tests carry the measurements, not just the conclusion. Resting gravity is asserted against BOTH readings of that pose; each rotation is asserted to reach the slot the wire reads it from; and two properties guard the shape rather than the numbers — that the conversion is an isometry (a basis change may not stretch anything) and that it preserves handedness. That last one matters most: a permutation with the wrong number of sign flips is a REFLECTION, which looks plausible axis by axis and inverts every rotation. Mutation-checked: dropping only the negation fails 6 assertions across 4 of the 5 cases, the handedness test among them. Owed, and not claimed done: on-glass re-verification through a real iOS device, together with the two already owed on that rig (the 9e9bb9f4 sign fix and the Android calibration read) — one pass covers all three. G14's DualSense neutral acceleration is now unblocked by this measurement (1 g on slot 1, not the z-up the notes assumed) but is deliberately left to its own change; and that constant must NOT be propagated to switch_proto, which is a different device whose frame nobody has measured. Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) with the five new cases observed in the run's own output, and the iOS-triple typecheck green. --- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 19 ++-- .../PunktfunkKit/Gamepad/GamepadWire.swift | 24 +++++ .../GamepadMotionFrameTests.swift | 91 +++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 clients/apple/Tests/PunktfunkKitTests/GamepadMotionFrameTests.swift diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index e655ebaa..9907a5cc 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -616,17 +616,24 @@ public final class GamepadCapture { } let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG + // Into the DualSense report frame. GameController and the pad's own report do not agree + // about which slot is which axis — measured, both from the same controller, on 2026-08-07 + // — so forwarding GC's x/y/z straight through sent yaw where the game reads roll. See + // `GamepadWire.appleMotionToWire`. One change of basis, applied to both planes. + let g = GamepadWire.appleMotionToWire( + (Float(m.rotationRate.x), Float(m.rotationRate.y), Float(m.rotationRate.z))) + let a = GamepadWire.appleMotionToWire((ax, ay, az)) wire?.sendMotion( pad: UInt8(slot.pad), gyro: ( - GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs), - GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs), - GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs) + GamepadWire.motionRaw(g.0, scale: gs), + GamepadWire.motionRaw(g.1, scale: gs), + GamepadWire.motionRaw(g.2, scale: gs) ), accel: ( - GamepadWire.motionRaw(ax, scale: as_), - GamepadWire.motionRaw(ay, scale: as_), - GamepadWire.motionRaw(az, scale: as_) + GamepadWire.motionRaw(a.0, scale: as_), + GamepadWire.motionRaw(a.1, scale: as_), + GamepadWire.motionRaw(a.2, scale: as_) )) } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift index b2fae0d0..1c2a282c 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift @@ -73,6 +73,30 @@ public enum GamepadWire { public static func motionRaw(_ value: Float, scale: Float) -> Int16 { Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max))) } + + /// GameController's motion frame → the DualSense report frame the wire is defined in. + /// + /// The wire is a unit passthrough: the host writes these three components, in order, into the + /// virtual DualSense's report bytes 16../22.. — the same slots a real pad fills. So the frame + /// the wire is defined in is the pad's OWN report frame, and a client that forwards its + /// platform's axes unconverted is simply speaking a different language. + /// + /// Both frames were measured on 2026-08-07 from ONE physical DualSense on one desk — the pad + /// read twice, over raw HID and through GameController: + /// + /// DualSense report frame: (Right, Up, Backward) — axis 0 carries pitch, 1 yaw, 2 roll + /// GameController frame: (Right, Forward, Up) + /// + /// Matching them up: Right is already slot 0; Up is GC's z, so it moves to slot 1; and slot 2 + /// wants Backward, which is GC's y negated. Hence `(x, z, -y)`. + /// + /// Applied to gyro AND acceleration, because it is a change of basis and both are expressed in + /// that basis. The negation `forwardMotion` already does for acceleration is a separate matter + /// — that one converts Apple's gravity-VECTOR convention into the proper acceleration a real + /// pad reports, and it composes with this rather than replacing it. + public static func appleMotionToWire(_ v: (Float, Float, Float)) -> (Float, Float, Float) { + (v.0, v.2, -v.1) + } } extension Float { diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadMotionFrameTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadMotionFrameTests.swift new file mode 100644 index 00000000..9dcfbf50 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadMotionFrameTests.swift @@ -0,0 +1,91 @@ +// The motion frame conversion, pinned against the readings it was derived from. +// +// On 2026-08-07 one physical DualSense was read twice on one desk — over raw HID (the pad's own +// report) and through GameController — so both frames come from the same controller in the same +// orientations rather than from two documents: +// +// DualSense report frame: (Right, Up, Backward) axis 0 pitch, 1 yaw, 2 roll +// GameController frame: (Right, Forward, Up) +// +// The numbers below are those measurements. They are the reason the conversion is `(x, z, -y)` and +// not one of the five other permutations that also move gravity to slot 1, so they belong in a test +// rather than only in a commit message. + +import XCTest + +@testable import PunktfunkKit + +final class GamepadMotionFrameTests: XCTestCase { + private func wire(_ v: (Float, Float, Float)) -> (Float, Float, Float) { + GamepadWire.appleMotionToWire(v) + } + + /// Gravity at rest, face up. MEASURED: GameController read (+0.005, -0.192, +0.992) g while raw + /// HID on the same pad read (+0.021, +0.997, +0.160). The conversion has to carry one into the + /// other — including the small tilt term, which is what distinguishes this mapping from the one + /// that merely gets gravity onto the right slot. + func testRestingGravityLandsInTheDualSenseFrame() { + let apple: (Float, Float, Float) = (0.005, -0.192, 0.992) + let w = wire(apple) + XCTAssertEqual(w.0, 0.005, accuracy: 0.001, "right stays on slot 0") + XCTAssertEqual(w.1, 0.992, accuracy: 0.001, "up moves to slot 1 — the pad reads +1 g here") + XCTAssertEqual(w.2, 0.192, accuracy: 0.001, "slot 2 is Backward, so GC's Forward negates") + // The hardware's own reading of the same pose, to the precision two sessions of holding a + // controller by hand can agree to. + XCTAssertEqual(w.1, 0.997, accuracy: 0.02) + XCTAssertEqual(w.2, 0.160, accuracy: 0.05) + } + + /// The tilt term's SIGN is the whole point: before this conversion the client sent Apple's y + /// straight through, so a pad tilted nose-up reported itself tilted nose-down. + func testTheForeAftAxisIsNegatedNotJustMoved() { + XCTAssertEqual(wire((0, 1, 0)).2, -1, "GC +y (Forward) is the wire's -Backward") + XCTAssertEqual(wire((0, -1, 0)).2, 1) + XCTAssertEqual(wire((0, 1, 0)).0, 0, "and it must not leak into the other slots") + XCTAssertEqual(wire((0, 1, 0)).1, 0) + } + + /// Each rotation, as measured, must reach the slot the wire reads it from: the wire's gyro is + /// documented pitch/yaw/roll in slots 0/1/2, and the raw-HID run confirmed the pad agrees. + func testEachRotationReachesItsWireSlot() { + // Yaw is the reliable direct measurement — a continuous one-way spin, clockwise from above, + // read as NEGATIVE on GC's z. It must arrive negative on slot 1, where the pad puts yaw. + let yaw = wire((-0.2, 21.7, -122.2)) + XCTAssertEqual(yaw.1, -122.2, accuracy: 0.01) + XCTAssertLessThan(yaw.1, 0, "clockwise-from-above is negative about +Up, both frames agree") + + // Pitch: nose-down about Right stays on slot 0 and keeps its sign. + let pitch = wire((-79.4, 0, 0)) + XCTAssertEqual(pitch.0, -79.4, accuracy: 0.01) + + // Roll: about the fore-aft axis, which moves to slot 2 AND flips. + let roll = wire((0, 61.8, 0)) + XCTAssertEqual(roll.2, -61.8, accuracy: 0.01) + } + + /// A change of basis is linear and orthonormal: it may not stretch a vector, and applying it to + /// gyro and to acceleration must be the same operation. Both are asserted because the capture + /// path calls it twice, on two different quantities. + func testConversionIsAnIsometry() { + for v in [(1, 2, 3), (-4, 5, -6), (0, 0, 1), (7, 0, 0)] as [(Float, Float, Float)] { + let w = wire(v) + let before = (v.0 * v.0 + v.1 * v.1 + v.2 * v.2).squareRoot() + let after = (w.0 * w.0 + w.1 * w.1 + w.2 * w.2).squareRoot() + XCTAssertEqual(before, after, accuracy: 1e-4, "must not change magnitude") + } + } + + /// Right-handed in, right-handed out. A permutation with the wrong number of sign flips is a + /// REFLECTION, which reads as plausible on every single axis and inverts every rotation — the + /// exact failure this measurement exists to prevent. + func testHandednessIsPreserved() { + let x = wire((1, 0, 0)) + let y = wire((0, 1, 0)) + // x cross y must equal the image of z, not its negative. + let cx = (x.1 * y.2 - x.2 * y.1, x.2 * y.0 - x.0 * y.2, x.0 * y.1 - x.1 * y.0) + let z = wire((0, 0, 1)) + XCTAssertEqual(cx.0, z.0, accuracy: 1e-5) + XCTAssertEqual(cx.1, z.1, accuracy: 1e-5) + XCTAssertEqual(cx.2, z.2, accuracy: 1e-5) + } +}