133e25849d
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit: Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split along the same seams. The gamepad mode is couch-complete, and now on macOS too (the living-room Mac case), not just iOS/iPadOS: - GamepadSettingsView: a console-style, fully controller-navigable settings screen (X from the launcher) — up/down moves focus, left/right steps values (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a one-line description. Backed by GamepadMenuList, the vertical sibling of GamepadCarousel, and SettingsOptions — the option lists hoisted out of SettingsView statics and shared by the touch, tvOS and gamepad settings. - GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad — field rows open an on-screen controller keyboard (dpad grid, A types, X backspaces, B done); the launcher carousel ends in an Add Host tile, so the dead-end "add one with touch first" empty state is gone. - Launcher polish: contextual hint bar with the pad's real button glyphs, controller name + battery chip, one shared console chrome. - GamepadScreenBackground: an animated aurora (TimelineView-driven drifting blobs in the brand's violet family, breathing radii, slow hue shift, legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a .metal library only bundles reliably in one of the two build systems (SPM vs the xcodeproj's synced folders) these sources compile under. - macOS port: settings/add-host/library present as sized sheets (a macOS sheet takes its content's IDEAL size, and the GeometryReader-driven screens collapsed to nothing), NSScreen-based mode lists, scroll indicators .never (the "always show scroll bars" setting overrides .hidden), tray scrims so scrolled rows dim under the pinned title/hints, extra title clearance, and a PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/ library render-verified live on a real Mac + LAN hosts. - GamepadMenuInput: X button support, and (re)start now snapshots held buttons so a controller handoff press never fires twice (the B that closed the keyboard no longer also cancels the screen underneath). - Cleanups: one "Connection failed" alert in ContentView instead of one per home screen; HostDiscovery.advertises/unsaved shared by both home screens. - host: can_encode_444 stub for the non-Linux/Windows host build (the macOS synthetic-source loopback used by the Swift tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
9.9 KiB
Swift
189 lines
9.9 KiB
Swift
// DualSense adaptive-trigger effect parsing: the host forwards the raw 11-byte trigger
|
||
// parameter block a game wrote to its virtual DualSense (output report 0x02, bytes 11–21
|
||
// for L2 / 22–32 for R2: one mode byte + 10 parameter bytes). The mode values and layouts
|
||
// follow the community-established conventions (Nielk1's TriggerEffectGenerator, ds5w,
|
||
// inputtino) — Sony has never documented them. Parsing is TOTAL: any unknown or short
|
||
// block degrades to `.off`, never traps, so a game using an exotic raw mode can't break
|
||
// the session.
|
||
//
|
||
// `parse` is a pure function (unit-tested without a controller); `apply(to:)` maps the
|
||
// result onto Apple's GCDualSenseAdaptiveTrigger — exact for the 10-zone positional modes
|
||
// (0x21/0x26 → the positional resistiveStrengths/amplitudes APIs), best-effort for the
|
||
// composite ones (bow, galloping) that have no GC equivalent.
|
||
|
||
import Foundation
|
||
import GameController
|
||
|
||
/// A parsed DualSense trigger effect. Positions and strengths are normalized 0...1
|
||
/// (GCDualSenseAdaptiveTrigger's domain); `positional*` carry one value per trigger zone
|
||
/// (10 zones across the travel).
|
||
public enum DualSenseTriggerEffect: Equatable, Sendable {
|
||
case off
|
||
/// Constant resistance from `start` to the end of travel.
|
||
case feedback(start: Float, strength: Float)
|
||
/// Resistance from `start` that releases past `end` (trigger-break / weapon feel).
|
||
case weapon(start: Float, end: Float, strength: Float)
|
||
/// Vibration once the trigger passes `start`.
|
||
case vibration(start: Float, amplitude: Float, frequency: Float)
|
||
/// Per-zone resistance (10 zones).
|
||
case positionalFeedback(strengths: [Float])
|
||
/// Per-zone vibration amplitudes (10 zones) at `frequency`.
|
||
case positionalVibration(amplitudes: [Float], frequency: Float)
|
||
/// Resistance ramping `startStrength` → `endStrength` between `start` and `end`
|
||
/// (the closest GC rendering of the bow effect).
|
||
case slope(start: Float, end: Float, startStrength: Float, endStrength: Float)
|
||
|
||
/// Parse a raw trigger parameter block (`[mode, p0...p9]`, ≤ 11 bytes — shorter blocks
|
||
/// are zero-padded). Never fails: unknown modes are `.off`.
|
||
public static func parse(_ block: [UInt8]) -> DualSenseTriggerEffect {
|
||
guard let mode = block.first else { return .off }
|
||
var p = [UInt8](block.dropFirst())
|
||
if p.count < 10 { p.append(contentsOf: [UInt8](repeating: 0, count: 10 - p.count)) }
|
||
|
||
// Helpers for the rich (0x2x) modes: a 10-bit active-zone mask in p0/p1 and 3-bit
|
||
// per-zone values packed little-endian into the following 4 bytes.
|
||
let zoneMask = UInt16(p[0]) | (UInt16(p[1]) << 8)
|
||
let packed = UInt32(p[2]) | (UInt32(p[3]) << 8) | (UInt32(p[4]) << 16) | (UInt32(p[5]) << 24)
|
||
func zoneValues() -> [UInt8] {
|
||
(0..<10).map { i in
|
||
zoneMask & (1 << i) != 0 ? UInt8((packed >> (3 * UInt32(i))) & 0x07) : 0
|
||
}
|
||
}
|
||
// DualSense 3-bit strengths are 0...7 where an *active* zone's value v renders as
|
||
// (v+1)/8 — a present-but-zero strength still resists slightly.
|
||
func strength01(_ v: UInt8, active: Bool) -> Float {
|
||
active ? Float(v + 1) / 8 : 0
|
||
}
|
||
func zone01(_ z: Int) -> Float { Float(z) / 9 }
|
||
func firstActive() -> Int? { (0..<10).first { zoneMask & (1 << $0) != 0 } }
|
||
func lastActive() -> Int? { (0..<10).last { zoneMask & (1 << $0) != 0 } }
|
||
|
||
switch mode {
|
||
case 0x00, 0x05, 0xF0...0xFF:
|
||
// 0x00/0x05 are the documented off/reset modes; 0xFC/0xFB/0xFA show up from
|
||
// calibration-adjacent writes — all render as off.
|
||
return .off
|
||
|
||
case 0x01:
|
||
// Legacy continuous resistance: p0 = start position, p1 = force (both 0...255).
|
||
return .feedback(start: Float(p[0]) / 255, strength: max(Float(p[1]) / 255, 1.0 / 8))
|
||
|
||
case 0x02:
|
||
// Legacy section: p0 = start, p1 = end (0...255); full-strength inside.
|
||
let s = Float(p[0]) / 255
|
||
let e = Float(p[1]) / 255
|
||
return .weapon(start: min(s, e), end: max(max(s, e), min(s, e) + 0.01), strength: 1)
|
||
|
||
case 0x06:
|
||
// Legacy vibration ("automatic gun") — Nielk1's Simple_Vibration order:
|
||
// p0 = frequency Hz, p1 = amplitude, p2 = start position.
|
||
return .vibration(
|
||
start: Float(p[2]) / 255,
|
||
amplitude: max(Float(p[1]) / 255, 1.0 / 8),
|
||
frequency: Float(p[0]) / 255)
|
||
|
||
case 0x21:
|
||
// Feedback: 10-bit zone mask + 3-bit strength per zone. A uniform suffix maps
|
||
// exactly onto the simple feedback call; mixed strengths use the positional API.
|
||
guard let first = firstActive() else { return .off }
|
||
let values = zoneValues()
|
||
let active = values.enumerated().filter { zoneMask & (1 << $0.offset) != 0 }
|
||
if active.allSatisfy({ $0.element == active[0].element })
|
||
&& active.last?.offset == 9
|
||
&& active.map(\.offset) == Array(first...9)
|
||
{
|
||
return .feedback(
|
||
start: zone01(first), strength: strength01(active[0].element, active: true))
|
||
}
|
||
return .positionalFeedback(
|
||
strengths: values.enumerated().map {
|
||
strength01($0.element, active: zoneMask & (1 << $0.offset) != 0)
|
||
})
|
||
|
||
case 0x22:
|
||
// Bow (Nielk1 mode byte 0x22): start/end zones + draw strength and snap force
|
||
// packed as a 3-bit pair in p2 (low bits draw, bits 3-5 snap; p3 is always 0).
|
||
// No GC equivalent — render as a slope from draw resistance down to the snap.
|
||
guard let s = firstActive(), let e = lastActive(), s < e else { return .off }
|
||
let draw = strength01(p[2] & 0x07, active: true)
|
||
let snap = strength01((p[2] >> 3) & 0x07, active: true)
|
||
return .slope(start: zone01(s), end: zone01(e), startStrength: draw, endStrength: snap)
|
||
|
||
case 0x25:
|
||
// Weapon (Nielk1 mode byte 0x25): zone mask marks the start and end zones,
|
||
// p2 = strength (3-bit, stored minus one).
|
||
guard let s = firstActive(), let e = lastActive(), s < e else { return .off }
|
||
return .weapon(start: zone01(s), end: zone01(e), strength: strength01(p[2] & 0x07, active: true))
|
||
|
||
case 0x26:
|
||
// Vibration: 10-bit zone mask + 3-bit amplitude per zone, p8 = frequency Hz.
|
||
guard zoneMask != 0 else { return .off }
|
||
let amplitudes = zoneValues().enumerated().map {
|
||
strength01($0.element, active: zoneMask & (1 << $0.offset) != 0)
|
||
}
|
||
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[8]) / 255)
|
||
|
||
case 0x23:
|
||
// Galloping (Nielk1 mode byte 0x23): start/end zones, p2 = packed foot timing,
|
||
// p3 = frequency Hz. The temporal hoofbeat pattern has no GC equivalent —
|
||
// render as vibration across the active range.
|
||
guard let s = firstActive(), let e = lastActive() else { return .off }
|
||
var amplitudes = [Float](repeating: 0, count: 10)
|
||
for z in s...e { amplitudes[z] = 0.5 }
|
||
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[3]) / 255)
|
||
|
||
case 0x27:
|
||
// Machine (Nielk1 mode byte 0x27): start/end zones, p2 = two 3-bit amplitudes
|
||
// (low bits A, bits 3-5 B — raw 0...7, no minus-one), p3 = frequency Hz. The
|
||
// A/B alternation is temporal — render its stronger leg across the range.
|
||
guard let s = firstActive(), let e = lastActive() else { return .off }
|
||
let amp = Float(max(p[2] & 0x07, (p[2] >> 3) & 0x07)) / 7
|
||
guard amp > 0 else { return .off }
|
||
var amplitudes = [Float](repeating: 0, count: 10)
|
||
for z in s...e { amplitudes[z] = amp }
|
||
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[3]) / 255)
|
||
|
||
default:
|
||
return .off
|
||
}
|
||
}
|
||
}
|
||
|
||
extension DualSenseTriggerEffect {
|
||
/// Replay this effect on a physical DualSense trigger. Main-thread only (GameController
|
||
/// profile mutation). The GC `frequency` parameter is normalized 0...1 like ours.
|
||
@MainActor
|
||
public func apply(to trigger: GCDualSenseAdaptiveTrigger) {
|
||
switch self {
|
||
case .off:
|
||
trigger.setModeOff()
|
||
case let .feedback(start, strength):
|
||
trigger.setModeFeedbackWithStartPosition(start, resistiveStrength: strength)
|
||
case let .weapon(start, end, strength):
|
||
trigger.setModeWeaponWithStartPosition(start, endPosition: end, resistiveStrength: strength)
|
||
case let .vibration(start, amplitude, frequency):
|
||
trigger.setModeVibrationWithStartPosition(start, amplitude: amplitude, frequency: frequency)
|
||
case let .positionalFeedback(strengths):
|
||
var s = GCDualSenseAdaptiveTrigger.PositionalResistiveStrengths(
|
||
values: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||
withUnsafeMutableBytes(of: &s.values) { raw in
|
||
let f = raw.bindMemory(to: Float.self)
|
||
for (i, v) in strengths.prefix(10).enumerated() { f[i] = v }
|
||
}
|
||
trigger.setModeFeedback(resistiveStrengths: s)
|
||
case let .positionalVibration(amplitudes, frequency):
|
||
var a = GCDualSenseAdaptiveTrigger.PositionalAmplitudes(
|
||
values: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||
withUnsafeMutableBytes(of: &a.values) { raw in
|
||
let f = raw.bindMemory(to: Float.self)
|
||
for (i, v) in amplitudes.prefix(10).enumerated() { f[i] = v }
|
||
}
|
||
trigger.setModeVibration(amplitudes: a, frequency: frequency)
|
||
case let .slope(start, end, startStrength, endStrength):
|
||
trigger.setModeSlopeFeedback(
|
||
startPosition: start, endPosition: end,
|
||
startStrength: startStrength, endStrength: endStrength)
|
||
}
|
||
}
|
||
}
|