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
@@ -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,