Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173be61213 | ||
|
|
2032c48ffa | ||
|
|
9a52c279f1 | ||
|
|
5be494f490 | ||
|
|
0d5e5b436b | ||
|
|
3a48cc2470 | ||
|
|
64a392634e | ||
|
|
35285afafc | ||
|
|
0d0e7e6861 | ||
|
|
143454590f | ||
|
|
9409d0a04c | ||
|
|
212bdc3b08 | ||
|
|
45cb525035 | ||
|
|
6fed1510ba | ||
|
|
4fd240deab | ||
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
8ee224e5db | ||
|
|
e8499e6131 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
ed3d236ab8 |
Generated
+51
-32
@@ -947,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2893,11 +2893,12 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2915,7 +2916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2936,7 +2937,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2960,7 +2961,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2969,7 +2970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2981,7 +2982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2995,11 +2996,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3028,14 +3029,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3050,7 +3051,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3058,7 +3059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3070,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3103,7 +3104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3115,7 +3116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3323,7 +3324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3334,7 +3335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3346,11 +3347,13 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3367,7 +3370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3382,7 +3385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3402,7 +3405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3434,7 +3437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3519,7 +3522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3533,7 +3536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3556,7 +3559,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -4985,6 +4988,14 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.24.0"
|
||||
version = "0.23.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ suspend fun connectToHost(
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,26 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -255,6 +275,8 @@ class SettingsStore(context: Context) {
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -290,6 +312,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -335,6 +359,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
|
||||
@@ -877,6 +877,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||
ToggleRow(
|
||||
title = "Controller haptics",
|
||||
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||
"the pad keeps ordinary rumble for games that don't send them",
|
||||
checked = s.padHaptics,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +506,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
|
||||
@@ -23,8 +23,9 @@ import android.view.InputDevice
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
@@ -78,6 +79,33 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -105,12 +133,17 @@ class DsCapture(
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||
// joined, so ordering this first is what makes the borrow sound.
|
||||
stopPadAudio()
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
@@ -131,16 +164,94 @@ class DsCapture(
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||
* indices are taken.
|
||||
*
|
||||
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||
*/
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
startPadAudio(p.index)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||
private fun startPadAudio(index: Int) {
|
||||
val hook = padAudio ?: return
|
||||
if (padAudioStarted) return
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd < 0) {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
return
|
||||
}
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||
// debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||
// completions, which is precisely the fault this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
hook.start(index, fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
|
||||
*
|
||||
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
|
||||
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
|
||||
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
|
||||
* audio and its wire rumble on the way back in.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun stopPadAudio() {
|
||||
if (!padAudioStarted) return
|
||||
padAudioStarted = false
|
||||
// The hook's stop joins the render thread, so nothing is using the descriptor once it
|
||||
// returns — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
|
||||
stopPadAudio()
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
|
||||
@@ -92,6 +92,40 @@ class HidUsbLink(
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||
*
|
||||
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||
* renderer — must open its own.
|
||||
*
|
||||
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||
*
|
||||
* The caller owns the returned connection and must close it.
|
||||
*/
|
||||
fun openAuxConnection(): UsbDeviceConnection? {
|
||||
val dev = device ?: return null
|
||||
return usb.openDevice(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||
*
|
||||
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||
* HID claim untouched.
|
||||
*
|
||||
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||
*/
|
||||
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
|
||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -332,6 +336,46 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -54,6 +54,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
|
||||
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
|
||||
// write, and that bit disables the audio-haptics path — so one replayed command would
|
||||
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
|
||||
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
|
||||
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
|
||||
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
|
||||
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
@@ -156,6 +164,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
@@ -0,0 +1,959 @@
|
||||
//! Pad audio on Android (the 0xD1 plane) — tier A, WP9.
|
||||
//!
|
||||
//! The Android twin of [`pf_client_core::pad_audio`]: drain the host's per-pad DualSense streams,
|
||||
//! Opus-decode haptics (kind 0) and speaker (kind 1), interleave them into the pad's own
|
||||
//! 4-channel layout, and render them on the physical pad.
|
||||
//!
|
||||
//! # Why this needs a USB driver instead of an audio API
|
||||
//!
|
||||
//! Every other client hands the 4-channel stream to the platform's audio graph — WASAPI on
|
||||
//! Windows, PipeWire on Linux, CoreAudio on Apple. **Android has no such option for this device.**
|
||||
//! AOSP's `UsbAlsaManager` carries a hardcoded VID/PID denylist that includes the DualSense
|
||||
//! (`054c:0ce6`), so the kernel enumerates the pad's playback node and the framework then discards
|
||||
//! it: `hasOutput: false`. There is no `AudioDeviceInfo` for `setPreferredDevice` to target, and
|
||||
//! `/dev/snd` is closed to apps by SELinux. Android's own `UsbRequest` API cannot help either — it
|
||||
//! rejects any endpoint that is not bulk or interrupt.
|
||||
//!
|
||||
//! So this path drives the pad's isochronous endpoint directly, through `uac-host` on the file
|
||||
//! descriptor Java already owns. That is measured, not hoped: on a Nothing Phone (3) the claim
|
||||
//! succeeds unprivileged, the gamepad and the pad's microphone both keep working, and the
|
||||
//! underrun-free floor is **4 ms in flight** — including under eight-core load with the SoC in
|
||||
//! severe thermal throttling.
|
||||
//!
|
||||
//! # The exclusivity that shapes everything here, and what is actually known about it
|
||||
//!
|
||||
//! `valid_flag0` bit 1 (`HAPTICS_SELECT`) disables the audio-haptics path, and every rumble write
|
||||
//! that exists — `hid-playstation`, SDL, and our own [`crate::feedback`] path via `DsDevice` —
|
||||
//! asserts it. So haptics and classic rumble cannot both drive the coils **as coded**, and the
|
||||
//! arbitration selects rather than blends.
|
||||
//!
|
||||
//! What is NOT established is the stronger claim this module used to make: that the coils and the
|
||||
//! rumble motors are the same physical actuators, exclusive *in the firmware*. No teardown, vendor
|
||||
//! document or measurement supports it here; it traces to one reverse-engineered comment in SDL,
|
||||
//! and SDL's own modern path sets `HAPTICS_SELECT` **alone** (amplitude rides `ucEnableBits3`),
|
||||
//! which reads more like an independent mute for the audio path than a shared-actuator interlock.
|
||||
//! The combination that would settle it — rumble asserted with `HAPTICS_SELECT` CLEARED — is
|
||||
//! emitted by no code anywhere, so nothing here writes it either.
|
||||
//!
|
||||
//! The arbitration is therefore built on **evidence, not prediction**: haptics owns the coils only
|
||||
//! while haptics frames are actually arriving (see [`haptics_owns_coils`]). That is correct under
|
||||
//! either hypothesis, and it is what keeps a rumble-only title rumbling — it renders no haptics
|
||||
//! audio, the host's silence gate emits nothing, and the pad simply keeps its motors.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use punktfunk_core::audio::AudioGapTracker;
|
||||
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use punktfunk_core::client::NativeClient;
|
||||
#[cfg(target_os = "android")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(target_os = "android")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_os = "android")]
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(target_os = "android")]
|
||||
use std::time::Duration;
|
||||
|
||||
/// The pad's render layout: 4 interleaved channels — speaker FL/FR on 0/1, the voice coils on
|
||||
/// 2/3. Feeding a 2-channel stream would leave the coils silent rather than fail, which is the
|
||||
/// failure mode most worth not having.
|
||||
const PAD_CHANNELS: usize = 4;
|
||||
|
||||
/// Both plane kinds decode as 48 kHz stereo.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
|
||||
/// Ring ceiling, in sample frames. 60 ms — far above the in-flight depth, because this bounds
|
||||
/// *decoder* backlog when the USB side stalls, not stream latency. Overflow drops the oldest.
|
||||
const MAX_BUFFER_FRAMES: usize = (SAMPLE_RATE as usize / 1000) * 60;
|
||||
|
||||
/// Largest Opus frame this decodes in one call: 120 ms at 48 kHz, the codec's maximum.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const MAX_FRAME_SAMPLES: usize = 5760;
|
||||
|
||||
/// How much audio to keep in flight on the USB endpoint.
|
||||
///
|
||||
/// WP7 measured the underrun-free floor on real hardware at **4 ms** (clean across three sweeps,
|
||||
/// including one under eight-core load with the CPU thermally throttled); 3 ms was marginal and
|
||||
/// 2 ms never survived. 6 ms takes one step of headroom above that floor, because the same
|
||||
/// measurement found isolated transient events roughly once per three seconds that are *not*
|
||||
/// depth-dependent — so the floor is a floor, not a target.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const IN_FLIGHT_MS: u32 = 6;
|
||||
|
||||
// ---- tier-A registry ---------------------------------------------------------------------------
|
||||
|
||||
/// Which wire pad indices are currently rendering tier-A audio, as a bitmask over the 16 wire
|
||||
/// slots.
|
||||
///
|
||||
/// Read on the rumble poll thread and written on the JNI thread, so it is an atomic rather than a
|
||||
/// lock: the reader is on a latency path and must never block behind a start/stop.
|
||||
static TIER_A_PADS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Mark (or clear) a pad as rendering tier-A audio.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn set_tier_a(pad: u8, on: bool) {
|
||||
use std::sync::atomic::Ordering;
|
||||
let bit = 1u32 << (pad & 0x0f);
|
||||
if on {
|
||||
TIER_A_PADS.fetch_or(bit, Ordering::Relaxed);
|
||||
} else {
|
||||
TIER_A_PADS.fetch_and(!bit, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this pad's HAPTICS lane armed — i.e. did a renderer open a stream that wants the coils?
|
||||
///
|
||||
/// Armed is necessary but NOT sufficient to take the pad off wire rumble; see
|
||||
/// [`haptics_owns_coils`]. Speaker-only rendering never arms this: the speaker pair is a
|
||||
/// different pair of channels and cannot be disturbed by a rumble write.
|
||||
pub(crate) fn haptics_armed(pad: u8) -> bool {
|
||||
TIER_A_PADS.load(std::sync::atomic::Ordering::Relaxed) & (1u32 << (pad & 0x0f)) != 0
|
||||
}
|
||||
|
||||
/// Last instant a real (non-concealed) haptics frame was decoded for each pad, as ms on the
|
||||
/// process clock; `0` = never. Written by the render thread, read by the rumble poll thread.
|
||||
static HAPTICS_SEEN_MS: [std::sync::atomic::AtomicU64; 16] =
|
||||
[const { std::sync::atomic::AtomicU64::new(0) }; 16];
|
||||
|
||||
/// Process epoch for [`HAPTICS_SEEN_MS`] — `Instant` is not `const`-constructible.
|
||||
fn epoch() -> std::time::Instant {
|
||||
static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
*EPOCH.get_or_init(std::time::Instant::now)
|
||||
}
|
||||
|
||||
/// Milliseconds since [`epoch`], **1-based**. The `+ 1` reserves `0` as an unambiguous "never
|
||||
/// stamped" sentinel: without it, a haptics frame arriving in the first millisecond of the process
|
||||
/// would stamp `0` and be read as never-arrived, handing the coils to wire rumble mid-effect.
|
||||
/// Stamp and comparison share this clock, so the offset cancels and adds no skew.
|
||||
fn now_ms() -> u64 {
|
||||
epoch().elapsed().as_millis() as u64 + 1
|
||||
}
|
||||
|
||||
/// A pad is only silent for haptics once the host has stopped sending for longer than its own
|
||||
/// silence gate can explain. The host gates at −60 dBFS with a 250 ms hangover, so a title that
|
||||
/// renders no haptics audio emits NOTHING on the 0xD1 plane; doubling the hangover covers wire
|
||||
/// jitter and concealment without letting a real gap read as "live".
|
||||
const HAPTICS_IDLE_MS: u64 = 500;
|
||||
|
||||
/// Stamp a decoded haptics frame. Concealment (PLC) deliberately does not count — filling a gap
|
||||
/// is not evidence that the game is still driving the coils.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn note_haptics_frame(pad: u8) {
|
||||
HAPTICS_SEEN_MS[(pad & 0x0f) as usize].store(now_ms(), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Clear a pad's liveness (slot teardown). Wire indices are recycled, so a stale stamp would let
|
||||
/// a fresh pad inherit the previous one's ownership.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn clear_haptics_liveness(pad: u8) {
|
||||
HAPTICS_SEEN_MS[(pad & 0x0f) as usize].store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The arbitration, pure and testable: haptics owns the coils only while frames are ACTUALLY
|
||||
/// arriving. `seen_ms == 0` (never) is never live.
|
||||
pub(crate) fn haptics_owns_at(armed: bool, seen_ms: u64, now: u64) -> bool {
|
||||
armed && seen_ms != 0 && now.saturating_sub(seen_ms) < HAPTICS_IDLE_MS
|
||||
}
|
||||
|
||||
/// Does this pad's haptics stream currently own the coils, so wire rumble must stand down?
|
||||
///
|
||||
/// Arbitrating on **evidence** rather than on a prediction about the hardware is deliberate. Every
|
||||
/// rumble write this tree emits asserts `valid_flag0` bit 1 (`HAPTICS_SELECT`), which disables the
|
||||
/// audio-haptics path, so the two cannot both drive the coils *as coded* — whatever the firmware
|
||||
/// would allow. But a title that never renders haptics audio produces no 0xD1 frames at all, and
|
||||
/// suppressing its rumble on the assumption that "the stream carries the feedback" silences it
|
||||
/// outright. Frame arrival is the signal that tells the two cases apart, and it costs nothing.
|
||||
pub(crate) fn haptics_owns_coils(pad: u8) -> bool {
|
||||
let i = (pad & 0x0f) as usize;
|
||||
haptics_owns_at(
|
||||
haptics_armed(pad),
|
||||
HAPTICS_SEEN_MS[i].load(std::sync::atomic::Ordering::Relaxed),
|
||||
now_ms(),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the 4-channel mixer ---------------------------------------------------------------------
|
||||
|
||||
/// Interleave the two independent stereo streams into one 4-channel frame stream.
|
||||
///
|
||||
/// The kinds arrive on different cadences (haptics 5 ms, speaker 10 ms), so each has its own
|
||||
/// write cursor and [`pop`](Self::pop) emits everything the further-ahead kind has filled, with
|
||||
/// the lagging or absent kind's pair reading silence. A haptics-only session therefore renders
|
||||
/// the coils with a silent speaker pair, and vice versa, instead of stalling on the missing kind.
|
||||
///
|
||||
/// Samples are `i16` — the DualSense's own wire format — so nothing converts on the hot path.
|
||||
/// Pure logic, unit-tested below; pacing lives in the USB ring downstream.
|
||||
pub(crate) struct QuadMixer {
|
||||
/// Interleaved 4-channel samples; the front is the next frame out. Always
|
||||
/// `ready_frames() * PAD_CHANNELS` long.
|
||||
ring: VecDeque<i16>,
|
||||
/// Per-kind write cursor in FRAMES relative to the ring front, indexed by the wire `kind`.
|
||||
written: [usize; 2],
|
||||
/// Frames dropped to the ceiling — a stalled USB side, visible in the logs.
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
impl QuadMixer {
|
||||
pub(crate) fn new() -> QuadMixer {
|
||||
QuadMixer {
|
||||
ring: VecDeque::new(),
|
||||
written: [0; 2],
|
||||
dropped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one decoded stereo chunk (interleaved L/R) for `kind` at that kind's cursor,
|
||||
/// zero-extending as needed. Both cursors shift together on overflow, so the two kinds can
|
||||
/// never skew relative to one another.
|
||||
pub(crate) fn push(&mut self, kind: u8, stereo: &[i16]) {
|
||||
// Name both kinds rather than defaulting: a kind this build does not know belongs
|
||||
// nowhere in a 4-channel frame, and quietly folding it into the coil pair would render
|
||||
// an unknown stream straight into the actuators.
|
||||
let (k, off) = match kind {
|
||||
PAD_AUDIO_KIND_HAPTICS => (0usize, 2usize),
|
||||
PAD_AUDIO_KIND_SPEAKER => (1usize, 0usize),
|
||||
_ => return,
|
||||
};
|
||||
let frames = stereo.len() / 2;
|
||||
let base = self.written[k];
|
||||
let need = (base + frames) * PAD_CHANNELS;
|
||||
if self.ring.len() < need {
|
||||
self.ring.resize(need, 0);
|
||||
}
|
||||
for (i, fr) in stereo.chunks_exact(2).enumerate() {
|
||||
let at = (base + i) * PAD_CHANNELS + off;
|
||||
self.ring[at] = fr[0];
|
||||
self.ring[at + 1] = fr[1];
|
||||
}
|
||||
self.written[k] = base + frames;
|
||||
let over = self.ready_frames().saturating_sub(MAX_BUFFER_FRAMES);
|
||||
if over > 0 {
|
||||
self.dropped += over as u64;
|
||||
self.drop_front(over);
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames ready to output: the further-ahead kind's cursor.
|
||||
pub(crate) fn ready_frames(&self) -> usize {
|
||||
self.written[0].max(self.written[1])
|
||||
}
|
||||
|
||||
/// Frames discarded to the ceiling since construction.
|
||||
pub(crate) fn dropped_frames(&self) -> u64 {
|
||||
self.dropped
|
||||
}
|
||||
|
||||
/// Append every ready frame (interleaved 4-channel) to `out`; returns the frame count.
|
||||
pub(crate) fn pop(&mut self, out: &mut Vec<i16>) -> usize {
|
||||
let frames = self.ready_frames();
|
||||
let n = frames * PAD_CHANNELS;
|
||||
out.extend(self.ring.drain(..n.min(self.ring.len())));
|
||||
for w in &mut self.written {
|
||||
*w = w.saturating_sub(frames);
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// Throw the ready frames away — no sink to render them on right now.
|
||||
pub(crate) fn discard(&mut self) {
|
||||
let f = self.ready_frames();
|
||||
self.drop_front(f);
|
||||
}
|
||||
|
||||
fn drop_front(&mut self, frames: usize) {
|
||||
let n = (frames * PAD_CHANNELS).min(self.ring.len());
|
||||
self.ring.drain(..n);
|
||||
let f = n / PAD_CHANNELS;
|
||||
for w in &mut self.written {
|
||||
*w = w.saturating_sub(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- decode + packet loss concealment ---------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
/// Per-kind decode state: a stereo 48 kHz Opus decoder, the seq-gap tracker, and the last decoded
|
||||
/// frame size, which is the unit PLC synthesises in.
|
||||
struct KindStream {
|
||||
dec: opus::Decoder,
|
||||
gaps: AudioGapTracker,
|
||||
frame_samples: usize,
|
||||
}
|
||||
|
||||
/// Concealment frames to synthesise before decoding `seq`.
|
||||
///
|
||||
/// Zero until something has decoded, because there is nothing to size the PLC from yet. The
|
||||
/// tracker is fed regardless, so a gap seen before the first real frame cannot resurface later as
|
||||
/// a phantom. Pure, and unit-tested.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
fn plc_frames(gaps: &mut AudioGapTracker, seq: u32, frame_samples: usize) -> u32 {
|
||||
let missing = gaps.missing_before(seq);
|
||||
if frame_samples == 0 {
|
||||
0
|
||||
} else {
|
||||
missing
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the USB sink ------------------------------------------------------------------------------
|
||||
|
||||
/// Everything that talks to the pad. Linux and Android only: `usbfs` is a Linux kernel ABI, and
|
||||
/// this crate also builds as a host cdylib on macOS dev boxes, where the mixer and PLC above still
|
||||
/// compile and still run their tests.
|
||||
#[cfg(target_os = "android")]
|
||||
mod sink {
|
||||
use super::{IN_FLIGHT_MS, PAD_CHANNELS, SAMPLE_RATE};
|
||||
|
||||
/// Open the pad's 4-channel playback stream on a descriptor Java owns.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `fd` must be a live usbfs descriptor from an open `UsbDeviceConnection` that outlives the
|
||||
/// returned device — this **borrows** it and never closes it, because closing is
|
||||
/// `UsbDeviceConnection.close()`'s job and a double close would strand an unrelated
|
||||
/// descriptor much later.
|
||||
pub(super) unsafe fn device(fd: i32) -> usbfs_iso::UsbFsDevice {
|
||||
// SAFETY: forwarded from this function's own contract, which the JNI entry point upholds
|
||||
// by keeping the Java connection open for the lifetime of the renderer thread.
|
||||
unsafe { usbfs_iso::UsbFsDevice::from_borrowed_fd(fd) }
|
||||
}
|
||||
|
||||
/// Find the pad's 4-channel playback stream and open it.
|
||||
///
|
||||
/// Four channels is a hard requirement, not a preference: the voice coils *are* channels 3
|
||||
/// and 4, so a 2-channel alternate setting would open successfully and then render haptics
|
||||
/// into nothing.
|
||||
pub(super) fn open<'d>(
|
||||
dev: &'d usbfs_iso::UsbFsDevice,
|
||||
) -> Result<uac_host::Playback<'d>, uac_host::Error> {
|
||||
let blob = dev.raw_descriptors()?;
|
||||
let function = uac_host::parse(&blob)?;
|
||||
let stream = function
|
||||
.output_streams()
|
||||
.find(|s| usize::from(s.channels()) == PAD_CHANNELS)
|
||||
.ok_or(uac_host::Error::NoAudioFunction)?;
|
||||
|
||||
let opts = uac_host::OpenOptions {
|
||||
depth: usbfs_iso::Depth::Millis(IN_FLIGHT_MS),
|
||||
// One packet per URB: the finest granularity the bus offers, and what WP7 measured
|
||||
// the 4 ms floor with. Packing more multiplies one completion's latency.
|
||||
packets_per_urb: Some(1),
|
||||
// Keep the endpoint fed rather than gapping when the decoder is momentarily late.
|
||||
// A hole in an isochronous stream is silence forever; silence we chose is better.
|
||||
underrun: usbfs_iso::Underrun::FillSilence,
|
||||
..Default::default()
|
||||
};
|
||||
stream.open_with(dev, uac_host::Format::S16Le, SAMPLE_RATE, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the self test ------------------------------------------------------------------------------
|
||||
|
||||
/// Drive the pad directly with a synthetic tone, through **the real client path**.
|
||||
///
|
||||
/// This exists because the two things most likely to be wrong here cannot be unit-tested and are
|
||||
/// invisible without a host: whether the descriptor Kotlin handed over is one this renderer may
|
||||
/// drive exclusively, and whether the interface claim succeeds on this kernel. A standalone
|
||||
/// harness proves neither — it owns its descriptor by construction, which is exactly the condition
|
||||
/// that was violated when this renderer was handed the HID link's fd and the two engines began
|
||||
/// stealing each other's URB completions.
|
||||
///
|
||||
/// Opens the sink the same way [`render`] does and writes a sine into the voice-coil pair, which
|
||||
/// is felt rather than heard. Returns sample frames written, or a negative [`SelfTest`] code.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `fd` must be a live usbfs descriptor whose connection outlives the call, and which **nothing
|
||||
/// else is driving transfers on**.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) unsafe fn self_test(fd: i32, seconds: i32, hz: i32) -> i32 {
|
||||
// SAFETY: the caller's contract.
|
||||
let dev = unsafe { sink::device(fd) };
|
||||
let mut playback = match sink::open(&dev) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::warn!("pad audio self-test: could not open the stream: {e}");
|
||||
return SelfTest::OPEN_FAILED;
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"pad audio self-test: {} ch {} at {} Hz, {} us in flight",
|
||||
playback.channels(),
|
||||
playback.format(),
|
||||
playback.rate(),
|
||||
playback.schedule().in_flight_us()
|
||||
);
|
||||
|
||||
let rate = playback.rate();
|
||||
let channels = playback.channels() as usize;
|
||||
let frames_per_chunk = (rate as usize / 1000).max(1);
|
||||
let mut chunk = vec![0i16; frames_per_chunk * channels];
|
||||
let mut phase = 0.0f32;
|
||||
let step = std::f32::consts::TAU * hz.clamp(20, 500) as f32 / rate as f32;
|
||||
let total = u64::from(rate) * seconds.clamp(1, 30) as u64;
|
||||
let mut written = 0u64;
|
||||
|
||||
while written < total {
|
||||
for frame in chunk.chunks_mut(channels) {
|
||||
let sample = (phase.sin() * 16_384.0) as i16;
|
||||
phase += step;
|
||||
if phase >= std::f32::consts::TAU {
|
||||
phase -= std::f32::consts::TAU;
|
||||
}
|
||||
frame.fill(0);
|
||||
// Channels 2 and 3 are the voice coils; the speaker pair stays silent so a pass is
|
||||
// unambiguously FELT rather than merely audible.
|
||||
for c in 2..channels {
|
||||
frame[c] = sample;
|
||||
}
|
||||
}
|
||||
if let Err(e) = playback.write_interleaved(&chunk) {
|
||||
log::warn!("pad audio self-test: write failed after {written} frames: {e}");
|
||||
return SelfTest::WRITE_FAILED;
|
||||
}
|
||||
written += frames_per_chunk as u64;
|
||||
}
|
||||
let _ = playback.drain(Duration::from_millis(500));
|
||||
|
||||
let stats = playback.stats();
|
||||
log::info!(
|
||||
"pad audio self-test: {} frames, {} urbs, {} underruns, {} short bytes, {} urb errors",
|
||||
playback.frames_written(),
|
||||
stats.urbs_completed,
|
||||
stats.underruns,
|
||||
stats.short_bytes,
|
||||
stats.urb_errors
|
||||
);
|
||||
// Underruns are a producer-pacing property and deliberately NOT a failure here: the question
|
||||
// this answers is whether the client can drive the pad at all. Data reaching the bus is the
|
||||
// pass condition.
|
||||
if stats.urb_errors > 0 || playback.frames_written() == 0 {
|
||||
return SelfTest::NO_DATA;
|
||||
}
|
||||
playback.frames_written().min(i32::MAX as u64) as i32
|
||||
}
|
||||
|
||||
/// Negative results from [`self_test`]. Positive values are sample frames written.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) struct SelfTest;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl SelfTest {
|
||||
/// The claim or stream open failed — the OEM-kernel case, or a descriptor another engine owns.
|
||||
pub(crate) const OPEN_FAILED: i32 = -1;
|
||||
/// The stream opened but a write failed part-way.
|
||||
pub(crate) const WRITE_FAILED: i32 = -2;
|
||||
/// It ran, but nothing reached the bus.
|
||||
pub(crate) const NO_DATA: i32 = -3;
|
||||
}
|
||||
|
||||
// ---- the renderer worker -----------------------------------------------------------------------
|
||||
|
||||
/// A running renderer: the stop flag and the thread, joined on drop.
|
||||
///
|
||||
/// Mirrors [`crate::mic::MicCapture`]'s discipline — dropping the handle is what stops the stream,
|
||||
/// so a session teardown that forgets a step cannot leave a thread writing to a descriptor Java is
|
||||
/// about to close.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) struct PadAudio {
|
||||
pad: u8,
|
||||
stop: Arc<AtomicBool>,
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl Drop for PadAudio {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
// Belt and braces: the thread clears these itself on the way out, but if it died in a way
|
||||
// that skipped that, leaving the pad off wire rumble would cost the user all feedback.
|
||||
set_tier_a(self.pad, false);
|
||||
clear_haptics_liveness(self.pad);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the renderer for a pad whose descriptor Java has handed over.
|
||||
///
|
||||
/// Returns `None` when neither kind is enabled (nothing to render) or the thread will not start.
|
||||
/// **The caller must keep the `UsbDeviceConnection` open until the returned handle is dropped** —
|
||||
/// the renderer borrows the descriptor and never closes it.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn start(
|
||||
client: Arc<NativeClient>,
|
||||
pad: u8,
|
||||
fd: i32,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
) -> Option<PadAudio> {
|
||||
if !haptics && !speaker {
|
||||
return None;
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let join = spawn(client, Arc::clone(&stop), pad, fd, haptics, speaker)?;
|
||||
Some(PadAudio {
|
||||
pad,
|
||||
stop,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the pad-audio renderer — the 0xD1 plane's single consumer on Android.
|
||||
///
|
||||
/// `fd` is the pad's usbfs descriptor from `UsbDeviceConnection.getFileDescriptor()`; the caller
|
||||
/// **must** keep that connection open until [`stop`](AtomicBool) has been observed and the handle
|
||||
/// joined. Returns `None` if the thread could not be started.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn spawn(
|
||||
client: Arc<NativeClient>,
|
||||
stop: Arc<AtomicBool>,
|
||||
pad: u8,
|
||||
fd: i32,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
) -> Option<JoinHandle<()>> {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-pad-audio".into())
|
||||
.spawn(move || run(&client, &stop, pad, fd, haptics, speaker))
|
||||
.map_err(|e| log::warn!("pad-audio thread failed to start: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn run(client: &NativeClient, stop: &AtomicBool, pad: u8, fd: i32, haptics: bool, speaker: bool) {
|
||||
// Ask the scheduler for audio priority. Android does not hand SCHED_FIFO to ordinary app
|
||||
// threads, so -16 (ANDROID_PRIORITY_AUDIO) is the realistic knob — and WP7 measured that it
|
||||
// both applies and is enough to hold the 4 ms floor against eight busy cores.
|
||||
// SAFETY: `setpriority` on the calling thread; no pointers, no shared state.
|
||||
unsafe {
|
||||
libc::setpriority(libc::PRIO_PROCESS, 0, -16);
|
||||
}
|
||||
|
||||
// SAFETY: the caller's contract — the Java connection outlives this thread.
|
||||
let dev = unsafe { sink::device(fd) };
|
||||
// Through a reference, deliberately: `UsbFsDevice` has a `Drop`, and opening the stream in
|
||||
// this same scope would make the borrow outlive the value it borrows.
|
||||
render(&dev, client, stop, pad, haptics, speaker);
|
||||
}
|
||||
|
||||
/// Open the pad's stream and render on it until the session stops or the device goes away.
|
||||
#[cfg(target_os = "android")]
|
||||
fn render(
|
||||
dev: &usbfs_iso::UsbFsDevice,
|
||||
client: &NativeClient,
|
||||
stop: &AtomicBool,
|
||||
pad: u8,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
) {
|
||||
// A host that cannot send 0xD1 will never render anything here, so opening the stream would
|
||||
// claim the interface and (before the arbitration below) take the pad off wire rumble in
|
||||
// exchange for nothing. Against every released host this is the DEFAULT path — `pad_haptics`
|
||||
// is on with no UI to turn it off — so without this gate a wired DualSense simply stops
|
||||
// rumbling. Checked before `sink::open` so the iso interface is never claimed pointlessly.
|
||||
if client.host_caps() & punktfunk_core::quic::HOST_CAP_PAD_AUDIO == 0 {
|
||||
log::warn!("pad audio: host cannot send it (no HOST_CAP_PAD_AUDIO) — pad {pad} stays on wire rumble");
|
||||
drain_until_stop(client, stop);
|
||||
return;
|
||||
}
|
||||
match sink::open(dev) {
|
||||
Ok(mut playback) => {
|
||||
log::info!(
|
||||
"pad audio: pad={pad} {} ch {} at {} Hz, {} us in flight",
|
||||
playback.channels(),
|
||||
playback.format(),
|
||||
playback.rate(),
|
||||
playback.schedule().in_flight_us()
|
||||
);
|
||||
// ONLY NOW commit the trade. Declaring the pad's render capability makes the host
|
||||
// emit 0xD1, and taking the pad off wire rumble is what makes tier A and tier C
|
||||
// mutually exclusive — doing either before the stream is known to open would, on a
|
||||
// kernel that refuses the claim, leave the user with no haptics of any kind.
|
||||
let caps = (if haptics { 0x01 } else { 0 }) | (if speaker { 0x02 } else { 0 });
|
||||
client.set_pad_audio_caps(pad, caps);
|
||||
// Arm the HAPTICS lane only — a speaker-only setup drives channels 0/1, which no
|
||||
// rumble write can disturb, so taking the motors away would kill rumble with nothing
|
||||
// rendering haptics in exchange.
|
||||
set_tier_a(pad, haptics);
|
||||
|
||||
pump(client, stop, pad, haptics, speaker, &mut playback);
|
||||
|
||||
// Give the pad back to wire rumble before this thread goes away.
|
||||
client.set_pad_audio_caps(pad, 0);
|
||||
set_tier_a(pad, false);
|
||||
clear_haptics_liveness(pad);
|
||||
}
|
||||
Err(e) => {
|
||||
// A kernel that refuses the claim: some OEM kernels do, and there is no app-side fix.
|
||||
// Nothing was declared and nothing was suppressed, so the session simply carries on
|
||||
// at tier C with ordinary rumble — a clean degrade rather than silent total loss.
|
||||
log::warn!("pad audio unavailable on pad {pad}, staying on rumble: {e}");
|
||||
drain_until_stop(client, stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
/// Keep the plane drained without rendering, so a host that is sending 0xD1 does not back up
|
||||
/// against a consumer that never reads.
|
||||
fn drain_until_stop(client: &NativeClient, stop: &AtomicBool) {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if client.next_pad_audio(Duration::from_millis(20)).is_none()
|
||||
&& stop.load(Ordering::Relaxed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The steady state: decode arriving frames, interleave, and hand whole frames to the pad.
|
||||
#[cfg(target_os = "android")]
|
||||
fn pump(
|
||||
client: &NativeClient,
|
||||
stop: &AtomicBool,
|
||||
pad: u8,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
playback: &mut uac_host::Playback<'_>,
|
||||
) {
|
||||
let mut mixer = QuadMixer::new();
|
||||
let mut streams: [Option<KindStream>; 2] = [None, None];
|
||||
// Periodic accounting. Without it the only way to tell "the host is sending nothing" from
|
||||
// "frames arrive but render silently" is to guess, and those two have completely different
|
||||
// causes — one is host-side routing, the other is here.
|
||||
let mut frames_in = 0u64;
|
||||
let mut samples_in = 0u64;
|
||||
let mut peak = 0i32;
|
||||
let mut last_report = std::time::Instant::now();
|
||||
let mut pcm: Vec<i16> = Vec::with_capacity(MAX_FRAME_SAMPLES * 2);
|
||||
let mut out: Vec<i16> = Vec::with_capacity(MAX_BUFFER_FRAMES * PAD_CHANNELS);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// Report BEFORE the frame gate. Silence on the plane is a legitimate — and highly
|
||||
// diagnostic — state: it means the host's capture hears nothing, which is a routing
|
||||
// problem upstream rather than anything here. Reporting only when a frame arrives makes
|
||||
// that state indistinguishable from the renderer being dead.
|
||||
if last_report.elapsed() >= Duration::from_secs(1) {
|
||||
let st = playback.stats();
|
||||
log::info!(
|
||||
"pad audio: {frames_in} frames in, {samples_in} samples, peak={peak}, \
|
||||
{} written, {} underruns, {} short",
|
||||
playback.frames_written(),
|
||||
st.underruns,
|
||||
st.short_bytes
|
||||
);
|
||||
last_report = std::time::Instant::now();
|
||||
peak = 0;
|
||||
}
|
||||
|
||||
let Some(frame) = client.next_pad_audio(Duration::from_millis(10)) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// The settings gate each kind independently: haptics off but speaker on is a legitimate
|
||||
// configuration, and the host may still be sending both.
|
||||
let wanted = match frame.kind {
|
||||
PAD_AUDIO_KIND_HAPTICS => haptics,
|
||||
PAD_AUDIO_KIND_SPEAKER => speaker,
|
||||
_ => false,
|
||||
};
|
||||
if !wanted {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A real haptics frame is the evidence that the game is driving the coils, and therefore
|
||||
// that wire rumble must stand down for this pad (see `haptics_owns_coils`). Stamped on
|
||||
// arrival rather than after decode so a decoder hiccup cannot hand the coils back
|
||||
// mid-effect; concealment never reaches here, so PLC still does not count.
|
||||
if frame.kind == PAD_AUDIO_KIND_HAPTICS {
|
||||
note_haptics_frame(pad);
|
||||
}
|
||||
|
||||
frames_in += 1;
|
||||
let k = usize::from(frame.kind).min(1);
|
||||
let st = match &mut streams[k] {
|
||||
Some(s) => s,
|
||||
slot @ None => match opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo) {
|
||||
Ok(dec) => slot.insert(KindStream {
|
||||
dec,
|
||||
gaps: AudioGapTracker::default(),
|
||||
frame_samples: 0,
|
||||
}),
|
||||
Err(e) => {
|
||||
log::warn!("pad audio: no Opus decoder for kind {}: {e}", frame.kind);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Conceal whatever the sequence numbers say is missing, before decoding what arrived.
|
||||
let missing = plc_frames(&mut st.gaps, frame.seq, st.frame_samples);
|
||||
for _ in 0..missing {
|
||||
pcm.resize(st.frame_samples * 2, 0);
|
||||
match st.dec.decode(&[], &mut pcm, false) {
|
||||
Ok(n) => mixer.push(frame.kind, &pcm[..n * 2]),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// An empty payload is DTX silence: the tracker has already accounted for the sequence,
|
||||
// and there is nothing to decode.
|
||||
if !frame.opus.is_empty() {
|
||||
pcm.resize(MAX_FRAME_SAMPLES * 2, 0);
|
||||
match st.dec.decode(&frame.opus, &mut pcm, false) {
|
||||
Ok(n) => {
|
||||
st.frame_samples = n;
|
||||
samples_in += n as u64;
|
||||
// Peak of what actually decoded: distinguishes "frames arriving but silent"
|
||||
// (a host-side routing problem) from "frames arriving with signal that is not
|
||||
// reaching the actuators" (a problem here).
|
||||
peak = peak.max(
|
||||
pcm[..n * 2]
|
||||
.iter()
|
||||
.map(|s| i32::from(s.abs()))
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
mixer.push(frame.kind, &pcm[..n * 2]);
|
||||
}
|
||||
Err(e) => log::debug!("pad audio: opus decode failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Hand over whole frames only. `write` stages any remainder internally, so a partial
|
||||
// chunk is never padded with silence mid-stream.
|
||||
out.clear();
|
||||
if mixer.pop(&mut out) > 0 {
|
||||
if let Err(e) = playback.write_interleaved(&out) {
|
||||
if is_fatal(&e) {
|
||||
log::warn!("pad audio: stream lost: {e}");
|
||||
return;
|
||||
}
|
||||
log::debug!("pad audio: write hiccup: {e}");
|
||||
mixer.discard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = playback.drain(Duration::from_millis(100));
|
||||
let stats = playback.stats();
|
||||
log::info!(
|
||||
"pad audio stopped: {} frames, {} underruns, {} short bytes, {} dropped by backlog",
|
||||
playback.frames_written(),
|
||||
stats.underruns,
|
||||
stats.short_bytes,
|
||||
mixer.dropped_frames(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Is this the end of the stream, or just a bad moment?
|
||||
///
|
||||
/// A vanished device is unrecoverable here — the descriptor belongs to a `UsbDeviceConnection`
|
||||
/// that Java must re-open — so the thread exits and the session continues without tier A. Anything
|
||||
/// else is treated as transient.
|
||||
#[cfg(target_os = "android")]
|
||||
fn is_fatal(e: &uac_host::Error) -> bool {
|
||||
matches!(e, uac_host::Error::Transport(t) if t.is_disconnected())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn speaker_lands_on_the_front_pair_and_haptics_on_the_coils() {
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[100, 200]);
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[300, 400]);
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(m.pop(&mut out), 1);
|
||||
// Channels 0/1 are the speaker, 2/3 are the voice coils — the pad's own layout.
|
||||
assert_eq!(out, vec![100, 200, 300, 400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_haptics_only_session_still_renders_with_a_silent_speaker_pair() {
|
||||
// The case that matters most: `pad_speaker = "off"` must not stall the coils waiting for
|
||||
// a kind that will never arrive.
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[7, 8, 9, 10]);
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(m.pop(&mut out), 2);
|
||||
assert_eq!(out, vec![0, 0, 7, 8, 0, 0, 9, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_kinds_never_skew_when_the_ceiling_drops_frames() {
|
||||
let mut m = QuadMixer::new();
|
||||
// Push well past the ceiling on one kind, then a marker on the other. Both cursors must
|
||||
// have moved together, so the marker still lands on the same output frame boundary.
|
||||
let flood = vec![1i16; (MAX_BUFFER_FRAMES + 500) * 2];
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &flood);
|
||||
assert!(m.dropped_frames() > 0);
|
||||
assert_eq!(m.ready_frames(), MAX_BUFFER_FRAMES);
|
||||
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[42, 43]);
|
||||
let mut out = Vec::new();
|
||||
let frames = m.pop(&mut out);
|
||||
assert_eq!(frames, MAX_BUFFER_FRAMES);
|
||||
assert_eq!(out.len(), frames * PAD_CHANNELS);
|
||||
// The speaker sample went to the FRONT of the ring (its cursor was reset with the drop),
|
||||
// not to wherever the flooded kind happened to be.
|
||||
assert_eq!(&out[..4], &[42, 43, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaving_survives_uneven_cadences() {
|
||||
// Haptics arrive at 5 ms and the speaker at 10 ms; popping mid-flight must not lose the
|
||||
// lagging kind's alignment.
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 1, 2, 2]);
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[9, 9]);
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(m.pop(&mut out), 2);
|
||||
assert_eq!(out, vec![9, 9, 1, 1, 0, 0, 2, 2]);
|
||||
|
||||
// Next round: both cursors are back at zero, so a fresh speaker frame aligns with a fresh
|
||||
// haptics frame rather than inheriting the previous round's offset.
|
||||
out.clear();
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[5, 5]);
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[6, 6]);
|
||||
assert_eq!(m.pop(&mut out), 1);
|
||||
assert_eq!(out, vec![5, 5, 6, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_kind_is_dropped_rather_than_rendered_into_the_coils() {
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(9, &[999, 999]);
|
||||
assert_eq!(
|
||||
m.ready_frames(),
|
||||
0,
|
||||
"an unknown kind must not occupy a channel pair"
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2]);
|
||||
assert_eq!(m.pop(&mut out), 1);
|
||||
assert_eq!(out, vec![0, 0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_a_registry_tracks_pads_independently() {
|
||||
// A rumble command reaching a tier-A pad mutes its coils for the session, so this gate
|
||||
// has to be exact rather than approximately right.
|
||||
set_tier_a(3, true);
|
||||
assert!(haptics_armed(3));
|
||||
assert!(!haptics_armed(4));
|
||||
set_tier_a(4, true);
|
||||
assert!(haptics_armed(3) && haptics_armed(4));
|
||||
set_tier_a(3, false);
|
||||
assert!(!haptics_armed(3), "clearing one pad must not clear another");
|
||||
assert!(haptics_armed(4));
|
||||
set_tier_a(4, false);
|
||||
assert!(!haptics_armed(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_a_registry_wraps_the_pad_index_into_the_wire_slot_space() {
|
||||
// The wire pad space is 4 bits; an out-of-range index must not shift the mask into
|
||||
// undefined territory (a shift >= 32 is a panic in debug and garbage in release).
|
||||
set_tier_a(0x1f, true);
|
||||
assert!(haptics_armed(0x0f), "0x1f and 0x0f are the same wire slot");
|
||||
set_tier_a(0x0f, false);
|
||||
assert!(!haptics_armed(0x1f));
|
||||
}
|
||||
|
||||
/// The arbitration that keeps a rumble-only game working. Armed alone is NOT ownership: a
|
||||
/// title that never renders haptics audio produces no frames, so the host's silence gate
|
||||
/// emits nothing on 0xD1 and the pad must keep its motors.
|
||||
#[test]
|
||||
fn haptics_owns_the_coils_only_while_frames_actually_arrive() {
|
||||
// Never seen a frame — armed, but the game is not driving the coils.
|
||||
assert!(
|
||||
!haptics_owns_at(true, 0, 10_000),
|
||||
"an armed pad that has never received a frame must keep its rumble"
|
||||
);
|
||||
// A frame just arrived: haptics owns, rumble stands down.
|
||||
assert!(haptics_owns_at(true, 10_000, 10_000));
|
||||
// Still inside the idle window (the host's own 250 ms hangover, doubled).
|
||||
assert!(haptics_owns_at(true, 10_000, 10_000 + HAPTICS_IDLE_MS - 1));
|
||||
// The stream went quiet: the coils go back to wire rumble.
|
||||
assert!(
|
||||
!haptics_owns_at(true, 10_000, 10_000 + HAPTICS_IDLE_MS),
|
||||
"the coils must return to rumble once haptics stops arriving"
|
||||
);
|
||||
// Not armed (speaker-only, or no renderer): frames or not, rumble always owns.
|
||||
assert!(!haptics_owns_at(false, 10_000, 10_000));
|
||||
}
|
||||
|
||||
/// Clock skew must never strand a pad in the suppressed state.
|
||||
#[test]
|
||||
fn a_stamp_ahead_of_now_does_not_wrap_the_idle_window() {
|
||||
// The clock is monotonic so this should not arise, but an unsigned underflow would wrap
|
||||
// to ~2^64 ms and read as EXPIRED — handing the coils back mid-effect. `saturating_sub`
|
||||
// pins it to 0 (still live), and it self-corrects once the clock catches up.
|
||||
assert!(haptics_owns_at(true, 10_000, 9_000));
|
||||
// The 1-based clock is what makes this distinguishable: a frame stamped in the process's
|
||||
// first millisecond must read as LIVE, not as never-stamped.
|
||||
assert!(
|
||||
haptics_owns_at(true, 1, 1),
|
||||
"a frame stamped at t=0 must not be mistaken for never-stamped"
|
||||
);
|
||||
assert!(
|
||||
!haptics_owns_at(true, 0, 0),
|
||||
"never-seen stays never-seen at t=0"
|
||||
);
|
||||
}
|
||||
|
||||
/// Teardown drops the liveness stamp: wire indices are recycled, and a fresh pad must not
|
||||
/// inherit the previous occupant's ownership of the coils.
|
||||
#[test]
|
||||
fn clearing_liveness_hands_the_coils_back() {
|
||||
set_tier_a(2, true);
|
||||
note_haptics_frame(2);
|
||||
assert!(haptics_owns_coils(2));
|
||||
clear_haptics_liveness(2);
|
||||
assert!(
|
||||
!haptics_owns_coils(2),
|
||||
"a cleared stamp must release the coils"
|
||||
);
|
||||
set_tier_a(2, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_empties_without_disturbing_alignment() {
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2, 3, 4]);
|
||||
m.discard();
|
||||
assert_eq!(m.ready_frames(), 0);
|
||||
let mut out = Vec::new();
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[8, 9]);
|
||||
assert_eq!(m.pop(&mut out), 1);
|
||||
assert_eq!(out, vec![8, 9, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_stays_silent_until_something_has_decoded() {
|
||||
let mut g = AudioGapTracker::default();
|
||||
// A gap before the first decode has nothing to size concealment from, and must not be
|
||||
// replayed later as a phantom.
|
||||
assert_eq!(plc_frames(&mut g, 5, 0), 0);
|
||||
assert_eq!(plc_frames(&mut g, 6, 480), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_conceals_a_real_gap_once_a_frame_size_is_known() {
|
||||
let mut g = AudioGapTracker::default();
|
||||
assert_eq!(plc_frames(&mut g, 0, 0), 0);
|
||||
assert_eq!(plc_frames(&mut g, 1, 480), 0);
|
||||
// Sequence 2 and 3 never arrived.
|
||||
assert_eq!(plc_frames(&mut g, 4, 480), 2);
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
@@ -99,6 +104,14 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -21,12 +21,8 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
/// A caller that owns a particular pad passes the location id it wants (see
|
||||
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
|
||||
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
|
||||
/// lowest location id wins — an arbitrary but *stable* choice, where `Set.first` was neither.
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
@@ -47,57 +43,9 @@ final class DualSenseHID {
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// The IOKit location id of the device this instance opened — the handle a caller correlates
|
||||
/// with its `GCController`. `nil` until a successful `open`.
|
||||
private(set) var locationID: UInt32?
|
||||
|
||||
/// A device's location id, or `nil` if IOKit does not report one.
|
||||
static func locationID(of dev: IOHIDDevice) -> UInt32? {
|
||||
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
|
||||
}
|
||||
|
||||
/// Every connected DualSense/Edge, by location id — what a caller pairs against its controllers.
|
||||
static func attachedLocationIDs() -> [UInt32] {
|
||||
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
let matches = productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
|
||||
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
return []
|
||||
}
|
||||
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
|
||||
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
|
||||
return devices.compactMap(locationID(of:)).sorted()
|
||||
}
|
||||
|
||||
/// Which attached device to drive, as an index into `ids` — the whole selection rule, pure so
|
||||
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
|
||||
///
|
||||
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
|
||||
/// merely arbitrary — it can differ between two calls in one process. With two DualSenses that
|
||||
/// made each renderer's pad→device binding a coin flip: both could land on the same device
|
||||
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
|
||||
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
|
||||
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
|
||||
/// place never displaces one it can.
|
||||
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
|
||||
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
|
||||
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
|
||||
}
|
||||
|
||||
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
|
||||
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
|
||||
let ordered = Array(devices)
|
||||
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
|
||||
return nil
|
||||
}
|
||||
return ordered[i]
|
||||
}
|
||||
|
||||
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
|
||||
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
|
||||
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
@@ -107,21 +55,13 @@ final class DualSenseHID {
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = Self.pick(devices, preferring: preferred)
|
||||
let dev = devices.first
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
locationID = Self.locationID(of: dev)
|
||||
if let preferred, locationID != preferred {
|
||||
// Not fatal — one pad still gets rumble — but with two pads attached it means this
|
||||
// renderer is driving the wrong one, and it is invisible without the log line.
|
||||
log.error(
|
||||
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
|
||||
)
|
||||
}
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
@@ -130,16 +70,8 @@ final class DualSenseHID {
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
///
|
||||
/// Returns whether the write reached the device. The caller needs this: it used to be logged
|
||||
/// and swallowed, so a failed write still counted as a successful render. That matters most
|
||||
/// for a **stop**, which has nothing behind it — the renderer stamps its write clock even on
|
||||
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
|
||||
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
|
||||
/// stop left the motors running with nothing scheduled to try again.
|
||||
@discardableResult
|
||||
func rumble(low: UInt8, high: UInt8) -> Bool {
|
||||
guard let dev = device else { return false }
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
@@ -149,9 +81,7 @@ final class DualSenseHID {
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
|
||||
@@ -117,15 +117,7 @@ public final class GamepadFeedback {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
|
||||
// per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd, which the
|
||||
// renderer's own notes record as able to hang — plus `DualSenseHID.close()`, whose
|
||||
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
|
||||
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
|
||||
// change, and the main thread is what drives the presenter's CADisplayLink, so
|
||||
// blocking here hitches the picture mid-stream. The renderer is already detached from
|
||||
// routing above, so nothing observes it after this point.
|
||||
if let renderer { Task.detached { renderer.stop() } }
|
||||
renderer?.stop()
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
@@ -290,12 +282,6 @@ public final class GamepadFeedback {
|
||||
private func reset(_ controller: GCController?) {
|
||||
guard let c = controller else { return }
|
||||
c.playerIndex = .indexUnset
|
||||
// Put the lightbar out too. This class is what turned it on (see the `Led` and
|
||||
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
|
||||
// set stays lit in firmware after the stream ends — back at the launcher, or for a pad
|
||||
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
|
||||
// indicator IS the lightbar; a DualSense is not.
|
||||
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
|
||||
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
|
||||
@@ -459,18 +459,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
|
||||
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
|
||||
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
|
||||
// which handle died — lose `high` and `render` falls to the combined branch (selected
|
||||
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
|
||||
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
|
||||
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
|
||||
// which at least renders both motors somewhere.
|
||||
if low == nil || high == nil {
|
||||
log.warning("rumble: only one split-handle engine came up — falling back to combined")
|
||||
teardown() // disarms handlers, stops the survivor's players + engine, nils both
|
||||
}
|
||||
} else {
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
@@ -599,9 +587,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
// Ask for the device this renderer's controller actually is, so two attached DualSenses
|
||||
// do not both get driven through whichever one an unordered Set happened to yield first.
|
||||
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
|
||||
guard hid.open() else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
@@ -609,24 +595,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Correlate a `GCController` with an IOKit location id.
|
||||
///
|
||||
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
|
||||
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
|
||||
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
|
||||
/// missing. With one pad attached this is the same device it always was.
|
||||
static func hidLocationID(for c: GCController) -> UInt32? {
|
||||
let ids = DualSenseHID.attachedLocationIDs()
|
||||
guard ids.count > 1 else { return ids.first }
|
||||
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
|
||||
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
|
||||
return ids.first
|
||||
}
|
||||
return ids[rank]
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||
@@ -637,20 +605,8 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
let keepalive = levels != (0, 0)
|
||||
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
|
||||
if levels != lastHidWrite.levels || keepalive {
|
||||
if hid.rumble(low: levels.0, high: levels.1) {
|
||||
lastHidWrite = (levels, .now())
|
||||
} else {
|
||||
// The write did not reach the device. Do NOT stamp the clock — that would claim a
|
||||
// render that never happened, and for a stop there is nothing behind it: the
|
||||
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
|
||||
// target is (0, 0), so the motors would keep running with nothing scheduled.
|
||||
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
|
||||
// rebuilds it. Health is reported so the state is visible rather than silent.
|
||||
log.error("rumble: HID write failed — dropping the handle, falling back")
|
||||
closeHID()
|
||||
reportHealth("Lost the direct connection to this DualSense; using the system path.")
|
||||
return false
|
||||
}
|
||||
hid.rumble(low: levels.0, high: levels.1)
|
||||
lastHidWrite = (levels, .now())
|
||||
}
|
||||
return true
|
||||
#else
|
||||
|
||||
@@ -43,33 +43,5 @@ final class DualSenseHIDTests: XCTestCase {
|
||||
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||
}
|
||||
|
||||
// MARK: - Device selection (B14)
|
||||
|
||||
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
|
||||
/// `Set.first` from an unordered set, so the pad→device binding was a coin flip that could
|
||||
/// point both renderers at the same pad.
|
||||
func testPreferredIndexHonoursAnExplicitLocation() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
|
||||
}
|
||||
|
||||
/// No preference (or one the pad no longer has): fall back to the LOWEST id — arbitrary, but
|
||||
/// stable across calls, which `Set.first` was not.
|
||||
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
|
||||
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
|
||||
// open — it degrades to the same stable fallback.
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
|
||||
}
|
||||
|
||||
/// A device IOKit reports no location for must never displace one it can place.
|
||||
func testPreferredIndexSortsUnplaceableDevicesLast() {
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
|
||||
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -194,6 +194,12 @@ mod session_main {
|
||||
// it back. It goes on before the attach below, so a non-forwarding session never opens
|
||||
// — never grabs — the device.
|
||||
gamepad.set_forwarding(settings.gamepad_forwarding);
|
||||
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
|
||||
// slots declare their render caps at open time, which happens on attach — after this.
|
||||
gamepad.set_pad_audio_prefs(
|
||||
settings.pad_haptics,
|
||||
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -297,6 +303,11 @@ mod session_main {
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
echo_cancel: settings.echo_cancel,
|
||||
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||
// service learns the same prefs below so tier-A slots declare their render caps
|
||||
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
|
||||
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
|
||||
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
|
||||
# property stores entirely (the same version the host pins).
|
||||
winreg = "0.56"
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
|
||||
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
|
||||
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
|
||||
|
||||
@@ -97,13 +97,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
/// Settings device pickers via session main), or the OS default. A picked device that's
|
||||
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
|
||||
///
|
||||
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
|
||||
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
|
||||
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
|
||||
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
|
||||
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
|
||||
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
|
||||
pub(crate) fn device_by_id(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
id: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
let devices = enumerator
|
||||
.get_device_collection(direction)
|
||||
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
|
||||
let count = devices
|
||||
.get_nbr_devices()
|
||||
.map_err(|e| anyhow!("endpoint count: {e}"))?;
|
||||
for i in 0..count {
|
||||
let dev = devices
|
||||
.get_device_at_index(i)
|
||||
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
|
||||
if dev.get_id().is_ok_and(|got| got == id) {
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no active {direction:?} endpoint with id {id}")
|
||||
}
|
||||
|
||||
fn pick_device(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
var: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
||||
match enumerator.get_device(&id) {
|
||||
match device_by_id(enumerator, direction, &id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
var,
|
||||
|
||||
@@ -337,6 +337,9 @@ enum Ctl {
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
Forwarding(bool),
|
||||
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
|
||||
PadAudioPrefs(u8),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -503,6 +506,18 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::Forwarding(on));
|
||||
}
|
||||
|
||||
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
|
||||
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
|
||||
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
|
||||
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
|
||||
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
|
||||
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
|
||||
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
|
||||
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
|
||||
let bits = (haptics as u8) | ((speaker as u8) << 1);
|
||||
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -632,6 +647,11 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`, struct offsets 4..=9).
|
||||
/// The 47-byte effect struct is the USB report 0x02 minus its report-id byte, so struct
|
||||
/// offset 4 = report byte 5 (the same −1 shift that maps report offset 11 to
|
||||
/// [`Self::RIGHT_TRIGGER`] = 10 in [`trigger_packet`](Self::trigger_packet)).
|
||||
const AUDIO: usize = 4;
|
||||
const RIGHT_TRIGGER: usize = 10;
|
||||
const LEFT_TRIGGER: usize = 21;
|
||||
const PAD_LIGHTS: usize = 43;
|
||||
@@ -665,6 +685,29 @@ impl Ds5Feedback {
|
||||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||
p
|
||||
}
|
||||
|
||||
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
|
||||
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
|
||||
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
|
||||
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
|
||||
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
|
||||
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
|
||||
fn audio_haptics_packet() -> [u8; 47] {
|
||||
[0u8; 47]
|
||||
}
|
||||
|
||||
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
|
||||
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
|
||||
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
|
||||
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
|
||||
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
|
||||
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
|
||||
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
p[0] = (flags & 0x1E) << 3;
|
||||
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||
@@ -698,6 +741,14 @@ struct Slot {
|
||||
/// close lift a click held across detach/unplug.
|
||||
held_clicks: [bool; 2],
|
||||
last_accel: [i16; 3],
|
||||
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
|
||||
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
|
||||
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
|
||||
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
|
||||
/// disable-bit trap — see [`Worker::render_feedback`]).
|
||||
audio_caps: u8,
|
||||
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
|
||||
rumble_suppressed_logged: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -713,6 +764,8 @@ impl Slot {
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
audio_caps: 0,
|
||||
rumble_suppressed_logged: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +803,10 @@ struct Worker {
|
||||
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||
kind_override: GamepadPref,
|
||||
/// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder
|
||||
/// declares some: tier-A detection then never runs and every arrival stays caps-less.
|
||||
pad_audio_prefs: u8,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -955,11 +1012,18 @@ impl Worker {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
Self::set_slot_sensors(&mut slot, true);
|
||||
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
|
||||
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
|
||||
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
// Pad-audio render caps go in FIRST — the core ORs them into this (and
|
||||
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
|
||||
// set (0 for non-tier-A): wire indices are reused within a connection, so
|
||||
// a tier-A slot that closes must not leave its bits behind for the next
|
||||
// pad on the same index (the set_rumble_quirks rule).
|
||||
c.set_pad_audio_caps(index, slot.audio_caps);
|
||||
send(
|
||||
c,
|
||||
InputKind::GamepadArrival,
|
||||
@@ -982,6 +1046,27 @@ impl Worker {
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
if slot.audio_caps != 0 {
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
|
||||
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
|
||||
// "disable audio haptics") whenever its rumble path runs — which
|
||||
// would MUTE the voice coils the 0xD1 stream drives. One effects
|
||||
// packet with those bits CLEARED puts the pad back on audio haptics
|
||||
// ("Leaving emulated rumble bits off will restore audio haptics" —
|
||||
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
|
||||
// render_feedback so SDL never re-arms them.
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
|
||||
}
|
||||
// Hand the pad to the session's renderer worker. Windows correlation
|
||||
// needs the HID interface path; Linux matches the sink by signature.
|
||||
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||
tracing::info!(
|
||||
index,
|
||||
caps = slot.audio_caps,
|
||||
"tier-A DualSense: pad-audio render caps declared"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
id,
|
||||
index,
|
||||
@@ -995,6 +1080,35 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
|
||||
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
|
||||
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
|
||||
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
|
||||
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
|
||||
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
|
||||
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
|
||||
if self.pad_audio_prefs == 0 {
|
||||
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
|
||||
}
|
||||
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
|
||||
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
|
||||
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
|
||||
return 0; // not a DualSense/Edge — no wired check needed
|
||||
}
|
||||
use sdl3::joystick::ConnectionState;
|
||||
let wired = match pad.connection_state() {
|
||||
Ok(ConnectionState::Wired) => true,
|
||||
Ok(ConnectionState::Wireless) => false,
|
||||
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
|
||||
};
|
||||
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
|
||||
self.pad_audio_prefs
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
|
||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||
/// already gone (unplug).
|
||||
@@ -1011,6 +1125,11 @@ impl Worker {
|
||||
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
|
||||
}
|
||||
let slot = self.slots.remove(i);
|
||||
if slot.audio_caps != 0 {
|
||||
// Take the pad back from the pad-audio renderer (its device-gone path then
|
||||
// re-correlates — and finds nothing until a tier-A pad registers again).
|
||||
crate::pad_audio::unregister_tier_a(slot.index);
|
||||
}
|
||||
tracing::info!(
|
||||
id = slot.id,
|
||||
index = slot.index,
|
||||
@@ -1330,6 +1449,7 @@ impl Worker {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
}
|
||||
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1601,6 +1721,20 @@ impl Worker {
|
||||
// first; the physical silence backstop is in `close_slot_at`).
|
||||
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
|
||||
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
|
||||
// so a slot with tier-A haptics active never issues wire rumble (the stream
|
||||
// carries the feedback; the game's rumble is in its haptics mix).
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
if !slot.rumble_suppressed_logged {
|
||||
slot.rumble_suppressed_logged = true;
|
||||
tracing::info!(
|
||||
pad = slot.index,
|
||||
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||
}
|
||||
}
|
||||
@@ -1633,6 +1767,17 @@ impl Worker {
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
// The audio-control region of a DS5 output report a game wrote host-side
|
||||
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
|
||||
// pad's effects packet, but only where a tier-A renderer is actually live
|
||||
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
|
||||
// nothing streams to would just mute/blast a future session's start state.
|
||||
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
|
||||
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1647,6 +1792,8 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
| HidOutput::Trigger { pad, .. }
|
||||
| HidOutput::TrackpadHaptic { pad, .. }
|
||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||
// AudioCtl's pad is u16 on the wire; the index space is 0..MAX_PADS end to end.
|
||||
HidOutput::AudioCtl { pad, .. } => *pad as u8,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1671,6 +1818,7 @@ impl Worker {
|
||||
pinned: None,
|
||||
forwarding: true,
|
||||
kind_override: GamepadPref::Auto,
|
||||
pad_audio_prefs: 0,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -2006,5 +2154,43 @@ mod slot_tests {
|
||||
}),
|
||||
6
|
||||
);
|
||||
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
|
||||
assert_eq!(
|
||||
hidout_pad(&HidOutput::AudioCtl {
|
||||
pad: 7,
|
||||
flags: 0,
|
||||
raw: [0; 6]
|
||||
}),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
|
||||
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
|
||||
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
|
||||
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
|
||||
#[test]
|
||||
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
|
||||
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
|
||||
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
|
||||
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
|
||||
// bits1..4 (0b1011) → flag0 bits 4..7.
|
||||
assert_eq!(p[0], 0b1011_0000);
|
||||
assert_eq!(
|
||||
p[0] & 0x03,
|
||||
0,
|
||||
"haptics-select must NOT replay into p[0] bits 0/1"
|
||||
);
|
||||
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
|
||||
assert!(p[1..4].iter().all(|&b| b == 0));
|
||||
assert!(p[10..].iter().all(|&b| b == 0));
|
||||
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
|
||||
assert_eq!(p[0], 0);
|
||||
assert_eq!(&p[4..10], &raw);
|
||||
// The tier-A activation packet is the all-clear: every enable bit off — per
|
||||
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
|
||||
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ pub mod os;
|
||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||
// the bindings live on.
|
||||
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
|
||||
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
|
||||
// the tier-A pad registry the gamepad worker feeds it through.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod pad_audio;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod profiles;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,14 @@ pub struct SessionParams {
|
||||
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
|
||||
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
|
||||
pub echo_cancel: bool,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
|
||||
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
|
||||
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
|
||||
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
|
||||
/// off — see [`crate::pad_audio::speaker_active`]).
|
||||
pub pad_speaker: String,
|
||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||
pub clipboard: bool,
|
||||
@@ -356,6 +364,11 @@ fn pump(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
|
||||
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
|
||||
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
|
||||
let pad_speaker_on = crate::pad_audio::speaker_active(¶ms.pad_speaker);
|
||||
let pad_audio_on = params.pad_haptics || pad_speaker_on;
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
@@ -379,6 +392,11 @@ fn pump(
|
||||
0
|
||||
}) | (if params.phase_lock {
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
} else {
|
||||
0
|
||||
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
|
||||
}) | (if pad_audio_on {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
@@ -501,6 +519,20 @@ fn pump(
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
|
||||
// the settings could render. The output device is opened LAZILY once frames actually
|
||||
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
|
||||
// a session without a wired DualSense costs one idle 10 ms poll loop.
|
||||
let pad_audio_thread = pad_audio_on
|
||||
.then(|| {
|
||||
crate::pad_audio::spawn(
|
||||
connector.clone(),
|
||||
stop.clone(),
|
||||
params.pad_haptics,
|
||||
pad_speaker_on,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||
@@ -1066,6 +1098,9 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = pad_audio_thread {
|
||||
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = clipboard_thread {
|
||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||
}
|
||||
|
||||
@@ -993,6 +993,21 @@ pub struct Settings {
|
||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
||||
#[serde(default)]
|
||||
pub mic_device: String,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
|
||||
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
|
||||
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
|
||||
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
|
||||
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
|
||||
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
|
||||
/// a wired DS5. `default` so pre-existing stores load with it on.
|
||||
#[serde(default = "default_true")]
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
|
||||
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
|
||||
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
|
||||
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
|
||||
#[serde(default = "default_pad_speaker")]
|
||||
pub pad_speaker: String,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||
@@ -1036,6 +1051,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_pad_speaker() -> String {
|
||||
"pad".into()
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||
@@ -1119,6 +1138,8 @@ impl Default for Settings {
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
mic_device: String::new(),
|
||||
pad_haptics: true,
|
||||
pad_speaker: "pad".into(),
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
|
||||
@@ -10,14 +10,20 @@ use punktfunk_core::quic::HidOutput;
|
||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
||||
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
||||
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
|
||||
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
|
||||
/// fire).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HidoutDedup {
|
||||
led: Option<(u8, u8, u8)>,
|
||||
player_leds: Option<u8>,
|
||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||
trigger: [Option<Vec<u8>>; 2],
|
||||
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
|
||||
audio_ctl: Option<(u8, [u8; 6])>,
|
||||
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
|
||||
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
|
||||
haptics_select_logged: bool,
|
||||
}
|
||||
|
||||
impl HidoutDedup {
|
||||
@@ -60,6 +66,25 @@ impl HidoutDedup {
|
||||
}
|
||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||
HidOutput::TrackpadHaptic { .. } => true,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
let v = Some((*flags, *raw));
|
||||
if self.audio_ctl == v {
|
||||
false
|
||||
} else {
|
||||
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
|
||||
// audio haptics (not plain rumble emulation, whose all-zero audio region
|
||||
// never reaches here) — the trace that tells "the game does audio haptics"
|
||||
// apart from "the client just doesn't render them".
|
||||
if flags & 0x01 != 0 && !self.haptics_select_logged {
|
||||
self.haptics_select_logged = true;
|
||||
tracing::info!(
|
||||
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
|
||||
);
|
||||
}
|
||||
self.audio_ctl = v;
|
||||
true
|
||||
}
|
||||
}
|
||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||
@@ -123,4 +148,28 @@ mod tests {
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(d.should_forward(&trig(0, 2)));
|
||||
}
|
||||
|
||||
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
|
||||
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
|
||||
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
|
||||
#[test]
|
||||
fn audio_ctl_dedups_by_value() {
|
||||
let mut d = HidoutDedup::default();
|
||||
let audio = |flags, vol| HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags,
|
||||
raw: [vol, 0, 0, 0, 0, 0],
|
||||
};
|
||||
// Identical twice → exactly one emission.
|
||||
assert!(d.should_forward(&audio(0x17, 0x50)));
|
||||
assert!(!d.should_forward(&audio(0x17, 0x50)));
|
||||
// Either half changing (flags, or the raw region) forwards again.
|
||||
assert!(d.should_forward(&audio(0x16, 0x50)));
|
||||
assert!(d.should_forward(&audio(0x16, 0x60)));
|
||||
// The other kinds' state is untouched by audio traffic.
|
||||
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }));
|
||||
// `clear` (pad re-plug) re-arms the value dedup.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&audio(0x16, 0x60)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,11 +669,6 @@ impl GamepadManager {
|
||||
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
|
||||
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
// Finish any unplug whose removal frame only armed the grace — the producer sends that
|
||||
// frame once, so without this the uinput node would outlive the controller. The swept
|
||||
// mask is discarded because this manager keeps no per-index sibling state (the pads mix
|
||||
// rumble internally); if that ever changes, consume it like the other two backends do.
|
||||
self.slots.reap();
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((low, high)) = pad.pump_ff() {
|
||||
send(i as u16, low, high);
|
||||
|
||||
@@ -62,30 +62,15 @@ impl<P> PadSlots<P> {
|
||||
self.label
|
||||
}
|
||||
|
||||
/// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out
|
||||
/// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its
|
||||
/// per-index sibling state; an index another manager owns is `None` here, so it is never
|
||||
/// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few
|
||||
/// frames and returns re-arms nothing.
|
||||
///
|
||||
/// A frame can only ARM the grace, never complete it — no time has passed at the instant the
|
||||
/// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the
|
||||
/// manager's periodic pump is what actually finishes the unplug; a backend that only ever
|
||||
/// called `sweep` would keep the detached pad alive for the rest of the session.
|
||||
/// Drop every allocated pad whose `active_mask` bit has stayed clear for [`SWEEP_GRACE`] (the
|
||||
/// unplug sweep run on each state frame), logging each. Returns the swept indices as a bitmask
|
||||
/// so the caller resets its per-index sibling state; an index another manager owns is `None`
|
||||
/// here, so it is never swept. The grace is the devnode-churn debounce: a mask that glitches
|
||||
/// clear for a few frames and returns re-arms nothing.
|
||||
pub fn sweep(&mut self, active_mask: u16) -> u16 {
|
||||
self.sweep_at(active_mask, Instant::now())
|
||||
}
|
||||
|
||||
/// Drop every allocated pad whose grace has run out, logging each — the half of the unplug
|
||||
/// that needs no state frame. Returns the dropped indices as a bitmask, same as [`Self::sweep`].
|
||||
///
|
||||
/// This can only ever *complete* an unplug some frame already started: it never arms a clock,
|
||||
/// so however often it runs it cannot drop a pad whose `active_mask` bit never went clear.
|
||||
/// That is what makes it safe to call from a hot pump loop.
|
||||
pub fn reap(&mut self) -> u16 {
|
||||
self.reap_at(Instant::now())
|
||||
}
|
||||
|
||||
/// Backdate every armed grace clock by [`SWEEP_GRACE`], so the NEXT sweep drops the pads
|
||||
/// whose bits are still clear — consumer tests (the managers') drive the debounce without
|
||||
/// wall-clock sleeps. Test-only: production code has no business expiring the grace.
|
||||
@@ -96,37 +81,26 @@ impl<P> PadSlots<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm
|
||||
/// each slot's clock from the mask, then reap whatever has already run out.
|
||||
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window).
|
||||
fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 {
|
||||
for i in 0..MAX_PADS {
|
||||
let mut swept = 0u16;
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if active_mask & (1 << i) != 0 {
|
||||
self.inactive_since[i] = None; // active (again): a glitch never reaches the drop
|
||||
} else if self.pads[i].is_some() && self.inactive_since[i].is_none() {
|
||||
self.inactive_since[i] = Some(now); // newly inactive — start the grace
|
||||
}
|
||||
}
|
||||
self.reap_at(now)
|
||||
}
|
||||
|
||||
/// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads
|
||||
/// `inactive_since` and clears it, so a pad whose bit never went clear has no clock to run out
|
||||
/// and cannot be dropped here.
|
||||
fn reap_at(&mut self, now: Instant) -> u16 {
|
||||
let mut swept = 0u16;
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(since) = self.inactive_since[i] else {
|
||||
continue; // active, or never went clear — nothing to complete
|
||||
};
|
||||
if self.pads[i].is_none() {
|
||||
self.inactive_since[i] = None; // the slot went away by some other route
|
||||
continue;
|
||||
}
|
||||
if now.duration_since(since) >= SWEEP_GRACE {
|
||||
tracing::info!(index = i, "controller unplugged ({})", self.label);
|
||||
self.pads[i] = None;
|
||||
self.inactive_since[i] = None;
|
||||
swept |= 1 << i;
|
||||
if slot.is_none() {
|
||||
continue;
|
||||
}
|
||||
match self.inactive_since[i] {
|
||||
None => self.inactive_since[i] = Some(now), // newly inactive — start the grace
|
||||
Some(since) if now.duration_since(since) >= SWEEP_GRACE => {
|
||||
tracing::info!(index = i, "controller unplugged ({})", self.label);
|
||||
*slot = None;
|
||||
self.inactive_since[i] = None;
|
||||
swept |= 1 << i;
|
||||
}
|
||||
Some(_) => {} // inside the grace — hold
|
||||
}
|
||||
}
|
||||
swept
|
||||
@@ -187,56 +161,6 @@ mod tests {
|
||||
PadSlots::new("Test", "test pad", "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_frame_plus_a_reap_completes_the_unplug() {
|
||||
// The shape production actually produces: ONE cleared-mask frame, then time, then a reap
|
||||
// with no further frame. Before the arm/reap split the pad survived here forever.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(2, |i| Ok(i as u32)));
|
||||
assert_eq!(
|
||||
s.sweep(0b0),
|
||||
0,
|
||||
"a frame arms the grace but cannot itself drop"
|
||||
);
|
||||
assert!(s.get(2).is_some());
|
||||
s.expire_grace();
|
||||
assert_eq!(s.reap(), 1 << 2, "the reap did not complete the unplug");
|
||||
assert!(s.get(2).is_none());
|
||||
assert_eq!(s.reap(), 0, "nothing left to reap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reap_never_drops_a_pad_no_frame_ever_deactivated() {
|
||||
// Reaping COMPLETES an unplug; it must never invent one. A pad whose bit never went clear
|
||||
// has no armed clock, so any number of reaps — even with the clock backdated — leaves it.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(0, |i| Ok(i as u32)));
|
||||
for _ in 0..10 {
|
||||
assert_eq!(s.reap(), 0);
|
||||
s.expire_grace();
|
||||
}
|
||||
assert!(
|
||||
s.get(0).is_some(),
|
||||
"reap dropped a pad that never went inactive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glitch_that_returns_inside_the_grace_never_drops_the_pad() {
|
||||
// The anti-flap guarantee, now that reaps are frequent: a client mask that blips clear and
|
||||
// comes back must not churn a PnP devnode.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(0, |i| Ok(i as u32)));
|
||||
assert_eq!(s.sweep(0b0), 0); // bit clears — arms only
|
||||
for _ in 0..5 {
|
||||
assert_eq!(s.reap(), 0, "dropped a pad inside its grace");
|
||||
}
|
||||
assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms
|
||||
s.expire_grace();
|
||||
assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed");
|
||||
assert!(s.get(0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_once_and_reports_freshness() {
|
||||
let mut s = slots();
|
||||
|
||||
@@ -481,7 +481,8 @@ pub struct DsFeedback {
|
||||
|
||||
/// 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.
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks and the audio-control region 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 rest is zeroed),
|
||||
@@ -540,6 +541,21 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
|
||||
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
|
||||
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
|
||||
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
|
||||
// whenever an audio-valid flag is present or the region carries data; downstream dedup
|
||||
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
|
||||
let raw: [u8; 6] = data[5..11].try_into().unwrap();
|
||||
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
|
||||
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
|
||||
fb.hidout.push(HidOutput::AudioCtl {
|
||||
pad: pad.into(),
|
||||
flags,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -842,6 +858,48 @@ mod tests {
|
||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||
}
|
||||
|
||||
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
|
||||
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
|
||||
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
|
||||
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
|
||||
#[test]
|
||||
fn parse_output_surfaces_audio_ctl() {
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
|
||||
data[5] = 0x50; // headphone volume
|
||||
data[6] = 0x60; // speaker volume
|
||||
data[7] = 0x70; // mic volume
|
||||
data[8] = 0x05; // audio routing / enable bits
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(3, &data, &mut fb);
|
||||
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0b1_0111,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
}]
|
||||
);
|
||||
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
|
||||
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
|
||||
// the honest bytes either way.
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[9] = 0x01;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags: 0,
|
||||
raw: [0, 0, 0, 0, 0x01, 0],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
#[test]
|
||||
fn parse_output_rejects_garbage() {
|
||||
|
||||
@@ -217,10 +217,13 @@ impl<B: PadProto> UhidManager<B> {
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
|
||||
// on a later `pump` tick — this frame is the only one the producer sends).
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
self.reset_swept(swept);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.reset_pad(i);
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
@@ -279,12 +282,6 @@ impl<B: PadProto> UhidManager<B> {
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
let now = Instant::now();
|
||||
// Finish any unplug whose removal frame only armed the grace. The producer emits that
|
||||
// frame exactly once, so without this a detached pad — the single-pad session being the
|
||||
// common case — would never be destroyed. Runs BEFORE the loop so a reaped index is
|
||||
// already gone for `get_mut` here and for `heartbeat`'s `get` later in the same tick.
|
||||
let swept = self.slots.reap();
|
||||
self.reset_swept(swept);
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(pad) = self.slots.get_mut(i) else {
|
||||
continue;
|
||||
@@ -363,18 +360,6 @@ impl<B: PadProto> UhidManager<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the sibling state of every index a sweep or reap just dropped. Both halves of the
|
||||
/// unplug land here, so a pad torn down on the pump tick clears exactly what one torn down on
|
||||
/// a state frame would — in particular `hidout_dedup`, which has no watchdog to re-arm it and
|
||||
/// would otherwise swallow an identical lightbar/trigger re-assert after a re-plug.
|
||||
fn reset_swept(&mut self, swept: u16) {
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.reset_pad(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
|
||||
/// (re)connect starts from scratch and is always forwarded.
|
||||
fn reset_pad(&mut self, idx: usize) {
|
||||
@@ -490,6 +475,7 @@ mod tests {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
@@ -509,36 +495,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_removal_frame_plus_a_pump_tick_completes_the_unplug() {
|
||||
// The producer emits the cleared-mask frame exactly ONCE — `native/input.rs` guards it on
|
||||
// the bit still being set — so the teardown has to finish on the periodic pump. The
|
||||
// previous version of this test hand-fed a SECOND removal frame, which is what let the
|
||||
// never-reaped pad hide: with one frame and no pump, the device outlived the session.
|
||||
fn removal_frame_never_recreates_the_pad_it_swept() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(1, 0b10, 0));
|
||||
assert!(m.slots.get(1).is_some());
|
||||
// The one removal frame: arms the devnode-churn grace, drops nothing.
|
||||
// Bit 1 cleared: the first sweep only ARMS the devnode-churn grace — the pad holds (a
|
||||
// mask glitch must not flap PnP devices; see pad_slots::SWEEP_GRACE).
|
||||
m.handle(&frame(1, 0b00, 0));
|
||||
assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept");
|
||||
// A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE).
|
||||
m.pump(|_, _, _| {}, |_| {});
|
||||
assert!(
|
||||
m.slots.get(1).is_some(),
|
||||
"a tick inside the grace dropped it"
|
||||
);
|
||||
// Grace elapsed: the next tick completes the unplug, with no further frame.
|
||||
// Grace elapsed: the frame IS pad 1's removal — sweep, then early-return (no ensure).
|
||||
m.slots.expire_grace();
|
||||
m.pump(|_, _, _| {}, |_| {});
|
||||
assert!(
|
||||
m.slots.get(1).is_none(),
|
||||
"the pump tick never completed the unplug"
|
||||
);
|
||||
// …and a further cleared-mask frame must not resurrect it (the arm branch early-returns).
|
||||
m.handle(&frame(1, 0b00, 0));
|
||||
assert!(
|
||||
m.slots.get(1).is_none(),
|
||||
"a cleared-mask frame recreated the pad"
|
||||
);
|
||||
assert!(m.slots.get(1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -584,15 +552,10 @@ mod tests {
|
||||
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
|
||||
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
|
||||
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
|
||||
// on a PUMP tick, not on a second frame — that is all production ever sends.
|
||||
m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace
|
||||
// Unplug + recreate re-arms the dedup: the same level forwards again.
|
||||
m.handle(&frame(0, 0b0, 0)); // arms the sweep grace
|
||||
m.slots.expire_grace();
|
||||
assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward
|
||||
assert!(
|
||||
m.slots.get(0).is_none(),
|
||||
"the pump tick completed the unplug"
|
||||
);
|
||||
m.handle(&frame(0, 0b0, 0)); // grace elapsed — actually swept
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
|
||||
|
||||
@@ -318,10 +318,14 @@ impl GamepadManager {
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
|
||||
// on a later `pump_rumble` tick — this frame is the only one the producer sends).
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
self.reset_swept(swept);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -341,25 +345,10 @@ impl GamepadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the sibling state of every index a sweep or reap just dropped, so both halves of the
|
||||
/// unplug clear the same things.
|
||||
fn reset_swept(&mut self, swept: u16) {
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
|
||||
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
|
||||
/// (high-frequency) → `high` — matching the other backends.
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
// Finish any unplug whose removal frame only armed the grace — the producer sends that
|
||||
// frame once, so without this the XUSB devnode would outlive the controller.
|
||||
let swept = self.slots.reap();
|
||||
self.reset_swept(swept);
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((large, small)) = pad.service() {
|
||||
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
|
||||
|
||||
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
@@ -759,6 +765,16 @@ impl PunktfunkHidOutput {
|
||||
out.effect_len = 6;
|
||||
}
|
||||
HidOutput::HidRaw { .. } => return None,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
|
||||
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly —
|
||||
// pads are 0..16 (`input::MAX_PADS`) end to end.
|
||||
out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
|
||||
out.pad = *pad as u8;
|
||||
out.which = *flags;
|
||||
out.effect[0..6].copy_from_slice(raw);
|
||||
out.effect_len = 6;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1172,6 +1188,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
|
||||
/// design/pen-tablet-input.md.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x20;
|
||||
|
||||
/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||
/// stream (a real DualSense's voice coils).
|
||||
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||
/// stream.
|
||||
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
@@ -1186,6 +1221,20 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
|
||||
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
|
||||
// `input::encode_gamepad_arrival` applies).
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
|
||||
);
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
|
||||
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
|
||||
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
|
||||
@@ -1768,6 +1817,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// forward-compatible.
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x04;
|
||||
|
||||
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
||||
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
|
||||
@@ -2312,6 +2368,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||
/// thread (one puller, may run alongside the other planes' pullers).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
/// `buf` is writable for `buf_len` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||
c: *mut PunktfunkConnection,
|
||||
out_pad: *mut u8,
|
||||
out_kind: *mut u8,
|
||||
out_seq: *mut u32,
|
||||
out_pts_ns: *mut u64,
|
||||
buf: *mut u8,
|
||||
buf_len: usize,
|
||||
timeout_ms: u32,
|
||||
) -> i32 {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return -1,
|
||||
};
|
||||
if buf.is_null() && buf_len != 0 {
|
||||
return -1;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Some(f) => {
|
||||
if f.opus.is_empty() || f.opus.len() > buf_len {
|
||||
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
|
||||
// as loss would synthesize concealment) or doesn't fit — report "nothing
|
||||
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
|
||||
// be undecodable anyway).
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
|
||||
// checked before it is written; `buf` is a caller-owned writable region of
|
||||
// `buf_len` bytes and the copy length was just bounds-checked against it.
|
||||
unsafe {
|
||||
if !out_pad.is_null() {
|
||||
*out_pad = f.pad;
|
||||
}
|
||||
if !out_kind.is_null() {
|
||||
*out_kind = f.kind;
|
||||
}
|
||||
if !out_seq.is_null() {
|
||||
*out_seq = f.seq;
|
||||
}
|
||||
if !out_pts_ns.is_null() {
|
||||
*out_pts_ns = f.pts_ns;
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
|
||||
}
|
||||
f.opus.len() as i32
|
||||
}
|
||||
// `None` folds timeout and closed; the shutdown flag tells them apart so the
|
||||
// embedder's plane loop can exit instead of polling a dead session forever.
|
||||
None if c.inner.is_session_ended() => -1,
|
||||
None => 0,
|
||||
}
|
||||
}));
|
||||
r.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||
/// before. Latest-wins per pad; unknown bits are masked off.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u8,
|
||||
audio_caps: u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
c.inner.set_pad_audio_caps(pad, audio_caps);
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||
@@ -4412,3 +4579,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quic"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||
#[test]
|
||||
fn hidout_abi_maps_audio_ctl() {
|
||||
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0x17,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
|
||||
assert_eq!(out.pad, 3);
|
||||
assert_eq!(out.which, 0x17);
|
||||
assert_eq!(out.effect_len, 6);
|
||||
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
|
||||
assert_eq!(out.effect[6..], [0; 5]);
|
||||
// A raw passthrough report still has no C representation (skipped at the pull site).
|
||||
assert!(
|
||||
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: 0,
|
||||
data: vec![0x80],
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
|
||||
RfiRequest, RichInput,
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
|
||||
ProbeRequest, RfiRequest, RichInput,
|
||||
};
|
||||
use crate::session::Frame;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{
|
||||
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
|
||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||
use self::planes::{
|
||||
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
|
||||
};
|
||||
use self::probe::ProbeState;
|
||||
use self::pump::run_pump;
|
||||
@@ -122,6 +124,14 @@ pub struct NativeClient {
|
||||
rumble_sched: Arc<rumble::RumbleShared>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Mutex<Receiver<HidOutput>>,
|
||||
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
|
||||
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
|
||||
pad_audio: Mutex<Receiver<PadAudioFrame>>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
|
||||
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
||||
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
||||
@@ -418,6 +428,10 @@ impl NativeClient {
|
||||
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
||||
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (pad_audio_tx, pad_audio_rx) =
|
||||
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
|
||||
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
|
||||
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
|
||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||
let (host_timing_tx, host_timing_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
||||
@@ -459,6 +473,7 @@ impl NativeClient {
|
||||
let clock_offset_w = clock_offset.clone();
|
||||
let decode_lat_w = decode_lat.clone();
|
||||
let live_bitrate_w = live_bitrate.clone();
|
||||
let pad_audio_caps_w = pad_audio_caps.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
@@ -502,6 +517,8 @@ impl NativeClient {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps: pad_audio_caps_w,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -550,6 +567,8 @@ impl NativeClient {
|
||||
rumble: Mutex::new(rumble_rx),
|
||||
rumble_sched,
|
||||
hidout: Mutex::new(hidout_rx),
|
||||
pad_audio: Mutex::new(pad_audio_rx),
|
||||
pad_audio_caps,
|
||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||
host_timing: Mutex::new(host_timing_rx),
|
||||
cursor_shape: Mutex::new(cursor_shape_rx),
|
||||
@@ -1055,6 +1074,33 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
|
||||
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
|
||||
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
|
||||
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
|
||||
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
|
||||
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
|
||||
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
|
||||
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
|
||||
/// puller per the plane contract.
|
||||
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
|
||||
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
|
||||
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
|
||||
/// Call at controller attach, BEFORE the pad's arrival is sent (like
|
||||
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
|
||||
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
|
||||
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
|
||||
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
|
||||
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
|
||||
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
|
||||
slot.store(audio_caps & 0x03, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
||||
/// the host sent for an HDR session; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
||||
|
||||
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
pub(crate) const HIDOUT_QUEUE: usize = 32;
|
||||
|
||||
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
|
||||
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
|
||||
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
|
||||
/// newest frame (the renderer conceals the gap).
|
||||
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
|
||||
|
||||
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
||||
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
||||
pub(crate) const HDR_META_QUEUE: usize = 8;
|
||||
|
||||
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
|
||||
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
|
||||
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
|
||||
// (see [`input_task`]).
|
||||
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
|
||||
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
|
||||
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
|
||||
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||
tokio::spawn(input_task::run(
|
||||
conn.clone(),
|
||||
input_rx,
|
||||
gamepad_snapshots,
|
||||
pad_audio_arrivals,
|
||||
pad_audio_caps,
|
||||
));
|
||||
|
||||
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
||||
// Self-healing latency bound: every frame still queued once this task catches up is standing
|
||||
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
encode_lat.clone(),
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) async fn run(
|
||||
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
||||
rumble_feed: super::super::rumble::RumbleFeed,
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||
@@ -70,6 +71,11 @@ pub(super) async fn run(
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
|
||||
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
|
||||
let _ = pad_audio_tx.try_send(f);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||
let _ = hdr_meta_tx.try_send(m);
|
||||
|
||||
@@ -15,8 +15,16 @@ pub(super) async fn run(
|
||||
conn: quinn::Connection,
|
||||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
gamepad_snapshots: bool,
|
||||
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
|
||||
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
|
||||
// so unexpected high bits would make it drop the kind declaration entirely.
|
||||
pad_audio: bool,
|
||||
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
|
||||
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
|
||||
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
|
||||
) {
|
||||
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||
use std::sync::atomic::Ordering;
|
||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||
@@ -37,6 +45,17 @@ pub(super) async fn run(
|
||||
const ARRIVAL_RESENDS: u8 = 2;
|
||||
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
||||
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
|
||||
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
|
||||
// byte-identical to the plain index — the pre-pad-audio wire.
|
||||
let arrival_flags = |idx: usize| -> u32 {
|
||||
let caps = if pad_audio {
|
||||
pad_audio_caps[idx].load(Ordering::Relaxed)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
crate::input::encode_gamepad_arrival(idx as u8, caps)
|
||||
};
|
||||
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
@@ -81,13 +100,28 @@ pub(super) async fn run(
|
||||
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
||||
// so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
continue;
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
|
||||
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
|
||||
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
|
||||
// the usual source). Fold event-carried bits into the registry so the re-send
|
||||
// burst keeps them, then send with the negotiation-gated flags word.
|
||||
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_PADS {
|
||||
if ev_caps != 0 {
|
||||
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
|
||||
}
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send
|
||||
// burst so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let arr = crate::input::InputEvent {
|
||||
flags: arrival_flags(idx),
|
||||
..ev
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
}
|
||||
@@ -104,7 +138,7 @@ pub(super) async fn run(
|
||||
code: kind as u32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: idx as u32,
|
||||
flags: arrival_flags(idx),
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
} else {
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::Result;
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{HdrMeta, HidOutput};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
|
||||
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
|
||||
/// closed, so the command API always observes connection teardown.
|
||||
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
|
||||
/// [`NativeClient::next_pad_audio`].
|
||||
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
|
||||
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
|
||||
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
|
||||
|
||||
@@ -64,7 +64,11 @@ pub enum InputKind {
|
||||
GamepadRemove = 13,
|
||||
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||
/// Sent when the client opens a pad slot — before that pad's
|
||||
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, (flags >> 24) as u8)
|
||||
}
|
||||
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||
/// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||
/// it drop the declaration).
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
|
||||
|
||||
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
|
||||
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
|
||||
/// pre-pad-audio wire bytes exactly.
|
||||
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
|
||||
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
|
||||
}
|
||||
|
||||
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
|
||||
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
|
||||
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
|
||||
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
|
||||
/// An old-format word (index only) yields `audio_caps = 0`.
|
||||
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, ((flags >> 8) & 0x03) as u8)
|
||||
}
|
||||
|
||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||
///
|
||||
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
|
||||
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
|
||||
kind: u8,
|
||||
/// LI_CCAP_* bits (0x02 = rumble).
|
||||
capabilities: u16,
|
||||
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
|
||||
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
|
||||
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
|
||||
/// express pad audio and always sets `0`, as does an old client.
|
||||
audio_caps: u8,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -443,6 +480,31 @@ mod tests {
|
||||
assert_eq!((pad, seq), (9, 123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_arrival_flags_roundtrip() {
|
||||
// The capability bits ride bits 8/9; the index stays the low byte.
|
||||
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
|
||||
let flags = encode_gamepad_arrival(pad, caps);
|
||||
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
|
||||
assert_eq!(flags & 0xFF, pad as u32);
|
||||
}
|
||||
assert_eq!(
|
||||
encode_gamepad_arrival(2, 0b11),
|
||||
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
|
||||
// old host) is byte-identical to the plain index, and decodes with caps 0.
|
||||
assert_eq!(encode_gamepad_arrival(5, 0), 5);
|
||||
assert_eq!(decode_gamepad_arrival(5), (5, 0));
|
||||
// Undefined high bits (a future extension) never leak into the index OR the caps.
|
||||
assert_eq!(
|
||||
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
|
||||
(9, 1)
|
||||
);
|
||||
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
|
||||
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_snapshot_roundtrip() {
|
||||
let s = GamepadSnapshot {
|
||||
|
||||
@@ -120,7 +120,13 @@ pub use stats::Stats;
|
||||
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 14;
|
||||
/// v15: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||
/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||
/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 15;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -111,6 +111,16 @@ pub const CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// simply ignored — no behavior change in either direction.
|
||||
pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||
/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||
/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||
/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||
/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||
/// precedent, per pad; toward an older or incapable host nothing changes. `0x04` — `0x01` is
|
||||
/// [`CLIENT_CAP_CURSOR`], `0x02` is [`CLIENT_CAP_PHASE_LOCK`].
|
||||
pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x04;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
|
||||
@@ -132,6 +142,17 @@ pub const HOST_CAP_CURSOR: u8 = 0x08;
|
||||
/// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
pub const HOST_CAP_PEN: u8 = 0x10;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||
/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||
/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||
/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||
/// capable client marks its pads' render capabilities on their arrivals
|
||||
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||
/// toward exactly those pads. `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is
|
||||
/// [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state /
|
||||
/// clipboard.
|
||||
pub const HOST_CAP_PAD_AUDIO: u8 = 0x20;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
@@ -314,6 +335,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_cap_bits_are_distinct() {
|
||||
// The new pad-audio bits pack into the existing caps bytes without colliding with any
|
||||
// taken bit (a collision would silently negotiate an unrelated feature).
|
||||
assert_eq!(
|
||||
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
HOST_CAP_PAD_AUDIO
|
||||
& (HOST_CAP_GAMEPAD_STATE
|
||||
| HOST_CAP_CLIPBOARD
|
||||
| HOST_CAP_TEXT_INPUT
|
||||
| HOST_CAP_CURSOR
|
||||
| HOST_CAP_PEN),
|
||||
0
|
||||
);
|
||||
// Single-bit values (a multi-bit cap would OR neighbours in).
|
||||
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
|
||||
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xD1):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
|
||||
//! cursor state, pad audio.
|
||||
|
||||
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
/// (0xCE, host→client).
|
||||
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
/// host→client).
|
||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
@@ -332,6 +335,7 @@ const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
const HIDOUT_HID_RAW: u8 = 0x05;
|
||||
const HIDOUT_AUDIO_CTL: u8 = 0x06;
|
||||
|
||||
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||
@@ -372,6 +376,16 @@ pub enum HidOutput {
|
||||
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
||||
/// firmware watchdog — a lost datagram heals on the next refresh.
|
||||
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
||||
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
|
||||
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
|
||||
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
|
||||
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
|
||||
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
|
||||
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
|
||||
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
|
||||
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
|
||||
/// state on every output report.
|
||||
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -404,6 +418,12 @@ impl HidOutput {
|
||||
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
||||
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
||||
}
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
out.push(HIDOUT_AUDIO_CTL);
|
||||
out.extend_from_slice(&pad.to_le_bytes());
|
||||
out.push(*flags);
|
||||
out.extend_from_slice(raw);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -441,6 +461,11 @@ impl HidOutput {
|
||||
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
|
||||
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
|
||||
}),
|
||||
HIDOUT_AUDIO_CTL if b.len() >= 11 => Some(HidOutput::AudioCtl {
|
||||
pad: u16::from_le_bytes([b[2], b[3]]),
|
||||
flags: b[4],
|
||||
raw: b[5..11].try_into().unwrap(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -699,6 +724,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
|
||||
|
||||
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
|
||||
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
|
||||
|
||||
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
|
||||
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
|
||||
/// against the main audio plane.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PadAudioFrame {
|
||||
/// Gamepad index (the wire pad space, same as rumble/HID-output).
|
||||
pub pad: u8,
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
|
||||
pub kind: u8,
|
||||
pub seq: u32,
|
||||
pub pts_ns: u64,
|
||||
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
|
||||
pub opus: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Pad-audio datagram, host → client:
|
||||
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
|
||||
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
|
||||
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
|
||||
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
|
||||
b.push(PAD_AUDIO_MAGIC);
|
||||
b.push(pad);
|
||||
b.push(kind);
|
||||
b.extend_from_slice(&seq.to_le_bytes());
|
||||
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
b.extend_from_slice(opus);
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
|
||||
/// length bounds every read before it happens).
|
||||
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
|
||||
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
|
||||
return None;
|
||||
}
|
||||
Some(PadAudioFrame {
|
||||
pad: buf[1],
|
||||
kind: buf[2],
|
||||
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
|
||||
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
|
||||
opus: buf[15..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::quic::*;
|
||||
@@ -1027,6 +1118,12 @@ mod tests {
|
||||
f
|
||||
},
|
||||
},
|
||||
// The DS5 audio-control region (haptics-select + speaker volume asserted).
|
||||
HidOutput::AudioCtl {
|
||||
pad: 1,
|
||||
flags: 0b0_0101,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
@@ -1045,6 +1142,47 @@ mod tests {
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_ctl_wire_layout_and_truncation() {
|
||||
// The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes].
|
||||
let a = HidOutput::AudioCtl {
|
||||
pad: 0x0201,
|
||||
flags: 0x17,
|
||||
raw: [1, 2, 3, 4, 5, 6],
|
||||
};
|
||||
let d = a.encode();
|
||||
assert_eq!(d, [0xCD, 0x06, 0x01, 0x02, 0x17, 1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(HidOutput::decode(&d), Some(a));
|
||||
// Truncated buffers are rejected outright (fixed length — never a partial read).
|
||||
for n in 2..d.len() {
|
||||
assert_eq!(HidOutput::decode(&d[..n]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_datagram_roundtrip_and_truncation() {
|
||||
let opus = [0x5Au8; 61];
|
||||
let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus);
|
||||
assert_eq!(d[0], PAD_AUDIO_MAGIC);
|
||||
assert_eq!(d.len(), 15 + opus.len());
|
||||
let f = decode_pad_audio_datagram(&d).unwrap();
|
||||
assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999));
|
||||
assert_eq!(f.opus, opus);
|
||||
// Truncated headers are rejected outright (never partially read).
|
||||
for n in 0..15 {
|
||||
assert_eq!(decode_pad_audio_datagram(&d[..n]), None);
|
||||
}
|
||||
// Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa.
|
||||
assert!(decode_audio_datagram(&d).is_none());
|
||||
assert!(decode_mic_datagram(&d).is_none());
|
||||
assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
|
||||
// Empty payload (DTX) is legal — header-only datagram.
|
||||
let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]);
|
||||
assert_eq!(hdr.len(), 15);
|
||||
assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_state_roundtrip() {
|
||||
for (flags, x, y) in [
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
||||
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
||||
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF plane codecs,
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xD1 plane codecs,
|
||||
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
|
||||
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
||||
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
|
||||
|
||||
@@ -259,6 +259,17 @@ windows = { version = "0.62", features = [
|
||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||
"Win32_System_Com",
|
||||
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
|
||||
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
|
||||
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
|
||||
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
|
||||
# ACL repair + the devnode's pad-index marker value.
|
||||
"Win32_Media_Audio",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Variant",
|
||||
"Win32_Devices_Properties",
|
||||
"Win32_System_Registry",
|
||||
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
|
||||
@@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||
// `pad-endpoint` devtest.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/pad_endpoint.rs"]
|
||||
pub(crate) mod pad_endpoint;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
|
||||
@@ -102,6 +102,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
}
|
||||
|
||||
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
|
||||
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
|
||||
/// devnode marker, registry-only reads); this is just the per-pass collection.
|
||||
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
renders
|
||||
.iter()
|
||||
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
|
||||
.map(|(_, id)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||
@@ -118,7 +129,17 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
|
||||
// The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters
|
||||
// them out of every role. Identity is platform data (stamped container / devnode marker),
|
||||
// so it is collected HERE and passed in, like the candidate lists themselves.
|
||||
let pad_ids = pad_render_ids(&renders);
|
||||
let wiring = plan(
|
||||
&renders,
|
||||
&captures,
|
||||
want.as_deref(),
|
||||
host_audio_requested(),
|
||||
&pad_ids,
|
||||
);
|
||||
let done = |wiring: Wiring| WiredPlan {
|
||||
wiring,
|
||||
fingerprint,
|
||||
@@ -171,7 +192,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
if let Some((mic_name, mic_id)) = &wiring.mic_render {
|
||||
if default_render_id().as_deref() == Some(mic_id.as_str()) {
|
||||
// Audible preference = the host_audio plan's loopback pick (real hardware first).
|
||||
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
|
||||
match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render {
|
||||
Some((name, id)) => match set_default_endpoint(&id) {
|
||||
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
|
||||
"default playback was the virtual-mic target — moved it so desktop \
|
||||
@@ -228,8 +249,10 @@ fn park_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default.prev")
|
||||
}
|
||||
|
||||
/// The current default RENDER endpoint id, if any.
|
||||
fn default_render_id() -> Option<String> {
|
||||
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
|
||||
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
|
||||
/// default playback device).
|
||||
pub(crate) fn default_render_id() -> Option<String> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.ok()?
|
||||
.get_default_device(&Direction::Render)
|
||||
@@ -337,11 +360,13 @@ pub(crate) fn restore_default_playback() {
|
||||
}
|
||||
|
||||
/// Open a device by endpoint id, with a name for error context.
|
||||
///
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||
/// docs), so it fails at random on ids that are perfectly valid.
|
||||
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
|
||||
.get_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
|
||||
super::pad_endpoint::open_wasapi_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
@@ -388,8 +413,9 @@ const _: () = {
|
||||
|
||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
|
||||
/// fails.
|
||||
fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default
|
||||
/// through the same machinery.
|
||||
pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -627,8 +627,11 @@ enum DefaultKind {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
|
||||
let Ok(dev) = en.get_device(id) else {
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
|
||||
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
|
||||
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
|
||||
return DefaultKind::Unknown;
|
||||
};
|
||||
let name = dev.get_friendlyname().unwrap_or_default();
|
||||
|
||||
@@ -253,25 +253,16 @@ pub(crate) fn install_steam_audio_pair() -> bool {
|
||||
mic || spk
|
||||
}
|
||||
|
||||
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
||||
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
||||
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
||||
/// per-arch `drivers\Windows10\{arch}\` directory.
|
||||
///
|
||||
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
|
||||
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
|
||||
/// inside, which is this function's own business.
|
||||
fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
use windows::core::{s, w, PCWSTR};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
/// Full path of a Steam Remote Play driver INF under Steam's per-arch driver directory
|
||||
/// (`%CommonProgramFiles(x86)%\Steam\drivers\Windows10\{arch}\<inf_name>`), as a NUL-terminated
|
||||
/// UTF-16 buffer. Shared by [`try_install_steam_audio`] and the pad-endpoint provisioning
|
||||
/// ([`super::pad_endpoint`]), which feeds the same INF to `UpdateDriverForPlugAndPlayDevicesW`
|
||||
/// when no installed Steam Streaming Speakers devnode exposes its `oemNN.inf`. `None` when the
|
||||
/// environment expansion fails (existence is the caller's check).
|
||||
pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
|
||||
use windows::Win32::System::LibraryLoader::{
|
||||
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
};
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
|
||||
return false;
|
||||
}
|
||||
// Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
let subdir = "x64";
|
||||
@@ -290,8 +281,33 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
let n =
|
||||
unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) };
|
||||
if n == 0 || n as usize > path.len() {
|
||||
return None;
|
||||
}
|
||||
path.truncate(n as usize); // keeps the NUL
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
||||
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
||||
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
||||
/// per-arch `drivers\Windows10\{arch}\` directory.
|
||||
///
|
||||
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
|
||||
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
|
||||
/// inside, which is this function's own business.
|
||||
fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
use windows::core::{s, w, PCWSTR};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::System::LibraryLoader::{
|
||||
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
};
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(path) = steam_driver_inf_path(inf_name) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot
|
||||
// pick up a planted `newdev.dll` from the working directory. The handle is checked before use.
|
||||
|
||||
@@ -137,7 +137,20 @@ pub(crate) fn plan(
|
||||
captures: &[Endpoint],
|
||||
mic_want: Option<&str>,
|
||||
host_audio: bool,
|
||||
pad_renders: &[String],
|
||||
) -> Wiring {
|
||||
// 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice
|
||||
// would play out of a pad "speaker"), never a loopback source (a game's controller
|
||||
// audio cues would stream as desktop audio), and — since this shadows `renders` for
|
||||
// every tier below — never the flagged last resort either. Their names carry no virtual
|
||||
// marker (they are stamped "DualSense Wireless Controller" on purpose, so games read
|
||||
// them as the pad's speaker), so the name rules alone would take one for real hardware.
|
||||
let renders: Vec<Endpoint> = renders
|
||||
.iter()
|
||||
.filter(|(_, id)| !pad_renders.iter().any(|p| p == id))
|
||||
.cloned()
|
||||
.collect();
|
||||
let renders = renders.as_slice();
|
||||
let find_render = |needle: &str| {
|
||||
renders
|
||||
.iter()
|
||||
@@ -323,7 +336,7 @@ mod tests {
|
||||
ep("Microphone (Webcam)"),
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
@@ -352,7 +365,7 @@ mod tests {
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
@@ -372,7 +385,7 @@ mod tests {
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let w = plan(&renders, &[], None, true);
|
||||
let w = plan(&renders, &[], None, true, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.unwrap().0,
|
||||
"Speakers (Apple Audio Device)"
|
||||
@@ -389,7 +402,7 @@ mod tests {
|
||||
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
|
||||
];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &[], None, host_audio);
|
||||
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||
}
|
||||
}
|
||||
@@ -401,7 +414,7 @@ mod tests {
|
||||
fn headless_cable_only_mic_wins() {
|
||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.mic_render.is_some(), "mic must claim the only cable");
|
||||
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
|
||||
}
|
||||
@@ -419,7 +432,7 @@ mod tests {
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
@@ -447,7 +460,7 @@ mod tests {
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
@@ -461,7 +474,7 @@ mod tests {
|
||||
fn steam_mic_only_no_echo() {
|
||||
let renders = [ep("Speakers (Steam Streaming Microphone)")];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.mic_render.is_some());
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
@@ -477,7 +490,7 @@ mod tests {
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &[], None, host_audio);
|
||||
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Speakers)",
|
||||
@@ -498,7 +511,7 @@ mod tests {
|
||||
ep("Altavoces (Steam Streaming Microphone)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Altavoces (Steam Streaming Microphone)"
|
||||
@@ -521,7 +534,7 @@ mod tests {
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Realtek HD Audio)",
|
||||
@@ -543,7 +556,7 @@ mod tests {
|
||||
];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
|
||||
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
|
||||
@@ -558,7 +571,7 @@ mod tests {
|
||||
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
|
||||
];
|
||||
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
||||
let w = plan(&renders, &captures, Some("voicemeeter input"), false);
|
||||
let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
|
||||
@@ -574,7 +587,7 @@ mod tests {
|
||||
#[test]
|
||||
fn no_virtual_device() {
|
||||
let renders = [ep("Speakers (Realtek HD Audio)")];
|
||||
let w = plan(&renders, &[], None, false);
|
||||
let w = plan(&renders, &[], None, false, &[]);
|
||||
assert!(w.mic_render.is_none());
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
}
|
||||
@@ -592,7 +605,7 @@ mod tests {
|
||||
];
|
||||
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.as_ref().unwrap().0,
|
||||
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)",
|
||||
@@ -615,7 +628,7 @@ mod tests {
|
||||
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
|
||||
];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &[], None, host_audio);
|
||||
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||
assert!(w.mic_render.is_some(), "host_audio={host_audio}");
|
||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||
}
|
||||
@@ -630,7 +643,7 @@ mod tests {
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Some Virtual Audio Device)"),
|
||||
];
|
||||
let w = plan(&renders, &[], None, false);
|
||||
let w = plan(&renders, &[], None, false, &[]);
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
@@ -656,7 +669,7 @@ mod tests {
|
||||
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
|
||||
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
let msg = describe_no_loopback(&renders, &w);
|
||||
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
|
||||
@@ -667,10 +680,70 @@ mod tests {
|
||||
// anyway), while the Steam pair is the remedy that adds a capturable sink.
|
||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
let msg = describe_no_loopback(&renders, &w);
|
||||
assert!(msg.contains("install Steam"), "{msg}");
|
||||
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
|
||||
}
|
||||
|
||||
/// A stamped pad endpoint is invisible to the plan. Its name carries NO virtual marker — on
|
||||
/// purpose, games must read it as the pad's speaker — so the name rules alone would classify
|
||||
/// it as real hardware and hand it the loopback; only the id exclusion prevents that.
|
||||
/// Measured fact: the wiring plan on the target box already enumerated a stamped endpoint.
|
||||
#[test]
|
||||
fn pad_endpoints_invisible() {
|
||||
let renders = [
|
||||
ep("DualSense Wireless Controller"),
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
];
|
||||
let pads = [renders[0].1.clone()];
|
||||
let w = plan(&renders, &[], None, false, &pads);
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
// Even an operator mic override matching the pad's name must not claim it; with the
|
||||
// pad as the only render endpoint there is honestly no mic target and no loopback.
|
||||
let w = plan(
|
||||
&renders[..1],
|
||||
&[],
|
||||
Some("wireless controller"),
|
||||
false,
|
||||
&pads,
|
||||
);
|
||||
assert!(w.mic_render.is_none());
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
/// The exclusion has to survive the LAST RESORT tier, which this merge introduced alongside
|
||||
/// pad audio. `last_resort` matches on the Steam-Speakers name, but it reads the same
|
||||
/// shadowed `renders`, so a pad can never be reached through it either — otherwise the whole
|
||||
/// desktop mix would be routed into the controller's voice coils.
|
||||
#[test]
|
||||
fn a_pad_is_never_the_last_resort() {
|
||||
// Only the pad and the Steam pair exist; the mic reserves the Streaming Microphone, so
|
||||
// the plan falls all the way through to the last resort.
|
||||
let renders = [
|
||||
ep("DualSense Wireless Controller"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let pads = [renders[0].1.clone()];
|
||||
let w = plan(&renders, &captures, None, false, &pads);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Speakers)",
|
||||
"the last resort must skip the pad"
|
||||
);
|
||||
assert!(w.loopback_last_resort);
|
||||
|
||||
// …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable
|
||||
// rather than falling back onto the coils.
|
||||
let w = plan(&renders[..1], &captures, None, false, &pads);
|
||||
assert!(
|
||||
w.loopback_render.is_none(),
|
||||
"a pad was taken as the last resort"
|
||||
);
|
||||
assert!(!w.loopback_last_resort);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +384,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
index: idx,
|
||||
kind: 2,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
println!(
|
||||
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
||||
@@ -430,6 +431,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
index: idx,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
println!(
|
||||
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
||||
@@ -486,6 +488,119 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
|
||||
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
|
||||
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
|
||||
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
|
||||
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||
use crate::audio::pad_endpoint as pe;
|
||||
let idx: u8 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--index")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
// `--endpoint <id>` drives ANY render endpoint, not just a provisioned pad one. It is the
|
||||
// discriminator between "this process cannot activate anything" and "our endpoint is broken":
|
||||
// aim the same binary at a known-good endpoint and see whether it succeeds there.
|
||||
let endpoint_override: Option<String> = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--endpoint")
|
||||
.nth(1)
|
||||
.cloned();
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("ensure") => {
|
||||
let p = pe::ensure(idx)?;
|
||||
println!(
|
||||
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
|
||||
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("remove") => match pe::find(idx)? {
|
||||
Some(p) => {
|
||||
pe::remove(&p);
|
||||
println!(
|
||||
"pad-endpoint remove: requested removal of {}",
|
||||
p.device_instance
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
// `punktfunk-host pad-endpoint <n> tone [seconds] [hz]` — drive the endpoint directly so
|
||||
// the whole pad-audio chain can be exercised without a game. Without this, every attempt
|
||||
// costs a game launch and a failure does not say which link broke.
|
||||
Some("tone") => {
|
||||
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0);
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
// `find` (a system lookup), NOT `endpoint_for` (the service's in-process
|
||||
// cache): this runs as a separate CLI process and has no cache of its own.
|
||||
let Some(ep) = pe::find(idx)? else {
|
||||
println!(
|
||||
"pad-endpoint tone: no pad-audio devnode for pad {idx} — run \
|
||||
`ensure` first"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
if ep.endpoint_id.is_empty() {
|
||||
println!("pad-endpoint tone: pad {idx} has no endpoint id yet");
|
||||
return Ok(());
|
||||
}
|
||||
ep.endpoint_id
|
||||
}
|
||||
};
|
||||
// `--pair front` drives the pad's SPEAKER instead of the voice coils — the only way to
|
||||
// exercise the speaker kind without a game that renders one.
|
||||
let pair = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--pair")
|
||||
.nth(1)
|
||||
.map_or(pe::TonePair::Back, |s| pe::TonePair::parse(s));
|
||||
println!(
|
||||
"pad-endpoint tone: {hz} Hz into the {} of {endpoint_id} for {secs}s",
|
||||
pair.label()
|
||||
);
|
||||
pe::render_test_tone(&endpoint_id, secs, hz, pair)?;
|
||||
println!(
|
||||
"pad-endpoint tone: done. A connected client with pad audio enabled should have \
|
||||
buzzed; the host log shows whether the gate opened."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// `punktfunk-host pad-endpoint capture [seconds]` — the receiving half of `tone`. Run
|
||||
// both at once to exercise render -> engine -> loopback -> pair routing with no game and
|
||||
// no client attached.
|
||||
Some("capture") => {
|
||||
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => match pe::find(idx)? {
|
||||
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
|
||||
_ => {
|
||||
println!("pad-endpoint capture: pad {idx} has no endpoint — run `ensure`");
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
println!("pad-endpoint capture: listening on {endpoint_id} for {secs}s");
|
||||
pe::capture_probe(&endpoint_id, secs)
|
||||
}
|
||||
Some("status") => pe::print_status(idx),
|
||||
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
|
||||
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
|
||||
///
|
||||
|
||||
@@ -65,6 +65,8 @@ pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
|
||||
index: *b.first()?,
|
||||
kind: *b.get(1)?,
|
||||
capabilities: le16(2)? as u16,
|
||||
// GameStream's LI_CCAP vocabulary can't express pad audio — native-plane only.
|
||||
audio_caps: 0,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
@@ -138,6 +140,7 @@ mod tests {
|
||||
index,
|
||||
kind,
|
||||
capabilities,
|
||||
..
|
||||
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
|
||||
else {
|
||||
panic!("expected Arrival");
|
||||
|
||||
@@ -602,6 +602,10 @@ fn real_main() -> Result<()> {
|
||||
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
|
||||
// Windows: pad-audio endpoint provisioning (`ensure`/`status`) + the pnputil removal
|
||||
// escape hatch (`remove`). `--index N` selects the pad slot (default 0).
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("pad-endpoint") => devtest::pad_endpoint(&args),
|
||||
// Capture→encode→file pipeline spike (dev tool).
|
||||
Some("spike") => spike::run(parse_spike(&args[1..])?),
|
||||
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
|
||||
|
||||
@@ -64,6 +64,12 @@ use pairing::pair_ceremony;
|
||||
mod audio;
|
||||
use audio::audio_thread;
|
||||
|
||||
/// Per-pad DualSense audio (the 0xD1 plane): loopback capture of the pre-provisioned pad
|
||||
/// endpoints → per-kind silence gate → stereo Opus → `PAD_AUDIO_MAGIC` datagrams. The input
|
||||
/// thread spawns/reaps one streamer per arriving pad (`input`); the Welcome advertises the cap
|
||||
/// via `pad_audio::host_cap` (`handshake`).
|
||||
mod pad_audio;
|
||||
|
||||
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
|
||||
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
|
||||
mod input;
|
||||
@@ -344,6 +350,14 @@ pub(crate) async fn serve(
|
||||
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
|
||||
// endpoint churn).
|
||||
let mic_service = crate::audio::MicPump::start();
|
||||
// Windows, env-gated (PUNKTFUNK_PAD_AUDIO / _SLOTS): pre-provision the per-pad "DualSense
|
||||
// speaker" render endpoints once per host lifetime — idempotent devnode + stamp work on a
|
||||
// dedicated COM thread, results published for sessions to query by pad index
|
||||
// (crate::audio::pad_endpoint::endpoint_for). If any stamp is stored-but-not-served, the
|
||||
// worker performs ONE AudioEndpointBuilder+Audiosrv restart now, before any session exists.
|
||||
// Failures log once and leave the feature off: pads still work, just without pad audio.
|
||||
#[cfg(target_os = "windows")]
|
||||
crate::audio::pad_endpoint::provision_at_startup();
|
||||
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
|
||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||
@@ -1176,9 +1190,14 @@ async fn serve_session(
|
||||
let input_handle = {
|
||||
let conn = conn.clone();
|
||||
let gamepad = welcome.gamepad;
|
||||
// Pad audio (0xD1) negotiated: the Welcome advertised the cap (Windows + provisioned
|
||||
// endpoints + the client asked — handshake reads `pad_audio::host_cap`). Read back off
|
||||
// the Welcome rather than recomputed, so the input thread's spawns cannot disagree
|
||||
// with what the client was told.
|
||||
let pad_audio_on = welcome.host_caps & punktfunk_core::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-input".into())
|
||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
|
||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad, pad_audio_on))
|
||||
.context("spawn input thread")?
|
||||
};
|
||||
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
||||
|
||||
@@ -564,6 +564,16 @@ pub(super) async fn negotiate(
|
||||
punktfunk_core::quic::HOST_CAP_PEN
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Per-pad DualSense audio (0xD1 + HidOutput::AudioCtl): granted only when the
|
||||
// client asked AND this host can capture it — Windows with the feature enabled
|
||||
// and at least one pad endpoint provisioned at startup. A capable client then
|
||||
// marks its pads' renderers on their arrivals; the input thread streams toward
|
||||
// exactly those pads (`super::pad_audio`).
|
||||
| if super::pad_audio::host_cap(hello.client_caps) {
|
||||
punktfunk_core::quic::HOST_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||
|
||||
@@ -515,6 +515,75 @@ impl Pads {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-pad 0xD1 streamers (`super::pad_audio`), keyed by pad index like every per-pad table
|
||||
/// here (bounded by [`MAX_WIRE_PADS`]; only slots 0..4 can ever have a provisioned endpoint —
|
||||
/// `spawn` refuses the rest). Spawned when a negotiated session's DualSense-family arrival
|
||||
/// declares renderer bits, reaped on remove / re-declare / session teardown.
|
||||
struct PadAudioSlots {
|
||||
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
|
||||
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
|
||||
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
|
||||
}
|
||||
|
||||
impl PadAudioSlots {
|
||||
fn new() -> PadAudioSlots {
|
||||
PadAudioSlots {
|
||||
slots: std::array::from_fn(|_| None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
|
||||
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
|
||||
/// retries, since arrivals are only re-sent a few times per slot open).
|
||||
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
|
||||
let idx = pad as usize;
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
if let Some((have, _)) = &self.slots[idx] {
|
||||
if *have == kinds {
|
||||
return; // identical re-arrival — keep the running streamer
|
||||
}
|
||||
tracing::info!(
|
||||
pad = idx,
|
||||
"pad-audio kinds changed — restarting the streamer"
|
||||
);
|
||||
self.stop(idx);
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
|
||||
self.slots[idx] = Some((kinds, h));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop + reap one pad's streamer. The join rides a detached reaper thread: a quiet pad's
|
||||
/// capturer can sit out its ~5 s recv timeout, and this thread must keep its ≤4 ms
|
||||
/// feedback cadence (games block on GET_REPORT handshakes) — the reaper still joins, just
|
||||
/// not here. A failed reaper spawn falls back to the handle's own drop (signal + join).
|
||||
fn stop(&mut self, idx: usize) {
|
||||
if let Some((_, h)) = self.slots.get_mut(idx).and_then(|s| s.take()) {
|
||||
h.signal();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk1-padreap".into())
|
||||
.spawn(move || h.stop());
|
||||
}
|
||||
}
|
||||
|
||||
/// Session teardown: flag every streamer FIRST so they wind down concurrently, then join —
|
||||
/// the worst case is ONE quiet-endpoint recv timeout (~5 s), well inside the session's
|
||||
/// 10 s side-thread join grace, not one per pad.
|
||||
fn stop_all(&mut self) {
|
||||
for s in self.slots.iter().flatten() {
|
||||
s.1.signal();
|
||||
}
|
||||
for s in &mut self.slots {
|
||||
if let Some((_, h)) = s.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
||||
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
|
||||
/// every pure-gyro motion sample up to 4 ms of quantization).
|
||||
@@ -629,20 +698,6 @@ const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
|
||||
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
|
||||
const RUMBLE_STOP_BURST: u8 = 2;
|
||||
|
||||
/// Clear a removed pad's rumble bookkeeping — the level, the "we have seen a level" flag, and any
|
||||
/// stop re-sends still owed. Together these end the pad's lease, so a re-plug on the same wire
|
||||
/// index inherits nothing that could buzz the new device.
|
||||
///
|
||||
/// The per-pad rumble **sequence is deliberately not a parameter**: it must stay monotonic for the
|
||||
/// life of the connection because the client gates on it with a wrapping half-space compare and
|
||||
/// never resets its side (`punktfunk-core/src/client/pump/datagram_task.rs`). Resetting it here is
|
||||
/// the bug pinned by [`tests::rumble_seq_survives_a_removal_so_the_client_gate_accepts`].
|
||||
fn clear_pad_feedback(state: &mut (u16, u16), seen: &mut bool, stop_burst: &mut u8) {
|
||||
*state = (0, 0);
|
||||
*seen = false;
|
||||
*stop_burst = 0;
|
||||
}
|
||||
|
||||
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
|
||||
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
|
||||
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
|
||||
@@ -683,8 +738,13 @@ pub(super) fn input_thread(
|
||||
conn: quinn::Connection,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
gamepad: GamepadPref,
|
||||
pad_audio_on: bool,
|
||||
) {
|
||||
let mut pads = Pads::new(gamepad);
|
||||
// Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on`
|
||||
// — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that
|
||||
// declare renderer bits, reaped on remove/teardown below.
|
||||
let mut pad_streams = PadAudioSlots::new();
|
||||
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
||||
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
||||
// is 5000 u32s.
|
||||
@@ -838,32 +898,58 @@ pub(super) fn input_thread(
|
||||
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
|
||||
}
|
||||
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
|
||||
// stale rumble lease (a lease still ticking would buzz the new pad).
|
||||
//
|
||||
// `rumble_seq` deliberately SURVIVES — do not reset it here. The client's
|
||||
// rumble reorder gate (`client/pump/datagram_task.rs`) is per-CONNECTION
|
||||
// and has no reset path, so restarting this counter strands every later
|
||||
// envelope for the re-plugged pad until the host climbs back past the
|
||||
// value the client already stored (up to 128 sends ≈ 15 s of continuous
|
||||
// rumble, or dozens of separate rumble events). The three clears below are
|
||||
// what actually kill a stale lease; the sibling `pad_seq` gate keeps its
|
||||
// value across a removal for exactly the same reason (see the comment at
|
||||
// the top of this arm).
|
||||
clear_pad_feedback(
|
||||
&mut rumble_state[idx],
|
||||
&mut rumble_seen[idx],
|
||||
&mut rumble_stop_burst[idx],
|
||||
);
|
||||
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
|
||||
rumble_state[idx] = (0, 0);
|
||||
rumble_seen[idx] = false;
|
||||
rumble_seq[idx] = 0;
|
||||
rumble_stop_burst[idx] = 0;
|
||||
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
|
||||
// rest of this arm, so a reordered stale removal can't kill the
|
||||
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
|
||||
pad_streams.stop(idx);
|
||||
}
|
||||
}
|
||||
InputKind::GamepadArrival => {
|
||||
// Per-pad controller kind declaration (mixed types): route this pad's future
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
|
||||
// `flags` = pad index. Applied before the pad's first frame (the client sends it
|
||||
// on slot open), so the device is built as the right type from the start.
|
||||
let idx = ev.flags as usize;
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire
|
||||
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
|
||||
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
|
||||
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
|
||||
// Applied before the pad's first frame (the client sends it on slot open),
|
||||
// so the device is built as the right type from the start. The audio caps
|
||||
// are surfaced here for the 0xD1 capture path (which emits pad audio only
|
||||
// toward pads that declared a renderer).
|
||||
let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags);
|
||||
let idx = pad as usize;
|
||||
let kind = GamepadPref::from_u8(ev.code as u8);
|
||||
if audio_caps != 0 {
|
||||
tracing::debug!(
|
||||
pad = idx,
|
||||
haptics = audio_caps & 0x01 != 0,
|
||||
speaker = audio_caps & 0x02 != 0,
|
||||
"pad-audio render caps declared (arrival flags bits 8/9)"
|
||||
);
|
||||
}
|
||||
pads.set_kind(idx, kind);
|
||||
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
|
||||
// renderer, only on a session that negotiated the cap. Idempotent across
|
||||
// the arrival re-sends (same kinds keeps the running streamer); a
|
||||
// re-declare without bits — or as a kind with no pad audio — stops it.
|
||||
if pad_audio_on {
|
||||
let want = if matches!(
|
||||
kind,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
) {
|
||||
audio_caps
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if want != 0 {
|
||||
pad_streams.ensure(&conn, pad, want);
|
||||
} else {
|
||||
pad_streams.stop(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
@@ -1019,6 +1105,9 @@ pub(super) fn input_thread(
|
||||
flags: 0,
|
||||
});
|
||||
}
|
||||
// Reap the per-pad 0xD1 streamers with the session (after the instant release sends above
|
||||
// — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all).
|
||||
pad_streams.stop_all();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1096,72 +1185,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pad re-plug must not strand the client's rumble reorder gate.
|
||||
///
|
||||
/// The client's `rumble_last_seq` lives for the whole QUIC connection and has no reset path
|
||||
/// (`punktfunk-core/src/client/pump/datagram_task.rs`), so this host's per-pad rumble counter
|
||||
/// has to stay monotonic across a `GamepadRemove`. Regression: the removal arm used to do
|
||||
/// `rumble_seq[idx] = 0`, which made every envelope after a re-plug fail `seq_newer` until the
|
||||
/// counter climbed back past the value the client had already stored — up to 128 sends.
|
||||
///
|
||||
/// Drives the real wire encoder and the real gate, so it fails if either side's rule moves.
|
||||
#[test]
|
||||
fn rumble_seq_survives_a_removal_so_the_client_gate_accepts() {
|
||||
use punktfunk_core::input::GamepadSnapshot;
|
||||
use punktfunk_core::quic::{decode_rumble_envelope, encode_rumble_datagram_v2};
|
||||
|
||||
// The client half: one per-pad slot, per connection, never reset.
|
||||
let deliver = |seq: u8, gate: &mut Option<u8>| {
|
||||
let d = encode_rumble_datagram_v2(0, 0x4000, 0x8000, seq, 400);
|
||||
let env = decode_rumble_envelope(&d)
|
||||
.expect("v2 envelope decodes")
|
||||
.envelope
|
||||
.expect("v2 tail present");
|
||||
if GamepadSnapshot::seq_newer(env.seq, *gate) {
|
||||
*gate = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// The host half: one wrapping counter, bumped on every change and every renewal.
|
||||
let mut gate: Option<u8> = None;
|
||||
let mut seq = 0u8;
|
||||
|
||||
// A long rumble before the unplug pushes the client's stored seq well past zero.
|
||||
for _ in 0..100 {
|
||||
seq = seq.wrapping_add(1);
|
||||
assert!(deliver(seq, &mut gate));
|
||||
}
|
||||
assert_eq!(gate, Some(100));
|
||||
|
||||
// The pad is unplugged mid-buzz: the lease is cleared, the counter is not.
|
||||
let (mut state, mut seen, mut burst) = ((0x1234u16, 0x5678u16), true, RUMBLE_STOP_BURST);
|
||||
clear_pad_feedback(&mut state, &mut seen, &mut burst);
|
||||
assert_eq!(
|
||||
(state, seen, burst),
|
||||
((0, 0), false, 0),
|
||||
"lease not cleared"
|
||||
);
|
||||
|
||||
// It returns on the same wire index and the game rumbles again: the very first envelope
|
||||
// has to reach the actuator.
|
||||
seq = seq.wrapping_add(1);
|
||||
assert!(
|
||||
deliver(seq, &mut gate),
|
||||
"first envelope after a re-plug was dropped by the client's reorder gate"
|
||||
);
|
||||
|
||||
// Non-vacuity: the pre-fix behaviour (counter restarted at 0) really is rejected, and
|
||||
// stays rejected for the whole forward window — this is the bug, reproduced.
|
||||
let mut stranded = Some(100u8);
|
||||
assert!(
|
||||
(1..=100).all(|s| !deliver(s, &mut stranded)),
|
||||
"test is vacuous — a restarted counter should have been gated out"
|
||||
);
|
||||
}
|
||||
|
||||
/// Incremental wire events accumulate into the full pad frame the virtual xpad applies.
|
||||
#[test]
|
||||
fn gamepad_accumulator() {
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
|
||||
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
|
||||
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
|
||||
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
|
||||
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
|
||||
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
|
||||
//! reopen-with-backoff on capture death, the same monotonic-seq-kept-across-reopens discipline,
|
||||
//! the same power-of-two encode-warn throttle.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
|
||||
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
|
||||
/// `kinds` bit for the speaker stream.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
|
||||
|
||||
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
|
||||
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
|
||||
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const HAPTICS_FRAME_MS: u32 = 5;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const SPEAKER_FRAME_MS: u32 = 10;
|
||||
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const HAPTICS_FRAME_SAMPLES: usize =
|
||||
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const SPEAKER_FRAME_SAMPLES: usize =
|
||||
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
|
||||
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
|
||||
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
|
||||
/// the pure splitter logic keeps its own copy).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const CAP_CHANNELS: usize = 4;
|
||||
|
||||
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
|
||||
/// very frame (haptics are felt latency; the first active frame must ship). ≈ −60 dBFS.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const GATE_OPEN_PEAK: f32 = 1e-3;
|
||||
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
|
||||
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
|
||||
/// clipped, short enough that an idle pad costs nothing in steady state.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const GATE_HANGOVER_MS: u32 = 250;
|
||||
|
||||
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
|
||||
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
|
||||
#[cfg(target_os = "windows")]
|
||||
const PAD_AUDIO_BITRATE: i32 = 64_000;
|
||||
|
||||
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
|
||||
/// rarely render pad audio) must cost ZERO encodes and ZERO datagrams, not a permanent 200 Hz
|
||||
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
|
||||
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
|
||||
/// unit-tested below.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct SilenceGate {
|
||||
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
|
||||
hangover_frames: u32,
|
||||
/// Consecutive sub-threshold frames seen so far while open.
|
||||
quiet: u32,
|
||||
/// Starts closed: a pad no game ever renders into never opens (and never sends).
|
||||
open: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl SilenceGate {
|
||||
fn new(frame_ms: u32) -> SilenceGate {
|
||||
SilenceGate {
|
||||
hangover_frames: (GATE_HANGOVER_MS / frame_ms).max(1),
|
||||
quiet: 0,
|
||||
open: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one frame; `true` = encode + send it. Signal opens the gate on THIS frame; the
|
||||
/// frame that completes the hangover closes it and is itself suppressed (the client
|
||||
/// already has ~250 ms of ramped-out silence by then).
|
||||
fn feed(&mut self, frame: &[f32]) -> bool {
|
||||
if frame.iter().any(|s| s.abs() >= GATE_OPEN_PEAK) {
|
||||
self.open = true;
|
||||
self.quiet = 0;
|
||||
} else if self.open {
|
||||
self.quiet += 1;
|
||||
if self.quiet >= self.hangover_frames {
|
||||
self.open = false;
|
||||
self.quiet = 0;
|
||||
}
|
||||
}
|
||||
self.open
|
||||
}
|
||||
}
|
||||
|
||||
/// One kind's send-admission + seq bookkeeping (pure logic — the capture thread wraps it with
|
||||
/// the encoder and the datagram send). `seq` is monotonic per (pad, kind) and NEVER advances
|
||||
/// while the gate is closed: frozen-seq = deliberate silence — the client tells silence from
|
||||
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
|
||||
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
|
||||
/// sees a gap, not a restart.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct LaneCtl {
|
||||
gate: SilenceGate,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl LaneCtl {
|
||||
fn new(frame_ms: u32) -> LaneCtl {
|
||||
LaneCtl {
|
||||
gate: SilenceGate::new(frame_ms),
|
||||
seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit one frame: `Some(seq)` = encode + send it with this seq (advanced for the next);
|
||||
/// `None` = gated — do not send, do not advance. An encode failure AFTER admission leaves a
|
||||
/// one-frame seq gap, which the client conceals exactly like datagram loss.
|
||||
fn admit(&mut self, frame: &[f32]) -> Option<u32> {
|
||||
if !self.gate.feed(frame) {
|
||||
return None;
|
||||
}
|
||||
let seq = self.seq;
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
Some(seq)
|
||||
}
|
||||
}
|
||||
|
||||
/// De-interleave one 4-ch block (FL FR BL BR) into its stereo pairs: `(front, back)` — front =
|
||||
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
|
||||
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
|
||||
/// across channels.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
|
||||
let mut front = Vec::with_capacity(block.len() / 2);
|
||||
let mut back = Vec::with_capacity(block.len() / 2);
|
||||
for s in block.chunks_exact(CAP_CHANNELS) {
|
||||
front.extend_from_slice(&s[..2]);
|
||||
back.extend_from_slice(&s[2..4]);
|
||||
}
|
||||
(front, back)
|
||||
}
|
||||
|
||||
/// Accumulates interleaved 4-ch capture and cuts it into the wire contract's per-kind stereo
|
||||
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
|
||||
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
|
||||
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct PadFramer {
|
||||
kinds: u8,
|
||||
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
|
||||
acc: Vec<f32>,
|
||||
/// Front-pair stereo accumulation toward the next 10 ms speaker frame.
|
||||
front: Vec<f32>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl PadFramer {
|
||||
fn new(kinds: u8) -> PadFramer {
|
||||
PadFramer {
|
||||
kinds,
|
||||
acc: Vec::with_capacity(HAPTICS_FRAME_SAMPLES * CAP_CHANNELS * 4),
|
||||
front: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one capture chunk; `emit(kind, stereo_frame)` fires for each completed frame
|
||||
/// (haptics first — it is the latency-critical pair).
|
||||
fn feed(&mut self, chunk: &[f32], mut emit: impl FnMut(u8, &[f32])) {
|
||||
self.acc.extend_from_slice(chunk);
|
||||
let block_len = HAPTICS_FRAME_SAMPLES * CAP_CHANNELS;
|
||||
while self.acc.len() >= block_len {
|
||||
let block: Vec<f32> = self.acc.drain(..block_len).collect();
|
||||
let (front, back) = split_quad(&block);
|
||||
if self.kinds & KIND_BIT_HAPTICS != 0 {
|
||||
emit(punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS, &back);
|
||||
}
|
||||
if self.kinds & KIND_BIT_SPEAKER != 0 {
|
||||
self.front.extend_from_slice(&front);
|
||||
let frame_len = SPEAKER_FRAME_SAMPLES * 2;
|
||||
while self.front.len() >= frame_len {
|
||||
let frame: Vec<f32> = self.front.drain(..frame_len).collect();
|
||||
emit(punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER, &frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the partial frames straddling a capture gap (reopen). The seq/gate state is NOT
|
||||
/// here — [`LaneCtl`] deliberately survives reopens, so the client sees a gap, not a
|
||||
/// restart.
|
||||
fn clear(&mut self) {
|
||||
self.acc.clear();
|
||||
self.front.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A running per-pad streamer. [`stop`](PadAudioHandle::stop) (or drop) flags the thread and
|
||||
/// joins it; [`signal`](PadAudioHandle::signal) only flags — the input thread's teardown flags
|
||||
/// every pad first so the joins overlap instead of serializing the capturer's worst-case ~5 s
|
||||
/// quiet-endpoint recv timeout.
|
||||
pub(super) struct PadAudioHandle {
|
||||
stop: Arc<AtomicBool>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PadAudioHandle {
|
||||
/// Flag the streamer to wind down without waiting for it.
|
||||
pub(super) fn signal(&self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Stop + reap. Bounded by the capturer's ~5 s quiet-endpoint recv timeout in the worst
|
||||
/// case — the mid-session reap paths run this on a detached reaper thread for that reason
|
||||
/// (`input.rs::PadAudioSlots::stop`); session teardown affords it inline (the 10 s
|
||||
/// side-thread join grace covers it).
|
||||
pub(super) fn stop(mut self) {
|
||||
self.reap();
|
||||
}
|
||||
|
||||
fn reap(&mut self) {
|
||||
self.signal();
|
||||
if let Some(join) = self.join.take() {
|
||||
let _ = join.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle dropped without `stop()` (reaper-spawn failure) still winds its thread down.
|
||||
impl Drop for PadAudioHandle {
|
||||
fn drop(&mut self) {
|
||||
self.reap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this session's Welcome should advertise
|
||||
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
|
||||
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
|
||||
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
|
||||
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
|
||||
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
|
||||
/// picks it up on its next connect.
|
||||
pub(super) fn host_cap(client_caps: u8) -> bool {
|
||||
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
asked
|
||||
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
|
||||
&& crate::audio::pad_endpoint::provisioned_endpoints()
|
||||
.is_some_and(|eps| !eps.is_empty())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Only the Windows virtual DualSense exposes pad audio endpoints today.
|
||||
let _ = asked;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the per-pad streamer toward `conn` for `pad`, streaming the kinds in `kinds` (bit 0 =
|
||||
/// haptics, bit 1 = speaker — the arrival's audio-caps packing). `stop` is this handle's own
|
||||
/// flag (fresh per spawn — pad streamers stop individually, not with the session). `None` when
|
||||
/// the slot has no provisioned endpoint (provisioning failed or still running, or the slot is
|
||||
/// past `PUNKTFUNK_PAD_AUDIO_SLOTS` — only 0..4 can ever have one) or the thread cannot spawn;
|
||||
/// the pad itself keeps working either way, just without audio.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn spawn(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
|
||||
return None;
|
||||
}
|
||||
let Some(ep) = crate::audio::pad_endpoint::endpoint_for(pad) else {
|
||||
tracing::debug!(
|
||||
pad,
|
||||
"pad-audio arrival for a slot without a provisioned endpoint — not streaming"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
if ep.endpoint_id.is_empty() {
|
||||
// The devnode-without-endpoint shape (`find`) — never in the provisioned set, but
|
||||
// cheap to refuse rather than spin the open/backoff loop on an empty id.
|
||||
return None;
|
||||
}
|
||||
let stop_t = stop.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-pad{pad}"))
|
||||
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
|
||||
{
|
||||
Ok(join) => Some(PadAudioHandle {
|
||||
stop,
|
||||
join: Some(join),
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
|
||||
/// without the audio side (and never advertise the cap, see [`host_cap`]).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(super) fn spawn(
|
||||
_conn: quinn::Connection,
|
||||
_pad: u8,
|
||||
_kinds: u8,
|
||||
_stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
None
|
||||
}
|
||||
|
||||
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
|
||||
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
|
||||
#[cfg(target_os = "windows")]
|
||||
struct Lane {
|
||||
kind: u8,
|
||||
ctl: LaneCtl,
|
||||
enc: opus::Encoder,
|
||||
encode_errs: u64,
|
||||
}
|
||||
|
||||
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
|
||||
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
|
||||
let mut lanes = Vec::new();
|
||||
for (bit, kind, frame_ms) in [
|
||||
(
|
||||
KIND_BIT_HAPTICS,
|
||||
punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS,
|
||||
HAPTICS_FRAME_MS,
|
||||
),
|
||||
(
|
||||
KIND_BIT_SPEAKER,
|
||||
punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER,
|
||||
SPEAKER_FRAME_MS,
|
||||
),
|
||||
] {
|
||||
if kinds & bit == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut enc = opus::Encoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
enc.set_bitrate(opus::Bitrate::Bits(PAD_AUDIO_BITRATE)).ok();
|
||||
enc.set_vbr(false).ok();
|
||||
lanes.push(Lane {
|
||||
kind,
|
||||
ctl: LaneCtl::new(frame_ms),
|
||||
enc,
|
||||
encode_errs: 0,
|
||||
});
|
||||
}
|
||||
Ok(lanes)
|
||||
}
|
||||
|
||||
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
|
||||
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
|
||||
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn pad_audio_thread(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
endpoint_id: String,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
use crate::audio::AudioCapturer as _;
|
||||
let mut lanes = match build_lanes(kinds) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %e, "pad-audio opus encoder init failed — pad continues without audio");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if lanes.is_empty() {
|
||||
return; // spawn() refuses kinds == 0 — belt and braces
|
||||
}
|
||||
let mut framer = PadFramer::new(kinds);
|
||||
// One Opus frame per datagram; 64 kbps CBR at ≤10 ms is ~80 bytes — sized with the session
|
||||
// plane's slack.
|
||||
let mut opus_buf = vec![0u8; 1500];
|
||||
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
|
||||
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
|
||||
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
|
||||
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
tracing::info!(
|
||||
pad,
|
||||
haptics = kinds & KIND_BIT_HAPTICS != 0,
|
||||
speaker = kinds & KIND_BIT_SPEAKER != 0,
|
||||
"pad audio streaming (0xD1, Opus 48 kHz, silence-gated)"
|
||||
);
|
||||
'session: while !stop.load(Ordering::SeqCst) {
|
||||
if capturer.is_none() {
|
||||
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
|
||||
Ok(c) => {
|
||||
if last_failed.take().is_some() {
|
||||
tracing::info!(pad, "pad-audio capture reopened");
|
||||
}
|
||||
capturer = Some(c);
|
||||
framer.clear(); // drop the partial frames straddling the gap
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(pad, error = %format!("{e:#}"), "pad-audio open failed — will retry");
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// An empty chunk is a QUIET endpoint (the capturer's idle timeout), not a death — keep
|
||||
// it; only a genuine Err (capture thread ended) drops the capturer for reopen.
|
||||
let chunk = match capturer.as_mut().unwrap().next_chunk() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %format!("{e:#}"), "pad-audio capture lost — reopening");
|
||||
capturer = None;
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut session_gone = false;
|
||||
framer.feed(&chunk, |kind, frame| {
|
||||
if session_gone {
|
||||
return;
|
||||
}
|
||||
let Some(lane) = lanes.iter_mut().find(|l| l.kind == kind) else {
|
||||
return; // framer emits only enabled kinds — unreachable, but never panic here
|
||||
};
|
||||
// Gated = deliberate silence: no datagram AND a frozen seq (the client tells
|
||||
// silence from loss by seq continuity).
|
||||
let Some(seq) = lane.ctl.admit(frame) else {
|
||||
return;
|
||||
};
|
||||
let pts_ns = now_ns();
|
||||
match lane.enc.encode_float(frame, &mut opus_buf) {
|
||||
Ok(n) => {
|
||||
let d = punktfunk_core::quic::encode_pad_audio_datagram(
|
||||
pad,
|
||||
kind,
|
||||
seq,
|
||||
pts_ns,
|
||||
&opus_buf[..n],
|
||||
);
|
||||
if conn.send_datagram(d.into()).is_err() {
|
||||
session_gone = true; // connection gone — the session is over
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
lane.encode_errs += 1;
|
||||
if lane.encode_errs.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
pad,
|
||||
kind,
|
||||
error = %e,
|
||||
count = lane.encode_errs,
|
||||
"pad-audio opus encode failed — dropping frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if session_gone {
|
||||
break 'session;
|
||||
}
|
||||
}
|
||||
// Dropping the capturer stops its WASAPI thread. Nothing to park: pad capture is per-pad,
|
||||
// per-session by design (unlike the session audio slot there is no cross-session reuse).
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
|
||||
|
||||
/// A stereo frame of `n` samples at a constant level.
|
||||
fn frame(level: f32, n: usize) -> Vec<f32> {
|
||||
vec![level; n * 2]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_opens_immediately_and_closes_after_hangover() {
|
||||
let mut g = SilenceGate::new(HAPTICS_FRAME_MS);
|
||||
// 250 ms of 5 ms frames.
|
||||
assert_eq!(g.hangover_frames, 50);
|
||||
// Closed from birth: an idle pad never sends.
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// A peak at exactly the threshold opens on THIS frame (haptics are felt latency).
|
||||
assert!(g.feed(&frame(GATE_OPEN_PEAK, HAPTICS_FRAME_SAMPLES)));
|
||||
// 49 quiet frames ride the hangover; the 50th completes 250 ms and is suppressed.
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// ... and stays closed.
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// Sub-threshold wiggle does not reopen; real signal does (negative peaks count).
|
||||
assert!(!g.feed(&frame(9e-4, HAPTICS_FRAME_SAMPLES)));
|
||||
assert!(g.feed(&frame(-0.5, HAPTICS_FRAME_SAMPLES)));
|
||||
// A loud frame mid-hangover rearms the full 250 ms.
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(g.feed(&frame(0.02, HAPTICS_FRAME_SAMPLES)));
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_hangover_scales_with_frame_ms() {
|
||||
let mut g = SilenceGate::new(SPEAKER_FRAME_MS);
|
||||
assert_eq!(g.hangover_frames, 25); // 250 ms of 10 ms frames
|
||||
assert!(g.feed(&frame(0.1, SPEAKER_FRAME_SAMPLES)));
|
||||
for _ in 0..24 {
|
||||
assert!(g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_freezes_while_gated_and_survives_reopen() {
|
||||
let mut lane = LaneCtl::new(HAPTICS_FRAME_MS);
|
||||
// Two audible frames: seq 0, 1.
|
||||
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(0));
|
||||
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(1));
|
||||
// The hangover is still sent (seq advances), then the gate closes and seq FREEZES —
|
||||
// deliberate silence the client tells from loss by continuity.
|
||||
for i in 0..49u32 {
|
||||
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), Some(2 + i));
|
||||
}
|
||||
for _ in 0..500 {
|
||||
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), None);
|
||||
}
|
||||
// A capture reopen resets ONLY the framer (PadFramer::clear) — LaneCtl is deliberately
|
||||
// untouched, so the next audible frame CONTINUES the sequence (gap, not restart).
|
||||
assert_eq!(lane.admit(&frame(0.9, HAPTICS_FRAME_SAMPLES)), Some(51));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitter_exact_pairs() {
|
||||
// Interleave [FL FR BL BR] × 2 frames with distinct values everywhere.
|
||||
let quad = [0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0];
|
||||
let (front, back) = split_quad(&quad);
|
||||
assert_eq!(front, [0.0, 1.0, 10.0, 11.0]);
|
||||
assert_eq!(back, [2.0, 3.0, 12.0, 13.0]);
|
||||
// A ragged tail (never produced by the capturer) is dropped, not smeared.
|
||||
let (front, back) = split_quad(&quad[..7]);
|
||||
assert_eq!((front.len(), back.len()), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_cuts_the_wire_cadence() {
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
|
||||
let mut got: Vec<(u8, usize, f32)> = Vec::new();
|
||||
// 10 ms of capture (480 samples), fed in ragged chunks: exactly two 5 ms haptics
|
||||
// frames from the back pair, then one 10 ms speaker frame from the front pair.
|
||||
let mut quad = Vec::new();
|
||||
for _ in 0..2 * HAPTICS_FRAME_SAMPLES {
|
||||
quad.extend_from_slice(&[0.25, 0.25, -0.5, -0.5]);
|
||||
}
|
||||
for chunk in quad.chunks(101) {
|
||||
f.feed(chunk, |kind, frame| got.push((kind, frame.len(), frame[0])));
|
||||
}
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
|
||||
(PAD_AUDIO_KIND_SPEAKER, 2 * SPEAKER_FRAME_SAMPLES, 0.25),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_masks_disabled_kinds() {
|
||||
// 20 ms of all-ones capture: 4 potential haptics frames, 2 potential speaker frames.
|
||||
let quad = vec![1.0f32; 4 * HAPTICS_FRAME_SAMPLES * CAP_CHANNELS];
|
||||
let mut kinds_seen = Vec::new();
|
||||
// Haptics-only: the front pair is never split out, let alone encoded.
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS);
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_HAPTICS; 4]);
|
||||
// Speaker-only: no haptics frames.
|
||||
let mut f = PadFramer::new(KIND_BIT_SPEAKER);
|
||||
kinds_seen.clear();
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_SPEAKER; 2]);
|
||||
// kinds = 0 is never spawned, but the framer must still be total: nothing comes out.
|
||||
let mut f = PadFramer::new(0);
|
||||
kinds_seen.clear();
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert!(kinds_seen.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_clear_drops_partials_only() {
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
|
||||
let mut emitted = 0;
|
||||
// 100 samples: no frame boundary reached yet.
|
||||
f.feed(&vec![0.1; 100 * CAP_CHANNELS], |_, _| emitted += 1);
|
||||
assert_eq!(emitted, 0);
|
||||
f.clear();
|
||||
// After the gap: exactly one haptics frame from 240 fresh samples — the 100 stale
|
||||
// samples are gone (they would skew every later frame boundary).
|
||||
f.feed(
|
||||
&vec![0.2; HAPTICS_FRAME_SAMPLES * CAP_CHANNELS],
|
||||
|kind, frame| {
|
||||
emitted += 1;
|
||||
assert_eq!(
|
||||
(kind, frame.len()),
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES)
|
||||
);
|
||||
},
|
||||
);
|
||||
assert_eq!(emitted, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cap_requires_the_client_bit() {
|
||||
// Without CLIENT_CAP_PAD_AUDIO the answer is no on EVERY platform (on Windows the
|
||||
// env + provisioning legs are environment-dependent — not unit-tested here).
|
||||
assert!(!host_cap(0));
|
||||
assert!(!host_cap(punktfunk_core::quic::CLIENT_CAP_CURSOR));
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
Wire-compatible with 0.23.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host (or the other way round) streams exactly as it does today.
|
||||
|
||||
This release is mostly about making 0.23.0 right. **If you stream to an Android phone or to the Linux or Windows desktop app, update the client** — 0.23.0's new send-a-frame-in-pieces delivery had a fault that could throw most of the video away on a busy link, and the fix lives in the client. The other big one is **Automatic bitrate**, which could decide early in a session that your connection was worth 20 Mb/s and then hold you there for the rest of it: a sweep of that path fixed eleven separate faults, and a session that used to need seventeen minutes to climb out now does it in about sixteen seconds. Alongside those: the Linux and Windows app finally gets the frame scheduler the phone and Apple apps have had, the Steam Deck plugin reaches every setting instead of nine of them, controllers can be told *not* to be forwarded for couches that pass the pad through some other way, and plugin output shows up in the web console's log page instead of nowhere at all.
|
||||
|
||||
## New
|
||||
|
||||
- **The Linux and Windows app schedules frames onto your screen instead of throwing them at it.** Until now the desktop app showed each frame the instant it finished decoding, so every frame carried whatever jitter the network and the decoder had just added — the same problem the phone and Apple apps had before their rebuilds. It now has the same two-way choice under **Prioritize**: **Lowest latency** (the default, and what you have today) or **Smoothness**, which holds a small buffer of frames and releases one per screen refresh so an uneven stream plays evenly. The buffer is 1–3 frames, your pick. On screens where the graphics driver holds a queue of pending frames — which is most Windows machines with an AMD card, and any machine under a compositor that does the same — the app now also stops that queue from building up, which is where a whole extra refresh of delay used to come from.
|
||||
|
||||
- **V-Sync and "Follow variable refresh rate" are real settings now.** Both rows have existed for a while and neither did anything: the app picked one presentation mode at startup and kept it. **V-Sync** off asks for the tear-capable mode and says in the stats overlay which mode it actually got, because a graphics driver is free to refuse. **Follow variable refresh rate** lets a VRR/FreeSync/G-SYNC display follow the stream's own cadence rather than a fixed grid; where your driver supports the newest tear-free presentation mode it turns itself on, and elsewhere it stays off unless you ask for it, because on those drivers it costs more than it wins. Whether you actually *have* variable refresh is now measured from what your display does rather than believed from what it claims — no platform reports it honestly enough to trust.
|
||||
|
||||
- **The Steam Deck plugin covers every setting, in a sidebar that fits on screen.** Nine of the client's settings had a row here and about twenty did not, so a Deck that never sees a desktop could not reach its own decoder, full chroma, HDR, audio layout, echo cancellation, touch or mouse model, scroll direction, auto-wake, or either audio device. All of it is here now, split across a left rail of categories — the layout SteamOS's own Settings uses — so every page fits without scrolling and nothing is more than one hop away. The categories, their order and the wording match the console's settings screen, because two different orders for one set of settings is how people stop trusting either. Two things are deliberately absent and named as such: which physical controller is player 1, and the remembered window size.
|
||||
|
||||
- **The stats overlay has an off switch on the Steam Deck.** Reported from the field: there was no way to turn it off from the plugin, and it defaults to on — so a Deck configured only through this panel streamed with the overlay up and no way here to put it down. The keyboard shortcut and the three-finger tap both existed but neither is discoverable from a couch. The row now sits at the foot of the section, worded the way the console words it.
|
||||
|
||||
- **"Forward controllers" — an off switch for setups that hand the pad over another way.** If your controller already reaches the host by USB passthrough (VirtualHere and friends), or is simply plugged into the host, it arrived there twice: once as the real device and once as the virtual pad the client built from the same hands. Games read both, so a stick drifts against the second, centred pad and menus take every input twice. The new per-client setting defaults to on — today's behaviour — and can be set per profile. On Linux and Windows it does more than stop sending: opening a controller is what *claims* it, so with this off the app never opens one at all, leaving the device free for the passthrough tool to bind. The consequence is stated at the setting: the controller escape chord is read off forwarded pads, so it is unavailable while this is off. The Apple and Android apps never claim the pad, so they keep their chords and only stop sending — losing an Apple TV's only way out of a stream would have been the worse bug.
|
||||
|
||||
- **Plugin output reaches the web console's log page.** Plugins do not run as children of the host, so nothing they printed ever passed through the host's own logging and the Logs page could not show a single plugin line. The fallback was a terminal on the host box — and on Windows, no log file at all, so a failing plugin was diagnosable only by stopping its task and re-running it by hand. That is exactly what the console exists to avoid, and it left the one question a stuck user asks with no answer. The plugin runner now sends its output to the host, where it joins everything else under one timeline; the Logs page grows a **Host / Plugins** switch beside the level filter, and an empty Plugins view says the thing that is usually actually wrong (the runner isn't running) rather than telling you to adjust the filter.
|
||||
|
||||
- **The Windows app can show you where its log lives.** "Check the client log" never said where that was. **Settings ▸ About** grows an **Open log folder** row, and the message you get when a stream fails to start now names the path. It opens the folder rather than the file, so the previous session's rotated log is in reach too.
|
||||
|
||||
## Improved
|
||||
|
||||
- **The stats overlay's display figure splits into two numbers.** It used to be one number covering everything between "decoded" and "on your screen", which is two very different things stacked: the app's own work, and the wait for the screen to accept the frame. They are now separate, so a high figure diagnoses itself — if the second number dominates it is the refresh rate floor or a queue in the driver, and if the first dominates it is us. The Detailed tier also names the presentation mode actually in use, which answers most "why is my display number a whole refresh" questions on its own.
|
||||
|
||||
- **The full-chroma explanation names what it actually needs.** The Windows app's caption said 4:4:4 was "HEVC only, and only where the host can encode it", which sends people hunting through host settings; the web console's explainer was similarly vague. Both now name the real requirement. Host-side, the log line that reports the decision stopped being named after the capture side when it was never about capture — a field report burned real time hunting a capture problem because of it — and a session that asked for full chroma and did not get it now says which end declined.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **0.23.0 could freeze the picture on Android and on the Linux and Windows app.** 0.23.0 started cutting each frame into pieces and sending them as they were produced. The receiving side then sized every arriving frame at the largest a frame is ever allowed to be — many megabytes — so its budget for frames-in-progress ran out after about three. With normal traffic that meant twelve frames arriving and nine of them thrown away before a single byte could be placed: on any link that reorders packets at all, a loss storm that does not end. Frames never complete, the picture freezes, and the client keeps begging for a fresh one. A second fault in the same path killed one frame in roughly every 1408 outright — about once every twelve seconds at 120 fps, each costing a freeze and a recovery. Only Android and the Linux/Windows desktop app ever took this path, which is why it read as a platform-specific video fault in the field; the Apple apps and the Windows in-process client were never affected. **Updating the client is what fixes it** — a 0.23.0 client still has the bug whatever host it talks to.
|
||||
|
||||
- **Automatic bitrate could pin a session at 20 Mb/s for the rest of its life.** Sessions start at 20 Mb/s and climb, and a host refuses a climb while it is briefly behind on encoding. But an ordinary hitch at startup — which arrives while the rate is still at that 20 Mb/s floor — was enough to make the host refuse, and the client cannot tell a momentary refusal apart from an encoder that genuinely cannot go faster: both arrive looking identical. Two of them and the client concluded there was a permanent ceiling. Escaping cost 12.5% per minute, so crossing the gap to what a fast link could actually carry took upwards of twenty minutes, and in practice often never happened — which is why "my bitrate is stuck at 20" has been such a persistent report. Three things changed: the host no longer refuses climbs merely because it is running in its own high-effort mode (that mode exists so it *can* keep up — refusing climbs once it is working refuses the thing that worked), the client re-checks after 12 seconds instead of a minute and backs that off only if the limit proves real, and a request granted in full is taken as proof the limit is gone rather than nudging the guess upward. Measured end to end: a session pinned at 20 Mb/s under a 300 Mb/s connection now reaches 150 Mb/s in about sixteen seconds, where the same case previously needed around seventeen minutes.
|
||||
|
||||
- **Automatic bitrate drew permanent conclusions from single moments.** Five more faults of the same shape. The reference points it compares against — network delay, decode time, encode time — could arm off *one* window, and since each is a rolling minimum that one window became the floor; a calm window followed by ordinary motion then read as congestion on a link that was never the problem. Changing resolution or refresh rate re-based only some of what it had learned, so switching *up* a mode was scored against the old mode's easier numbers and cratered the rate instead of raising it. The high-water mark that bounds how far every later climb may step never decayed and was raised by damaged windows — a stall's backlog arriving at once, or a flush's queue — which are exactly the windows that overstate what was delivered. The decoder ceiling latched *at* the rate that had just choked, authorising a climb straight back into the failure, and a network hiccup could be mistaken for a decoder one. And a manual bitrate cap bound only ceilings the speed test had learned, so it did nothing at all if the session already started above it.
|
||||
|
||||
- **Your connection was measured including the redundancy sent to protect it.** Two checks compare what actually arrived against what the encoder was asked to produce, and both counted every byte accepted — packet headers, audio, and the extra error-correction data the host adds *in answer to* packet loss. So the measurement rose with the loss it was supposed to detect: at 25% redundancy the check passed while the encoder was emitting barely half its target, and the permanent high-water mark inherited that inflation for good. The signal was weakest on exactly the lossy links it exists for. Only the actual video payload is counted now.
|
||||
|
||||
- **A host that re-chose the rate never told the client.** When a host rebuilds its pipeline it can legitimately re-pick an Automatic rate — a 1080p session mirroring a 4K panel needs roughly three times what it negotiated — but that number never reached the client, which kept its own stale copy as the basis for every later step. So a client believing 20 Mb/s while the host encoded 60 would compute its first climb from the stale figure and ask for 40: a request to go *down*, paying for an encoder rebuild to get there. The host now tells the client whenever the applied rate moves, using a message that already meant exactly that and which existing clients already handle arriving unprompted — no wire change, and older clients are unaffected.
|
||||
|
||||
- **On KDE hosts, a hidden mouse pointer stayed on screen.** Since 0.22.0 a KDE stream always had a cursor and it never went away again — not in a game, not in Big Picture, not with a controller in hand. The host was blending an arrow onto the picture forever because the signal KDE sends to say "the pointer is not here" was being ignored. It is honoured now, so a game that hides the pointer mid-stream actually hides it. GNOME hosts keep the behaviour they have, because there the same signal means something different and honouring it made the cursor flicker.
|
||||
|
||||
- **A KDE host could stream a copy of your monitor instead of its own screen.** KDE remembers display arrangements per set of connected screens, and one of the things it remembers is "this screen mirrors that one". Because the streamed screen carries a stable name, any arrangement that had ever recorded it as a mirror got that re-applied on every later session that reproduced the same set of monitors — which is why it looked so arbitrary: the stream cloned the panel whenever exactly one monitor was live, and behaved normally the moment the others came back. A mirroring screen is not its own desktop; it takes the physical screen's size and viewport instead of the one you negotiated. The streamed screen now says outright that it mirrors nothing, and if it ever finds itself mirroring anyway it says so in the log instead of leaving that as something only you can see.
|
||||
|
||||
- **Waking a Windows PC no longer fails the first connection.** A host that had just woken refused connections with a message claiming its virtual-display driver was not installed, on machines where it plainly was. Resuming re-registers that driver while the rest of the wake is still going, and a client reconnecting a second later landed inside the gap. The host checked exactly once, read the gap as a dead driver, and answered a device that was seconds from ready by resetting it — a reset that was then refused, because the host itself was holding the driver open, and reported as a success anyway. It now waits out a driver that is mid-wake instead of resetting it, reports what a reset actually did rather than what the device looks like afterwards, only ever runs one reset when several sessions arrive at once, and says in the log how long it waited and what it saw.
|
||||
|
||||
- **AV1 streams quietly decoded in software.** Every AV1 session opened a software decoder no matter what your graphics card could do, because of how the decoder was being looked up by name. Each frame then failed the hardware check and the session walked down its fallback ladder mid-stream — around three seconds of black, with "hardware decode active" already printed and every hardware check green. Decoders are now chosen by what they can actually do. H.264 and HEVC pick exactly what they always did, and every decode log now names the decoder in use, which was the whole diagnosis and no line said it. Separately, a software-decoded HDR stream used to be shown washed out with no warning and an overlay badge claiming a tone-map that never ran; it now warns, and the badge distinguishes the two.
|
||||
|
||||
- **A 120 fps session sent 132 frames a second.** The option that runs the virtual display at a multiple of the session's rate promises extra display refreshes without extra frames on the wire, but it only enforced a floor between frames — so content that always had a frame ready settled about ten percent above the rate you negotiated. That is ten percent more bitrate, encode and decode for frames a 120 Hz screen can only drop. The pacing now holds the long-run average at the negotiated rate while keeping the same room for jitter, and a source running at or below the rate is never delayed.
|
||||
|
||||
- **On an iPad, Escape handed the mouse back to iPadOS.** iPadOS releases the pointer by itself when you press Escape — its built-in "let me out". But Escape in a stream is a game key, not a request to give the mouse back, so pressing it for an in-game menu silently cost you the capture until you clicked into the video again. An unwanted release is now re-requested, briefly and a bounded number of times. Every deliberate way out (the menu, the two keyboard chords, switching apps) is untouched, and while the re-grab is in flight the local cursor stays hidden and pointer movement is held, so it reads as "Escape did nothing to my mouse" rather than a cursor blinking in and out.
|
||||
|
||||
- **The Windows app showed settings something else had already changed.** A field report said a codec setting "changed by itself" between sessions. Nothing writes it back — what they saw was a stale copy. The app read the settings file once at startup, but it is not the file's only writer (the stream itself stores its window size, and the console and the Deck plugin save too), so the page showed values another part of Punktfunk had already replaced — until you touched any row, at which point the value visibly jumped. It re-reads the file on entry now, and on the profile path too. A related one: an older build's save used to *drop* settings a newer client had written, and now carries them through untouched.
|
||||
|
||||
- **A Windows host could fight your sound settings.** When no usable playback device was left — a display isolated, the speakers excluded, the microphone holding a virtual device — the host re-ran its whole audio setup every two seconds for as long as it took anyone to notice, including writing your default recording device back each time. That silently undid any recording-device change you made while a stream was up. An impossible arrangement is now recognised as impossible: the host says so once, with the devices it found and why each was rejected, then waits for a device to actually appear or disappear instead of retrying a verdict that cannot change. The default recording device is only asserted when the plan changed or something else moved it.
|
||||
|
||||
- **PlayStation Accessories stopped offering a controller update that could never finish.** The emulated DualSense reported a 2021-era firmware version, so Sony's app — and games using their controller library — offered an update that can only ever end in "can't complete the update", since the virtual pad speaks no update protocol. A real pad plugged in directly reads as up to date, which made the prompt look like Punktfunk having corrupted the controller. It now reports a version above anything Sony has shipped, rather than chasing their latest and resurrecting the prompt with every Sony release.
|
||||
|
||||
- **Rumble stopped for good after unplugging and plugging a controller back in.** Reconnect a pad mid-session — or have Bluetooth drop it for a moment — and roughly half the time it never rumbled again for the rest of that session. Everything else about the controller kept working, which is what made it look random rather than broken. The host restarts a counter when a controller goes away, and the client uses that counter to throw away force-feedback that arrives out of order; because the client does *not* restart its side, everything sent after the reconnect looked older than what it had already seen, and it dropped the lot until the host counted back past where it left off. How long that took depended on how much the pad had rumbled before you unplugged it, so a quiet session healed at once and a busy one stayed silent. The counter now survives a reconnect, which is what the same code already does for the controller's buttons and sticks.
|
||||
|
||||
- **An unplugged controller stayed plugged in as far as the game was concerned.** Unplug a pad mid-session and the game kept seeing a connected controller that never pressed anything again — it simply never went away. If it was your only controller, this happened every time. Tearing the virtual pad down takes a brief settling delay first, so a momentary glitch cannot make a device disappear and reappear, but the second look that finishes the job only ever happened when *another* controller sent something afterwards. With one controller there is nothing left to send it. The teardown now completes on the host's own clock, about a third of a second after the pad goes, whether or not anything else is connected — and a glitch that comes back within the delay still leaves the device alone.
|
||||
|
||||
- **The speed test overstated your connection, and Automatic bitrate believed it.** Throughput was worked out by dividing what the client received by how long the *host* spent sending — a window wrong on both ends, since the host's clock stops the moment its send window closes, while the data is still draining through the network toward you. On a gigabit link a test aiming at 2 Gb/s "measured" 1266 Mb/s and set an 886 Mb/s ceiling the link could never carry, permanently, for the whole session. It is now measured over the interval the client actually received across, and video around the test contaminates neither half of the sum. Two guards ride along: a manual bitrate cap now binds no matter what any test concludes, and a decoder that keeps drowning below the link's ceiling has that noticed and remembered, instead of a 30–60 second cycle of climbing back into the same wall and flushing — a 1440p120 case cost a dropped-frame burst every cycle.
|
||||
|
||||
- **Plugins on Linux could not reach anything else on the machine.** Reported by a user who could not get the VirtualHere plugin to talk to their VirtualHere client, and the reason was ours: the plugin runner was given its own private temporary directory. But integrating with things already running on the box is the entire job of a plugin, and on Linux those talk through that directory. So a plugin would launch a vendor program happily and then never be able to reach the service behind it — while the identical command worked perfectly in the operator's own terminal. No setting could fix it. Plugins now see the real one.
|
||||
|
||||
- **Android: a decoder hiccup turned into a burst of broken frames.** When the decoder handed back an input slot it could not actually fill, both the slot and the video in it were dropped on the floor — leaking one of the decoder's input buffers each time, until the pipeline ran out of them entirely and the resulting keyframe storm read as a decode fault rather than the bookkeeping mistake it was. The dropped video also left a hole nothing asked to repair, so the damage was free to reach the screen. Both go back now.
|
||||
|
||||
- **Android: the app could pin the wrong refresh rate for a whole session.** Asking a phone for 120 Hz is a request the system may refuse — Smooth Display off, battery saver, thermal limits, an OEM's own governor. The app took the answer on faith and could only ever revise it downward, so a refused request left it aiming at screen refreshes that never arrive, for the rest of the session, with no way back. It now corrects in both directions: instantly toward a faster screen, and toward a slower one after eight consecutive agreeing observations, because one slow sample is a missed callback and eight in a row is a display that really did slow down. Two related fixes: the app now holds back when the system stops confirming that frames reached the screen, instead of feeding a queue that has stopped draining until the decoder stalls; and the timing margin it adapts now widens on frames that actually missed the screen rather than on ordinary pacing, which on a healthy phone had been walking it to its ceiling and re-imposing the delay the 0.23.0 work had just measured away.
|
||||
|
||||
- **Windows stutter reports blamed the wrong thing.** The host tries to tell you whether frames stopped arriving because the *game* went quiet — a menu, a loading screen, an ordinary hitch — or because the display path did. Its witness for the display path never worked: it was reading timestamps in one unit and comparing them against another, so it saw zero display activity always, and every quiet stretch was reported as the game going quiet. The category the whole thing exists to catch was unreachable. It reads correctly now, and it can tell "the witness was working and saw nothing" from "the witness was not working", which are opposite conclusions. If you have a stall report from an earlier version, its verdict is not evidence.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- **Versions.** All unchanged from 0.23.0: wire protocol 2, C ABI 14, virtual-display driver protocol 6, Windows virtual-gamepad channel 3. No new negotiated capability bits — `VIDEO_CAP_MULTI_SLICE` (`0x80`) was already the video-caps byte's last free bit and nothing needed the next one. The only C-header change is documentation: `PunktfunkProbeResult::elapsed_ms` now means the client-measured receive interval (see the ABR entry), and the probe clamp comment corrects 3 Gbps → 10 Gbps to match `MAX_PROBE_KBPS`.
|
||||
|
||||
- **Slice-streamed reassembly.** Every ordinary access unit on the streamed path is now opened by a sentinel header (the block flush at `MIN_STREAM_BLOCK_SHARDS` guarantees it), and the reassembler was sizing those at `max_frame_bytes` — 8–64 MiB after the QUIC handshake clamp. Each AU therefore allocated and zeroed a multi-megabyte buffer, and `IN_FLIGHT_BUF_FACTOR × max_frame_bytes` was exhausted after ~3 concurrent frames. A sentinel now sizes to its own block extent (a slice sentinel by its wire base, a legacy one by its full-K position) and grows as later blocks or the final block's totals reveal more, with the in-flight budget re-checked on growth. Separately, `flush_block` drained `pending` to empty when the AU length was an exact multiple of the shard payload, leaving `finish_streamed` to seal a final block of one zero-padded filler shard whose derived base overlapped the block flushed a moment earlier — correctly read as a lying header, killing the AU. A flush now retains one whole shard, restoring the invariant `StreamedAu::pending` already documented.
|
||||
|
||||
- **Desktop presentation engine.** `pf-presenter` gains `present_pace.rs` (pure state + arithmetic): `FrameStore` (newest-wins slot or smoothing FIFO with preroll-to-capacity, drop-oldest overflow and underflow re-arming the preroll — the Apple/Android semantics, with `qDrop`/`qDry`), `LatchClock` (panel grid from `VK_KHR_present_wait` glass stamps, publishing the host-facing `LatchGrid`), and `PresentGate` (one undisplayed present in flight on FIFO surfaces, 100 ms stale force-open; inert on MAILBOX/IMMEDIATE and without present timing). Settings ride the keys the Apple client already writes into the shared profile catalog — `present_priority` / `smooth_buffer` / `vsync` / `allow_vrr`, now tier-P routed — and `PresentPriority::resolve` mirrors the Android reference exactly, so a profile authored on any client means the same thing everywhere. PyroWave collapses smoothness to latency (its plane-ring retirement assumes the depth-2 newest-wins hand-off, and all-intra frames make buffering moot).
|
||||
|
||||
- **Present-mode ladder and `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`.** Mode selection is a preference ladder rather than a constant: V-Sync off → IMMEDIATE, FIFO_RELAXED, then tear-free; V-Sync on + VRR + fullscreen → LATEST_READY first; otherwise MAILBOX then FIFO. The extension postdates ash 0.38's headers (Vulkan 1.3.281), so the mode value, extension name and `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` are hand-declared. **Trap:** the surface advertises the mode even with the extension disabled, and using it on that basis is undefined — the ladder only offers it where the device feature came back true and was enabled. The gate predicate splits in two on purpose: `needs_glass_gate()` is FIFO and FIFO_RELAXED only (gating on a driver that already retires stale images pays the serialisation twice — the ~27 ms an A/B measured), while `vblank_locked()` is the whole FIFO family including LATEST_READY, since the VRR cadence probe's premise still holds there. Measured on .21 (NVIDIA 610.43.03, GNOME/Wayland): `display 2.6 ms (pace 0.6 + latch 2.0)` on the VRR ladder against 13–28 ms for FIFO + gate, and 1.4 ms for MAILBOX. ⚠ Unverified off that box: whether Windows (Vulkan WSI through DXGI) or Intel drivers expose the mode at all — `surface present modes` and `fifo_latest_ready=` are logged unconditionally, so one run on any box settles it. `allow_vrr` is automatic only where the device offers the mode, and stays behind `PUNKTFUNK_VRR_FIFO=1` elsewhere.
|
||||
|
||||
- **VRR detection is measured, never queried.** No portable query exists (SDL exposes none, Wayland does not report adaptive-sync state, Windows surfaces nothing through Vulkan) and the platforms that do answer have been caught lying. The discriminator is quantization: `CadenceProbe` folds each present delta to its distance from the nearest multiple of the *display mode's* period — not the learned one, which is circular when the stream runs below panel rate — and takes the median. Tri-state: Unknown below 24 deltas, after a display change, and outside a FIFO-family mode (MAILBOX decouples presents from scanout, so its stamps are never grid-quantized; the same panel read `no` on FIFO and `yes` on MAILBOX in one minute).
|
||||
|
||||
- **`PanelGrid`, shared.** The panel-period learner is extracted to `punktfunk_core::phase::PanelGrid` and adopted by the Android, iOS and desktop presenters, replacing three copies with the same bug: a seed capped at the display mode's refresh, when the mode is only a *claim*. Narrowing is immediate; widening needs eight consecutive agreeing observations and then takes the narrowest of them. `preferredDisplayModeId` on Android is a request the system may refuse, which is the case that made this a session-length failure.
|
||||
|
||||
- **ABR probe measurement.** The reassembler stamps probe-scoped counters (bytes, packets, first/last arrival, monotonic ns) at `FLAG_PROBE` routing, so the throughput divisor is the client's first→last arrival interval and video around the burst contaminates neither numerator nor denominator; the host duration remains the fallback below two probe packets. `ProbeOutcome`/`PunktfunkProbeResult` layouts are unchanged. The controller latches `decode_cap_kbps` when two *consecutive* backoffs carry decode-severe evidence at a similar pre-backoff rate (mirroring `host_cap_kbps`), re-probing on the `CAP_REPROBE_WINDOWS` clock; one spurious flush never latches.
|
||||
|
||||
- **ABR sweep — eleven defects.** Wire format and ABI untouched throughout; 34 abr tests plus 2 host tests. Host side: `cadence_degraded` was latched true for as long as the session was *escalated* (adaptive capture depth or pipelined retrieve), independent of whether encode was still missing deadlines — and escalation needs only ~20 net behind-frames, which a startup hitch supplies while ABR is still in slow start at the 20 Mbps default. The rule moves into `encode_behind_cadence`: an escalated session is still judged strictly (any net behind-frame keeps it flagged, where an unescalated one gets the full bucket), but escalation alone no longer flags it. `adopt_built_bitrate` now publishes the rate a rebuilt pipeline actually opened at (`build_pipeline` re-resolves an Automatic rate whenever the source delivers an unnegotiated size — the mirrored-panel case — and the encoder's clamp can land below what control already acked), pushed to the control task as the existing 9-byte `BitrateChanged`. Client side: all three rolling baselines (OWD, decode, encode) now require `BASELINE_MIN_WINDOWS` of evidence via one shared `score_baseline` — the three copies had drifted apart, and `on_ack` clears the encode baseline after every self-requested decrease, re-opening the one-sample hole each time. A mode switch rebases decode and OWD as well as encode, and drops `proven_kbps` with them. `proven_kbps` is raised only by *clean* windows (it never decays and holds permanent authority over climb step size, and the windows that overstate delivered throughput are precisely the damaged ones). `decode_cap_kbps` latches just *under* the choke rate, inside the ±1/8 band the evidence already required, and credits a bare jump-to-live flush only where the decode signal is absent. The two throughput-driven gates are fed data-shard payload counted at the reassembler's routing decision rather than `bytes_received` (which includes headers, FEC parity, probe filler and audio — at 25% FEC the utilization gate passed with the encoder emitting ~55% of target). `PUNKTFUNK_ABR_MAX_MBPS` binds at construction, not only on probe-learned ceilings, and a session starting above its cap steps down to it (no congestion signal will ever find that — the link is fine, the cap is policy). Cap escape re-probes after 12 s, doubling each time the lift is immediately re-learned, and a request granted **in full** at or above the cap drops the cap outright rather than nudging it +12.5%. An ack above the current ceiling raises the ceiling to meet it (`set_ceiling` still only ever raises, still clamps to `PUNKTFUNK_ABR_MAX_MBPS`), so a host-initiated re-target is not immediately stepped back down. Finally, a `SetBitrate` dropped by a full control queue no longer counts toward `MAX_UNACKED` — three of those retired the controller for the session while blaming an "older host".
|
||||
|
||||
- **Frame pacing under `PUNKTFUNK_VDISPLAY_HZ_MULT`.** The frame-driven trigger enforced its pace as a per-gap floor only (sleep to 0.9×interval, wake on arrival), so an always-ready source settled at 1.11× the negotiated rate. `PaceBudget` accrues one frame of credit per interval of *real elapsed* time, caps at 1.25 frames of post-stall burst, and charges one per submitted frame — the 0.9 floor keeps its jitter headroom while the long-run average cannot exceed the rate. Anchoring to real elapsed time preserves the synchronous-encode overlap the arrival-anchored floor bought and cannot fight the phase lock's submit grid. The charge sits under the same guard as the gate: the legacy fixed tick paces by its own grid, and charging without accruing would bank unbounded debt.
|
||||
|
||||
- **Force-feedback lifecycle.** `native/input.rs` no longer resets `rumble_seq[idx]` on `GamepadRemove`: the client's v2 reorder gate is per-connection with no reset path, so restarting the sender's counter stranded every later envelope behind `seq_newer` until it climbed past the stored value (up to 128 sends). The lease clears that actually end a rumble move into `clear_pad_feedback`, whose signature omits the seq so the arm cannot regress. `PadSlots` splits `sweep` (arm the grace from a state frame) from a new `reap` (complete the unplug, no frame required), and all three backends reap from the periodic pump they already run; `reap` never arms a clock, so it cannot invent an unplug however often it runs. Regression tests drive the unplug through a pump tick rather than the hand-fed second frame the old tests used — that second frame is not something the producer ever sends, and its absence is what hid both defects.
|
||||
|
||||
- **ETW present witness.** The consumer was opened without `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, so `ProcessTrace` converted every event's `TimeStamp` to FILETIME regardless of `ClientContext=1` — FILETIME ticks are ~4 orders of magnitude above QPC, so every comparison was false, `etw=none` always printed, and `classify()` convicted every compose-silence hole as CONTENT-SILENCE, making FRAME-GENERATION unreachable. Two comments asserted the wrong contract and now state the real one. `summary()` and `window_counts()` merge into one `window_report()` (one ring lock, one anchor — they could previously disagree about the same hole); `present_history`/`queue_history` now mean witness *liveness* within a 5 s lookback ending at the hole's start rather than "an event ever sat in the ring", and the static ring is cleared per session. Conviction thresholds untouched. **Any stall verdict from before this fix is void.**
|
||||
|
||||
- **Hardware decoder selection.** `avcodec_find_decoder(id)` returns the registry's first decoder for the id and upstream orders the native AV1 decoder *last* on purpose ("hwaccel hooks only, so prefer external decoders"), so all three hardware backends were opening libdav1d — which ignores `hw_device_ctx` and never calls `get_format`. `find_hw_decoder` walks `av_codec_iterate` and takes the first decoder whose `avcodec_get_hw_config` advertises the backend's surface via `HW_DEVICE_CTX`, so a build without a usable hardware decoder fails at open in milliseconds and the ladder runs there. Registry order still wins among capable decoders; the software path keeps the id lookup deliberately (libdav1d is the fastest CPU AV1, and the native decoder has no software path).
|
||||
|
||||
- **KWin mirroring.** `OutputConfigurationStore` persists `replicationSource` per *setup* (the exact connected-output set, matched by EDID/connector), and our virtual output carries a stable name by design — so a stored mirror entry re-applies on every session reproducing that monitor set. `applyMirroring` overrides scale and render offset to the source's, and the protocol states that a mirroring output may not be in the output order, so the primary assertion silently stops meaning anything too. The topology config now includes `set_replication_source(ours, "")`, gated on output-management v13 where the request appeared (wayland-rs does not range-check requests; an out-of-range opcode kills the connection). `extend`/`auto` issue no topology calls by design, so they get `clear_replication_source`, which enumerates and applies only when our output really is mirroring. The device's `replication_source` event is now read and warned on.
|
||||
|
||||
- **KWin cursor visibility.** Two producer contracts meet on `SPA_META_Cursor` id 0. KWin rewrites cursor meta on every enqueued buffer and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream — covering both a globally hidden cursor and a client null-cursor surface — so there id 0 *is* the hide. Mutter only rewrites meta when the cursor changed, so recycled buffers carry stale id-0 regions between damage frames, and honouring those flickered the cursor off between hovers. A flag rides from the backend that created the output through `capture_virtual_output` into the parser's `CursorState`; the portal-monitor path stays on the stale-meta contract (only Mutter's HDR mirror routes through it today).
|
||||
|
||||
- **Plugin runner and logging.** `punktfunk-scripting.service` drops `PrivateTmp=yes` and adds `/tmp` to `ReadWritePaths` (which `ProtectSystem=strict` would otherwise make read-only) — VirtualHere's client IPC is the `/tmp/vhclient` + `/tmp/vhclient_response` FIFO pair, and X11 is `/tmp/.X11-unix`. The runner tees stdout to `POST /api/v1/plugins/logs`, joining the host's ring under one cursor with target `plugin:<name>`; stdout stays authoritative, the queue is bounded and drops oldest (then reports how many), the shipper backs off when the host is away and re-sends a batch the host refused. `plugin_may_access` is an exclusion list, so `/plugins/logs` is reachable today only because it does not match `/ui-credential` — now asserted directly, since a change there would silence plugin logs with no other symptom. Two shipper bugs fixed with tests that fail against the previous code: the recursion guard was held across the whole `await fetch` and `enqueue` checked it (so every line logged while a POST was open was dropped — worst exactly when the host is slow), and an explicit `flush()` hit the same guard and returned having sent nothing, which is the shutdown path where the last lines say whether shutdown was clean.
|
||||
|
||||
- **Gamepad claiming and the new setting.** `forward_gamepads` is tier-P (profile-routable), default on. On Linux and Windows it prevents the session opening any pad slot and enables no Valve HIDAPI drivers, because opening a controller is what claims the device node and a claimed device cannot be bound by a passthrough tool. Menu navigation is unaffected (the launcher still opens the active pad, and a session supersedes menu mode either way). Apple and Android claim nothing, so they gate only the wire sends; Android does stop its DualSense and Steam Controller 2 USB captures, which do claim.
|
||||
|
||||
- **Virtual DualSense firmware version.** The feature report `0x20` update version moves `0x0154` → `0x0999`, above anything Sony has shipped, in both blobs (host uhid and the Windows driver; the DualSense Edge shares them). The old value existed to keep the kernel and SDL on the flag0 `COMPATIBLE_VIBRATION` convention, but `parse_ds_output` has since learned `COMPATIBLE_VIBRATION2` (firmware ≥ 2.24), so writers that read the version now use the v2 flag; both conventions land in the same rumble plane.
|
||||
|
||||
- **Windows client settings store.** `Settings` gains unknown-key passthrough, matching the contract `SettingsOverlay::extra` already gave profiles — additive, empty on every existing store, and an empty map serialises to nothing so no file churns. The page re-bases on the file at entry, and the profile-scope commit arm reloads before cloning (it was diffing overlay absorption against stale globals). `save()` was already temp+rename.
|
||||
|
||||
- **Environment.** `PUNKTFUNK_PRESENT_MODE` gains explicit `mailbox` and `fifo_relaxed` arms (both previously folded into the default with every typo) and falls back to the settings rather than to mailbox on an unknown name. New: `PUNKTFUNK_PRESENTER=arrival` (disables the whole desktop pacing engine for a field A/B without a rebuild), `PUNKTFUNK_PRESENT_DEBUG`, `PUNKTFUNK_VRR_FIFO=1`.
|
||||
|
||||
- **Android release channel.** A `vX.Y.Z` tag now publishes to Play **production at 100%**, not alpha — production access came through 2026-08-01. Canary is unchanged on `internal`, and its run-number version codes always outrank production so testers keep the newer build. Play's "What's new" gets its own file, `docs/releases/whatsnew/vX.Y.Z.txt`, capped at 500 *characters* per language (`•` is three bytes in UTF-8, so a byte count can reject a legal file). A tag without that file fails the android job at step 1, before the build: Play does not show an empty "What's new" when the file is missing — it carries the previous release's text onto the new version, which is the same shape as the v0.22.3 notes announcing a feature that tag never contained. The gate also rejects a file byte-identical to another release's. `android-promote.yml` is the lever for promoting a tested build, halting a rollout, or rolling production back onto an older version code, with `dry_run` defaulting to true.
|
||||
@@ -1,6 +0,0 @@
|
||||
• Automatic bitrate no longer gets stuck at 20 Mbps for a whole session. It now climbs to what your connection really carries in seconds, not minutes.
|
||||
• Fixes a fault from 0.23.0 that could freeze the picture on a busy connection.
|
||||
• Fixes a refresh-rate mix-up that left the picture juddering for a whole session.
|
||||
• A decoder hiccup no longer causes a burst of broken frames.
|
||||
• Controllers rumble again after a reconnect.
|
||||
• New "Forward controllers" switch, for pads that reach the PC another way.
|
||||
+158
-3
@@ -58,7 +58,13 @@
|
||||
// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 14
|
||||
// v15: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||
// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||
// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||
// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||
// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||
// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 15
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -82,6 +88,13 @@
|
||||
// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
|
||||
|
||||
// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||
// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||
// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||
// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
#define PUNKTFUNK_HIDOUT_AUDIO_CTL 5
|
||||
|
||||
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
||||
|
||||
@@ -266,6 +279,28 @@
|
||||
// design/pen-tablet-input.md.)
|
||||
#define PUNKTFUNK_HOST_CAP_PEN 16
|
||||
|
||||
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||
// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||
// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||
// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||
#define PUNKTFUNK_HOST_CAP_PAD_AUDIO 32
|
||||
|
||||
// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||
// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||
#define PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS 0
|
||||
|
||||
// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||
// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||
#define PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER 1
|
||||
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||
// stream (a real DualSense's voice coils).
|
||||
#define PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS 1
|
||||
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||
// stream.
|
||||
#define PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER 2
|
||||
|
||||
// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor
|
||||
// channel, `design/remote-desktop-sweep.md` M2).
|
||||
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
|
||||
@@ -276,6 +311,13 @@
|
||||
// forward-compatible.
|
||||
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
|
||||
|
||||
// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||
// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||
// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||
// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||
// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
#define PUNKTFUNK_CLIENT_CAP_PAD_AUDIO 4
|
||||
|
||||
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
||||
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||
@@ -342,6 +384,19 @@
|
||||
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
||||
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||
|
||||
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||
// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||
// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||
// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||
// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||
// it drop the declaration).
|
||||
#define ARRIVAL_FLAG_PAD_AUDIO_HAPTICS (1 << 8)
|
||||
|
||||
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||
// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||
// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||
#define ARRIVAL_FLAG_PAD_AUDIO_SPEAKER (1 << 9)
|
||||
|
||||
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||
// client's snapshot fold and the host's per-pad accumulators.
|
||||
#define MAX_PADS 16
|
||||
@@ -627,6 +682,18 @@
|
||||
#define CLIENT_CAP_PHASE_LOCK 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||
// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||
// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||
// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||
// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||
// precedent, per pad; toward an older or incapable host nothing changes. `0x04` — `0x01` is
|
||||
// [`CLIENT_CAP_CURSOR`], `0x02` is [`CLIENT_CAP_PHASE_LOCK`].
|
||||
#define CLIENT_CAP_PAD_AUDIO 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
@@ -652,6 +719,19 @@
|
||||
#define HOST_CAP_PEN 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||
// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||
// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||
// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||
// capable client marks its pads' render capabilities on their arrivals
|
||||
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||
// toward exactly those pads. `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is
|
||||
// [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state /
|
||||
// clipboard.
|
||||
#define HOST_CAP_PAD_AUDIO 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
@@ -939,7 +1019,9 @@
|
||||
// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
// (0xCE, host→client).
|
||||
// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
// host→client).
|
||||
#define PUNKTFUNK_AUDIO_MAGIC 201
|
||||
#endif
|
||||
|
||||
@@ -1043,6 +1125,31 @@
|
||||
#define CURSOR_RELATIVE_HINT 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||
// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||
// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||
// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||
// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||
// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||
// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||
// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||
// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||
#define PAD_AUDIO_MAGIC 209
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||
// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||
#define PAD_AUDIO_KIND_HAPTICS 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||
// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||
#define PAD_AUDIO_KIND_SPEAKER 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||
@@ -1357,7 +1464,11 @@ enum PunktfunkInputKind
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13,
|
||||
// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||
// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||
// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||
// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||
// Sent when the client opens a pad slot — before that pad's
|
||||
// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
@@ -2266,6 +2377,50 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||
// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||
// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||
// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||
// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||
// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||
// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||
// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||
// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||
// thread (one puller, may run alongside the other planes' pullers).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
// `buf` is writable for `buf_len` bytes.
|
||||
int32_t punktfunk_connection_next_pad_audio(PunktfunkConnection *c,
|
||||
uint8_t *out_pad,
|
||||
uint8_t *out_kind,
|
||||
uint32_t *out_seq,
|
||||
uint64_t *out_pts_ns,
|
||||
uint8_t *buf,
|
||||
uintptr_t buf_len,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||
// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||
// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||
// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||
// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||
// before. Latest-wins per pad; unknown bits are masked off.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle. Callable from any thread.
|
||||
PunktfunkStatus punktfunk_connection_set_pad_audio_caps(PunktfunkConnection *c,
|
||||
uint8_t pad,
|
||||
uint8_t audio_caps);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
|
||||
Reference in New Issue
Block a user