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:
2026-06-11 16:28:33 +02:00
parent d86896da16
commit 1d605fb781
24 changed files with 2321 additions and 142 deletions
+14 -2
View File
@@ -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
View File
@@ -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 56.)
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 1121
// for L2 / 2232 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 @@
// Hostclient 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 14),
// 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), GCDualSense 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 hostclient 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 {
+4 -1
View File
@@ -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
+23 -1
View File
@@ -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"
);
+1
View File
@@ -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"
+93 -1
View File
@@ -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
+46 -14
View File
@@ -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 {
Ok(t) => t,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
};
let _ = ready_tx.send(Ok((negotiated, fingerprint)));
let (
conn,
mut session,
mut ctrl_send,
mut ctrl_recv,
negotiated,
resolved_gamepad,
fingerprint,
) = match setup.await {
Ok(t) => t,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
};
let _ = ready_tx.send(Ok((negotiated, resolved_gamepad, fingerprint)));
// Input task: embedder events → QUIC datagrams.
let input_conn = conn.clone();
+61
View File
@@ -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)]
+4
View File
@@ -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;
+66 -12
View File
@@ -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");
+167 -60
View File
@@ -106,8 +106,6 @@ mod btn1 {
/// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits).
mod btn2 {
pub const PS: u8 = 0x01;
/// Set from a touchpad-press rich event (no equivalent on the GameStream xpad).
#[allow(dead_code)]
pub const TOUCHPAD: u8 = 0x02;
#[allow(dead_code)]
pub const MUTE: u8 = 0x04;
@@ -227,6 +225,9 @@ impl DsState {
if on(gs::BTN_GUIDE) {
s.buttons[2] |= btn2::PS;
}
if on(gs::BTN_TOUCHPAD) {
s.buttons[2] |= btn2::TOUCHPAD;
}
s
}
@@ -247,10 +248,40 @@ impl DsState {
}
}
/// Serialize a full input report `0x01` (pure — unit-testable without `/dev/uhid`). Field
/// offsets per the kernel's `struct dualsense_input_report`, this report's one consumer:
/// x..rz 0-5, seq 6, buttons[4] 7-10, reserved[4] 11-14, gyro[3] 15-20, accel[3] 21-26,
/// sensor_timestamp 27-30, reserved2 31, points[2] 32-39 (static_assert(sizeof == 63)).
/// The report id occupies r[0], so struct offset N = r[N + 1].
fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8, ts: u32) {
r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1])
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = st.l2;
r[6] = st.r2;
r[7] = seq; // seq_number (struct off 6)
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
r[9] = st.buttons[1]; // off 8
r[10] = st.buttons[2]; // off 9
r[11] = st.buttons[3]; // off 10
for (i, v) in st.gyro.iter().enumerate() {
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
}
for (i, v) in st.accel.iter().enumerate() {
r[22 + i * 2..24 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 21
}
r[28..32].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 27)
pack_touch(&mut r[33..37], &st.touch[0]); // touch point 1 (struct off 32)
pack_touch(&mut r[37..41], &st.touch[1]); // touch point 2
}
fn pack_touch(dst: &mut [u8], t: &Touch) {
// byte0: bit7 = NOT active (1 = no contact), bits0-6 = contact id.
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
let (x, y) = (t.x.min(DS_TOUCH_W), t.y.min(DS_TOUCH_H));
// The kernel advertises ABS_MT ranges 0..=W-1 / 0..=H-1 — never emit the size itself.
let (x, y) = (t.x.min(DS_TOUCH_W - 1), t.y.min(DS_TOUCH_H - 1));
dst[1] = (x & 0xFF) as u8;
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
dst[3] = ((y >> 4) & 0xFF) as u8;
@@ -317,30 +348,10 @@ impl DualSensePad {
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
let mut r = [0u8; DS_INPUT_REPORT_LEN];
r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1])
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = st.l2;
r[6] = st.r2;
self.seq = self.seq.wrapping_add(1);
r[7] = self.seq; // seq_number (struct off 6)
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
r[9] = st.buttons[1]; // off 8
r[10] = st.buttons[2]; // off 9
r[11] = st.buttons[3]; // off 10
for (i, v) in st.gyro.iter().enumerate() {
r[15 + i * 2..17 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 14
}
for (i, v) in st.accel.iter().enumerate() {
r[21 + i * 2..23 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 20
}
self.ts = self.ts.wrapping_add(1); // monotonic sensor timestamp is all the kernel needs
r[27..31].copy_from_slice(&self.ts.to_le_bytes()); // sensor_timestamp (struct off 26)
pack_touch(&mut r[34..38], &st.touch[0]); // touch point 1 (struct off 33)
pack_touch(&mut r[38..42], &st.touch[1]); // touch point 2
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts);
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
@@ -413,38 +424,55 @@ impl Drop for DualSensePad {
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the kernel zeroes
/// the rest), so an ungated parse would turn every plain rumble write into a lightbar-off +
/// triggers-off broadcast.
fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
// data[0] is the report id (0x02). Be defensive about short reports.
if data.first() != Some(&0x02) || data.len() < 48 {
return;
}
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
// and route to the universal rumble plane (0xCA). We don't gate on the report's valid-flags
// (matching the LED/trigger handling) — the manager only forwards a *change*, so a report
// that touches only the LED doesn't spam a rumble-stop.
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
// and route to the universal rumble plane (0xCA).
if flag0 & 0x03 != 0 {
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
}
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
let (r, g, b) = (data[45], data[46], data[47]);
fb.hidout.push(HidOutput::Led { pad, r, g, b });
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: data[44] & 0x1F,
});
// Adaptive-trigger parameter blocks: L2 at bytes 11..22, R2 at 22..33 (11 bytes each).
if flag1 & 0x04 != 0 {
let (r, g, b) = (data[45], data[46], data[47]);
fb.hidout.push(HidOutput::Led { pad, r, g, b });
}
if flag1 & 0x10 != 0 {
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: data[44] & 0x1F,
});
}
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
if data.len() >= 33 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 0,
effect: data[11..22].to_vec(),
});
fb.hidout.push(HidOutput::Trigger {
pad,
which: 1,
effect: data[22..33].to_vec(),
});
if flag0 & 0x04 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 1,
effect: data[11..22].to_vec(),
});
}
if flag0 & 0x08 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 0,
effect: data[22..33].to_vec(),
});
}
}
}
@@ -553,9 +581,10 @@ impl DualSenseManager {
let t = &mut self.state[idx].touch[slot];
t.active = active;
t.id = slot as u8;
// Normalized 0..=65535 → the touchpad's reported resolution.
t.x = ((x as u32 * DS_TOUCH_W as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * DS_TOUCH_H as u32) / u16::MAX as u32) as u16;
// Normalized 0..=65535 → the touchpad's coordinate range (0..=W-1 / 0..=H-1,
// what the kernel advertises as the ABS_MT extents).
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
}
RichInput::Motion { gyro, accel, .. } => {
self.state[idx].gyro = gyro;
@@ -621,14 +650,19 @@ impl DualSenseManager {
mod tests {
use super::*;
/// A DualSense USB output report (`0x02`) parses into motor rumble (0xCA), lightbar, player
/// LEDs, and both adaptive-trigger blocks (0xCD).
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
#[test]
fn parse_output_report() {
let mut data = vec![0u8; 48];
data[0] = 0x02; // report id
data[1] = 0x0F; // valid_flag0: vibration + haptics + R2 + L2 triggers
data[2] = 0x14; // valid_flag1: lightbar + player indicators
data[3] = 0x80; // right (high-freq) motor
data[4] = 0x40; // left (low-freq) motor
data[11] = 0x21; // right-trigger block mode byte (report bytes 11..22)
data[22] = 0x26; // left-trigger block mode byte (report bytes 22..33)
data[44] = 0x03; // player LEDs (low 5 bits)
data[45] = 10; // R
data[46] = 20; // G
@@ -646,13 +680,86 @@ mod tests {
assert!(fb
.hidout
.contains(&HidOutput::PlayerLeds { pad: 0, bits: 3 }));
assert_eq!(
fb.hidout
.iter()
.filter(|h| matches!(h, HidOutput::Trigger { .. }))
.count(),
2
);
// The report's FIRST block (bytes 11..22) is the RIGHT trigger → wire which = 1.
let triggers: Vec<_> = fb
.hidout
.iter()
.filter_map(|h| match h {
HidOutput::Trigger { which, effect, .. } => Some((*which, effect[0])),
_ => None,
})
.collect();
assert_eq!(triggers, vec![(1, 0x21), (0, 0x26)]);
}
/// Writers set only the valid-flag bits for the fields they mean to change (the kernel
/// zeroes the rest of the report) — a plain rumble write must NOT blank the lightbar /
/// player LEDs / triggers, and an LED-only write must not stop the motors.
#[test]
fn parse_output_respects_valid_flags() {
// Kernel-style rumble write: only the vibration flags set, everything else zero.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0x03; // compatible vibration + haptics select
data[3] = 0xFF;
data[4] = 0xFF;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(fb.rumble, Some((0xFF00, 0xFF00)));
assert!(fb.hidout.is_empty(), "rumble write must not emit hidout");
// Lightbar-only write: no rumble surfaced (would otherwise spam rumble-stops).
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[2] = 0x04; // lightbar control enable
data[45] = 1;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert!(fb.rumble.is_none());
assert_eq!(fb.hidout.len(), 1);
assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. }));
}
/// The input report's sensor/touch bytes must land exactly where the kernel's
/// `struct dualsense_input_report` reads them (gyro at struct offset 15, accel 21,
/// timestamp 27, touch points 32 — report byte = struct offset + 1). A one-byte slip
/// here turns client motion into noise and conjures phantom touch contacts.
#[test]
fn input_report_layout_matches_hid_playstation() {
let mut st = DsState::neutral();
st.gyro = [0x1122, 0x3344, 0x5566];
st.accel = [0x778, 0x99A, 0xBBC];
st.touch[0] = Touch {
active: true,
id: 5,
x: 0x123,
y: 0x356,
};
// touch[1] stays inactive — its NOT-active bit must be set.
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &st, 7, 0xAABBCCDD);
assert_eq!(r[0], 0x01);
assert_eq!(r[7], 7); // seq_number (struct off 6)
assert_eq!(&r[16..22], &[0x22, 0x11, 0x44, 0x33, 0x66, 0x55]); // gyro LE
assert_eq!(&r[22..28], &[0x78, 0x07, 0x9A, 0x09, 0xBC, 0x0B]); // accel LE
assert_eq!(&r[28..32], &[0xDD, 0xCC, 0xBB, 0xAA]); // sensor_timestamp LE
// Touch point 1 at struct off 32 = r[33..37]: contact byte (active → bit7 clear),
// then 12-bit x / 12-bit y packed.
assert_eq!(r[33], 5);
assert_eq!(r[34], 0x23);
assert_eq!(r[35], 0x61); // x_hi nibble 0x1 | (y & 0xF) << 4 (y=0x356 → 0x6 << 4)
assert_eq!(r[36], 0x35); // y >> 4
assert_eq!(r[37] & 0x80, 0x80); // touch point 2 inactive
}
/// The wire touchpad-click bit (Moonlight's extended position) lands in `buttons[2]`.
#[test]
fn from_gamepad_maps_touchpad_click() {
use punktfunk_core::input::gamepad as gs;
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD);
let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], 0);
}
/// A short / wrong-id report yields nothing.
+129 -12
View File
@@ -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();
+56
View File
@@ -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