diff --git a/CLAUDE.md b/CLAUDE.md index 02f987a..c0293a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,11 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc capture→…→reassembled; audio measured live (~200 pkts/s). `punktfunk-client-rs` is the working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad). The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect` - (pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`send_input`. + (pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/ + `send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad + preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome + echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD` + env > uinput Xbox 360; DualSense (UHID) only on Linux hosts. ## What's left @@ -59,7 +63,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust - prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. Tests: `swift test` in + prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. **Gamepads (2026-06-11):** + controller discovery + selection in Settings (`GamepadManager` — exactly one pad + forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical + controller, user-overridable), capture incl. DualSense touchpad/motion + (`GamepadCapture`/`GamepadWire`), feedback rendering (rumble → CoreHaptics; lightbar / + player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/ + `GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser). + Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense + motion sign/scale derived, not yet live-verified. Tests: `swift test` in `clients/apple` (unit + real-codec round trip), `test-loopback.sh` (Swift client vs synthetic m3-hosts on loopback — runs on macOS; includes the pairing ceremony + `--require-pairing` gate), diff --git a/clients/apple/README.md b/clients/apple/README.md index 3525a89..c40720a 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -17,7 +17,9 @@ received AUs spanning 983 ms of host capture clock. The connector underneath (`punktfunk_core::client::NativeClient` over the C ABI) carries the full session: video AUs, **Opus audio** (`nextAudio()`), **rumble** (`nextRumble()`), -input incl. gamepads, and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see +**DualSense feedback** (`nextHidOutput()` — lightbar, player LEDs, adaptive-trigger +effects), input incl. gamepads + DualSense touchpad/motion (`sendTouchpad`/`sendMotion`), +and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see `m3.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned reconnect, wrong-pin rejection). The host (`punktfunk-host m3-host`) is a persistent listener: reconnect at will during development. @@ -40,6 +42,20 @@ What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3): motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2). Scroll arrives via the stream view's `scrollWheel` override instead of GC (trackpad/Magic Mouse gestures never reach GCMouse's scroll dpad), WHEEL_DELTA(120)-scaled. + - `GamepadManager.swift` — app-lifetime controller discovery + selection (`.shared`): + watches `GCController` connect/disconnect, fingerprints each pad for the Settings UI + (name, capabilities, battery), and selects the ONE controller forwarded to the host + (user pin via "Use controller", else most recently connected extended gamepad). + - `GamepadCapture.swift` — the active controller → wire: snapshot-diff over + `GCExtendedGamepad` into incremental `gamepadButton`/`gamepadAxis` events (pad 0), + plus DualSense touchpad contacts and ~250 Hz motion samples on the rich-input plane + (the GC→DualSense unit conversions live in `GamepadWire`, one place). Held state is + released on the wire on controller switch / app deactivation / stop. + - `GamepadFeedback.swift` + `DualSenseTriggerEffect.swift` — host feedback → the real + controller: one drain thread for `nextRumble()` (→ `CHHapticEngine` per handle + locality) and `nextHidOutput()` (lightbar → `GCDeviceLight`, player LEDs → + `playerIndex`, adaptive-trigger effect blocks → a total, table-driven parser → + `GCDualSenseAdaptiveTrigger`, exact for the 10-zone positional modes). - **`PunktfunkClient`** (the app): hosts grid (saved in UserDefaults), "+" toolbar sheet to add hosts, stream mode in Settings (⌘,), two trust flows — the trust-on-first-use fingerprint prompt over the live-but-blurred stream, and SPAKE2 PIN @@ -47,13 +63,20 @@ What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3): `ClientIdentityStore` keeps the client identity in the Keychain and presents it on every connect) — then pinned reconnects, fps/Mb-s HUD. Settings also picks the HOST compositor (KWin/wlroots/Mutter/gamescope, default automatic — the host honors it - only if that backend is available there). (Audio playback and - gamepad capture are not wired into the app yet — the connector surface is there; see - notes 5–6.) + only if that backend is available there) and has a **Controllers** section: every + detected controller (capability glyphs, battery, "In use" badge), which one to forward + ("Use controller", default automatic), and the virtual pad type the host creates + ("Controller type": Automatic / Xbox 360 / DualSense — Automatic matches the physical + pad; resolved at connect time, the host pad is fixed per session). Gamepad capture + + feedback run with streaming (`SessionModel` owns them, same trust gate as audio). - **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip (VTCompressionSession-encoded HEVC rebuilt as the host's wire shape → `AnnexB` → - VTDecompressionSession → pixels); loopback integration against real local hosts - (`test-loopback.sh` — stream round trip, plus the PIN pairing ceremony and the + VTDecompressionSession → pixels); table-driven DualSense trigger-effect parsing + (`DualSenseTriggerEffectTests`) and the gamepad wire conversions + (`GamepadWireTests`); loopback integration against real local hosts + (`test-loopback.sh` — stream round trip incl. gamepad/touchpad/motion sends and a + host-scripted feedback burst asserted on the rumble + HID-output planes + (`PUNKTFUNK_TEST_FEEDBACK=1`), plus the PIN pairing ceremony and the `--require-pairing` gate against a second, armed host); the remote first-light test above. @@ -112,10 +135,12 @@ signing, bundle id `io.unom.punktfunk`. Notes: the enum *constants* import into Swift as a distinct same-named type — bridge with `.rawValue` (see the top of `PunktfunkConnection.swift`). Don't fight the generated header. 2. **ABI contract**: one video pump thread per connection, plus optionally one *separate* - audio drain thread for `nextAudio()`/`nextRumble()` (the core keeps per-plane borrow - slots, so the planes never alias); `send()` is enqueue-only and safe alongside all of - them. The wrapper's per-plane locks make `close()` safe from anywhere (it waits out - in-flight polls, ≤ their timeouts). + audio drain thread for `nextAudio()` and one feedback drain thread for + `nextRumble()`/`nextHidOutput()` (the core keeps per-plane borrow slots, so the planes + never alias; rumble + HID-output are two planes drained sequentially by the one + feedback thread); `send()` is enqueue-only and safe alongside all of them. The + wrapper's per-plane locks make `close()` safe from anywhere (it waits out in-flight + polls, ≤ their timeouts). 3. **Decode flow**: the host opens every stream with an IDR carrying VPS/SPS/PPS in-band and recovery keyframes re-send them — "refresh the format description on every IDR" (what `StreamView` does) is sufficient; there is no out-of-band extradata, ever. @@ -139,10 +164,20 @@ signing, bundle id `io.unom.punktfunk`. Notes: side buffers 320 ms and drops the newest packet when the puller lags. Wall-clock `ptsNs` shares the host clock with video AUs for A/V sync. Wiring this into `PunktfunkClient` is the next app-side task. -6. **Gamepads**: `GCController` → `.gamepadButton(...)`/`.gamepadAxis(...)` events (wire - contract documented on the constructors; the host accumulates them into a virtual - Xbox 360 pad). Poll `nextRumble()` and feed `GCDeviceHaptics` for force feedback. - Client-side capture isn't in `InputCapture` yet. +6. **Gamepads — wired end to end.** Exactly ONE controller (the `GamepadManager` + selection) forwards as pad 0; the host accumulates the incremental events into a + virtual pad whose TYPE the client negotiates in the Hello (`gamepad:` connect + parameter, echoed resolved in `resolvedGamepad` — Automatic resolves from the physical + pad at connect time; host precedence: explicit client choice > host `PUNKTFUNK_GAMEPAD` + env > Xbox 360). A DualSense session carries the full feel: adaptive-trigger blocks + (`DualSenseTriggerEffect.parse` — mode bytes per the community convention + (Nielk1/ds5w/inputtino), total, unknown → `.off`), lightbar, player LEDs, touchpad, + motion. **Motion scale constants** (`GamepadWire.gyroLSBPerRadS` = 20 LSB per deg/s, + `accelLSBPerG` = 10000) are derived from hid-playstation's math over the host's fixed + calibration blob, not yet live-verified — if gyro/accel feel wrong in a real game, + correct sign/scale in `GamepadCapture.forwardMotion`/`GamepadWire` and `evtest` the + host's virtual pad. Twin identical controllers share a fingerprint base, so a manual + pin can swap between them across reconnects (documented in the Settings footer). 7. **Trust — the full ceremony exists now (SPAKE2).** `generateIdentity()` once (persist both PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN the host prints when it ARMS pairing (`--allow-pairing`/`--require-pairing`; one PIN diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 1a853ad..9d6ba61 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -24,6 +24,7 @@ struct ContentView: View { @AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.hz") private var hz = 60 @AppStorage("punktfunk.compositor") private var compositor = 0 + @AppStorage("punktfunk.gamepadType") private var gamepadType = 0 @State private var showAddHost = false @State private var pairingTarget: StoredHost? #if !os(macOS) @@ -383,12 +384,17 @@ struct ContentView: View { } private func connect(_ host: StoredHost) { + // The gamepad-type setting resolves NOW (Automatic → match the active physical + // controller): the host's virtual pad backend is fixed per session. model.connect( to: host, width: UInt32(clamping: width), height: UInt32(clamping: height), hz: UInt32(clamping: hz), compositor: PunktfunkConnection.Compositor( - rawValue: UInt32(clamping: compositor)) ?? .auto) + rawValue: UInt32(clamping: compositor)) ?? .auto, + gamepad: GamepadManager.shared.resolveType( + setting: PunktfunkConnection.GamepadType( + rawValue: UInt32(clamping: gamepadType)) ?? .auto)) } // MARK: - Trust on first use @@ -525,7 +531,8 @@ struct ContentView: View { /// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use, /// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without /// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope|… overrides the - /// compositor preference (same names as the host env knob). (IPv4/hostname only.) + /// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual + /// pad type (same names as the host env knobs). (IPv4/hostname only.) private func autoConnectIfAsked() { guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"], !target.isEmpty, model.phase == .idle @@ -547,11 +554,19 @@ struct ContentView: View { let c = PunktfunkConnection.Compositor(name: name) { pref = c } + var pad = GamepadManager.shared.resolveType( + setting: PunktfunkConnection.GamepadType( + rawValue: UInt32(clamping: gamepadType)) ?? .auto) + if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"], + let g = PunktfunkConnection.GamepadType(name: name) { + pad = g + } model.connect( to: host, width: UInt32(clamping: width), height: UInt32(clamping: height), hz: UInt32(clamping: hz), compositor: pref, + gamepad: pad, autoTrust: true) } } diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index 554677d..1265534 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -60,11 +60,14 @@ final class SessionModel: ObservableObject { let meter = FrameMeter() private var statsTimer: Timer? private var audio: SessionAudio? + private var gamepadCapture: GamepadCapture? + private var gamepadFeedback: GamepadFeedback? var isBusy: Bool { phase != .idle } func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32, compositor: PunktfunkConnection.Compositor = .auto, + gamepad: PunktfunkConnection.GamepadType = .auto, autoTrust: Bool = false) { guard phase == .idle else { return } phase = .connecting @@ -80,7 +83,8 @@ final class SessionModel: ObservableObject { let result = Result { try PunktfunkConnection( host: host.address, port: host.port, width: width, height: height, refreshHz: hz, - pinSHA256: pin, identity: identity, compositor: compositor) } + pinSHA256: pin, identity: identity, compositor: compositor, + gamepad: gamepad) } await MainActor.run { [weak self] in guard let self else { return } // The user may have abandoned this attempt (window closed, another host @@ -135,16 +139,26 @@ final class SessionModel: ObservableObject { statsTimer = nil let audio = self.audio self.audio = nil + // Gamepad capture is main-actor (releases held buttons on the wire while the + // connection is still up); the feedback drain joins off-main like audio. + gamepadCapture?.stop() + gamepadCapture = nil + let feedback = gamepadFeedback + gamepadFeedback = nil if let conn = connection { - // Audio teardown waits its drain thread out and close() waits out in-flight - // polls + joins the Rust worker threads — keep both off the main actor, in - // this order (no audio poll left when the handle is freed). + // Drain-thread teardown waits the pullers out and close() waits out in-flight + // polls + joins the Rust worker threads — keep all of it off the main actor, + // in this order (no poll left on any plane when the handle is freed). Task.detached { audio?.stop() + feedback?.stop() conn.close() } } else { - Task.detached { audio?.stop() } + Task.detached { + audio?.stop() + feedback?.stop() + } } connection = nil activeHost = nil @@ -177,6 +191,16 @@ final class SessionModel: ObservableObject { micUID: defaults.string(forKey: "punktfunk.micUID") ?? "", micEnabled: defaults.object(forKey: "punktfunk.micEnabled") as? Bool ?? true) self.audio = audio + // Gamepads: forward GamepadManager's active controller as pad 0 and render the + // host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the + // session's virtual pad is a DualSense). Same trust gate as audio — nothing is + // forwarded during the trust prompt. + let capture = GamepadCapture(connection: conn, manager: .shared) + capture.start() + gamepadCapture = capture + let feedback = GamepadFeedback(connection: conn, manager: .shared) + feedback.start() + gamepadFeedback = feedback } private func startStatsTimer() { diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index bd5157f..cd0887f 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -1,6 +1,6 @@ -// App settings (⌘,): the stream mode + the host compositor. The host creates a native -// virtual output at exactly this size/refresh — there is no scaling anywhere in the -// pipeline. +// App settings (⌘,): the stream mode, the host compositor, and controllers. The host +// creates a native virtual output at exactly this size/refresh — there is no scaling +// anywhere in the pipeline. #if os(macOS) import AppKit @@ -8,13 +8,16 @@ import AppKit import PunktfunkKit import SwiftUI +@MainActor struct SettingsView: View { @Environment(\.dismiss) private var dismiss @AppStorage("punktfunk.width") private var width = 1920 @AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.hz") private var hz = 60 @AppStorage("punktfunk.compositor") private var compositor = 0 + @AppStorage("punktfunk.gamepadType") private var gamepadType = 0 @AppStorage("punktfunk.micEnabled") private var micEnabled = true + @ObservedObject private var gamepads = GamepadManager.shared #if os(macOS) @AppStorage("punktfunk.speakerUID") private var speakerUID = "" @AppStorage("punktfunk.micUID") private var micUID = "" @@ -83,15 +86,107 @@ struct SettingsView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.top, 8) + ForEach(gamepads.controllers) { controller in + controllerRow(controller) + .padding(.horizontal, 24) + } + TVSelectionRow( + title: "Use controller", options: controllerOptions, + selection: $gamepads.preferredID) + TVSelectionRow( + title: "Controller type", options: Self.padTypes, selection: $gamepadType) + Text(Self.controllersFooter) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.top, 8) } .frame(maxWidth: 1000) .frame(maxWidth: .infinity) .padding(60) } .navigationTitle("Settings") + .onAppear { + gamepads.refresh() + gamepads.startDiscovery() + } + .onDisappear { gamepads.stopDiscovery() } } #endif + // MARK: - Controllers + + private static let padTypes: [(label: String, tag: Int)] = [ + ("Automatic", 0), + ("Xbox 360", 1), + ("DualSense", 2), + ] + + private static let controllersFooter = + "One controller is forwarded to the host, as player 1 — Automatic picks the most " + + "recently connected one. The type is the virtual pad the host creates: Automatic " + + "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad " + + "and motion), and changes apply from the next session. Two identical controllers " + + "may swap a manual selection after reconnecting." + + /// "Use controller" choices: Automatic, every forwardable controller, and — so a stale + /// pin stays visible instead of leaving the Picker selection tag-less — any pinned id + /// that is NOT among the selectable (extended) entries, present-but-unusable included. + private var controllerOptions: [(label: String, tag: String)] { + let selectable = gamepads.controllers.filter(\.isExtended) + var options: [(label: String, tag: String)] = [("Automatic", "")] + options += selectable.map { ($0.name, $0.id) } + if !gamepads.preferredID.isEmpty, + !selectable.contains(where: { $0.id == gamepads.preferredID }) { + options.append(("Unavailable controller", gamepads.preferredID)) + } + return options + } + + private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View { + HStack(spacing: 10) { + Image(systemName: controller.isDualSense ? "playstation.logo" : "gamecontroller.fill") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(controller.name) + HStack(spacing: 8) { + if !controller.isExtended { + Text(controller.productCategory) + } + if controller.hasAdaptiveTriggers { + Image(systemName: "r2.button.roundedtop.horizontal") + } + if controller.hasLight { + Image(systemName: "lightbulb.fill") + } + if controller.hasMotion { + Image(systemName: "gyroscope") + } + if controller.hasHaptics { + Image(systemName: "waveform") + } + if let level = controller.batteryLevel { + Text("\(Int(level * 100))%") + if controller.isCharging { + Image(systemName: "bolt.fill") + } + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if gamepads.active?.id == controller.id { + Text("In use") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(.green.opacity(0.2))) + .foregroundStyle(.green) + } + } + } + private var sharedBody: some View { Form { Section { @@ -170,8 +265,39 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + Section { + if gamepads.controllers.isEmpty { + Text("No controllers detected") + .foregroundStyle(.secondary) + } else { + ForEach(gamepads.controllers) { controller in + controllerRow(controller) + } + } + Picker("Use controller", selection: $gamepads.preferredID) { + ForEach(controllerOptions, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + Picker("Controller type", selection: $gamepadType) { + ForEach(Self.padTypes, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + } header: { + Text("Controllers") + } footer: { + Text(Self.controllersFooter) + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) + .onAppear { + gamepads.refresh() + gamepads.startDiscovery() + } + .onDisappear { gamepads.stopDiscovery() } #if os(macOS) .frame(width: 380) .fixedSize() diff --git a/clients/apple/Sources/PunktfunkKit/DualSenseTriggerEffect.swift b/clients/apple/Sources/PunktfunkKit/DualSenseTriggerEffect.swift new file mode 100644 index 0000000..de568ef --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/DualSenseTriggerEffect.swift @@ -0,0 +1,188 @@ +// DualSense adaptive-trigger effect parsing: the host forwards the raw 11-byte trigger +// parameter block a game wrote to its virtual DualSense (output report 0x02, bytes 11–21 +// for L2 / 22–32 for R2: one mode byte + 10 parameter bytes). The mode values and layouts +// follow the community-established conventions (Nielk1's TriggerEffectGenerator, ds5w, +// inputtino) — Sony has never documented them. Parsing is TOTAL: any unknown or short +// block degrades to `.off`, never traps, so a game using an exotic raw mode can't break +// the session. +// +// `parse` is a pure function (unit-tested without a controller); `apply(to:)` maps the +// result onto Apple's GCDualSenseAdaptiveTrigger — exact for the 10-zone positional modes +// (0x21/0x26 → the positional resistiveStrengths/amplitudes APIs), best-effort for the +// composite ones (bow, galloping) that have no GC equivalent. + +import Foundation +import GameController + +/// A parsed DualSense trigger effect. Positions and strengths are normalized 0...1 +/// (GCDualSenseAdaptiveTrigger's domain); `positional*` carry one value per trigger zone +/// (10 zones across the travel). +public enum DualSenseTriggerEffect: Equatable, Sendable { + case off + /// Constant resistance from `start` to the end of travel. + case feedback(start: Float, strength: Float) + /// Resistance from `start` that releases past `end` (trigger-break / weapon feel). + case weapon(start: Float, end: Float, strength: Float) + /// Vibration once the trigger passes `start`. + case vibration(start: Float, amplitude: Float, frequency: Float) + /// Per-zone resistance (10 zones). + case positionalFeedback(strengths: [Float]) + /// Per-zone vibration amplitudes (10 zones) at `frequency`. + case positionalVibration(amplitudes: [Float], frequency: Float) + /// Resistance ramping `startStrength` → `endStrength` between `start` and `end` + /// (the closest GC rendering of the bow effect). + case slope(start: Float, end: Float, startStrength: Float, endStrength: Float) + + /// Parse a raw trigger parameter block (`[mode, p0...p9]`, ≤ 11 bytes — shorter blocks + /// are zero-padded). Never fails: unknown modes are `.off`. + public static func parse(_ block: [UInt8]) -> DualSenseTriggerEffect { + guard let mode = block.first else { return .off } + var p = [UInt8](block.dropFirst()) + if p.count < 10 { p.append(contentsOf: [UInt8](repeating: 0, count: 10 - p.count)) } + + // Helpers for the rich (0x2x) modes: a 10-bit active-zone mask in p0/p1 and 3-bit + // per-zone values packed little-endian into the following 4 bytes. + let zoneMask = UInt16(p[0]) | (UInt16(p[1]) << 8) + let packed = UInt32(p[2]) | (UInt32(p[3]) << 8) | (UInt32(p[4]) << 16) | (UInt32(p[5]) << 24) + func zoneValues() -> [UInt8] { + (0..<10).map { i in + zoneMask & (1 << i) != 0 ? UInt8((packed >> (3 * UInt32(i))) & 0x07) : 0 + } + } + // DualSense 3-bit strengths are 0...7 where an *active* zone's value v renders as + // (v+1)/8 — a present-but-zero strength still resists slightly. + func strength01(_ v: UInt8, active: Bool) -> Float { + active ? Float(v + 1) / 8 : 0 + } + func zone01(_ z: Int) -> Float { Float(z) / 9 } + func firstActive() -> Int? { (0..<10).first { zoneMask & (1 << $0) != 0 } } + func lastActive() -> Int? { (0..<10).last { zoneMask & (1 << $0) != 0 } } + + switch mode { + case 0x00, 0x05, 0xF0...0xFF: + // 0x00/0x05 are the documented off/reset modes; 0xFC/0xFB/0xFA show up from + // calibration-adjacent writes — all render as off. + return .off + + case 0x01: + // Legacy continuous resistance: p0 = start position, p1 = force (both 0...255). + return .feedback(start: Float(p[0]) / 255, strength: max(Float(p[1]) / 255, 1.0 / 8)) + + case 0x02: + // Legacy section: p0 = start, p1 = end (0...255); full-strength inside. + let s = Float(p[0]) / 255 + let e = Float(p[1]) / 255 + return .weapon(start: min(s, e), end: max(max(s, e), min(s, e) + 0.01), strength: 1) + + case 0x06: + // Legacy vibration ("automatic gun") — Nielk1's Simple_Vibration order: + // p0 = frequency Hz, p1 = amplitude, p2 = start position. + return .vibration( + start: Float(p[2]) / 255, + amplitude: max(Float(p[1]) / 255, 1.0 / 8), + frequency: Float(p[0]) / 255) + + case 0x21: + // Feedback: 10-bit zone mask + 3-bit strength per zone. A uniform suffix maps + // exactly onto the simple feedback call; mixed strengths use the positional API. + guard let first = firstActive() else { return .off } + let values = zoneValues() + let active = values.enumerated().filter { zoneMask & (1 << $0.offset) != 0 } + if active.allSatisfy({ $0.element == active[0].element }) + && active.last?.offset == 9 + && active.map(\.offset) == Array(first...9) + { + return .feedback( + start: zone01(first), strength: strength01(active[0].element, active: true)) + } + return .positionalFeedback( + strengths: values.enumerated().map { + strength01($0.element, active: zoneMask & (1 << $0.offset) != 0) + }) + + case 0x22: + // Bow (Nielk1 mode byte 0x22): start/end zones + draw strength and snap force + // packed as a 3-bit pair in p2 (low bits draw, bits 3-5 snap; p3 is always 0). + // No GC equivalent — render as a slope from draw resistance down to the snap. + guard let s = firstActive(), let e = lastActive(), s < e else { return .off } + let draw = strength01(p[2] & 0x07, active: true) + let snap = strength01((p[2] >> 3) & 0x07, active: true) + return .slope(start: zone01(s), end: zone01(e), startStrength: draw, endStrength: snap) + + case 0x25: + // Weapon (Nielk1 mode byte 0x25): zone mask marks the start and end zones, + // p2 = strength (3-bit, stored minus one). + guard let s = firstActive(), let e = lastActive(), s < e else { return .off } + return .weapon(start: zone01(s), end: zone01(e), strength: strength01(p[2] & 0x07, active: true)) + + case 0x26: + // Vibration: 10-bit zone mask + 3-bit amplitude per zone, p8 = frequency Hz. + guard zoneMask != 0 else { return .off } + let amplitudes = zoneValues().enumerated().map { + strength01($0.element, active: zoneMask & (1 << $0.offset) != 0) + } + return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[8]) / 255) + + case 0x23: + // Galloping (Nielk1 mode byte 0x23): start/end zones, p2 = packed foot timing, + // p3 = frequency Hz. The temporal hoofbeat pattern has no GC equivalent — + // render as vibration across the active range. + guard let s = firstActive(), let e = lastActive() else { return .off } + var amplitudes = [Float](repeating: 0, count: 10) + for z in s...e { amplitudes[z] = 0.5 } + return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[3]) / 255) + + case 0x27: + // Machine (Nielk1 mode byte 0x27): start/end zones, p2 = two 3-bit amplitudes + // (low bits A, bits 3-5 B — raw 0...7, no minus-one), p3 = frequency Hz. The + // A/B alternation is temporal — render its stronger leg across the range. + guard let s = firstActive(), let e = lastActive() else { return .off } + let amp = Float(max(p[2] & 0x07, (p[2] >> 3) & 0x07)) / 7 + guard amp > 0 else { return .off } + var amplitudes = [Float](repeating: 0, count: 10) + for z in s...e { amplitudes[z] = amp } + return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[3]) / 255) + + default: + return .off + } + } +} + +extension DualSenseTriggerEffect { + /// Replay this effect on a physical DualSense trigger. Main-thread only (GameController + /// profile mutation). The GC `frequency` parameter is normalized 0...1 like ours. + @MainActor + public func apply(to trigger: GCDualSenseAdaptiveTrigger) { + switch self { + case .off: + trigger.setModeOff() + case let .feedback(start, strength): + trigger.setModeFeedbackWithStartPosition(start, resistiveStrength: strength) + case let .weapon(start, end, strength): + trigger.setModeWeaponWithStartPosition(start, endPosition: end, resistiveStrength: strength) + case let .vibration(start, amplitude, frequency): + trigger.setModeVibrationWithStartPosition(start, amplitude: amplitude, frequency: frequency) + case let .positionalFeedback(strengths): + var s = GCDualSenseAdaptiveTrigger.PositionalResistiveStrengths( + values: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + withUnsafeMutableBytes(of: &s.values) { raw in + let f = raw.bindMemory(to: Float.self) + for (i, v) in strengths.prefix(10).enumerated() { f[i] = v } + } + trigger.setModeFeedback(resistiveStrengths: s) + case let .positionalVibration(amplitudes, frequency): + var a = GCDualSenseAdaptiveTrigger.PositionalAmplitudes( + values: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + withUnsafeMutableBytes(of: &a.values) { raw in + let f = raw.bindMemory(to: Float.self) + for (i, v) in amplitudes.prefix(10).enumerated() { f[i] = v } + } + trigger.setModeVibration(amplitudes: a, frequency: frequency) + case let .slope(start, end, startStrength, endStrength): + trigger.setModeSlopeFeedback( + startPosition: start, endPosition: end, + startStrength: startStrength, endStrength: endStrength) + } + } +} diff --git a/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift new file mode 100644 index 0000000..f87912f --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift @@ -0,0 +1,308 @@ +// Gamepad capture → punktfunk/1 datagrams. Forwards exactly ONE controller — whatever +// GamepadManager selected — as pad 0, for the lifetime of a streaming session. +// +// The wire is incremental (one button/axis transition per 18-byte event, accumulated +// host-side into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the +// full GCExtendedGamepad state on every valueChanged and diff against the previous +// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. +// +// DualSense extras ride the rich-input plane (0xCC): touchpad contacts normalized +// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion +// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g — +// derived from the host's fixed calibration blob; the conversion lives in ONE place, +// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both +// unless the session's virtual pad is a DualSense. +// +// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture +// toggle — a controller can't click local UI, so it always drives the host while the app +// is active. On deactivation, controller switch, or stop, every held control is released +// on the wire (the host pad would otherwise stay stuck on the last state). + +#if os(macOS) +import AppKit +#else +import UIKit +#endif +import Combine +import Foundation +import GameController + +/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`). +public enum GamepadWire { + public static let dpadUp: UInt32 = 0x0001 + public static let dpadDown: UInt32 = 0x0002 + public static let dpadLeft: UInt32 = 0x0004 + public static let dpadRight: UInt32 = 0x0008 + public static let start: UInt32 = 0x0010 + public static let back: UInt32 = 0x0020 + public static let leftStickClick: UInt32 = 0x0040 + public static let rightStickClick: UInt32 = 0x0080 + public static let leftShoulder: UInt32 = 0x0100 + public static let rightShoulder: UInt32 = 0x0200 + public static let guide: UInt32 = 0x0400 + public static let a: UInt32 = 0x1000 + public static let b: UInt32 = 0x2000 + public static let x: UInt32 = 0x4000 + public static let y: UInt32 = 0x8000 + /// DualSense touchpad click (Moonlight's extended-button bit position). + public static let touchpadClick: UInt32 = 0x10_0000 + + public static let allButtons: [UInt32] = [ + dpadUp, dpadDown, dpadLeft, dpadRight, start, back, + leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide, + a, b, x, y, touchpadClick, + ] + + public static let axisLSX: UInt32 = 0 + public static let axisLSY: UInt32 = 1 + public static let axisRSX: UInt32 = 2 + public static let axisRSY: UInt32 = 3 + public static let axisLT: UInt32 = 4 + public static let axisRT: UInt32 = 5 + + /// Raw DualSense gyro units per rad/s: hid-playstation's calibration over the host's + /// fixed blob resolves to 20 LSB per deg/s. + public static let gyroLSBPerRadS: Float = 20 * 180 / .pi + /// Raw DualSense accelerometer units per g (same derivation). + public static let accelLSBPerG: Float = 10_000 + + /// GC touchpad coordinates (±1, +y up) → wire (0...65535, origin top-left, +y down). + public static func touchpad(x: Float, y: Float) -> (x: UInt16, y: UInt16) { + let wx = ((x.clamped(to: -1...1) + 1) / 2 * 65535).rounded() + let wy = ((1 - y.clamped(to: -1...1)) / 2 * 65535).rounded() + return (UInt16(wx), UInt16(wy)) + } + + /// Scale + clamp one motion component into the raw signed-16 sensor domain. + public static func motionRaw(_ value: Float, scale: Float) -> Int16 { + Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max))) + } +} + +extension Float { + fileprivate func clamped(to range: ClosedRange) -> Float { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} + +@MainActor +public final class GamepadCapture { + private let connection: PunktfunkConnection + private let manager: GamepadManager + private var activeSub: AnyCancellable? + private var observers: [NSObjectProtocol] = [] + private var bound: GCController? + /// App inactive → GC stops delivering; everything is released and stays silent. + private var suspended = false + + // Last wire state (the diff base — also what releaseAll() unwinds). + private var buttons: UInt32 = 0 + private var axes: [Int32] = [0, 0, 0, 0, 0, 0] + private var fingerActive: [Bool] = [false, false] + private var lastMotionNs: UInt64 = 0 + + /// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate). + private static let motionIntervalNs: UInt64 = 4_000_000 + + public init(connection: PunktfunkConnection, manager: GamepadManager) { + self.connection = connection + self.manager = manager + } + + public func start() { + // Fires immediately with the current selection, then on every change — a switch + // releases the old controller's wire state before the new one takes over. + activeSub = manager.$active.sink { [weak self] dc in + MainActor.assumeIsolated { self?.rebind(to: dc?.controller) } + } + #if os(macOS) + let resign = NSApplication.willResignActiveNotification + let activate = NSApplication.didBecomeActiveNotification + #else + let resign = UIApplication.willResignActiveNotification + let activate = UIApplication.didBecomeActiveNotification + #endif + observers.append(NotificationCenter.default.addObserver( + forName: resign, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.suspended = true + self?.releaseAll() + } + }) + observers.append(NotificationCenter.default.addObserver( + forName: activate, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.suspended = false + if let ext = self.bound?.extendedGamepad { self.sync(ext) } + } + }) + } + + public func stop() { + releaseAll() + rebind(to: nil) + activeSub = nil + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + } + + private func rebind(to controller: GCController?) { + guard controller !== bound else { return } + releaseAll() + if let ext = bound?.extendedGamepad { + ext.valueChangedHandler = nil + (ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil + (ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil + } + if let motion = bound?.motion { + motion.valueChangedHandler = nil + // Power the sensors back down — left active they keep the pad streaming + // gyro/accel over Bluetooth (battery drain) long after the session. + if motion.sensorsRequireManualActivation { motion.sensorsActive = false } + } + bound = controller + guard let c = controller, let ext = c.extendedGamepad else { return } + + ext.valueChangedHandler = { [weak self] g, _ in + MainActor.assumeIsolated { self?.sync(g) } + } + // Wake the host pad immediately (pads are created lazily from the first event; + // a DualSense's UHID handshake + initial lightbar write only start then). + connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0)) + sync(ext) + + if let ds = ext as? GCDualSenseGamepad { + ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in + MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) } + } + ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in + MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) } + } + } + if let motion = c.motion { + if motion.sensorsRequireManualActivation { motion.sensorsActive = true } + motion.valueChangedHandler = { [weak self] m in + MainActor.assumeIsolated { self?.forwardMotion(m) } + } + } + } + + /// Snapshot the profile into wire state and send every transition since the last one. + private func sync(_ g: GCExtendedGamepad) { + guard !suspended else { return } + let newButtons = Self.buttonMask(g) + let changed = newButtons ^ buttons + if changed != 0 { + for bit in GamepadWire.allButtons where changed & bit != 0 { + connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0)) + } + buttons = newButtons + } + let newAxes: [Int32] = [ + Int32((g.leftThumbstick.xAxis.value * 32767).rounded()), + Int32((g.leftThumbstick.yAxis.value * 32767).rounded()), + Int32((g.rightThumbstick.xAxis.value * 32767).rounded()), + Int32((g.rightThumbstick.yAxis.value * 32767).rounded()), + Int32((g.leftTrigger.value * 255).rounded()), + Int32((g.rightTrigger.value * 255).rounded()), + ] + for (i, v) in newAxes.enumerated() where v != axes[i] { + connection.send(.gamepadAxis(UInt32(i), value: v, pad: 0)) + axes[i] = v + } + } + + private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 { + var b: UInt32 = 0 + if g.dpad.up.isPressed { b |= GamepadWire.dpadUp } + if g.dpad.down.isPressed { b |= GamepadWire.dpadDown } + if g.dpad.left.isPressed { b |= GamepadWire.dpadLeft } + if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } + if g.buttonMenu.isPressed { b |= GamepadWire.start } + if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } + if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } + if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } + if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } + if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder } + if g.buttonHome?.isPressed == true { b |= GamepadWire.guide } + if g.buttonA.isPressed { b |= GamepadWire.a } + if g.buttonB.isPressed { b |= GamepadWire.b } + if g.buttonX.isPressed { b |= GamepadWire.x } + if g.buttonY.isPressed { b |= GamepadWire.y } + if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true { + b |= GamepadWire.touchpadClick + } + return b + } + + /// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on + /// lift — treated as the lift signal (a real finger landing on the precise center + /// momentarily reads as a lift; harmless for a 1-in-65k coincidence). + private func touch(finger: Int, x: Float, y: Float) { + guard !suspended else { return } + let lifted = x == 0 && y == 0 + if lifted { + if fingerActive[finger] { + fingerActive[finger] = false + connection.sendTouchpad(finger: UInt8(finger), active: false, x: 0, y: 0) + } + return + } + fingerActive[finger] = true + let w = GamepadWire.touchpad(x: x, y: y) + connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y) + } + + private func forwardMotion(_ m: GCMotion) { + guard !suspended else { return } + let now = DispatchTime.now().uptimeNanoseconds + guard now &- lastMotionNs >= Self.motionIntervalNs else { return } + lastMotionNs = now + // Total acceleration in g: gravity + user when split, else the raw vector. + let ax: Float + let ay: Float + let az: Float + if m.hasGravityAndUserAcceleration { + ax = Float(m.gravity.x + m.userAcceleration.x) + ay = Float(m.gravity.y + m.userAcceleration.y) + az = Float(m.gravity.z + m.userAcceleration.z) + } else { + ax = Float(m.acceleration.x) + ay = Float(m.acceleration.y) + az = Float(m.acceleration.z) + } + let gs = GamepadWire.gyroLSBPerRadS + let as_ = GamepadWire.accelLSBPerG + connection.sendMotion( + 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) + ), + accel: ( + GamepadWire.motionRaw(ax, scale: as_), + GamepadWire.motionRaw(ay, scale: as_), + GamepadWire.motionRaw(az, scale: as_) + )) + } + + /// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The + /// host's virtual pad returns to rest instead of running with the last state. + private func releaseAll() { + for bit in GamepadWire.allButtons where buttons & bit != 0 { + connection.send(.gamepadButton(bit, down: false, pad: 0)) + } + buttons = 0 + for (i, v) in axes.enumerated() where v != 0 { + connection.send(.gamepadAxis(UInt32(i), value: 0, pad: 0)) + axes[i] = 0 + } + for (f, active) in fingerActive.enumerated() where active { + connection.sendTouchpad(finger: UInt8(f), active: false, x: 0, y: 0) + fingerActive[f] = false + } + } +} diff --git a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift new file mode 100644 index 0000000..b0a22a9 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift @@ -0,0 +1,315 @@ +// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and +// HID-output (0xCD) planes and replays them on the active physical controller — +// +// rumble → CHHapticEngine players (per-handle localities when the pad has them, +// one combined engine otherwise), +// lightbar → GCDeviceLight, +// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4), +// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger. +// +// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists +// only on DualSense sessions — the drain always polls both planes with short timeouts and +// never spins, so an Xbox session just renders rumble. GameController profile mutation +// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself +// touches neither. When GamepadManager switches the active controller mid-session, the +// old pad is reset (triggers off, player index unset) and the last known feedback state +// is replayed onto the new one. + +import Combine +import CoreHaptics +import Foundation +import GameController +import os + +private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad") + +private final class FeedbackStopFlag: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + var isStopped: Bool { + lock.lock() + defer { lock.unlock() } + return stopped + } + func stop() { + lock.lock() + stopped = true + lock.unlock() + } +} + +/// Rumble → CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound, +/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero +/// amplitude and torn down on retarget; players run only while their motor is on, so an +/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets) +/// downgrade to silence — rumble is best-effort by design. +private final class RumbleRenderer: @unchecked Sendable { + private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) + + private struct Motor { + let engine: CHHapticEngine + let player: CHHapticAdvancedPatternPlayer + var playing = false + } + + private var controller: GCController? + private var low: Motor? + private var high: Motor? + private var broken = false + + func retarget(_ c: GCController?) { + queue.async { + self.teardown() + self.controller = c + self.broken = false + } + } + + func apply(low lowAmp: UInt16, high highAmp: UInt16) { + queue.async { + guard !self.broken else { return } + if (lowAmp != 0 || highAmp != 0), self.low == nil, self.high == nil { + self.setup() + } + if self.high != nil { + self.drive(&self.low, Float(lowAmp) / 65535) + self.drive(&self.high, Float(highAmp) / 65535) + } else { + // Combined engine: whichever motor is stronger wins. + self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535) + } + } + } + + func stop() { + queue.sync { self.teardown() } + } + + /// Engines per handle when the pad distinguishes them (low = left/heavy motor, + /// high = right/light — the Xbox/XInput convention the wire carries); one combined + /// engine otherwise, driven by whichever amplitude is stronger. + private func setup() { + guard let haptics = controller?.haptics else { return } + let localities = haptics.supportedLocalities + if localities.contains(.leftHandle), localities.contains(.rightHandle) { + low = makeMotor(haptics, .leftHandle) + high = makeMotor(haptics, .rightHandle) + } else { + low = makeMotor(haptics, .default) + } + if low == nil && high == nil { + broken = true // no usable engine (e.g. Siri Remote) — stay silent + } + } + + private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? { + guard let engine = haptics.createEngine(withLocality: locality) else { return nil } + do { + try engine.start() + let event = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)], + relativeTime: 0, + duration: TimeInterval(GCHapticDurationInfinite)) + let player = try engine.makeAdvancedPlayer(with: CHHapticPattern(events: [event], parameters: [])) + return Motor(engine: engine, player: player) + } catch { + log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)") + return nil + } + } + + private func drive(_ motor: inout Motor?, _ amplitude: Float) { + guard var m = motor else { return } + do { + if amplitude > 0 { + if !m.playing { + try m.player.start(atTime: CHHapticTimeImmediate) + m.playing = true + } + try m.player.sendParameters( + [CHHapticDynamicParameter( + parameterID: .hapticIntensityControl, + value: amplitude, relativeTime: 0)], + atTime: CHHapticTimeImmediate) + } else if m.playing { + try m.player.stop(atTime: CHHapticTimeImmediate) + m.playing = false + } + motor = m + } catch { + log.warning("haptic update failed — rumble disabled: \(error, privacy: .public)") + teardown() + broken = true + } + } + + private func teardown() { + for m in [low, high].compactMap({ $0 }) { + try? m.player.stop(atTime: CHHapticTimeImmediate) + m.engine.stop() + } + low = nil + high = nil + } +} + +public final class GamepadFeedback { + private let connection: PunktfunkConnection + private let flag = FeedbackStopFlag() + private let drainDone = DispatchSemaphore(value: 0) + private var drainStarted = false + private let rumble = RumbleRenderer() + private var activeSub: AnyCancellable? + + // Last applied feedback (main-actor) — replayed when the active controller changes. + @MainActor private var target: GCController? + @MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)? + @MainActor private var lastPlayerBits: UInt8? + @MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil] + + public init(connection: PunktfunkConnection, manager: GamepadManager) { + self.connection = connection + Task { @MainActor in + self.activeSub = manager.$active.sink { [weak self] dc in + MainActor.assumeIsolated { self?.retarget(dc?.controller) } + } + } + } + + /// Map the DualSense player-LED bit patterns (5 LEDs, hid-playstation's player + /// conventions) onto GCControllerPlayerIndex. Unknown patterns fall back to the lit + /// count, clamped to the four indices GC offers. + public static func playerIndex(forBits bits: UInt8) -> GCControllerPlayerIndex { + switch bits & 0x1F { + case 0: return .indexUnset + case 0b00100: return .index1 + case 0b01010: return .index2 + case 0b10101: return .index3 + case 0b11011: return .index4 + default: + let lit = (bits & 0x1F).nonzeroBitCount + return GCControllerPlayerIndex(rawValue: min(lit, 4) - 1) ?? .index1 + } + } + + public func start() { + guard !drainStarted else { return } + drainStarted = true + // No hidout traffic can exist on a non-DualSense session — poll that plane + // nonblocking there and let rumble own the wait. + let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0 + let thread = Thread { [connection, flag, drainDone, weak self] in + while !flag.isStopped { + do { + if let r = try connection.nextRumble(timeoutMs: 10), r.pad == 0 { + self?.rumble.apply(low: r.low, high: r.high) + } + // Drain a BOUNDED burst of hidout events: only the first poll waits, + // and the cap + stop check keep sustained 0xCD traffic (a game writing + // per-frame LED/trigger reports) from starving the rumble poll above + // or blocking stop() past one cycle. + var burst = 0 + while burst < 64, !flag.isStopped, + let ev = try connection.nextHidOutput( + timeoutMs: burst == 0 ? hidTimeout : 0) { + self?.render(ev) + burst += 1 + } + } catch { + break // .closed (or fatal) — the session is over + } + } + drainDone.signal() + } + thread.name = "punktfunk-feedback" + thread.qualityOfService = .userInteractive + thread.start() + } + + /// Stop the drain and silence the motors. Blocks until the drain thread exits (≤ one + /// poll cycle) — call off the main actor, before `connection.close()`. + public func stop() { + flag.stop() + if drainStarted { + drainDone.wait() + drainStarted = false + } + rumble.stop() + // Drop the retarget subscription and the dead session's cached feedback — a + // controller change after teardown must not replay this session's triggers/LEDs. + Task { @MainActor in + self.activeSub = nil + self.lastLight = nil + self.lastPlayerBits = nil + self.lastTrigger = [nil, nil] + self.reset(self.target) + self.target = nil + } + } + + private func render(_ ev: PunktfunkConnection.HidOutputEvent) { + DispatchQueue.main.async { + MainActor.assumeIsolated { self.apply(ev) } + } + } + + @MainActor + private func apply(_ ev: PunktfunkConnection.HidOutputEvent) { + switch ev { + case let .led(pad, r, g, b): + guard pad == 0 else { return } + lastLight = (r, g, b) + target?.light?.color = GCColor( + red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255) + case let .playerLEDs(pad, bits): + guard pad == 0 else { return } + lastPlayerBits = bits + target?.playerIndex = Self.playerIndex(forBits: bits) + case let .triggerEffect(pad, which, effect): + guard pad == 0, which < 2 else { return } + let parsed = DualSenseTriggerEffect.parse(effect) + lastTrigger[Int(which)] = parsed + if let trigger = adaptiveTrigger(which) { + parsed.apply(to: trigger) + } + } + } + + @MainActor + private func retarget(_ controller: GCController?) { + guard controller !== target else { return } + reset(target) + target = controller + rumble.retarget(controller) + // Replay the session's feedback state so a swapped-in controller looks the same. + if let (r, g, b) = lastLight { + controller?.light?.color = GCColor( + red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255) + } + if let bits = lastPlayerBits { + controller?.playerIndex = Self.playerIndex(forBits: bits) + } + for which in 0..<2 { + if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) { + effect.apply(to: trigger) + } + } + } + + @MainActor + private func reset(_ controller: GCController?) { + guard let c = controller else { return } + c.playerIndex = .indexUnset + if let ds = c.extendedGamepad as? GCDualSenseGamepad { + ds.leftTrigger.setModeOff() + ds.rightTrigger.setModeOff() + } + } + + @MainActor + private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? { + guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil } + return which == 0 ? ds.leftTrigger : ds.rightTrigger + } +} diff --git a/clients/apple/Sources/PunktfunkKit/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/GamepadManager.swift new file mode 100644 index 0000000..b099fc3 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/GamepadManager.swift @@ -0,0 +1,166 @@ +// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches +// GCController connect/disconnect from launch, so the Settings page shows live controller +// state without a session, and the session components (GamepadCapture / GamepadFeedback) +// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0. +// +// Selection: the user can pin a controller in Settings (persisted under +// "punktfunk.gamepadID"); with no pin — or the pinned one absent — the most recently +// connected extended gamepad wins. GCController has no stable hardware serial, so the pin +// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins); +// identical twin controllers may swap a pin across reconnects, which the Settings footer +// documents. +// +// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own +// `Settings{}` scene — there is no common ancestor view to inject from. + +import Combine +import Foundation +import GameController + +@MainActor +public final class GamepadManager: ObservableObject { + public static let shared = GamepadManager() + + /// One detected controller, decorated for the Settings UI. + public struct DiscoveredController: Identifiable, Equatable { + /// Stable-ish fingerprint: `vendorName|productCategory` (+ `#n` for twins). + public let id: String + /// User-facing name (the vendor string, e.g. "DualSense Wireless Controller"). + public let name: String + public let productCategory: String + /// The full extended profile exists — only these are forwardable. + public let isExtended: Bool + public let isDualSense: Bool + public let hasLight: Bool + public let hasHaptics: Bool + public let hasMotion: Bool + public let hasAdaptiveTriggers: Bool + /// 0...1, nil when the controller doesn't report a battery (e.g. wired). + public let batteryLevel: Float? + public let isCharging: Bool + public let controller: GCController + + public static func == (l: DiscoveredController, r: DiscoveredController) -> Bool { + l.id == r.id && l.controller === r.controller + && l.batteryLevel == r.batteryLevel && l.isCharging == r.isCharging + } + } + + /// Every detected controller, in connect order (Settings lists these). + @Published public private(set) var controllers: [DiscoveredController] = [] + + /// The one controller forwarded to the host (pad 0); nil when none qualifies. + @Published public private(set) var active: DiscoveredController? + + /// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it + /// reselects immediately, so a Settings Picker can bind straight to this. + @Published public var preferredID: String { + didSet { + UserDefaults.standard.set(preferredID, forKey: Self.preferredKey) + reselect() + } + } + + private static let preferredKey = "punktfunk.gamepadID" + /// Connect order (identity-keyed) — drives both twin de-dup suffixes and auto-pick. + private var connectOrder: [ObjectIdentifier] = [] + private var observers: [NSObjectProtocol] = [] + + private init() { + preferredID = UserDefaults.standard.string(forKey: Self.preferredKey) ?? "" + observers.append(NotificationCenter.default.addObserver( + forName: .GCControllerDidConnect, object: nil, queue: .main + ) { [weak self] n in + MainActor.assumeIsolated { + guard let self, let c = n.object as? GCController else { return } + self.noteConnected(c) + } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .GCControllerDidDisconnect, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.rebuild() } + }) + for c in GCController.controllers() { connectOrder.append(ObjectIdentifier(c)) } + rebuild() + } + + /// Re-read battery levels etc. (the notifications only fire on connect/disconnect) — + /// Settings calls this on appear. + public func refresh() { + rebuild() + } + + /// Scan for nearby wireless controllers while the Settings page is visible. + public func startDiscovery() { + GCController.startWirelessControllerDiscovery() + } + + public func stopDiscovery() { + GCController.stopWirelessControllerDiscovery() + } + + /// Connect-time resolution of the user's controller-type setting: an explicit choice + /// wins; `.auto` matches the virtual pad to the active physical controller (DualSense → + /// DualSense, anything else → Xbox 360); no controller at all defers to the host. + public func resolveType( + setting: PunktfunkConnection.GamepadType + ) -> PunktfunkConnection.GamepadType { + guard setting == .auto else { return setting } + guard let active else { return .auto } + return active.isDualSense ? .dualSense : .xbox360 + } + + private func noteConnected(_ c: GCController) { + let key = ObjectIdentifier(c) + connectOrder.removeAll { $0 == key } + connectOrder.append(key) + rebuild() + } + + private func rebuild() { + let present = GCController.controllers() + connectOrder.removeAll { key in !present.contains { ObjectIdentifier($0) == key } } + for c in present where !connectOrder.contains(ObjectIdentifier(c)) { + connectOrder.append(ObjectIdentifier(c)) + } + // In connect order, fingerprinting twins by their position among same-named pads. + let ordered = connectOrder.compactMap { key in + present.first { ObjectIdentifier($0) == key } + } + var seen: [String: Int] = [:] + controllers = ordered.map { c in + let base = "\(c.vendorName ?? "Controller")|\(c.productCategory)" + let n = (seen[base] ?? 0) + 1 + seen[base] = n + return Self.describe(c, id: n == 1 ? base : "\(base)#\(n)") + } + reselect() + } + + private func reselect() { + let candidates = controllers.filter(\.isExtended) + // The pin wins when present; otherwise the most recently connected extended pad + // (list is in connect order). A stale pin falls back to automatic. + active = candidates.last { $0.id == preferredID } ?? candidates.last + } + + private static func describe(_ c: GCController, id: String) -> DiscoveredController { + let extended = c.extendedGamepad + let ds = extended as? GCDualSenseGamepad + return DiscoveredController( + id: id, + name: c.vendorName ?? c.productCategory, + productCategory: c.productCategory, + isExtended: extended != nil, + isDualSense: ds != nil, + hasLight: c.light != nil, + hasHaptics: c.haptics != nil, + hasMotion: c.motion != nil, + // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration. + hasAdaptiveTriggers: ds != nil, + batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil }, + isCharging: c.battery?.batteryState == .charging, + controller: c) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift index 341387d..93d47fa 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift @@ -1,11 +1,13 @@ // Swift wrapper around the punktfunk-core C ABI's punktfunk/1 connection API. // // Threading contract (mirrors the C header): one PunktfunkConnection is pumped from a single -// video thread via nextAU(); nextAudio()/nextRumble() may each run on their own (single) -// drain thread — the core keeps per-plane borrow slots, so the planes never alias; -// send() is enqueue-only and safe alongside all of them. The pointers inside an AU/audio -// packet are only valid until the next call of the same kind, so we copy into Data here — -// the copies are small and keep the Swift side memory-safe. +// video thread via nextAU(); nextAudio() runs on its own (single) drain thread, and +// nextRumble()/nextHidOutput() share one feedback drain thread (two core planes, one puller +// each — polling them sequentially from one thread is within the contract); the core keeps +// per-plane borrow slots, so the planes never alias. send() is enqueue-only and safe +// alongside all of them. The pointers inside an AU/audio packet are only valid until the +// next call of the same kind, so we copy into Data here — the copies are small and keep the +// Swift side memory-safe. // // Trust: pass the host's pinned certificate fingerprint (the host logs it at startup, and // `hostFingerprint` reports what a trust-on-first-use connect observed — persist it, e.g. @@ -126,8 +128,11 @@ public final class PunktfunkConnection { /// Held across the blocking next_au call; close() takes it (same plane-lock → abiLock /// order as the pullers) so it can never free the handle under an in-flight poll. private let pumpLock = NSLock() - /// Same role for the audio/rumble drain thread (its own plane in the core). + /// Same role for the audio drain thread (its own plane in the core). private let audioLock = NSLock() + /// Same role for the feedback drain thread (rumble + HID-output — two core planes, + /// drained sequentially by one thread). + private let feedbackLock = NSLock() /// Negotiated session mode (host-confirmed). public private(set) var width: UInt32 = 0 @@ -163,6 +168,33 @@ public final class PunktfunkConnection { } } + /// Which virtual gamepad the host creates for this session's pads (the + /// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else + /// X-Box 360); `.dualSense` is honored only on hosts with UHID (Linux) — games then see + /// a real DualSense and their lightbar / adaptive-trigger writes come back on the + /// HID-output plane (`nextHidOutput`). The host's actual choice is `resolvedGamepad`. + public enum GamepadType: UInt32, CaseIterable, Sendable { + case auto = 0 + case xbox360 = 1 + case dualSense = 2 + + /// Loose name parsing for env/dev hooks, mirroring the host's + /// `GamepadPref::from_name`. + public init?(name: String) { + switch name.lowercased() { + case "auto", "default": self = .auto + case "xbox", "xbox360", "x360", "uinput": self = .xbox360 + case "dualsense", "ds", "ps5": self = .dualSense + default: return nil + } + } + } + + /// The virtual gamepad backend the host actually resolved (the Welcome's echo of the + /// requested `gamepad`). `.auto` = an older host that didn't say — assume Xbox 360, no + /// DualSense feedback. + public private(set) var resolvedGamepad: GamepadType = .auto + /// Connect and start a session at the requested mode (the host creates a native virtual /// output at exactly this size/refresh). Blocks up to `timeoutMs`. /// @@ -176,12 +208,16 @@ public final class PunktfunkConnection { /// /// `compositor`: which backend should drive the virtual output host-side (see /// `Compositor`; `.auto` = host decides). + /// + /// `gamepad`: which virtual pad the host creates for this session's controllers (see + /// `GamepadType`; `.auto` = host decides). Check `resolvedGamepad` afterwards. public init( host: String, port: UInt16 = 9777, width: UInt32, height: UInt32, refreshHz: UInt32, pinSHA256: Data? = nil, identity: ClientIdentity? = nil, compositor: Compositor = .auto, + gamepad: GamepadType = .auto, timeoutMs: UInt32 = 10_000 ) throws { if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin } @@ -191,14 +227,16 @@ public final class PunktfunkConnection { withOptionalCString(identity?.keyPEM) { key in if let pin = pinSHA256 { return pin.withUnsafeBytes { p in - punktfunk_connect_ex( + punktfunk_connect_ex2( cs, port, width, height, refreshHz, compositor.rawValue, + gamepad.rawValue, p.bindMemory(to: UInt8.self).baseAddress, &observed, cert, key, timeoutMs) } } - return punktfunk_connect_ex( + return punktfunk_connect_ex2( cs, port, width, height, refreshHz, compositor.rawValue, + gamepad.rawValue, nil, &observed, cert, key, timeoutMs) } } @@ -210,6 +248,9 @@ public final class PunktfunkConnection { self.width = w self.height = h self.refreshHz = hz + var gp: UInt32 = 0 + _ = punktfunk_connection_gamepad(handle, &gp) + resolvedGamepad = GamepadType(rawValue: gp) ?? .auto } /// Ask the host to switch the live session to a new mode (window resized) — no @@ -285,10 +326,10 @@ public final class PunktfunkConnection { /// Pull the next force-feedback update for the GCController haptics engine: /// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop. - /// Shares the audio drain thread's plane (call from that thread). + /// Drain from the (single) feedback thread, alongside `nextHidOutput`. public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? { - audioLock.lock() - defer { audioLock.unlock() } + feedbackLock.lock() + defer { feedbackLock.unlock() } guard let h = liveHandle() else { throw PunktfunkClientError.closed } var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0 @@ -305,6 +346,55 @@ public final class PunktfunkConnection { } } + /// One DualSense feedback event a game wrote to the host's virtual pad — replay it on + /// the real controller (GCDeviceLight, GCControllerPlayerIndex, + /// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these. + public enum HidOutputEvent: Sendable, Equatable { + /// Lightbar color. + case led(pad: UInt8, r: UInt8, g: UInt8, b: UInt8) + /// Player-indicator LEDs (low 5 bits). + case playerLEDs(pad: UInt8, bits: UInt8) + /// Adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense + /// trigger parameter block (mode byte + params, ≤ 11 bytes) — parse with + /// `DualSenseTriggerEffect`. + case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8]) + } + + /// Pull the next DualSense feedback event (lightbar / player LEDs / adaptive triggers); + /// nil on timeout, throws `.closed` once the session ended. Drain from the (single) + /// feedback thread, alongside `nextRumble`. Nothing ever arrives unless + /// `resolvedGamepad == .dualSense` — poll with a short timeout, never spin. + public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? { + feedbackLock.lock() + defer { feedbackLock.unlock() } + guard let h = liveHandle() else { throw PunktfunkClientError.closed } + + var out = PunktfunkHidOutput() + let rc = punktfunk_connection_next_hidout(h, &out, timeoutMs) + switch rc { + case statusOK: + switch Int32(out.kind) { + case PUNKTFUNK_HIDOUT_LED: + return .led(pad: out.pad, r: out.r, g: out.g, b: out.b) + case PUNKTFUNK_HIDOUT_PLAYER_LEDS: + return .playerLEDs(pad: out.pad, bits: out.player_bits) + case PUNKTFUNK_HIDOUT_TRIGGER: + // The fixed C array imports as a tuple — copy out the valid prefix. + let len = Int(min(out.effect_len, UInt8(PUNKTFUNK_HID_EFFECT_MAX))) + let effect = withUnsafeBytes(of: out.effect) { Array($0.prefix(len)) } + return .triggerEffect(pad: out.pad, which: out.which, effect: effect) + default: + return nil // unknown kind from a newer host — skip (forward-compatible) + } + case statusNoFrame: + return nil + case statusClosed: + throw PunktfunkClientError.closed + default: + throw PunktfunkClientError.status(rc) + } + } + /// Send one input event (delivered to the host as a QUIC datagram). Thread-safe; /// silently dropped after close. public func send(_ event: PunktfunkInputEvent) { @@ -323,10 +413,12 @@ public final class PunktfunkConnection { abiLock.unlock() pumpLock.lock() // pullers exit at their next poll boundary, releasing these audioLock.lock() + feedbackLock.lock() abiLock.lock() let h = handle handle = nil abiLock.unlock() + feedbackLock.unlock() audioLock.unlock() pumpLock.unlock() if let h { @@ -349,6 +441,43 @@ public final class PunktfunkConnection { } } + /// Send one DualSense touchpad contact to the host's virtual pad (rich-input plane). + /// `x`/`y` are normalized 0...65535 across the touchpad, origin top-left, +y down. + /// Non-blocking enqueue (same discipline as `send`); pointless on non-DualSense + /// sessions — the host ignores it there. + public func sendTouchpad(pad: UInt8 = 0, finger: UInt8, active: Bool, x: UInt16, y: UInt16) { + abiLock.lock() + defer { abiLock.unlock() } + guard let h = handle, !closeRequested else { return } + var rich = PunktfunkRichInput() + rich.kind = UInt8(PUNKTFUNK_RICH_TOUCHPAD) + rich.pad = pad + rich.finger = finger + rich.active = active ? 1 : 0 + rich.x = x + rich.y = y + _ = punktfunk_connection_send_rich_input(h, &rich) + } + + /// Send one DualSense motion sample to the host's virtual pad (rich-input plane). The + /// values are raw DualSense sensor units, written verbatim into the virtual pad's input + /// report — convert with `GamepadCapture`'s scale constants (gyro: rad/s → 20 LSB per + /// deg/s; accel: g → 10000 LSB per g). + public func sendMotion( + pad: UInt8 = 0, + gyro: (Int16, Int16, Int16), accel: (Int16, Int16, Int16) + ) { + abiLock.lock() + defer { abiLock.unlock() } + guard let h = handle, !closeRequested else { return } + var rich = PunktfunkRichInput() + rich.kind = UInt8(PUNKTFUNK_RICH_MOTION) + rich.pad = pad + rich.gyro = gyro + rich.accel = accel + _ = punktfunk_connection_send_rich_input(h, &rich) + } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). @@ -387,10 +516,12 @@ public extension PunktfunkInputEvent { } // Gamepad (wire contract in punktfunk_core::input::gamepad): one transition per event, - // `pad` = controller index, accumulated host-side into a virtual Xbox 360 pad. + // `pad` = controller index, accumulated host-side into a virtual Xbox 360 or DualSense + // pad (the session's negotiated `GamepadType`). /// `button` is a GameStream buttonFlags bit (A=0x1000 B=0x2000 X=0x4000 Y=0x8000, - /// dpad=0x1/2/4/8, start=0x10 back=0x20 LS=0x40 RS=0x80 LB=0x100 RB=0x200 guide=0x400). + /// dpad=0x1/2/4/8, start=0x10 back=0x20 LS=0x40 RS=0x80 LB=0x100 RB=0x200 guide=0x400, + /// touchpad click=0x100000 — DualSense sessions only, the xpad has no such button). static func gamepadButton(_ button: UInt32, down: Bool, pad: UInt32 = 0) -> PunktfunkInputEvent { make( PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue, diff --git a/clients/apple/Tests/PunktfunkKitTests/DualSenseTriggerEffectTests.swift b/clients/apple/Tests/PunktfunkKitTests/DualSenseTriggerEffectTests.swift new file mode 100644 index 0000000..011e621 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/DualSenseTriggerEffectTests.swift @@ -0,0 +1,190 @@ +// Table-driven coverage of the DualSense trigger-effect parser: every supported mode +// byte, the packed 10-zone decoding, and the it-must-never-trap guarantee for garbage. +// Pure data → data, no controller needed. + +import XCTest + +@testable import PunktfunkKit + +final class DualSenseTriggerEffectTests: XCTestCase { + /// Build an 11-byte block: mode + up to 10 params (zero-padded). + private func block(_ mode: UInt8, _ params: [UInt8] = []) -> [UInt8] { + var b = [mode] + params + while b.count < 11 { b.append(0) } + return b + } + + /// Pack a 10-zone effect: active-zone bitmask (p0/p1) + 3-bit values (p2...p5). + private func zones(_ mode: UInt8, values: [UInt8], extra: [UInt8] = []) -> [UInt8] { + precondition(values.count == 10) + var mask: UInt16 = 0 + var packed: UInt32 = 0 + for (i, v) in values.enumerated() where v > 0 { + mask |= 1 << UInt16(i) + packed |= UInt32(v & 0x07) << (3 * UInt32(i)) + } + var p: [UInt8] = [ + UInt8(mask & 0xFF), UInt8(mask >> 8), + UInt8(packed & 0xFF), UInt8((packed >> 8) & 0xFF), + UInt8((packed >> 16) & 0xFF), UInt8((packed >> 24) & 0xFF), + ] + p += extra + return block(mode, p) + } + + func testOffModes() { + XCTAssertEqual(DualSenseTriggerEffect.parse(block(0x00)), .off) + XCTAssertEqual(DualSenseTriggerEffect.parse(block(0x05, [9, 9, 9])), .off) + XCTAssertEqual(DualSenseTriggerEffect.parse(block(0xFC)), .off) + } + + func testUnknownAndGarbageNeverTrap() { + XCTAssertEqual(DualSenseTriggerEffect.parse([]), .off) + XCTAssertEqual(DualSenseTriggerEffect.parse([0x99]), .off) + XCTAssertEqual(DualSenseTriggerEffect.parse([0x42, 0xFF]), .off) + // Short blocks of known modes parse with zero-padded params. + XCTAssertEqual( + DualSenseTriggerEffect.parse([0x01, 128]), + .feedback(start: 128.0 / 255, strength: 1.0 / 8)) + // Every possible mode byte with random-ish params returns *something*. + for mode in UInt8.min...UInt8.max { + _ = DualSenseTriggerEffect.parse(block(mode, [255, 255, 255, 255, 255, 255, 255, 255, 255, 255])) + } + } + + func testLegacySimpleModes() { + // 0x01: continuous resistance (start, force). + XCTAssertEqual( + DualSenseTriggerEffect.parse(block(0x01, [51, 255])), + .feedback(start: 51.0 / 255, strength: 1)) + // Zero force still resists faintly (an active effect is never strength 0). + XCTAssertEqual( + DualSenseTriggerEffect.parse(block(0x01, [0, 0])), + .feedback(start: 0, strength: 1.0 / 8)) + // 0x02: section between start and end. + guard case let .weapon(start, end, strength) = + DualSenseTriggerEffect.parse(block(0x02, [51, 204])) else { + return XCTFail("0x02 should parse as weapon") + } + XCTAssertEqual(start, 51.0 / 255, accuracy: 0.001) + XCTAssertEqual(end, 204.0 / 255, accuracy: 0.001) + XCTAssertEqual(strength, 1) + // 0x06: vibration — Nielk1's Simple_Vibration order is (frequency, amplitude, position). + XCTAssertEqual( + DualSenseTriggerEffect.parse(block(0x06, [30, 128, 51])), + .vibration(start: 51.0 / 255, amplitude: 128.0 / 255, frequency: 30.0 / 255)) + } + + func testFeedback0x21UniformSuffixSimplifies() { + // Zones 3...9 all at strength 4 → one simple feedback call from zone 3. + let b = zones(0x21, values: [0, 0, 0, 4, 4, 4, 4, 4, 4, 4]) + XCTAssertEqual( + DualSenseTriggerEffect.parse(b), + .feedback(start: 3.0 / 9, strength: 5.0 / 8)) + } + + func testFeedback0x21MixedGoesPositional() { + let b = zones(0x21, values: [0, 0, 2, 0, 0, 7, 0, 0, 0, 1]) + guard case let .positionalFeedback(strengths) = DualSenseTriggerEffect.parse(b) else { + return XCTFail("mixed zones should parse positional") + } + XCTAssertEqual(strengths.count, 10) + XCTAssertEqual(strengths[2], 3.0 / 8) // 3-bit value 2 → (2+1)/8 + XCTAssertEqual(strengths[5], 8.0 / 8) + XCTAssertEqual(strengths[9], 2.0 / 8) + XCTAssertEqual(strengths[0], 0) // inactive zone + XCTAssertEqual(strengths[3], 0) + } + + func testFeedback0x21EmptyMaskIsOff() { + XCTAssertEqual( + DualSenseTriggerEffect.parse(zones(0x21, values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])), + .off) + } + + func testWeapon0x25() { + // Nielk1 Weapon = mode 0x25: start zone 2, end zone 7 from the mask; p2 = strength. + var b = zones(0x25, values: [0, 0, 1, 0, 0, 0, 0, 1, 0, 0]) + b[3] = 6 // p2 (block[3] = params[2]): strength, stored minus one + guard case let .weapon(start, end, strength) = DualSenseTriggerEffect.parse(b) else { + return XCTFail("0x25 should parse as weapon") + } + XCTAssertEqual(start, 2.0 / 9, accuracy: 0.001) + XCTAssertEqual(end, 7.0 / 9, accuracy: 0.001) + XCTAssertEqual(strength, 7.0 / 8) + // A single active zone can't form a section. + XCTAssertEqual( + DualSenseTriggerEffect.parse(zones(0x25, values: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])), + .off) + } + + func testBow0x22BecomesSlope() { + // Nielk1 Bow = mode 0x22, draw + snap packed as a 3-bit pair in p2 (p3 always 0). + var b = zones(0x22, values: [0, 1, 0, 0, 0, 0, 0, 0, 1, 0]) + b[3] = (7 & 0x07) | ((3 & 0x07) << 3) // p2: draw (low) | snap (bits 3-5) + guard case let .slope(start, end, from, to) = DualSenseTriggerEffect.parse(b) else { + return XCTFail("0x22 should parse as slope") + } + XCTAssertEqual(start, 1.0 / 9, accuracy: 0.001) + XCTAssertEqual(end, 8.0 / 9, accuracy: 0.001) + XCTAssertEqual(from, 8.0 / 8) + XCTAssertEqual(to, 4.0 / 8) + } + + func testVibration0x26() { + var b = zones(0x26, values: [0, 0, 0, 0, 0, 5, 5, 5, 5, 5]) + b[9] = 40 // p8 (block[9]): frequency Hz + guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else { + return XCTFail("0x26 should parse as positional vibration") + } + XCTAssertEqual(amps[4], 0) + XCTAssertEqual(amps[5], 6.0 / 8) + XCTAssertEqual(amps[9], 6.0 / 8) + XCTAssertEqual(freq, 40.0 / 255, accuracy: 0.001) + XCTAssertEqual( + DualSenseTriggerEffect.parse(zones(0x26, values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])), + .off) + } + + func testGalloping0x23() { + // Nielk1 Galloping = mode 0x23: zone range + p3 frequency (p2 is foot timing). + var b = zones(0x23, values: [0, 0, 1, 0, 0, 0, 1, 0, 0, 0]) + b[4] = 20 // p3 (block[4]): frequency + guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else { + return XCTFail("0x23 should parse as positional vibration") + } + XCTAssertEqual(amps[1], 0) + XCTAssertEqual(amps[2], 0.5) + XCTAssertEqual(amps[6], 0.5) + XCTAssertEqual(amps[7], 0) + XCTAssertEqual(freq, 20.0 / 255, accuracy: 0.001) + } + + func testMachine0x27() { + // Nielk1 Machine = mode 0x27: zone range, p2 = two raw 3-bit amplitudes, p3 = frequency. + var b = zones(0x27, values: [0, 0, 0, 1, 0, 0, 0, 0, 1, 0]) + b[3] = (2 & 0x07) | ((6 & 0x07) << 3) // p2: amplitude A = 2, B = 6 + b[4] = 90 // p3: frequency + guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else { + return XCTFail("0x27 should parse as positional vibration") + } + XCTAssertEqual(amps[2], 0) + XCTAssertEqual(amps[3], 6.0 / 7, accuracy: 0.001) // the stronger leg + XCTAssertEqual(amps[8], 6.0 / 7, accuracy: 0.001) + XCTAssertEqual(amps[9], 0) + XCTAssertEqual(freq, 90.0 / 255, accuracy: 0.001) + // Zero amplitudes render as off, whatever the mask says. + var z = zones(0x27, values: [0, 0, 0, 1, 0, 0, 0, 0, 1, 0]) + z[3] = 0 + XCTAssertEqual(DualSenseTriggerEffect.parse(z), .off) + } + + /// The host's PUNKTFUNK_TEST_FEEDBACK burst sends [0x21, 1, 2, 3, ...] — make sure the + /// loopback test's expected shape stays a real parse result. + func testScriptedLoopbackBlockParses() { + let scripted: [UInt8] = [0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + if case .off = DualSenseTriggerEffect.parse(scripted) { + XCTFail("the scripted test block should parse as a feedback effect") + } + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift new file mode 100644 index 0000000..c74833b --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift @@ -0,0 +1,86 @@ +// The gamepad wire contract: button bit positions (must match +// punktfunk_core::input::gamepad), GC→DualSense touchpad/motion conversions, and the +// player-LED-bits → GCControllerPlayerIndex map. All pure functions. + +import GameController +import XCTest + +@testable import PunktfunkKit + +final class GamepadWireTests: XCTestCase { + func testButtonBitsMatchTheRustWireContract() { + // punktfunk_core::input::gamepad constants, spot-checked bit for bit. + XCTAssertEqual(GamepadWire.dpadUp, 0x0001) + XCTAssertEqual(GamepadWire.dpadDown, 0x0002) + XCTAssertEqual(GamepadWire.dpadLeft, 0x0004) + XCTAssertEqual(GamepadWire.dpadRight, 0x0008) + XCTAssertEqual(GamepadWire.start, 0x0010) + XCTAssertEqual(GamepadWire.back, 0x0020) + XCTAssertEqual(GamepadWire.leftStickClick, 0x0040) + XCTAssertEqual(GamepadWire.rightStickClick, 0x0080) + XCTAssertEqual(GamepadWire.leftShoulder, 0x0100) + XCTAssertEqual(GamepadWire.rightShoulder, 0x0200) + XCTAssertEqual(GamepadWire.guide, 0x0400) + XCTAssertEqual(GamepadWire.a, 0x1000) + XCTAssertEqual(GamepadWire.b, 0x2000) + XCTAssertEqual(GamepadWire.x, 0x4000) + XCTAssertEqual(GamepadWire.y, 0x8000) + XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000) + // Every button is enumerated exactly once (releaseAll walks this list). + let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 } + XCTAssertEqual(combined, 0x0010_F7FF) + XCTAssertEqual(GamepadWire.allButtons.count, 16) + XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count) + // Axis ids. + XCTAssertEqual(GamepadWire.axisLSX, 0) + XCTAssertEqual(GamepadWire.axisLSY, 1) + XCTAssertEqual(GamepadWire.axisRSX, 2) + XCTAssertEqual(GamepadWire.axisRSY, 3) + XCTAssertEqual(GamepadWire.axisLT, 4) + XCTAssertEqual(GamepadWire.axisRT, 5) + } + + func testTouchpadConversionCorners() { + // GC ±1 with +y up → wire 0...65535 with origin top-left, +y down. + let topLeft = GamepadWire.touchpad(x: -1, y: 1) + XCTAssertEqual(topLeft.x, 0) + XCTAssertEqual(topLeft.y, 0) + let bottomRight = GamepadWire.touchpad(x: 1, y: -1) + XCTAssertEqual(bottomRight.x, 65535) + XCTAssertEqual(bottomRight.y, 65535) + let center = GamepadWire.touchpad(x: 0, y: 0) + XCTAssertEqual(Int(center.x), 32768, accuracy: 1) + XCTAssertEqual(Int(center.y), 32768, accuracy: 1) + // Out-of-range input clamps instead of wrapping. + let wild = GamepadWire.touchpad(x: 5, y: -7) + XCTAssertEqual(wild.x, 65535) + XCTAssertEqual(wild.y, 65535) + } + + func testMotionScalingAndClamping() { + // 20 raw LSB per deg/s — one full revolution per second (360 deg/s = 2π rad/s). + XCTAssertEqual(GamepadWire.motionRaw(2 * .pi, scale: GamepadWire.gyroLSBPerRadS), 7200) + // 1 g → 10000 raw. + XCTAssertEqual(GamepadWire.motionRaw(1, scale: GamepadWire.accelLSBPerG), 10000) + XCTAssertEqual(GamepadWire.motionRaw(-1, scale: GamepadWire.accelLSBPerG), -10000) + // Saturation, not overflow. + XCTAssertEqual(GamepadWire.motionRaw(100, scale: GamepadWire.accelLSBPerG), Int16.max) + XCTAssertEqual(GamepadWire.motionRaw(-100, scale: GamepadWire.accelLSBPerG), Int16.min) + XCTAssertEqual(GamepadWire.motionRaw(0, scale: GamepadWire.gyroLSBPerRadS), 0) + } + + func testPlayerIndexMap() { + // hid-playstation's DualSense player-LED patterns. + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0), .indexUnset) + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00100), .index1) + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b01010), .index2) + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b10101), .index3) + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b11011), .index4) + // Unknown patterns: lit-count fallback, clamped to GC's four indices. + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00001), .index1) + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00011), .index2) + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b11111), .index4) + // Bits above the 5 LEDs are ignored. + XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b1110_0000), .indexUnset) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift index b2f478f..bf32a47 100644 --- a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift @@ -43,17 +43,51 @@ final class LoopbackIntegrationTests: XCTestCase { XCTAssertGreaterThanOrEqual(lastIndex, 24) // Input goes the other way (enqueue-only; the host logs the count on close) — - // including the touch kinds and the mic uplink plane (the synthetic host counts - // the datagrams; injection/decoding are Linux-side concerns). + // including the touch kinds, gamepad events, the rich-input plane (DualSense + // touchpad/motion), and the mic uplink plane (the synthetic host counts the + // datagrams; injection/decoding are Linux-side concerns). conn.send(.mouseMove(dx: 1, dy: 2)) conn.send(.key(0x41, down: true)) conn.send(.key(0x41, down: false)) conn.send(.touchDown(id: 0, x: 100, y: 200, surfaceWidth: 1280, surfaceHeight: 720)) conn.send(.touchMove(id: 0, x: 110, y: 210, surfaceWidth: 1280, surfaceHeight: 720)) conn.send(.touchUp(id: 0)) + conn.send(.gamepadButton(GamepadWire.a, down: true, pad: 0)) + conn.send(.gamepadButton(GamepadWire.a, down: false, pad: 0)) + conn.send(.gamepadAxis(GamepadWire.axisLSX, value: 12345, pad: 0)) + conn.send(.gamepadAxis(GamepadWire.axisRT, value: 200, pad: 0)) + conn.sendTouchpad(finger: 0, active: true, x: 32768, y: 16384) + conn.sendTouchpad(finger: 0, active: false, x: 0, y: 0) + conn.sendMotion(gyro: (100, -100, 0), accel: (0, 0, 10000)) conn.sendMic(Data([0xFC, 0xFF, 0xFE]), seq: 0, ptsNs: 1) // tiny opus-ish frame conn.sendMic(Data(), seq: 1, ptsNs: 2) // DTX silence frame + // The synthetic host (PUNKTFUNK_TEST_FEEDBACK=1, set by test-loopback.sh) scripts + // one feedback burst on the host→client planes — drain both and verify, end to + // end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD). + if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" { + var rumble: (pad: UInt16, low: UInt16, high: UInt16)? + var hidout: [PunktfunkConnection.HidOutputEvent] = [] + let feedbackDeadline = Date().addingTimeInterval(10) + while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline { + if rumble == nil, let r = try conn.nextRumble(timeoutMs: 100) { rumble = r } + if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) } + } + XCTAssertEqual(rumble?.pad, 0) + XCTAssertEqual(rumble?.low, 0x4000) + XCTAssertEqual(rumble?.high, 0x8000) + XCTAssertTrue( + hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)), + "missing the scripted lightbar event: \(hidout)") + XCTAssertTrue( + hidout.contains(.playerLEDs(pad: 0, bits: 0b00100)), + "missing the scripted player-LED event: \(hidout)") + XCTAssertTrue( + hidout.contains(.triggerEffect( + pad: 0, which: 1, effect: [0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])), + "missing the scripted trigger event: \(hidout)") + } + conn.close() XCTAssertThrowsError(try conn.nextAU(timeoutMs: 10)) { error in guard case PunktfunkClientError.closed = error else { diff --git a/clients/apple/test-loopback.sh b/clients/apple/test-loopback.sh index a015977..7de42af 100755 --- a/clients/apple/test-loopback.sh +++ b/clients/apple/test-loopback.sh @@ -19,7 +19,9 @@ CFG="$(mktemp -d "${TMPDIR:-/tmp}/punktfunk-loopback.XXXXXX")" PAIR_LOG="$CFG/pairing-host.log" mkdir -p "$CFG/open" "$CFG/paired" trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" 2>/dev/null || true' EXIT -HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" \ +# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the +# handshake, so the Swift test can assert the host→client feedback planes end to end. +HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \ target/release/punktfunk-host m3-host --port "$PORT" --source synthetic --frames 300 & HOST_PID=$! HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \ @@ -41,4 +43,5 @@ fi cd clients/apple PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \ + PUNKTFUNK_TEST_FEEDBACK=1 \ swift test --filter LoopbackIntegrationTests diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index 58942dc..4245175 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -27,10 +27,16 @@ //! `gamescope`); the host honors it if available, else auto-detects and reports the resolved //! choice in its Welcome (logged as `session offer … compositor=…`). //! +//! `--gamepad NAME` requests a host virtual-pad backend (`auto`|`xbox360`|`dualsense`); the +//! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360, +//! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`). +//! //! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test] -//! [--pin HEX] [--compositor NAME]` (M4 adds VAAPI decode + wgpu present on this skeleton.) +//! [--pin HEX] [--compositor NAME] [--gamepad NAME]` +//! (M4 adds VAAPI decode + wgpu present on this skeleton.) use anyhow::{anyhow, Context, Result}; +use punktfunk_core::config::GamepadPref; use punktfunk_core::config::Role; use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome}; @@ -59,6 +65,8 @@ struct Args { name: String, /// `--compositor NAME` — request a host compositor backend (auto|kwin|wlroots|mutter|gamescope). compositor: CompositorPref, + /// `--gamepad NAME` — request a host virtual-pad backend (auto|xbox360|dualsense). + gamepad: GamepadPref, } fn parse_mode(m: &str) -> Option { @@ -145,6 +153,17 @@ fn parse_args() -> Args { } }, }; + // Same fail-closed discipline for --gamepad. + let gamepad = match get("--gamepad") { + None => GamepadPref::Auto, + Some(s) => match GamepadPref::from_name(s) { + Some(g) => g, + None => { + eprintln!("--gamepad must be one of: auto, xbox360, dualsense"); + std::process::exit(2); + } + }, + }; Args { connect: get("--connect").unwrap_or("127.0.0.1:9777").to_string(), mode, @@ -158,6 +177,7 @@ fn parse_args() -> Args { pair: get("--pair").map(String::from), name: get("--name").unwrap_or("punktfunk-client-rs").to_string(), compositor, + gamepad, } } @@ -242,6 +262,7 @@ async fn session(args: Args) -> Result<()> { abi_version: punktfunk_core::ABI_VERSION, mode: args.mode, compositor: args.compositor, + gamepad: args.gamepad, } .encode(), ) @@ -254,6 +275,7 @@ async fn session(args: Args) -> Result<()> { encrypt = welcome.encrypt, frames = welcome.frames, compositor = welcome.compositor.as_str(), + gamepad = welcome.gamepad.as_str(), "session offer" ); diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index c316666..df7aa21 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -32,6 +32,7 @@ parse_deps = false "BTN_B" = "PUNKTFUNK_BTN_B" "BTN_X" = "PUNKTFUNK_BTN_X" "BTN_Y" = "PUNKTFUNK_BTN_Y" +"BTN_TOUCHPAD" = "PUNKTFUNK_BTN_TOUCHPAD" "AXIS_LS_X" = "PUNKTFUNK_AXIS_LS_X" "AXIS_LS_Y" = "PUNKTFUNK_AXIS_LS_Y" "AXIS_RS_X" = "PUNKTFUNK_AXIS_RS_X" diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 5ef7036..ff74d2a 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -621,6 +621,19 @@ pub const PUNKTFUNK_COMPOSITOR_MUTTER: u32 = 3; /// gamescope (spawned nested). pub const PUNKTFUNK_COMPOSITOR_GAMESCOPE: u32 = 4; +/// Gamepad-backend preference for [`punktfunk_connect_ex2`] (`gamepad` arg): which virtual pad +/// the host creates for this session's controllers. Precedence host-side: an explicit client +/// choice > the host's `PUNKTFUNK_GAMEPAD` env var > X-Box 360. `AUTO` (or any unrecognized +/// value) = host decides. The resolved choice is echoed over the protocol (`Welcome`) and +/// readable via [`punktfunk_connection_gamepad`]. +pub const PUNKTFUNK_GAMEPAD_AUTO: u32 = 0; +/// uinput X-Box 360 pad (the universal default — every game speaks XInput). +pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1; +/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion — +/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored +/// only where available (Linux hosts); otherwise the host falls back to X-Box 360. +pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2; + /// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. /// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to /// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`. @@ -674,6 +687,7 @@ pub unsafe extern "C" fn punktfunk_connect( /// the `PUNKTFUNK_COMPOSITOR_*` values). `PUNKTFUNK_COMPOSITOR_AUTO` (or any unrecognized value) /// lets the host decide; a concrete value is honored only if available, else the host falls back /// to auto-detect. The resolved choice is logged host-side and returned over the protocol. +/// Equivalent to [`punktfunk_connect_ex2`] with `gamepad = PUNKTFUNK_GAMEPAD_AUTO`. /// /// # Safety /// Same as [`punktfunk_connect`]. @@ -691,6 +705,49 @@ pub unsafe extern "C" fn punktfunk_connect_ex( client_cert_pem: *const std::os::raw::c_char, client_key_pem: *const std::os::raw::c_char, timeout_ms: u32, +) -> *mut PunktfunkConnection { + unsafe { + punktfunk_connect_ex2( + host, + port, + width, + height, + refresh_hz, + compositor, + PUNKTFUNK_GAMEPAD_AUTO, + pin_sha256, + observed_sha256_out, + client_cert_pem, + client_key_pem, + timeout_ms, + ) + } +} + +/// Like [`punktfunk_connect_ex`], but additionally requests which virtual `gamepad` backend the +/// host creates for this session's pads (one of the `PUNKTFUNK_GAMEPAD_*` values). +/// `PUNKTFUNK_GAMEPAD_AUTO` (or any unrecognized value) lets the host decide (its +/// `PUNKTFUNK_GAMEPAD` env var, else X-Box 360); a concrete value is honored only if that +/// backend is available on the host. The resolved choice is readable via +/// [`punktfunk_connection_gamepad`] — only a DualSense session emits HID-output feedback. +/// +/// # Safety +/// Same as [`punktfunk_connect`]. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connect_ex2( + host: *const std::os::raw::c_char, + port: u16, + width: u32, + height: u32, + refresh_hz: u32, + compositor: u32, + gamepad: u32, + pin_sha256: *const u8, + observed_sha256_out: *mut u8, + client_cert_pem: *const std::os::raw::c_char, + client_key_pem: *const std::os::raw::c_char, + timeout_ms: u32, ) -> *mut PunktfunkConnection { let r = std::panic::catch_unwind(AssertUnwindSafe(|| { if host.is_null() { @@ -705,7 +762,14 @@ pub unsafe extern "C" fn punktfunk_connect_ex( height, refresh_hz, }; - let pref = crate::config::CompositorPref::from_u8(compositor as u8); + // "Any unrecognized value = Auto" must hold for the FULL u32 domain — `as u8` + // would wrap 0x101 into a concrete choice before from_u8's fallback could apply. + let pref = u8::try_from(compositor) + .map(crate::config::CompositorPref::from_u8) + .unwrap_or_default(); + let gamepad = u8::try_from(gamepad) + .map(crate::config::GamepadPref::from_u8) + .unwrap_or_default(); let pin = if pin_sha256.is_null() { None } else { @@ -725,6 +789,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex( port, mode, pref, + gamepad, pin, identity, std::time::Duration::from_millis(timeout_ms as u64), @@ -1153,6 +1218,33 @@ pub unsafe extern "C" fn punktfunk_connection_mode( }) } +/// The virtual gamepad backend the host actually resolved for this session (one of the +/// `PUNKTFUNK_GAMEPAD_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex2`] +/// preference). `PUNKTFUNK_GAMEPAD_AUTO` = an older host that didn't say — assume X-Box 360, +/// no HID-output feedback. Safe any time after connect. +/// +/// # Safety +/// `c` is a valid connection handle; `gamepad` is writable (NULL is skipped). +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_gamepad( + c: *const PunktfunkConnection, + gamepad: *mut u32, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + unsafe { + if !gamepad.is_null() { + *gamepad = c.inner.resolved_gamepad.to_u8() as u32; + } + } + PunktfunkStatus::Ok + }) +} + /// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without /// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the /// stream continues at the new mode — the first new-mode access unit is an IDR with diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 2cd9742..8a1764f 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -11,7 +11,7 @@ //! invariant) plus a blocking data-plane pump; frames cross to the embedder over a bounded //! channel. All methods are safe to call from any single embedder thread. -use crate::config::{CompositorPref, Mode, Role}; +use crate::config::{CompositorPref, GamepadPref, Mode, Role}; use crate::error::{PunktfunkError, Result}; use crate::input::InputEvent; use crate::quic::{ @@ -71,6 +71,9 @@ pub struct NativeClient { /// SHA-256 fingerprint of the certificate the host actually presented. A TOFU caller /// (`pin = None`) persists this and passes it as the pin from then on. pub host_fingerprint: [u8; 32], + /// The virtual gamepad backend the host actually resolved ([`Welcome::gamepad`]). + /// `Auto` = an older host that didn't say (assume X-Box 360, no DualSense feedback). + pub resolved_gamepad: GamepadPref, } impl NativeClient { @@ -84,11 +87,13 @@ impl NativeClient { /// `identity`: this client's persistent self-signed identity (PEM cert + PKCS#8 key, /// see [`endpoint::generate_identity`]), presented via TLS client auth so a host can /// recognize a paired client. `None` = anonymous (rejected by hosts requiring pairing). + #[allow(clippy::too_many_arguments)] pub fn connect( host: &str, port: u16, mode: Mode, compositor: CompositorPref, + gamepad: GamepadPref, pin: Option<[u8; 32]>, identity: Option<(String, String)>, timeout: Duration, @@ -101,7 +106,8 @@ impl NativeClient { let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec)>(); let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::(); let (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + let (ready_tx, ready_rx) = + std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); let mode_slot = Arc::new(std::sync::Mutex::new(mode)); @@ -127,6 +133,7 @@ impl NativeClient { port, mode, compositor, + gamepad, pin, identity, frame_tx, @@ -144,7 +151,7 @@ impl NativeClient { }) .map_err(PunktfunkError::Io)?; - let (negotiated, fingerprint) = match ready_rx.recv_timeout(timeout) { + let (negotiated, resolved_gamepad, fingerprint) = match ready_rx.recv_timeout(timeout) { Ok(Ok(t)) => t, Ok(Err(e)) => return Err(e), Err(_) => { @@ -166,6 +173,7 @@ impl NativeClient { worker: Some(worker), mode: mode_slot, host_fingerprint: fingerprint, + resolved_gamepad, }) } @@ -364,6 +372,7 @@ struct WorkerArgs { port: u16, mode: Mode, compositor: CompositorPref, + gamepad: GamepadPref, pin: Option<[u8; 32]>, identity: Option<(String, String)>, frame_tx: SyncSender, @@ -374,7 +383,7 @@ struct WorkerArgs { mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec)>, rich_input_rx: tokio::sync::mpsc::UnboundedReceiver, reconfig_rx: tokio::sync::mpsc::UnboundedReceiver, - ready_tx: std::sync::mpsc::Sender>, + ready_tx: std::sync::mpsc::Sender>, shutdown: Arc, mode_slot: Arc>, } @@ -387,6 +396,7 @@ async fn worker_main(args: WorkerArgs) { port, mode, compositor, + gamepad, pin, identity, frame_tx, @@ -437,6 +447,7 @@ async fn worker_main(args: WorkerArgs) { abi_version: crate::ABI_VERSION, mode, compositor, + gamepad, } .encode(), ) @@ -448,6 +459,12 @@ async fn worker_main(args: WorkerArgs) { "host resolved compositor" ); } + if welcome.gamepad != GamepadPref::Auto { + tracing::info!( + gamepad = welcome.gamepad.as_str(), + "host resolved gamepad backend" + ); + } // Reserve our data-plane port, then start the host. let probe = std::net::UdpSocket::bind("0.0.0.0:0")?; @@ -466,18 +483,33 @@ async fn worker_main(args: WorkerArgs) { let transport = UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?; let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?; - Ok::<_, PunktfunkError>((conn, session, send, recv, welcome.mode, fingerprint)) + Ok::<_, PunktfunkError>(( + conn, + session, + send, + recv, + welcome.mode, + welcome.gamepad, + fingerprint, + )) }; - let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, fingerprint) = - match setup.await { - Ok(t) => t, - Err(e) => { - let _ = ready_tx.send(Err(e)); - return; - } - }; - let _ = ready_tx.send(Ok((negotiated, fingerprint))); + let ( + conn, + mut session, + mut ctrl_send, + mut ctrl_recv, + negotiated, + resolved_gamepad, + fingerprint, + ) = match setup.await { + Ok(t) => t, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + }; + let _ = ready_tx.send(Ok((negotiated, resolved_gamepad, fingerprint))); // Input task: embedder events → QUIC datagrams. let input_conn = conn.clone(); diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index cad5144..cb5ea3a 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -130,6 +130,67 @@ impl CompositorPref { } } +/// Which virtual gamepad the host should create for a client's pads. +/// +/// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the +/// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the +/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is +/// honored only if that backend is available on the host (DualSense needs Linux UHID); otherwise +/// the host falls back and reports the real choice in `Welcome`. The wire form is a single byte +/// (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`), appended to `Hello`/`Welcome` — older peers +/// simply omit/ignore it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum GamepadPref { + /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). + #[default] + Auto, + /// uinput X-Box 360 pad (the universal default — every game speaks XInput). + Xbox360, + /// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion. + DualSense, +} + +impl GamepadPref { + /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`. + pub fn to_u8(self) -> u8 { + match self { + GamepadPref::Auto => 0, + GamepadPref::Xbox360 => 1, + GamepadPref::DualSense => 2, + } + } + + /// Inverse of [`to_u8`](Self::to_u8). An unknown byte decodes to `Auto` — forward-compatible: + /// a future concrete value a peer doesn't recognize degrades to "let the host decide". + pub fn from_u8(v: u8) -> Self { + match v { + 1 => GamepadPref::Xbox360, + 2 => GamepadPref::DualSense, + _ => GamepadPref::Auto, + } + } + + /// Parse a CLI/config name (case-insensitive, with the usual aliases). `None` for an + /// unrecognized name, so callers can error rather than silently defaulting to `Auto`. + pub fn from_name(s: &str) -> Option { + Some(match s.trim().to_ascii_lowercase().as_str() { + "auto" | "default" => GamepadPref::Auto, + "xbox" | "xbox360" | "x360" | "uinput" => GamepadPref::Xbox360, + "dualsense" | "ds" | "ps5" => GamepadPref::DualSense, + _ => return None, + }) + } + + /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`). + pub fn as_str(self) -> &'static str { + match self { + GamepadPref::Auto => "auto", + GamepadPref::Xbox360 => "xbox360", + GamepadPref::DualSense => "dualsense", + } + } +} + /// Per-block FEC parameters. Recovery count is derived from `fec_percent` exactly as /// GameStream does: `m = ceil(k * fec_percent / 100)`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/punktfunk-core/src/input.rs b/crates/punktfunk-core/src/input.rs index f56755f..8d010b1 100644 --- a/crates/punktfunk-core/src/input.rs +++ b/crates/punktfunk-core/src/input.rs @@ -63,6 +63,10 @@ pub mod gamepad { pub const BTN_B: u32 = 0x2000; pub const BTN_X: u32 = 0x4000; pub const BTN_Y: u32 = 0x8000; + /// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2` + /// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on + /// the same bit. Only the DualSense backend renders it; the xpad has no such button. + pub const BTN_TOUCHPAD: u32 = 0x10_0000; /// Axis ids for `InputKind::GamepadAxis`. pub const AXIS_LS_X: u32 = 0; diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index 7feb26e..f2a992e 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -22,7 +22,9 @@ //! reported back for persisting). The data plane adds AES-GCM on top. //! All integers little-endian; every message is `u16 length || payload`. -use crate::config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role}; +use crate::config::{ + CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role, +}; use crate::error::{PunktfunkError, Result}; /// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start). @@ -45,6 +47,11 @@ pub struct Hello { /// choice in [`Welcome::compositor`]. Appended to the wire form — omitted by older clients /// (decodes to `Auto`). pub compositor: CompositorPref, + /// Which virtual gamepad the host should create for this session's pads (`Auto` = host + /// decides: its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). Resolved choice echoed in + /// [`Welcome::gamepad`]. Appended to the wire form — omitted by older clients (decodes + /// to `Auto`). + pub gamepad: GamepadPref, } /// `host → client`: the complete session offer. @@ -65,6 +72,11 @@ pub struct Welcome { /// [`Hello::compositor`] preference if available, else the host's auto-detected choice). /// Appended to the wire form — `Auto` when an older host omitted it (i.e. "unknown"). pub compositor: CompositorPref, + /// The virtual gamepad backend the host actually resolved (the client's [`Hello::gamepad`] + /// preference if available, else env var / X-Box 360). A client uses this to know whether + /// DualSense feedback (0xCD) can arrive at all. Appended to the wire form — `Auto` when an + /// older host omitted it (i.e. "unknown, assume X-Box 360"). + pub gamepad: GamepadPref, } /// `client → host`: data plane is bound, begin streaming. @@ -359,13 +371,14 @@ pub mod pake { impl Hello { pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(21); + let mut b = Vec::with_capacity(22); b.extend_from_slice(MAGIC); b.extend_from_slice(&self.abi_version.to_le_bytes()); b.extend_from_slice(&self.mode.width.to_le_bytes()); b.extend_from_slice(&self.mode.height.to_le_bytes()); b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it + b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline b } @@ -381,11 +394,15 @@ impl Hello { height: u32at(12), refresh_hz: u32at(16), }, - // Optional trailing byte — an older client that omits it requests `Auto`. + // Optional trailing bytes — an older client that omits them requests `Auto`. compositor: b .get(20) .map(|&v| CompositorPref::from_u8(v)) .unwrap_or_default(), + gamepad: b + .get(21) + .map(|&v| GamepadPref::from_u8(v)) + .unwrap_or_default(), }) } } @@ -411,13 +428,14 @@ impl Welcome { b.extend_from_slice(&self.salt); b.extend_from_slice(&self.frames.to_le_bytes()); b.push(self.compositor.to_u8()); // appended at offset 53 — older clients read [0..53] and skip it + b.push(self.gamepad.to_u8()); // appended at offset 54 — same back-compat discipline b } pub fn decode(b: &[u8]) -> Result { // Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22] // scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45] - // salt[45..49] frames[49..53] compositor[53] (optional trailing byte). + // salt[45..49] frames[49..53] compositor[53] gamepad[54] (optional trailing bytes). if b.len() < 53 || &b[0..4] != MAGIC { return Err(PunktfunkError::InvalidArg("bad Welcome")); } @@ -449,12 +467,16 @@ impl Welcome { key, salt, frames: u32at(49), - // Optional trailing byte — an older host that omits it leaves the resolved - // compositor unknown (`Auto`). + // Optional trailing bytes — an older host that omits them leaves the resolved + // compositor / gamepad backend unknown (`Auto`). compositor: b .get(53) .map(|&v| CompositorPref::from_u8(v)) .unwrap_or_default(), + gamepad: b + .get(54) + .map(|&v| GamepadPref::from_u8(v)) + .unwrap_or_default(), }) } @@ -1126,6 +1148,7 @@ mod tests { salt: [1, 2, 3, 4], frames: 600, compositor: CompositorPref::Gamescope, + gamepad: GamepadPref::DualSense, }; assert_eq!(Welcome::decode(&w.encode()).unwrap(), w); } @@ -1140,6 +1163,7 @@ mod tests { refresh_hz: 120, }, compositor: CompositorPref::Kwin, + gamepad: GamepadPref::DualSense, }; assert_eq!(Hello::decode(&h.encode()).unwrap(), h); let s = Start { @@ -1171,11 +1195,29 @@ mod tests { assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto); } + #[test] + fn gamepad_pref_wire_and_names() { + for p in [ + GamepadPref::Auto, + GamepadPref::Xbox360, + GamepadPref::DualSense, + ] { + assert_eq!(GamepadPref::from_u8(p.to_u8()), p); + assert_eq!(GamepadPref::from_name(p.as_str()), Some(p)); + } + // Aliases + unknowns. + assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense)); + assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360)); + assert_eq!(GamepadPref::from_name("nope"), None); + // Unknown wire byte degrades to Auto (forward-compatible). + assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto); + } + #[test] fn hello_welcome_compositor_back_compat() { - // A new client/host appends one byte; the field is optional on decode, so a legacy - // peer's shorter message still decodes (compositor = Auto), and a legacy peer reading a - // new message ignores the trailing byte. Simulate both directions by truncation. + // Trailing optional bytes (compositor at 20/53, gamepad at 21/54): a legacy peer's + // shorter message still decodes (missing fields = Auto), and a legacy peer reading a + // new message ignores the trailing bytes. Simulate both directions by truncation. let h = Hello { abi_version: 2, mode: Mode { @@ -1184,13 +1226,19 @@ mod tests { refresh_hz: 60, }, compositor: CompositorPref::Mutter, + gamepad: GamepadPref::DualSense, }; let enc = h.encode(); - assert_eq!(enc.len(), 21); - // Legacy (20-byte) Hello → Auto, mode intact. + assert_eq!(enc.len(), 22); + // Legacy (20-byte) Hello → both Auto, mode intact. let legacy = Hello::decode(&enc[..20]).unwrap(); assert_eq!(legacy.compositor, CompositorPref::Auto); + assert_eq!(legacy.gamepad, GamepadPref::Auto); assert_eq!(legacy.mode, h.mode); + // Compositor-era (21-byte) Hello → compositor intact, gamepad Auto. + let mid = Hello::decode(&enc[..21]).unwrap(); + assert_eq!(mid.compositor, CompositorPref::Mutter); + assert_eq!(mid.gamepad, GamepadPref::Auto); let w = Welcome { abi_version: 2, @@ -1207,13 +1255,18 @@ mod tests { salt: [9, 8, 7, 6], frames: 0, compositor: CompositorPref::Kwin, + gamepad: GamepadPref::Xbox360, }; let wenc = w.encode(); - assert_eq!(wenc.len(), 54); + assert_eq!(wenc.len(), 55); let legacy_w = Welcome::decode(&wenc[..53]).unwrap(); assert_eq!(legacy_w.compositor, CompositorPref::Auto); + assert_eq!(legacy_w.gamepad, GamepadPref::Auto); assert_eq!(legacy_w.frames, 0); assert_eq!(legacy_w.key, w.key); + let mid_w = Welcome::decode(&wenc[..54]).unwrap(); + assert_eq!(mid_w.compositor, CompositorPref::Kwin); + assert_eq!(mid_w.gamepad, GamepadPref::Auto); } #[test] @@ -1257,6 +1310,7 @@ mod tests { refresh_hz: 60, }, compositor: CompositorPref::Auto, + gamepad: GamepadPref::Auto, } .encode(); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); diff --git a/crates/punktfunk-host/src/inject/dualsense.rs b/crates/punktfunk-host/src/inject/dualsense.rs index 68bdc67..bed9e0c 100644 --- a/crates/punktfunk-host/src/inject/dualsense.rs +++ b/crates/punktfunk-host/src/inject/dualsense.rs @@ -106,8 +106,6 @@ mod btn1 { /// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits). mod btn2 { pub const PS: u8 = 0x01; - /// Set from a touchpad-press rich event (no equivalent on the GameStream xpad). - #[allow(dead_code)] pub const TOUCHPAD: u8 = 0x02; #[allow(dead_code)] pub const MUTE: u8 = 0x04; @@ -227,6 +225,9 @@ impl DsState { if on(gs::BTN_GUIDE) { s.buttons[2] |= btn2::PS; } + if on(gs::BTN_TOUCHPAD) { + s.buttons[2] |= btn2::TOUCHPAD; + } s } @@ -247,10 +248,40 @@ impl DsState { } } +/// Serialize a full input report `0x01` (pure — unit-testable without `/dev/uhid`). Field +/// offsets per the kernel's `struct dualsense_input_report`, this report's one consumer: +/// x..rz 0-5, seq 6, buttons[4] 7-10, reserved[4] 11-14, gyro[3] 15-20, accel[3] 21-26, +/// sensor_timestamp 27-30, reserved2 31, points[2] 32-39 (static_assert(sizeof == 63)). +/// The report id occupies r[0], so struct offset N = r[N + 1]. +fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8, ts: u32) { + r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1]) + r[1] = st.lx; + r[2] = st.ly; + r[3] = st.rx; + r[4] = st.ry; + r[5] = st.l2; + r[6] = st.r2; + r[7] = seq; // seq_number (struct off 6) + r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons + r[9] = st.buttons[1]; // off 8 + r[10] = st.buttons[2]; // off 9 + r[11] = st.buttons[3]; // off 10 + for (i, v) in st.gyro.iter().enumerate() { + r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15 + } + for (i, v) in st.accel.iter().enumerate() { + r[22 + i * 2..24 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 21 + } + r[28..32].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 27) + pack_touch(&mut r[33..37], &st.touch[0]); // touch point 1 (struct off 32) + pack_touch(&mut r[37..41], &st.touch[1]); // touch point 2 +} + fn pack_touch(dst: &mut [u8], t: &Touch) { // byte0: bit7 = NOT active (1 = no contact), bits0-6 = contact id. dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 }; - let (x, y) = (t.x.min(DS_TOUCH_W), t.y.min(DS_TOUCH_H)); + // The kernel advertises ABS_MT ranges 0..=W-1 / 0..=H-1 — never emit the size itself. + let (x, y) = (t.x.min(DS_TOUCH_W - 1), t.y.min(DS_TOUCH_H - 1)); dst[1] = (x & 0xFF) as u8; dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4); dst[3] = ((y >> 4) & 0xFF) as u8; @@ -317,30 +348,10 @@ impl DualSensePad { /// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2). pub fn write_state(&mut self, st: &DsState) -> Result<()> { - let mut r = [0u8; DS_INPUT_REPORT_LEN]; - r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1]) - r[1] = st.lx; - r[2] = st.ly; - r[3] = st.rx; - r[4] = st.ry; - r[5] = st.l2; - r[6] = st.r2; self.seq = self.seq.wrapping_add(1); - r[7] = self.seq; // seq_number (struct off 6) - r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons - r[9] = st.buttons[1]; // off 8 - r[10] = st.buttons[2]; // off 9 - r[11] = st.buttons[3]; // off 10 - for (i, v) in st.gyro.iter().enumerate() { - r[15 + i * 2..17 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 14 - } - for (i, v) in st.accel.iter().enumerate() { - r[21 + i * 2..23 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 20 - } self.ts = self.ts.wrapping_add(1); // monotonic sensor timestamp is all the kernel needs - r[27..31].copy_from_slice(&self.ts.to_le_bytes()); // sensor_timestamp (struct off 26) - pack_touch(&mut r[34..38], &st.touch[0]); // touch point 1 (struct off 33) - pack_touch(&mut r[38..42], &st.touch[1]); // touch point 2 + let mut r = [0u8; DS_INPUT_REPORT_LEN]; + serialize_state(&mut r, st, self.seq, self.ts); let mut ev = [0u8; UHID_EVENT_SIZE]; ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); @@ -413,38 +424,55 @@ impl Drop for DualSensePad { /// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is /// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB, /// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client. +/// +/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1` +/// at data[2]) — writers only set the bits for fields they mean to change (the kernel zeroes +/// the rest), so an ungated parse would turn every plain rumble write into a lightbar-off + +/// triggers-off broadcast. fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) { // data[0] is the report id (0x02). Be defensive about short reports. if data.first() != Some(&0x02) || data.len() < 48 { return; } + let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2 + let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators // Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at // data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer, - // and route to the universal rumble plane (0xCA). We don't gate on the report's valid-flags - // (matching the LED/trigger handling) — the manager only forwards a *change*, so a report - // that touches only the LED doesn't spam a rumble-stop. - let high = (data[3] as u16) << 8; - let low = (data[4] as u16) << 8; - fb.rumble = Some((low, high)); + // and route to the universal rumble plane (0xCA). + if flag0 & 0x03 != 0 { + let high = (data[3] as u16) << 8; + let low = (data[4] as u16) << 8; + fb.rumble = Some((low, high)); + } // Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44. - let (r, g, b) = (data[45], data[46], data[47]); - fb.hidout.push(HidOutput::Led { pad, r, g, b }); - fb.hidout.push(HidOutput::PlayerLeds { - pad, - bits: data[44] & 0x1F, - }); - // Adaptive-trigger parameter blocks: L2 at bytes 11..22, R2 at 22..33 (11 bytes each). + if flag1 & 0x04 != 0 { + let (r, g, b) = (data[45], data[46], data[47]); + fb.hidout.push(HidOutput::Led { pad, r, g, b }); + } + if flag1 & 0x10 != 0 { + fb.hidout.push(HidOutput::PlayerLeds { + pad, + bits: data[44] & 0x1F, + }); + } + // Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the + // report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's + // ps5.hpp. Wire convention: which 0 = L2, 1 = R2. if data.len() >= 33 { - fb.hidout.push(HidOutput::Trigger { - pad, - which: 0, - effect: data[11..22].to_vec(), - }); - fb.hidout.push(HidOutput::Trigger { - pad, - which: 1, - effect: data[22..33].to_vec(), - }); + if flag0 & 0x04 != 0 { + fb.hidout.push(HidOutput::Trigger { + pad, + which: 1, + effect: data[11..22].to_vec(), + }); + } + if flag0 & 0x08 != 0 { + fb.hidout.push(HidOutput::Trigger { + pad, + which: 0, + effect: data[22..33].to_vec(), + }); + } } } @@ -553,9 +581,10 @@ impl DualSenseManager { let t = &mut self.state[idx].touch[slot]; t.active = active; t.id = slot as u8; - // Normalized 0..=65535 → the touchpad's reported resolution. - t.x = ((x as u32 * DS_TOUCH_W as u32) / u16::MAX as u32) as u16; - t.y = ((y as u32 * DS_TOUCH_H as u32) / u16::MAX as u32) as u16; + // Normalized 0..=65535 → the touchpad's coordinate range (0..=W-1 / 0..=H-1, + // what the kernel advertises as the ABS_MT extents). + t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16; + t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16; } RichInput::Motion { gyro, accel, .. } => { self.state[idx].gyro = gyro; @@ -621,14 +650,19 @@ impl DualSenseManager { mod tests { use super::*; - /// A DualSense USB output report (`0x02`) parses into motor rumble (0xCA), lightbar, player - /// LEDs, and both adaptive-trigger blocks (0xCD). + /// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor + /// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with + /// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2). #[test] fn parse_output_report() { let mut data = vec![0u8; 48]; data[0] = 0x02; // report id + data[1] = 0x0F; // valid_flag0: vibration + haptics + R2 + L2 triggers + data[2] = 0x14; // valid_flag1: lightbar + player indicators data[3] = 0x80; // right (high-freq) motor data[4] = 0x40; // left (low-freq) motor + data[11] = 0x21; // right-trigger block mode byte (report bytes 11..22) + data[22] = 0x26; // left-trigger block mode byte (report bytes 22..33) data[44] = 0x03; // player LEDs (low 5 bits) data[45] = 10; // R data[46] = 20; // G @@ -646,13 +680,86 @@ mod tests { assert!(fb .hidout .contains(&HidOutput::PlayerLeds { pad: 0, bits: 3 })); - assert_eq!( - fb.hidout - .iter() - .filter(|h| matches!(h, HidOutput::Trigger { .. })) - .count(), - 2 - ); + // The report's FIRST block (bytes 11..22) is the RIGHT trigger → wire which = 1. + let triggers: Vec<_> = fb + .hidout + .iter() + .filter_map(|h| match h { + HidOutput::Trigger { which, effect, .. } => Some((*which, effect[0])), + _ => None, + }) + .collect(); + assert_eq!(triggers, vec![(1, 0x21), (0, 0x26)]); + } + + /// Writers set only the valid-flag bits for the fields they mean to change (the kernel + /// zeroes the rest of the report) — a plain rumble write must NOT blank the lightbar / + /// player LEDs / triggers, and an LED-only write must not stop the motors. + #[test] + fn parse_output_respects_valid_flags() { + // Kernel-style rumble write: only the vibration flags set, everything else zero. + let mut data = vec![0u8; 48]; + data[0] = 0x02; + data[1] = 0x03; // compatible vibration + haptics select + data[3] = 0xFF; + data[4] = 0xFF; + let mut fb = DsFeedback::default(); + parse_ds_output(0, &data, &mut fb); + assert_eq!(fb.rumble, Some((0xFF00, 0xFF00))); + assert!(fb.hidout.is_empty(), "rumble write must not emit hidout"); + + // Lightbar-only write: no rumble surfaced (would otherwise spam rumble-stops). + let mut data = vec![0u8; 48]; + data[0] = 0x02; + data[2] = 0x04; // lightbar control enable + data[45] = 1; + let mut fb = DsFeedback::default(); + parse_ds_output(0, &data, &mut fb); + assert!(fb.rumble.is_none()); + assert_eq!(fb.hidout.len(), 1); + assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. })); + } + + /// The input report's sensor/touch bytes must land exactly where the kernel's + /// `struct dualsense_input_report` reads them (gyro at struct offset 15, accel 21, + /// timestamp 27, touch points 32 — report byte = struct offset + 1). A one-byte slip + /// here turns client motion into noise and conjures phantom touch contacts. + #[test] + fn input_report_layout_matches_hid_playstation() { + let mut st = DsState::neutral(); + st.gyro = [0x1122, 0x3344, 0x5566]; + st.accel = [0x778, 0x99A, 0xBBC]; + st.touch[0] = Touch { + active: true, + id: 5, + x: 0x123, + y: 0x356, + }; + // touch[1] stays inactive — its NOT-active bit must be set. + let mut r = [0u8; DS_INPUT_REPORT_LEN]; + serialize_state(&mut r, &st, 7, 0xAABBCCDD); + assert_eq!(r[0], 0x01); + assert_eq!(r[7], 7); // seq_number (struct off 6) + assert_eq!(&r[16..22], &[0x22, 0x11, 0x44, 0x33, 0x66, 0x55]); // gyro LE + assert_eq!(&r[22..28], &[0x78, 0x07, 0x9A, 0x09, 0xBC, 0x0B]); // accel LE + assert_eq!(&r[28..32], &[0xDD, 0xCC, 0xBB, 0xAA]); // sensor_timestamp LE + // Touch point 1 at struct off 32 = r[33..37]: contact byte (active → bit7 clear), + // then 12-bit x / 12-bit y packed. + assert_eq!(r[33], 5); + assert_eq!(r[34], 0x23); + assert_eq!(r[35], 0x61); // x_hi nibble 0x1 | (y & 0xF) << 4 (y=0x356 → 0x6 << 4) + assert_eq!(r[36], 0x35); // y >> 4 + assert_eq!(r[37] & 0x80, 0x80); // touch point 2 inactive + } + + /// The wire touchpad-click bit (Moonlight's extended position) lands in `buttons[2]`. + #[test] + fn from_gamepad_maps_touchpad_click() { + use punktfunk_core::input::gamepad as gs; + let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0); + assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD); + let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0); + assert_eq!(s.buttons[2], 0); } /// A short / wrong-id report yields nothing. diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 97b4590..d7391d3 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -23,7 +23,7 @@ //! with GameStream pairing) and logs the SHA-256 fingerprint clients pin. use anyhow::{anyhow, Context, Result}; -use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, Role}; +use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role}; use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::packet::{FLAG_PIC, FLAG_SOF}; use punktfunk_core::quic::{ @@ -387,6 +387,10 @@ async fn serve_session( M3Source::Synthetic => None, }; + // Resolve the client's gamepad-backend preference (pure env/cfg check — no probing + // needed; the actual pads are created lazily by the input thread). + let gamepad = resolve_gamepad(hello.gamepad); + // Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport). let probe = std::net::UdpSocket::bind("0.0.0.0:0")?; let udp_port = probe.local_addr()?.port(); @@ -412,10 +416,12 @@ async fn serve_session( M3Source::Synthetic => frames, M3Source::Virtual => 0, // unbounded — client streams until we close }, - // Report the resolved backend back to the client (Auto for the synthetic source). + // Report the resolved backends back to the client (compositor: Auto for the + // synthetic source). compositor: compositor .map(|c| c.as_pref()) .unwrap_or(CompositorPref::Auto), + gamepad, }; io::write_msg(&mut send, &welcome.encode()).await?; @@ -434,6 +440,7 @@ async fn serve_session( udp_port, mode = ?hello.mode, compositor = compositor.map(|c| c.id()).unwrap_or("none"), + gamepad = welcome.gamepad.as_str(), "handshake complete — streaming" ); @@ -483,9 +490,10 @@ async fn serve_session( let (rich_tx, rich_rx) = std::sync::mpsc::channel::(); let input_handle = { let conn = conn.clone(); + let gamepad = welcome.gamepad; std::thread::Builder::new() .name("punktfunk-m3-input".into()) - .spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx)) + .spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx, gamepad)) .context("spawn input thread")? }; // One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops @@ -549,6 +557,37 @@ async fn serve_session( None }; + // Test hook (synthetic source only): a scripted feedback burst on the host→client + // planes — rumble (0xCA) + DualSense HID-output (0xCD) — so loopback tests can assert + // the client's feedback path without a real game writing output reports to a real pad. + if opts.source == M3Source::Synthetic + && std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1") + { + use punktfunk_core::quic::HidOutput; + let d = punktfunk_core::quic::encode_rumble_datagram(0, 0x4000, 0x8000); + let _ = conn.send_datagram(d.to_vec().into()); + for h in [ + HidOutput::Led { + pad: 0, + r: 10, + g: 20, + b: 30, + }, + HidOutput::PlayerLeds { + pad: 0, + bits: 0b00100, + }, + HidOutput::Trigger { + pad: 0, + which: 1, + effect: vec![0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }, + ] { + let _ = conn.send_datagram(h.encode().into()); + } + tracing::info!("PUNKTFUNK_TEST_FEEDBACK: scripted rumble + hidout burst sent"); + } + // Data plane on a native thread (no async on the hot path — design invariant). let cfg = welcome.session_config(Role::Host); let source = opts.source; @@ -854,12 +893,16 @@ enum PadBackend { } impl PadBackend { - fn select() -> PadBackend { + /// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference, + /// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only + /// ever construct the X-Box backend, whatever the resolution said. + fn select(kind: GamepadPref) -> PadBackend { #[cfg(target_os = "linux")] - if std::env::var("PUNKTFUNK_GAMEPAD").as_deref() == Ok("dualsense") { + if kind == GamepadPref::DualSense { tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)"); return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new()); } + let _ = kind; PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new()) } @@ -900,19 +943,20 @@ impl PadBackend { } /// The per-session input thread: route pointer/keyboard events to the host-lifetime injector -/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (uinput X-Box pads or, -/// with `PUNKTFUNK_GAMEPAD=dualsense`, virtual DualSense pads), with rich client→host input -/// (touchpad / motion, `rich_rx`) merged in and feedback pumped between events — rumble on the -/// universal datagram plane, DualSense LED/trigger feedback on the HID-output plane. The gamepads -/// are created and torn down with the session; the pointer/keyboard injector (and its portal -/// grant) lives in the service, across sessions. +/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the +/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich +/// client→host input (touchpad / motion, `rich_rx`) merged in and feedback pumped between +/// events — rumble on the universal datagram plane, DualSense LED/trigger feedback on the +/// HID-output plane. The gamepads are created and torn down with the session; the +/// pointer/keyboard injector (and its portal grant) lives in the service, across sessions. fn input_thread( rx: std::sync::mpsc::Receiver, rich_rx: std::sync::mpsc::Receiver, conn: quinn::Connection, inj_tx: std::sync::mpsc::Sender, + gamepad: GamepadPref, ) { - let mut pads = PadBackend::select(); + let mut pads = PadBackend::select(gamepad); let mut pad_state = [PadState::default(); MAX_WIRE_PADS]; let mut pad_mask = 0u16; // Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams), @@ -1079,6 +1123,56 @@ fn synthetic_stream(session: &mut Session, frames: u32, stop: &AtomicBool) -> Re Ok(()) } +/// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins, +/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360. The +/// DualSense backend needs Linux UHID — when unavailable any DualSense wish degrades to +/// X-Box 360 (never an error: a session without rich pads still streams). +fn pick_gamepad(pref: GamepadPref, env: Option<&str>, dualsense_available: bool) -> GamepadPref { + let want = match pref { + GamepadPref::Auto => env + .and_then(GamepadPref::from_name) + .unwrap_or(GamepadPref::Auto), + explicit => explicit, + }; + match want { + GamepadPref::DualSense if dualsense_available => GamepadPref::DualSense, + _ => GamepadPref::Xbox360, + } +} + +/// Resolve the client's gamepad-backend preference (the env/logging shell around +/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. +fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { + let env = std::env::var("PUNKTFUNK_GAMEPAD").ok(); + let chosen = pick_gamepad(pref, env.as_deref(), cfg!(target_os = "linux")); + match pref { + GamepadPref::Auto => { + // The operator's env knob deserves a diagnostic when it didn't drive the + // choice — a typo, or a DualSense wish on a non-UHID host, would otherwise + // degrade silently. + if let Some(env) = env.as_deref() { + if GamepadPref::from_name(env) != Some(chosen) { + tracing::warn!( + env, + chosen = chosen.as_str(), + "PUNKTFUNK_GAMEPAD unrecognized or unavailable — falling back" + ); + } + } + tracing::info!(gamepad = chosen.as_str(), "gamepad backend (client: auto)") + } + want if want == chosen => { + tracing::info!(gamepad = chosen.as_str(), "honoring client gamepad request") + } + want => tracing::warn!( + requested = want.as_str(), + chosen = chosen.as_str(), + "client-requested gamepad backend unavailable — falling back" + ), + } + chosen +} + /// Pure selection: choose the backend to drive from the client's `pref`, the set `available` /// right now, and the auto-`detected` default. A concrete preference wins only if it's available; /// otherwise (and for `Auto`) fall back to the detected default. `None` only when nothing is @@ -1358,6 +1452,23 @@ mod tests { ); } + #[test] + fn gamepad_resolution_precedence() { + use GamepadPref::*; + // An explicit client choice wins over the env var. + assert_eq!(pick_gamepad(DualSense, Some("xbox360"), true), DualSense); + assert_eq!(pick_gamepad(Xbox360, Some("dualsense"), true), Xbox360); + // Client Auto defers to the env var. + assert_eq!(pick_gamepad(Auto, Some("dualsense"), true), DualSense); + assert_eq!(pick_gamepad(Auto, Some("xbox360"), true), Xbox360); + // Auto + no env (or an unparseable one) → X-Box 360. + assert_eq!(pick_gamepad(Auto, None, true), Xbox360); + assert_eq!(pick_gamepad(Auto, Some("bogus"), true), Xbox360); + // DualSense degrades to X-Box 360 where the backend doesn't exist (non-Linux). + assert_eq!(pick_gamepad(DualSense, None, false), Xbox360); + assert_eq!(pick_gamepad(Auto, Some("dualsense"), false), Xbox360); + } + #[test] fn permanent_errors_short_circuit_retry() { // Permanent: config / version / missing-tool — retrying within a session can't fix these. @@ -1650,6 +1761,7 @@ mod tests { 19778, mode, CompositorPref::Auto, + GamepadPref::Auto, None, None, timeout @@ -1673,12 +1785,17 @@ mod tests { 19778, mode, CompositorPref::Auto, + GamepadPref::Auto, Some(host_fp), Some((cert.clone(), key.clone())), timeout, ) .expect("paired session"); assert_eq!(client.host_fingerprint, host_fp); + // The Welcome always reports a CONCRETE resolved gamepad backend. (Not asserted + // against a specific one: resolve_gamepad honors an ambient PUNKTFUNK_GAMEPAD — + // a dev box exporting it must not fail the suite.) + assert_ne!(client.resolved_gamepad, GamepadPref::Auto); drop(client); host.join().unwrap().unwrap(); diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 1eadfd7..bb8acbb 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -55,6 +55,21 @@ // gamescope (spawned nested). #define PUNKTFUNK_COMPOSITOR_GAMESCOPE 4 +// Gamepad-backend preference for [`punktfunk_connect_ex2`] (`gamepad` arg): which virtual pad +// the host creates for this session's controllers. Precedence host-side: an explicit client +// choice > the host's `PUNKTFUNK_GAMEPAD` env var > X-Box 360. `AUTO` (or any unrecognized +// value) = host decides. The resolved choice is echoed over the protocol (`Welcome`) and +// readable via [`punktfunk_connection_gamepad`]. +#define PUNKTFUNK_GAMEPAD_AUTO 0 + +// uinput X-Box 360 pad (the universal default — every game speaks XInput). +#define PUNKTFUNK_GAMEPAD_XBOX360 1 + +// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion — +// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored +// only where available (Linux hosts); otherwise the host falls back to X-Box 360. +#define PUNKTFUNK_GAMEPAD_DUALSENSE 2 + // 16-byte AEAD authentication tag appended by GCM. #define TAG_LEN 16 @@ -94,6 +109,11 @@ #define PUNKTFUNK_BTN_Y 32768 +// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2` +// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on +// the same bit. Only the DualSense backend renders it; the xpad has no such button. +#define PUNKTFUNK_BTN_TOUCHPAD 1048576 + // Axis ids for `InputKind::GamepadAxis`. #define PUNKTFUNK_AXIS_LS_X 0 @@ -501,6 +521,7 @@ PunktfunkConnection *punktfunk_connect(const char *host, // the `PUNKTFUNK_COMPOSITOR_*` values). `PUNKTFUNK_COMPOSITOR_AUTO` (or any unrecognized value) // lets the host decide; a concrete value is honored only if available, else the host falls back // to auto-detect. The resolved choice is logged host-side and returned over the protocol. +// Equivalent to [`punktfunk_connect_ex2`] with `gamepad = PUNKTFUNK_GAMEPAD_AUTO`. // // # Safety // Same as [`punktfunk_connect`]. @@ -517,6 +538,30 @@ PunktfunkConnection *punktfunk_connect_ex(const char *host, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Like [`punktfunk_connect_ex`], but additionally requests which virtual `gamepad` backend the +// host creates for this session's pads (one of the `PUNKTFUNK_GAMEPAD_*` values). +// `PUNKTFUNK_GAMEPAD_AUTO` (or any unrecognized value) lets the host decide (its +// `PUNKTFUNK_GAMEPAD` env var, else X-Box 360); a concrete value is honored only if that +// backend is available on the host. The resolved choice is readable via +// [`punktfunk_connection_gamepad`] — only a DualSense session emits HID-output feedback. +// +// # Safety +// Same as [`punktfunk_connect`]. +PunktfunkConnection *punktfunk_connect_ex2(const char *host, + uint16_t port, + uint32_t width, + uint32_t height, + uint32_t refresh_hz, + uint32_t compositor, + uint32_t gamepad, + const uint8_t *pin_sha256, + uint8_t *observed_sha256_out, + const char *client_cert_pem, + const char *client_key_pem, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Generate a persistent client identity: a self-signed certificate + private key, both // PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both @@ -659,6 +704,17 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c, uint32_t *refresh_hz); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// The virtual gamepad backend the host actually resolved for this session (one of the +// `PUNKTFUNK_GAMEPAD_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex2`] +// preference). `PUNKTFUNK_GAMEPAD_AUTO` = an older host that didn't say — assume X-Box 360, +// no HID-output feedback. Safe any time after connect. +// +// # Safety +// `c` is a valid connection handle; `gamepad` is writable (NULL is skipped). +PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Ask the host to switch the live session to `width`x`height`@`refresh_hz` without // reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the