Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/DeviceGyroRemapTests.swift
T
enricobuehler 1f54b75c5c feat(client/apple): the phone's gyro can speak for a gyro-less pad
Opt-in "Gyro from this device" (DefaultsKey.gyroFromDevice, off by default,
iOS only): while player 1's forwarded controller reports no rotation rate of
its own — no GCMotion, or the gravity-only motion an Xbox pad exposes — this
device's IMU sources pad 0's wire motion instead. The rumble-on-device
mirror's sibling, data flowing the other way: same session-scoped
UserDefaults read, same hardware-gated settings rows, same pad-0 rule.

DeviceGyro wraps CMDeviceMotion at the ~100 Hz CoreMotion ceiling on a
dedicated serial queue (not main — the controller path's main-queue delivery
is a known jitter source), converts with the shared GamepadWire constants,
and rotates each sample from the device's portrait frame into the controller
frame by interface orientation, so a phone clipped landscape yaws when the
player yaws instead of rolling. The remap matrix is derived and pinned by
DeviceGyroRemapTests.

GamepadCapture owns engage/stand-down (reconcile, suspend/resume, stop), and
suppresses pad 0's controller-motion forwarding while the mirror runs — two
writers on one pad's motion state would fight, and the accel-only stream
would stomp the mirror's gyro with zeros.

Also fixes the stale-motion latch from the gyro sweep on the controller
path: flush now parks motion at zero (keeping the last accel, so gravity
doesn't become free-fall), and the mirror's stop sends the same closing
zero. The host holds motion as state and re-emits it — a nonzero angular
velocity left behind read as endless rotation for as long as an overlay
(Control Center pull-down) kept the app inactive.
2026-08-07 11:38:13 +02:00

63 lines
2.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pins the phone-gyro mirror's device→controller frame remap (DeviceGyro.swift). The matrix is
// derived (like the wire scale constants), so these tests are the contract: if on-glass says an
// axis is wrong, fix the enum AND these expectations together.
#if os(iOS)
import UIKit
import XCTest
@testable import PunktfunkKit
final class DeviceGyroRemapTests: XCTestCase {
/// A distinct vector per axis so a swapped or flipped component can't cancel out.
private let v: (x: Float, y: Float, z: Float) = (1, 2, 3)
func testPortraitIsIdentity() {
let r = DeviceGyroRemap.identity.apply(x: v.x, y: v.y, z: v.z)
XCTAssertEqual([r.x, r.y, r.z], [1, 2, 3])
}
func testUpsideDownFlipsInPlane() {
let r = DeviceGyroRemap.flipped.apply(x: v.x, y: v.y, z: v.z)
XCTAssertEqual([r.x, r.y, r.z], [-1, -2, 3])
}
/// Device top to the player's LEFT: player-right = device-bottom (y), player-up =
/// device-right (+x). z (out of the screen) never changes — the screen faces the player.
func testTopLeftLandscape() {
let r = DeviceGyroRemap.topLeft.apply(x: v.x, y: v.y, z: v.z)
XCTAssertEqual([r.x, r.y, r.z], [-2, 1, 3])
}
/// Device top to the player's RIGHT: player-right = device-top (+y), player-up =
/// device-left (x).
func testTopRightLandscape() {
let r = DeviceGyroRemap.topRight.apply(x: v.x, y: v.y, z: v.z)
XCTAssertEqual([r.x, r.y, r.z], [2, -1, 3])
}
/// Interface orientation → remap: `.landscapeRight` means the Home edge is on the
/// player's right, i.e. the device top points LEFT (and vice versa).
func testOrientationMapping() {
XCTAssertEqual(DeviceGyroRemap(.portrait), .identity)
XCTAssertEqual(DeviceGyroRemap(.portraitUpsideDown), .flipped)
XCTAssertEqual(DeviceGyroRemap(.landscapeRight), .topLeft)
XCTAssertEqual(DeviceGyroRemap(.landscapeLeft), .topRight)
XCTAssertEqual(DeviceGyroRemap(.unknown), .identity)
}
/// Every remap must stay a proper rotation (right-handed): x̂ × ŷ = ẑ after mapping.
func testHandednessPreserved() {
for remap in [DeviceGyroRemap.identity, .flipped, .topLeft, .topRight] {
let x = remap.apply(x: 1, y: 0, z: 0)
let y = remap.apply(x: 0, y: 1, z: 0)
// Cross product of the two mapped in-plane basis vectors.
let cross = (
x: x.y * 0 - 0 * y.y, y: 0 * y.x - x.x * 0, z: x.x * y.y - x.y * y.x
)
XCTAssertEqual(cross.z, 1, "left-handed remap: \(remap)")
}
}
}
#endif