diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 2b16327b..32b306f2 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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. diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 86dbb4cc..58c1813f 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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? + /// 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) diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 41606725..4f00da49 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 350b44f6..94e68868 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 01cd7a47..e655ebaa 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -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) } } } diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadMotionReachTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadMotionReachTests.swift new file mode 100644 index 00000000..c979bec8 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadMotionReachTests.swift @@ -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)) + } +}