Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/GamepadMotionReachTests.swift
T
enricobuehler aaa58ad817 feat(client/apple): say when a pad's gyro can't reach the session, and stop powering it
G8's Apple half — the UI hint 77797a9e left owed, plus the suppression, which on
this client is worth more than it was on the SDL one.

The failure being fixed is entirely silent. A controller with a gyro, in a session
whose virtual pad has no motion plane, simply does nothing when tilted: nothing
in the app says so, and from the couch a session that resolved an X-Box backend
is indistinguishable from a broken sensor. The fix is the Controller type setting,
so the hint has to name it — a badge that only said "motion unavailable" would
leave the player exactly as stuck.

Asked per pad, off what the slot declared, via the predicate punktfunk-core now
carries. `GamepadCapture` is the one client where this is naturally per pad
already: `openSlot` computes `manager.declaredKind(for:)` and puts it in
`slot.pref`, so the question is answered where the pad is opened rather than on
every sample. `GamepadType.motionReaches(declared:asked:resolved:)` is static and
pure so it can be tested without a live session; the connection's instance method
fills in the two halves it owns, and `requestedGamepad` is stored beside
`resolvedGamepad` for the same reason it exists in the Rust client — the echo is
only this pad's answer when the pad declared what we asked for.

Where Apple differs from the SDL client, and better: it never powers the IMU. The
existing code already declined to activate sensors when forwarding was off,
reasoning that with nothing to forward there is no reason to make the pad stream
gyro over Bluetooth and burn its battery — `closeSlot` is careful to power them
back down for exactly that reason. A host that built this pad a backend without a
motion plane is the same situation, so it takes the same branch. No per-sample
check, no handler attached, and a DualSense in an X-Box-class session stops paying
for a sensor nobody reads.

The hint fires only for a pad that really has a gyro (`motion.hasRotationRate`).
A gravity-only GCMotion — what an X-Box controller exposes — would otherwise
produce a notice about a feature the player never had. That is a narrower
condition than the capture path itself uses, deliberately: making the capture
gate agree is G13's job and its own change.

The badge sits in the bottom-centre stack with the muted-mic badge and the
start-of-stream banner, at every stats tier and with the overlay off, because
this is not a statistic. Unlike the mic badge it is not a control: the setting is
not reachable mid-stream on every platform and applies from the next session
anyway. So it states the fact, names the setting, and leaves after the banner's
same 6 s. Every platform including tvOS — a DualSense on an Apple TV is an
ordinary way to play, and is exactly the pad this happens to. The model owns the
expiry rather than the view, so a second pad's hint replaces the first cleanly
instead of stacking, and ending the session cancels a pending clear rather than
carrying a stale hint into the next stream.

Non-vacuity proven by mutation, not assumed: collapsing the predicate to
`resolved.hasMotion` fails 4 assertions, including the mixed-pad row that is the
whole reason it is not a session-level check. The table mirrors the Rust one row
for row — a client that disagrees with the host here either kills a working gyro
or streams ~250 Hz into a void, and both are silent.

Gate: macOS `swift build` + the FULL suite (210 tests, 5 skipped, 0 failures) with
the two new cases observed in the run's own output, and the iOS-triple typecheck
green (`arm64-apple-ios17.0`, iOS slices + hand-assembled xcframework per the
memory recipe) — the badge and the overlay it joins are on every platform, so the
macOS build alone would not have covered them. tvOS remains unverifiable from
this Mac; the badge deliberately reuses the neighbouring banner's shape rather
than introducing anything tvOS-specific.
2026-08-07 16:55:03 +02:00

63 lines
3.5 KiB
Swift

// Whether a given pad's motion can reach the game. The Swift half of punktfunk-core's
// `pad_motion_reaches` — same rows as `config::tests::motion_reach_is_answered_per_pad_not_per_session`,
// because a client that disagrees with the host about this either kills a working gyro or keeps
// streaming ~250 Hz of samples nobody reads, and both failures are silent.
import PunktfunkCore
import XCTest
@testable import PunktfunkKit
final class GamepadMotionReachTests: XCTestCase {
private typealias Pad = PunktfunkConnection.GamepadType
func testOnlyTheXboxClassesLackAMotionPlane() {
for kind: Pad in [.xbox360, .xboxOne] {
XCTAssertFalse(kind.hasMotion, "\(kind) should have no motion plane")
}
for kind: Pad in [
.dualSense, .dualShock4, .dualSenseEdge, .switchPro,
.steamController, .steamDeck, .steamController2,
] {
XCTAssertTrue(kind.hasMotion, "\(kind) should carry motion")
}
// Unknown must not suppress: an older host that omitted the echo may well have resolved a
// DualSense, and silently killing its gyro is worse than sending into a void.
XCTAssertTrue(Pad.auto.hasMotion)
}
/// The per-pad question, case by case. Each row is a session a player can actually sit down to;
/// the comment says which of the three inputs decides it.
func testMotionReachIsAnsweredPerPadNotPerSession() {
// The case this predicate exists for, and the one a session-level check gets WRONG:
// "Automatic" with mixed pads. The handshake carries the active pad's kind (an X-Box pad),
// so the echo says X-Box 360 — but pad 1 declared a DualSense and the host built it one,
// with a motion plane. Reading the echo here kills a gyro that works.
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .xbox360, resolved: .xbox360))
// Its mirror: the pad that DID declare the X-Box kind still has nowhere to put motion.
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .xbox360, resolved: .xbox360))
// An explicit Switch Pro against a WINDOWS host, which folds it to X-Box 360. Declared ==
// asked, so the echo is this pad's answer and catches a fold nothing local could predict.
XCTAssertFalse(
Pad.motionReaches(declared: .switchPro, asked: .switchPro, resolved: .xbox360))
// The same declaration against a Linux host that builds it: unchanged, motion reaches.
XCTAssertTrue(
Pad.motionReaches(declared: .switchPro, asked: .switchPro, resolved: .switchPro))
// A DualSense wish on a host with no usable /dev/uhid degrades the same way.
XCTAssertFalse(
Pad.motionReaches(declared: .dualSense, asked: .dualSense, resolved: .xbox360))
// Nobody connected at dial time, so the handshake asked `.auto` and the host resolved it
// from its own env. A pad that shows up later declares its own kind and is judged on that.
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .auto, resolved: .xbox360))
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .auto, resolved: .dualSense))
// An old host that echoes nothing leaves `.auto`, which must not suppress.
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .dualSense, resolved: .auto))
// Even then the declaration still speaks when it is the thing without a plane.
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .dualSense, resolved: .auto))
}
}