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) + } +}