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.
This commit is contained in:
@@ -764,6 +764,15 @@ struct ContentView: View {
|
||||
// other in the seconds where they overlap.
|
||||
.overlay(alignment: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
// A forwarded pad has a gyro this session's virtual controller cannot
|
||||
// carry. Shown briefly at every stats tier and with the overlay off: the
|
||||
// failure is otherwise completely silent — the gyro just does nothing —
|
||||
// and the fix is a setting, so the hint has to name it. Every platform,
|
||||
// including tvOS, where a DualSense is an ordinary way to play.
|
||||
if captureEnabled, model.motionUnreachableKind != nil {
|
||||
MotionUnreachableBadge()
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// Shown for as long as the mic is muted, at every stats tier and with the
|
||||
// overlay off — see MicMutedBadge. tvOS has no microphone to mute.
|
||||
|
||||
@@ -153,6 +153,20 @@ final class SessionModel: ObservableObject {
|
||||
/// background's privacy mute never clears the user's choice. Local and instant: it gates
|
||||
/// capture on this device, nothing is sent to the host.
|
||||
@Published private(set) var micMuted = false
|
||||
/// The kind a controller declared when it turned out this session cannot carry its motion —
|
||||
/// set once per such pad, cleared after `motionHintSeconds`. Nil the rest of the time.
|
||||
///
|
||||
/// It exists because the failure is otherwise entirely silent: the gyro simply does nothing,
|
||||
/// with no way for the player to tell a dead sensor from a session that resolved a backend
|
||||
/// without a motion plane. The fix is a settings change, so the hint has to name it.
|
||||
@Published private(set) var motionUnreachableKind: PunktfunkConnection.GamepadType?
|
||||
/// Drops `motionUnreachableKind` again — held so a second pad's hint replaces the first
|
||||
/// cleanly, and so ending the session cancels a pending clear rather than letting it fire
|
||||
/// into a torn-down model.
|
||||
private var motionHintTimer: Task<Void, Never>?
|
||||
/// How long the motion hint stays up — the start-of-stream shortcut banner's 6 s, since the
|
||||
/// two share the bottom-centre stack and a player reads them the same way.
|
||||
private static let motionHintSeconds: UInt64 = 6
|
||||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||
@@ -524,6 +538,21 @@ final class SessionModel: ObservableObject {
|
||||
applyMicMute()
|
||||
}
|
||||
|
||||
/// A forwarded controller has a gyro this session cannot carry (see
|
||||
/// `GamepadCapture.onMotionUnreachable`). Show it briefly, then let it go.
|
||||
///
|
||||
/// Last pad wins, and its timer restarts: two such pads are the same one fact to a player, and
|
||||
/// a second hint appearing under a still-visible first would only read as a stutter.
|
||||
private func noteMotionUnreachable(_ kind: PunktfunkConnection.GamepadType) {
|
||||
motionUnreachableKind = kind
|
||||
motionHintTimer?.cancel()
|
||||
motionHintTimer = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.motionUnreachableKind = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute —
|
||||
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
|
||||
/// changed, the other still holds, so returning from the background can't un-mute a user who
|
||||
@@ -573,6 +602,11 @@ final class SessionModel: ObservableObject {
|
||||
// The mic mute is per-session and never persisted: the next stream starts live (if the
|
||||
// mic is enabled), rather than silently carrying a mute nobody remembers making.
|
||||
micMuted = false
|
||||
// Cancel before clearing: a pending clear firing into a torn-down session would be
|
||||
// harmless but pointless, and leaving the hint set would carry it into the next stream.
|
||||
motionHintTimer?.cancel()
|
||||
motionHintTimer = nil
|
||||
motionUnreachableKind = nil
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
@@ -722,6 +756,9 @@ final class SessionModel: ObservableObject {
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
// A pad with a gyro that this session cannot carry — say so once, briefly, and name the
|
||||
// setting that fixes it. Already main-actor (GamepadCapture fires it there).
|
||||
capture.onMotionUnreachable = { [weak self] kind in self?.noteMotionUnreachable(kind) }
|
||||
capture.start()
|
||||
gamepadCapture = capture
|
||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||
|
||||
@@ -267,6 +267,39 @@ struct StreamHUDView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// "This pad's gyro can't reach the game" — shown briefly when a forwarded controller with motion
|
||||
/// meets a session whose virtual controller has no motion plane (an X-Box class pad has no gyro in
|
||||
/// its HID contract, so every sample would be decoded and dropped).
|
||||
///
|
||||
/// Not a control, unlike `MicMutedBadge`: the fix is the Controller type setting, which is not
|
||||
/// reachable mid-stream on every platform, and changing it applies from the next session anyway.
|
||||
/// So this states the fact and names the setting, in the HUD's glass language, and gets out of the
|
||||
/// way — the alternative is what shipped before, which was a gyro that silently did nothing with
|
||||
/// no way to tell that from a broken sensor.
|
||||
///
|
||||
/// Every platform: a DualSense on an Apple TV is an ordinary way to play, and it is exactly the
|
||||
/// pad this can happen to.
|
||||
struct MotionUnreachableBadge: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "gyroscope")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
Text("Motion won't reach this session — set Controller type to DualSense")
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(
|
||||
"This controller's motion will not reach the game. "
|
||||
+ "Set Controller type to DualSense to enable it.")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides
|
||||
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
|
||||
|
||||
@@ -311,6 +311,51 @@ public final class PunktfunkConnection {
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this backend has a motion plane at all — whether a `sendMotion` sample to a
|
||||
/// host running it can reach the game, or is decoded and dropped. Mirrors the host's
|
||||
/// `GamepadPref::has_motion`; the X-Box classes have no gyro in their HID contract.
|
||||
///
|
||||
/// This answers for ONE backend. To ask it of a particular pad, go through
|
||||
/// `PunktfunkConnection.motionReaches(declared:)` — `resolvedGamepad` is not that pad's
|
||||
/// answer, because the host builds each virtual device from the pad's own
|
||||
/// `gamepadArrival` and falls back to the session default only for a pad that never
|
||||
/// declared one.
|
||||
///
|
||||
/// `.auto` answers `true` on purpose: it means "unknown" — an older host that omitted the
|
||||
/// echo, which may well have resolved a DualSense. Suppressing on unknown would silently
|
||||
/// break a working gyro, which is the worse of the two failures.
|
||||
public var hasMotion: Bool {
|
||||
switch self {
|
||||
case .auto: return true // unknown; assume it can, see above
|
||||
case .xbox360, .xboxOne: return false
|
||||
case .dualSense, .dualShock4, .dualSenseEdge, .switchPro,
|
||||
.steamController, .steamDeck, .steamController2:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether motion sent for ONE pad can reach the game: `declared` is the kind that pad
|
||||
/// announced in its `gamepadArrival`, `asked` is the session default the handshake carried,
|
||||
/// and `resolved` is the host's echo. Mirrors punktfunk-core's `pad_motion_reaches`, which
|
||||
/// carries the full argument; in short:
|
||||
///
|
||||
/// - the host builds each virtual device from that pad's declaration, so the echo is simply
|
||||
/// not this pad's answer when the two differ (under "Automatic" the handshake carries the
|
||||
/// ACTIVE pad's kind, so a couch with an X-Box pad and a DualSense echoes X-Box 360 while
|
||||
/// the host builds the DualSense a working motion plane);
|
||||
/// - the host FOLDS what it cannot build — a Switch Pro on Windows, a UHID backend on a
|
||||
/// host whose `/dev/uhid` is unusable — and nothing here can predict that;
|
||||
/// - but the echo IS one observed sample of that fold, for the kind we asked about, so it
|
||||
/// is authoritative for a pad that declared exactly that.
|
||||
///
|
||||
/// Static and pure so it can be tested without a live session; the connection's
|
||||
/// `motionReaches(declared:)` is the call site that fills in the other two.
|
||||
public static func motionReaches(
|
||||
declared: GamepadType, asked: GamepadType, resolved: GamepadType
|
||||
) -> Bool {
|
||||
declared == asked ? resolved.hasMotion : declared.hasMotion
|
||||
}
|
||||
}
|
||||
|
||||
/// The virtual gamepad backend the host actually resolved (the Welcome's echo of the
|
||||
@@ -318,6 +363,18 @@ public final class PunktfunkConnection {
|
||||
/// DualSense feedback.
|
||||
public private(set) var resolvedGamepad: GamepadType = .auto
|
||||
|
||||
/// The session default this connection's handshake ASKED for, kept beside the host's answer
|
||||
/// above. The pair is what makes the echo usable per pad — see `motionReaches(declared:)`.
|
||||
public private(set) var requestedGamepad: GamepadType = .auto
|
||||
|
||||
/// Whether motion sent for ONE pad can reach the game, given the kind that pad DECLARED in its
|
||||
/// `gamepadArrival` (`GamepadManager.declaredKind(for:)`) — this session's two halves of
|
||||
/// `GamepadType.motionReaches(declared:asked:resolved:)`, which carries the reasoning.
|
||||
public func motionReaches(declared: GamepadType) -> Bool {
|
||||
GamepadType.motionReaches(
|
||||
declared: declared, asked: requestedGamepad, resolved: resolvedGamepad)
|
||||
}
|
||||
|
||||
/// The compositor the host actually resolved for this session's virtual output (the
|
||||
/// Welcome's echo of the requested `compositor`, with `.auto` resolved to a concrete
|
||||
/// backend). `.auto` = an older host that didn't say. Clients use it to decide
|
||||
@@ -572,6 +629,9 @@ public final class PunktfunkConnection {
|
||||
var gp: UInt32 = 0
|
||||
_ = punktfunk_connection_gamepad(handle, &gp)
|
||||
resolvedGamepad = GamepadType(rawValue: gp) ?? .auto
|
||||
// What we asked for, straight off the parameter — the echo above only speaks for a pad
|
||||
// that declared this same kind (see `motionReaches(declared:)`).
|
||||
requestedGamepad = gamepad
|
||||
var comp: UInt32 = 0
|
||||
_ = punktfunk_connection_compositor(handle, &comp)
|
||||
resolvedCompositor = Compositor(rawValue: comp) ?? .auto
|
||||
|
||||
@@ -128,6 +128,15 @@ public final class GamepadCapture {
|
||||
/// gameplay can't end it (see ContentView's tvOS session branch).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
/// Fired ON MAIN, once per slot at open, when a controller that HAS a gyro was given a host
|
||||
/// backend without a motion plane — its motion is not being sent, because every sample would
|
||||
/// be decoded and dropped. The argument is the kind this pad declared, so the UI can name it.
|
||||
///
|
||||
/// It fires at open rather than on the first sample precisely because nothing is sampled: the
|
||||
/// IMU is never powered in this case (see `openSlot`), which is also what stops the pad
|
||||
/// burning battery streaming gyro nobody reads.
|
||||
public var onMotionUnreachable: ((PunktfunkConnection.GamepadType) -> Void)?
|
||||
|
||||
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
/// default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
/// passthrough such as VirtualHere, or a pad plugged into the host itself — where
|
||||
@@ -299,10 +308,24 @@ public final class GamepadCapture {
|
||||
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
|
||||
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
|
||||
// it back down), so with nothing to forward we simply never turn it on.
|
||||
//
|
||||
// A host that built this pad a backend WITHOUT a motion plane is the same situation: every
|
||||
// sample would be decoded and dropped, so there is equally nothing to forward. Asked per
|
||||
// pad off what this slot declared, not off the session echo — under "Automatic" a couch
|
||||
// with an X-Box pad on 0 and a DualSense on 1 echoes X-Box 360 while the host builds pad 1
|
||||
// a DualSense whose gyro works.
|
||||
let motionCanReach = connection.motionReaches(declared: slot.pref)
|
||||
if forwarding, let motion = c.motion {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
if motionCanReach {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
}
|
||||
} else if motion.hasRotationRate {
|
||||
// Only for a pad that really has a gyro. A gravity-only pad (an X-Box controller's
|
||||
// GCMotion) has nothing the player could expect to reach the game, so telling them
|
||||
// it didn't would be a nag about a feature they never had.
|
||||
onMotionUnreachable?(slot.pref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user