feat(gamepad): controller discovery + client-negotiated pad type + rich DualSense end to end
The Apple client grows full gamepad support and punktfunk/1 learns to negotiate the virtual pad type: - Protocol: Hello carries a GamepadPref byte (offset 21, the same trailing-byte back-compat pattern as the compositor; echoed resolved in Welcome at 54). Host precedence: explicit client choice > PUNKTFUNK_GAMEPAD env > Xbox 360, DualSense (UHID) only where available. ABI: punktfunk_connect_ex2 + punktfunk_connection_gamepad (connect_ex delegates; ABI_VERSION stays 2 — the trailing byte IS the compat mechanism). punktfunk-client-rs gets --gamepad. - Swift client: GamepadManager (app-lifetime discovery + selection — Settings lists every controller with capabilities/battery/"In use"; exactly ONE pad forwards as pad 0, auto = most recently connected, or pinned), GamepadCapture (snapshot-diff button/axis events, DualSense touchpad + ~250 Hz motion on the rich-input plane, held state released on switch/deactivate/stop), GamepadFeedback (rumble → CoreHaptics per-handle engines; lightbar → GCDeviceLight; player LEDs → playerIndex; adaptive-trigger blocks → the table-driven DualSenseTriggerEffect parser → GCDualSenseAdaptiveTrigger, exact for the 10-zone positional modes). The pad type auto-resolves from the physical controller at connect time, user-overridable in Settings. - Host DualSense fixes surfaced by adversarial review against hid-playstation / SDL / Nielk1 ground truth: input-report sensor/touch offsets were off by one (the kernel read garbage motion + phantom touches), the L2/R2 trigger blocks were swapped (the report is right-trigger-first), feedback now gates on the report's valid-flags (a plain rumble write no longer blanks lightbar/ triggers), and the touchpad rescale clamps to the advertised ABS_MT extents. - Tests: Hello/Welcome trailing-byte back-compat, pick_gamepad precedence, byte-exact input-report layout, valid-flag gating, per-mode trigger-parser table (incl. packed 3-bit zones), wire conversions, and a scripted loopback feedback burst (PUNKTFUNK_TEST_FEEDBACK=1) asserted through the xcframework on the rumble + HID-output planes. Validated: cargo test/clippy/fmt green on macOS + Linux (61 host tests), swift build/test green, test-loopback.sh green, tvOS/iOS targets compile. DualSense motion sign/scale is derived from the calibration blob, not yet live-verified (constants isolated in GamepadWire). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
+49
-14
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>) -> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Mode> {
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<u8>)>();
|
||||
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||
let (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::<Mode>();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(Mode, [u8; 32])>>();
|
||||
let (ready_tx, ready_rx) =
|
||||
std::sync::mpsc::channel::<Result<(Mode, GamepadPref, [u8; 32])>>();
|
||||
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<Frame>,
|
||||
@@ -374,7 +383,7 @@ struct WorkerArgs {
|
||||
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||
reconfig_rx: tokio::sync::mpsc::UnboundedReceiver<Mode>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, [u8; 32])>>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, GamepadPref, [u8; 32])>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||
}
|
||||
@@ -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 {
|
||||
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, fingerprint)));
|
||||
let _ = ready_tx.send(Ok((negotiated, resolved_gamepad, fingerprint)));
|
||||
|
||||
// Input task: embedder events → QUIC datagrams.
|
||||
let input_conn = conn.clone();
|
||||
|
||||
@@ -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<Self> {
|
||||
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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u8> {
|
||||
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<Welcome> {
|
||||
// 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");
|
||||
|
||||
@@ -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,40 +424,57 @@ 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.
|
||||
// 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.
|
||||
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: L2 at bytes 11..22, R2 at 22..33 (11 bytes each).
|
||||
}
|
||||
// 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(),
|
||||
});
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the rich-controller analog of
|
||||
/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`.
|
||||
@@ -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
|
||||
// The report's FIRST block (bytes 11..22) is the RIGHT trigger → wire which = 1.
|
||||
let triggers: Vec<_> = fb
|
||||
.hidout
|
||||
.iter()
|
||||
.filter(|h| matches!(h, HidOutput::Trigger { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
.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.
|
||||
|
||||
+129
-12
@@ -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::<punktfunk_core::quic::RichInput>();
|
||||
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<InputEvent>,
|
||||
rich_rx: std::sync::mpsc::Receiver<punktfunk_core::quic::RichInput>,
|
||||
conn: quinn::Connection,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user