Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362595b20f | ||
|
|
caa47e28e6 | ||
|
|
652abeb397 | ||
|
|
48511d1267 | ||
|
|
3e649d372e | ||
|
|
0d004c4680 | ||
|
|
8d7e273a96 |
Generated
-19
@@ -2893,7 +2893,6 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3347,8 +3346,6 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4988,14 +4985,6 @@ 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=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5075,14 +5064,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
@@ -84,9 +84,6 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,26 +145,6 @@ 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. When
|
||||
* this is on and the pad is captured, wire rumble for that pad is SUPPRESSED rather than mixed:
|
||||
* the DualSense's firmware treats audio haptics and classic rumble as mutually exclusive, so
|
||||
* the arbitration is a selection. 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
|
||||
@@ -263,8 +243,6 @@ 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
|
||||
@@ -299,8 +277,6 @@ 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()
|
||||
@@ -345,8 +321,6 @@ 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. */
|
||||
|
||||
@@ -496,28 +496,6 @@ 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 {
|
||||
|
||||
@@ -78,34 +78,6 @@ 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 — not at claim time,
|
||||
* because the index does not exist until the first report arrives and the host addresses the
|
||||
* `0xD1` stream by that index. [stop] is called **before** the USB link closes, 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. */
|
||||
@@ -139,17 +111,6 @@ class DsCapture(
|
||||
|
||||
/** 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.
|
||||
if (padAudioStarted) {
|
||||
padAudioStarted = false
|
||||
// stop() joins the render thread, so nothing is using the descriptor after it returns
|
||||
// — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
@@ -172,42 +133,6 @@ class DsCapture(
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
// The wire index exists from here on, and the host addresses pad audio by it. Fired on
|
||||
// the link thread, once per capture.
|
||||
if (!padAudioStarted && padAudio != null) {
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd >= 0) {
|
||||
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 {
|
||||
padAudio?.start(it.index, fd)
|
||||
}
|
||||
} else {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
|
||||
@@ -92,40 +92,6 @@ 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,10 +69,6 @@ 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. */
|
||||
@@ -336,46 +332,6 @@ 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,14 +64,6 @@ 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 = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -54,12 +54,6 @@ 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 rendering tier-A audio must never see wire rumble. `DsDevice` sets
|
||||
// `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble write, and that bit
|
||||
// *disables* audio haptics — so one replayed command would silently mute the voice
|
||||
// coils the 0xD1 stream is driving, for the rest of the session. Dropping it here
|
||||
// (rather than in Kotlin) keeps the rule next to the reason, and covers every caller.
|
||||
Ok(cmd) if crate::pad_audio::is_tier_a((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
@@ -162,11 +156,6 @@ 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,8 +37,6 @@ 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
|
||||
|
||||
@@ -1,770 +0,0 @@
|
||||
//! 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 firmware exclusivity that shapes everything here
|
||||
//!
|
||||
//! `valid_flag0` bit 1 (`HAPTICS_SELECT`) *disables* audio haptics and selects classic rumble, and
|
||||
//! Linux's `hid-playstation` sets it on every force-feedback update — as does SDL, and as does our
|
||||
//! own [`crate::feedback`] path. **Tier A and tier C are mutually exclusive in the pad's firmware**,
|
||||
//! so a pad rendering this stream must have its wire rumble suppressed rather than mixed. The
|
||||
//! arbitration is a selection, never a blend.
|
||||
|
||||
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 rendering tier-A audio, and therefore forbidden from receiving wire rumble?
|
||||
///
|
||||
/// **This is a firmware constraint, not a preference.** `valid_flag0` bit 1 (`HAPTICS_SELECT`)
|
||||
/// *disables* audio haptics and selects classic rumble, and `DsDevice` sets it on every rumble
|
||||
/// write — as Linux's `hid-playstation` and SDL both do. So a single rumble command reaching a
|
||||
/// tier-A pad silently mutes the voice coils this stream drives, for the rest of the session.
|
||||
/// Tier A and tier C are mutually exclusive **in the pad**: the arbitration selects, never blends.
|
||||
pub(crate) fn is_tier_a(pad: u8) -> bool {
|
||||
TIER_A_PADS.load(std::sync::atomic::Ordering::Relaxed) & (1u32 << (pad & 0x0f)) != 0
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) {
|
||||
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);
|
||||
set_tier_a(pad, true);
|
||||
|
||||
pump(client, stop, 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);
|
||||
}
|
||||
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,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
playback: &mut uac_host::Playback<'_>,
|
||||
) {
|
||||
let mut mixer = QuadMixer::new();
|
||||
let mut streams: [Option<KindStream>; 2] = [None, None];
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
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!(is_tier_a(3));
|
||||
assert!(!is_tier_a(4));
|
||||
set_tier_a(4, true);
|
||||
assert!(is_tier_a(3) && is_tier_a(4));
|
||||
set_tier_a(3, false);
|
||||
assert!(!is_tier_a(3), "clearing one pad must not clear another");
|
||||
assert!(is_tier_a(4));
|
||||
set_tier_a(4, false);
|
||||
assert!(!is_tier_a(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!(is_tier_a(0x0f), "0x1f and 0x0f are the same wire slot");
|
||||
set_tier_a(0x0f, false);
|
||||
assert!(!is_tier_a(0x1f));
|
||||
}
|
||||
|
||||
#[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,7 +145,6 @@ 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(),
|
||||
@@ -269,16 +268,7 @@ 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).
|
||||
// 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
|
||||
},
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// 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
|
||||
@@ -301,8 +291,6 @@ 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,11 +61,6 @@ 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
|
||||
@@ -104,14 +99,6 @@ 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 {
|
||||
@@ -121,8 +108,6 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,110 +460,6 @@ 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);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `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
|
||||
|
||||
@@ -61,6 +61,7 @@ default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10
|
||||
policy.
|
||||
|
||||
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
|
||||
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
mailbox|fifo|immediate|fifo_relaxed` (default MAILBOX, FIFO where the surface offers no
|
||||
MAILBOX — AMD on Windows), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
|
||||
demotion to software on healthy hardware).
|
||||
|
||||
@@ -188,12 +188,6 @@ mod session_main {
|
||||
if !settings.forward_pad.is_empty() {
|
||||
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||
}
|
||||
// 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,11 +291,6 @@ 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).
|
||||
|
||||
@@ -1670,6 +1670,22 @@ impl IddPushCapturer {
|
||||
// the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was
|
||||
// ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two
|
||||
// counters were capturer fields that nothing else touched.
|
||||
// One ETW read serves both evidence fields: the prose summary spans the gap plus
|
||||
// the same 300 ms lead-in the report's OS-event correlation uses (the disturbance
|
||||
// that CAUSED the hole lands just before it), while the discriminator counts span
|
||||
// the GAP ONLY — no lead-in: presents from the healthy flow right before the hole
|
||||
// would falsely acquit the content (the stall-ending frame's own present lands at
|
||||
// the window edge and stays well under the acquit bar). Both halves must come from
|
||||
// the same ring snapshot under the same clock anchor, or the prose and the verdict
|
||||
// can disagree about the same hole.
|
||||
let (etw, etw_counts) = self
|
||||
.etw
|
||||
.as_ref()
|
||||
.and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_report(from, now, Duration::from_millis(300)))
|
||||
})
|
||||
.unzip();
|
||||
let evidence = StallEvidence {
|
||||
// A publisher re-attach restarts `offered_total` near zero; a ring recreate resets
|
||||
// the stall watch before that can matter, but guard the delta anyway (a restarted
|
||||
@@ -1682,24 +1698,14 @@ impl IddPushCapturer {
|
||||
}
|
||||
}),
|
||||
max_heartbeat_age_ms: self.max_hb_age_us / 1_000,
|
||||
// The probe + ETW reads span the same window the report's OS-event correlation
|
||||
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
// The probe read spans the same window the report's OS-event correlation uses
|
||||
// (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
probes: now
|
||||
.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.zip(self.probes.as_deref())
|
||||
.map(|(from, p)| p.window(from, now)),
|
||||
etw: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.map(|from| w.summary(from, now))
|
||||
}),
|
||||
// The discriminator counts span the GAP ONLY — no lead-in: presents from the
|
||||
// healthy flow right before the hole would falsely acquit the content. The
|
||||
// stall-ending frame's own present lands at the window edge and stays well
|
||||
// under the acquit bar.
|
||||
etw_counts: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_counts(from, now))
|
||||
}),
|
||||
etw,
|
||||
etw_counts,
|
||||
};
|
||||
self.stall_watch.report(&stall, now, &evidence);
|
||||
}
|
||||
@@ -2453,6 +2459,18 @@ mod tests {
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// A LIVE witness (history true = it demonstrably worked just before the hole) reading
|
||||
// an exact zero is the strongest content conviction — the zero is a measurement, not
|
||||
// an absence.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||
Some(&counts(0, 0))
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// The present witness does NOT overrule the driver's own verdicts or the harder
|
||||
// classes — it only refines compose-silence.
|
||||
assert_eq!(
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! A second provider rides the same session: `Microsoft-Windows-DXGI` (user-mode), filtered to
|
||||
//! `Present`/`PresentMultiplaneOverlay` starts (ids 42/55) — one event per swapchain present,
|
||||
//! stamped with the PRESENTING process id. Together they are the compose-silence discriminator
|
||||
//! ([`EtwWatch::window_counts`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! ([`EtwWatch::window_report`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! display path dropped composed frames (the real display-path bug); BOTH silent = the content
|
||||
//! stopped presenting (benign pause — menus/loading/game hitch). The predecessor witnesses are
|
||||
//! retired for cause: DxgKrnl id 184 `Present` never fires on the modern redirected path, and
|
||||
@@ -48,7 +48,8 @@ use windows::Win32::System::Diagnostics::Etw::{
|
||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER, EVENT_FILTER_DESCRIPTOR, EVENT_FILTER_TYPE_EVENT_ID,
|
||||
EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, EVENT_TRACE_PROPERTIES,
|
||||
EVENT_TRACE_REAL_TIME_MODE, PROCESSTRACE_HANDLE, PROCESS_TRACE_MODE_EVENT_RECORD,
|
||||
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
|
||||
PROCESS_TRACE_MODE_RAW_TIMESTAMP, PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION,
|
||||
WNODE_FLAG_TRACED_GUID,
|
||||
};
|
||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||
use windows::Win32::System::Threading::{
|
||||
@@ -122,8 +123,13 @@ fn qpc_freq() -> i64 {
|
||||
})
|
||||
}
|
||||
|
||||
/// The consumer's per-event callback — record id + QPC timestamp (the session's `ClientContext`
|
||||
/// is 1, so `TimeStamp` IS a QPC value) and return; runs on the consumer thread.
|
||||
/// The consumer's per-event callback — record id + timestamp + pid into the ring and return;
|
||||
/// runs on the consumer thread. `TimeStamp` is a raw QPC value only because BOTH halves of the
|
||||
/// clock contract hold: `ClientContext = 1` makes QPC the session clock, and the consumer is
|
||||
/// opened with `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, which is what stops ProcessTrace converting
|
||||
/// every event's timestamp to FILETIME (100 ns units since 1601) on delivery. Without the flag
|
||||
/// the conversion happens REGARDLESS of the session clock, and every `ts <= to_q` comparison
|
||||
/// downstream is against the wrong clock — never true, a witness that silently reads empty.
|
||||
unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
|
||||
if record.is_null() {
|
||||
return;
|
||||
@@ -150,10 +156,10 @@ pub(super) struct EtwWatch {
|
||||
}
|
||||
|
||||
// SAFETY: both fields are plain kernel handle VALUES (u64 wrappers) owned by this watch; every
|
||||
// operation on them (summary reads the static ring; Drop stops/closes) is thread-safe by the ETW
|
||||
// API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
// operation on them (window_report reads the static ring; Drop stops/closes) is thread-safe by
|
||||
// the ETW API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
unsafe impl Send for EtwWatch {}
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `summary` (static-ring reads).
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `window_report` (static-ring reads).
|
||||
unsafe impl Sync for EtwWatch {}
|
||||
|
||||
static WATCH: Mutex<Weak<EtwWatch>> = Mutex::new(Weak::new());
|
||||
@@ -201,8 +207,10 @@ impl EtwWatch {
|
||||
let mut session = CONTROLTRACE_HANDLE::default();
|
||||
// SAFETY: `buf` is a live, zeroed allocation of base + name bytes; every write below is a
|
||||
// field of the properties struct at its head; `LoggerNameOffset = base` points at the
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 = QPC clock —
|
||||
// what makes event timestamps comparable to our probe windows.
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 selects QPC as
|
||||
// the SESSION clock — necessary but not sufficient for QPC comparisons: ProcessTrace
|
||||
// still converts every event's timestamp to FILETIME on delivery unless the consumer is
|
||||
// opened with PROCESS_TRACE_MODE_RAW_TIMESTAMP (set below).
|
||||
let rc = unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
@@ -224,6 +232,11 @@ impl EtwWatch {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// A fresh session gets a fresh ring: the static [`RING`] outlives any `EtwWatch`, so
|
||||
// whatever is in it belongs to a DEAD session — leaking it forward would let a previous
|
||||
// session's presents pose as this session's witness history. Race-free here: the
|
||||
// consumer thread that repopulates it is spawned below.
|
||||
RING.lock().unwrap().clear();
|
||||
|
||||
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
|
||||
// vblank/DPC keywords never reach us. Fatal on failure (the DDI families + queue
|
||||
@@ -240,7 +253,7 @@ impl EtwWatch {
|
||||
return None;
|
||||
}
|
||||
// The DXGI (user-mode) present witness rides the same session. Degraded-not-fatal: a
|
||||
// refusal only costs the per-process present counts — `window_counts` then reports
|
||||
// refusal only costs the per-process present counts — `window_report` then reports
|
||||
// no present history and classification stays honest (Unattributed, never a guess).
|
||||
if !enable_provider(session, &DXGI, &DXGI_FILTER_IDS) {
|
||||
tracing::debug!(
|
||||
@@ -252,8 +265,12 @@ impl EtwWatch {
|
||||
LoggerName: PWSTR(name.as_ptr() as *mut _),
|
||||
..Default::default()
|
||||
};
|
||||
log.Anonymous1.ProcessTraceMode =
|
||||
PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
|
||||
// RAW_TIMESTAMP is load-bearing: it stops ProcessTrace converting `EVENT_HEADER.TimeStamp`
|
||||
// to FILETIME on delivery, so events arrive stamped in the session clock (QPC, per the
|
||||
// ClientContext above) — the only clock the window edges are computed in.
|
||||
log.Anonymous1.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME
|
||||
| PROCESS_TRACE_MODE_EVENT_RECORD
|
||||
| PROCESS_TRACE_MODE_RAW_TIMESTAMP;
|
||||
log.Anonymous2.EventRecordCallback = Some(on_event);
|
||||
// SAFETY: `log` is a fully-initialized local; `name` outlives the call (OpenTrace copies
|
||||
// what it needs before returning).
|
||||
@@ -303,14 +320,34 @@ impl EtwWatch {
|
||||
Some(Self { session, consumer })
|
||||
}
|
||||
|
||||
/// Summarize the DDI activity inside `[from, to]` — the correlation line a stall report
|
||||
/// carries. Brackets that merely SPAN the window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). `"none"` when the window is clean.
|
||||
pub(super) fn summary(&self, from: Instant, to: Instant) -> String {
|
||||
// Instant → QPC: anchor both clocks now and offset backwards.
|
||||
/// One stall window's ETW evidence, both halves from a SINGLE ring snapshot under a SINGLE
|
||||
/// `(Instant::now(), qpc_now())` anchor: the DDI/present prose summary a stall report
|
||||
/// carries, and the structured discriminator counts the classifier folds in. The summary
|
||||
/// covers `[hole_from - lead_in, hole_to]` — the disturbance that CAUSED a hole lands just
|
||||
/// before DWM stops delivering, so the prose needs the lead-in. The counts cover
|
||||
/// `[hole_from, hole_to]` ONLY — presents from the healthy flow inside the lead-in would
|
||||
/// falsely acquit the content. Two separate reads (two locks, two anchors, syscalls in
|
||||
/// between) would let events arriving between them make the prose and the verdict disagree
|
||||
/// about the same hole — hence one method returning both.
|
||||
///
|
||||
/// Brackets that merely SPAN the summary window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). The summary reads `"none"` when the window
|
||||
/// is clean.
|
||||
pub(super) fn window_report(
|
||||
&self,
|
||||
hole_from: Instant,
|
||||
hole_to: Instant,
|
||||
lead_in: Duration,
|
||||
) -> (String, EtwWindowCounts) {
|
||||
// Instant → QPC: anchor both clocks once and offset backwards; every window edge below
|
||||
// derives from this one anchor.
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(hole_to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(hole_from), freq);
|
||||
let summary_from_q = from_q - duration_qpc(lead_in, freq);
|
||||
// One snapshot, then the lock drops: everything below — including the OpenProcess
|
||||
// syscalls behind `process_name` — runs off the copy, so the consumer callback never
|
||||
// queues behind a stall report.
|
||||
let events: Vec<(i64, u16, u32)> = {
|
||||
let ring = RING.lock().unwrap();
|
||||
ring.iter()
|
||||
@@ -318,6 +355,7 @@ impl EtwWatch {
|
||||
.copied()
|
||||
.collect()
|
||||
};
|
||||
let counts = count_window(&events, from_q, to_q, duration_qpc(LOOKBACK, freq));
|
||||
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
||||
let mut parts = Vec::new();
|
||||
for (start_id, stop_id, label) in [
|
||||
@@ -335,7 +373,7 @@ impl EtwWatch {
|
||||
} else if id == stop_id {
|
||||
if let Some(s) = open.take() {
|
||||
// The bracket [s, ts] counts when it intersects the window.
|
||||
if s <= to_q && ts >= from_q {
|
||||
if s <= to_q && ts >= summary_from_q {
|
||||
count += 1;
|
||||
max_ms = max_ms.max(ms(ts - s));
|
||||
}
|
||||
@@ -362,43 +400,34 @@ impl EtwWatch {
|
||||
] {
|
||||
let count = events
|
||||
.iter()
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= from_q && *ts <= to_q)
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= summary_from_q && *ts <= to_q)
|
||||
.count();
|
||||
if count > 0 {
|
||||
parts.push(format!("{label}×{count}"));
|
||||
}
|
||||
}
|
||||
// Present + queue accounting (DXGI 42/55 + BltQueueAddEntry/Complete): total presents
|
||||
// inside the window plus the top presenters, NAMED — the line that splits a
|
||||
// inside the summary window plus the top presenters, NAMED — the line that splits a
|
||||
// compose-silence hole into "the content stopped presenting" (no presents anywhere)
|
||||
// versus "presents flowed and the display path dropped them" (presents at rate while
|
||||
// the queue starves). "Present×0" is printed explicitly when the stream has history
|
||||
// but the window is empty — silence is a finding, not an absence.
|
||||
// the queue starves). "Present×0" is printed explicitly when the witness was LIVE
|
||||
// before the hole ([`LOOKBACK`]) but the window is empty — silence is a finding, not
|
||||
// an absence; a dead witness's window prints nothing rather than a fake zero.
|
||||
let mut per_pid: Vec<(u32, u32)> = Vec::new();
|
||||
let mut have_present_history = false;
|
||||
let (mut adds, mut completes) = (0u32, 0u32);
|
||||
let mut have_queue_history = false;
|
||||
for &(ts, id, pid) in &events {
|
||||
if ts < summary_from_q || ts > to_q {
|
||||
continue;
|
||||
}
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
have_present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID | BLT_COMPLETE_ID => {
|
||||
have_queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
if id == BLT_ADD_ID {
|
||||
adds += 1;
|
||||
} else {
|
||||
completes += 1;
|
||||
}
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => adds += 1,
|
||||
BLT_COMPLETE_ID => completes += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -415,61 +444,80 @@ impl EtwWatch {
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
parts.push(format!("Present×{total}({top})"));
|
||||
} else if have_present_history {
|
||||
} else if counts.present_history {
|
||||
parts.push("Present×0".to_string());
|
||||
}
|
||||
if have_queue_history {
|
||||
if counts.queue_history || adds > 0 || completes > 0 {
|
||||
parts.push(format!("blt-queue add×{adds} complete×{completes}"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
let summary = if parts.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// The structured discriminator read for `[from, to]` (the stall classifier's evidence):
|
||||
/// how many swapchain presents (DXGI 42/55, any process) and how many virtual-display
|
||||
/// queue entries (`BltQueueAddEntry`) landed in the window, plus whether each stream has
|
||||
/// EVER produced an event (distinguishing a true zero from a witness that is not working —
|
||||
/// e.g. the DXGI enable was refused, or an OS build renumbered the BltQueue events).
|
||||
pub(super) fn window_counts(&self, from: Instant, to: Instant) -> EtwWindowCounts {
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let ring = RING.lock().unwrap();
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in ring.iter() {
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
(summary, counts)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_counts`]'s read: the compose-silence discriminator's structured evidence.
|
||||
/// Witness-liveness lookback: the [`EtwWindowCounts`] history flags are true only when the
|
||||
/// stream produced at least one event inside the `LOOKBACK` window ENDING at the hole's start.
|
||||
/// "Ever produced an event" would be wrong in both directions: an event that arrived only AFTER
|
||||
/// the hole (the resume burst, the stall-ending frame) proves nothing about whether the witness
|
||||
/// was working DURING it, and a provider that died mid-session (or whose events aged out of the
|
||||
/// ring) would keep flying a stale known-working flag forever. Demonstrated life immediately
|
||||
/// BEFORE the hole is the claim the classifier actually needs; 5 s is far longer than any
|
||||
/// pre-stall active-flow gate, so a genuinely working witness cannot blink false across a
|
||||
/// frame-time lull.
|
||||
const LOOKBACK: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The discriminator's windowing math, factored pure (plain i64 QPC-tick arithmetic, no ETW,
|
||||
/// no clock reads) so the ring→counts contract is unit-testable without a session: presents
|
||||
/// (DXGI 42/55, any process) and queue entries (`BltQueueAddEntry`) inside `[from_q, to_q]`,
|
||||
/// witness liveness from `[from_q - lookback_q, from_q]` (see [`LOOKBACK`]). A
|
||||
/// `BltQueueCompleteIndirectPresent` proves the queue witness works exactly as an add does —
|
||||
/// both ride the same provider enable — so either satisfies `queue_history`.
|
||||
fn count_window(
|
||||
events: &[(i64, u16, u32)],
|
||||
from_q: i64,
|
||||
to_q: i64,
|
||||
lookback_q: i64,
|
||||
) -> EtwWindowCounts {
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in events {
|
||||
let in_window = ts >= from_q && ts <= to_q;
|
||||
let in_lookback = ts >= from_q.saturating_sub(lookback_q) && ts <= from_q;
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history |= in_lookback;
|
||||
if in_window {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history |= in_lookback;
|
||||
if in_window {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
BLT_COMPLETE_ID => out.queue_history |= in_lookback,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_report`]'s structured half: the compose-silence discriminator's evidence.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct EtwWindowCounts {
|
||||
/// Swapchain presents (any process — the game AND dwm both count) inside the window.
|
||||
pub(super) presents: u32,
|
||||
/// `BltQueueAddEntry` events (frames entering the virtual display's kernel queue) inside it.
|
||||
pub(super) queue_adds: u32,
|
||||
/// The present stream has produced at least one event EVER (witness known-working).
|
||||
/// The present stream demonstrated liveness inside [`LOOKBACK`] BEFORE the hole opened — a
|
||||
/// working witness whose in-window zero is a reading, not a dead one whose zero is noise.
|
||||
pub(super) present_history: bool,
|
||||
/// The queue stream has produced at least one event EVER (witness known-working).
|
||||
/// Queue-stream liveness inside [`LOOKBACK`] before the hole (`BltQueueAddEntry` or
|
||||
/// `BltQueueCompleteIndirectPresent` — either proves the witness works).
|
||||
pub(super) queue_history: bool,
|
||||
}
|
||||
|
||||
@@ -561,3 +609,72 @@ impl Drop for EtwWatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The module only compiles on Windows (lib.rs gates `mod windows`), so plain `cfg(test)` here
|
||||
// already means "Windows tests" — and [`count_window`] itself is pure tick math, no session.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// [`count_window`]'s contract: counts come from the hole window `[from, to]`; liveness
|
||||
/// comes ONLY from the lookback window ending at the hole's start. An event after the hole
|
||||
/// (the resume burst) or older than the lookback (a dead provider's leftovers) must not fly
|
||||
/// the known-working flag — those are exactly the shapes that used to convict every
|
||||
/// compose-silence hole as content.
|
||||
#[test]
|
||||
fn count_window_liveness_and_windowing() {
|
||||
// Hole [1000, 2000], lookback 500 → liveness window [500, 1000]. Plain ticks.
|
||||
let (from, to, lb) = (1_000i64, 2_000i64, 500i64);
|
||||
let ev = |ts: i64, id: u16| (ts, id, 42u32);
|
||||
|
||||
// The healthy shape: liveness demonstrated before the hole, activity inside it.
|
||||
let events = [
|
||||
ev(600, DXGI_PRESENT_ID), // lookback → present witness live
|
||||
ev(700, BLT_COMPLETE_ID), // lookback → queue witness live (completes count)
|
||||
ev(1_100, DXGI_PRESENT_ID), // in-window present
|
||||
ev(1_200, DXGI_PRESENT_MPO_ID), // in-window present (MPO path)
|
||||
ev(1_300, BLT_ADD_ID), // in-window queue add
|
||||
ev(1_400, 430), // non-witness id: never counted here
|
||||
];
|
||||
assert_eq!(
|
||||
count_window(&events, from, to, lb),
|
||||
EtwWindowCounts {
|
||||
presents: 2,
|
||||
queue_adds: 1,
|
||||
present_history: true,
|
||||
queue_history: true,
|
||||
}
|
||||
);
|
||||
|
||||
// In-window events count but do NOT confer liveness — the witness must have worked
|
||||
// BEFORE the hole for its zeros elsewhere to mean anything.
|
||||
let window_only = [ev(1_500, DXGI_PRESENT_ID), ev(1_600, BLT_ADD_ID)];
|
||||
let c = count_window(&window_only, from, to, lb);
|
||||
assert_eq!((c.presents, c.queue_adds), (1, 1));
|
||||
assert!(!c.present_history && !c.queue_history);
|
||||
|
||||
// An event only AFTER the hole proves nothing about the witness during it.
|
||||
let after_only = [ev(2_100, DXGI_PRESENT_ID), ev(2_200, BLT_ADD_ID)];
|
||||
assert_eq!(
|
||||
count_window(&after_only, from, to, lb),
|
||||
EtwWindowCounts::default()
|
||||
);
|
||||
|
||||
// Events that aged past the lookback (a previous session's leftovers) don't either.
|
||||
let stale = [ev(499, DXGI_PRESENT_ID), ev(1, BLT_ADD_ID)];
|
||||
assert_eq!(
|
||||
count_window(&stale, from, to, lb),
|
||||
EtwWindowCounts::default()
|
||||
);
|
||||
|
||||
// Both lookback edges are inclusive; the hole-start event is both liveness and count.
|
||||
let edges = [ev(500, DXGI_PRESENT_ID), ev(1_000, BLT_ADD_ID)];
|
||||
let c = count_window(&edges, from, to, lb);
|
||||
assert!(c.present_history && c.queue_history);
|
||||
assert_eq!((c.presents, c.queue_adds), (0, 1));
|
||||
|
||||
// A lookback reaching below tick 0 saturates instead of wrapping.
|
||||
let c = count_window(&[ev(0, DXGI_PRESENT_ID)], 3, to, i64::MAX);
|
||||
assert!(c.present_history);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) struct StallEvidence {
|
||||
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
|
||||
/// session is unavailable (non-admin dev run).
|
||||
pub(super) etw: Option<String>,
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_counts`]) —
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_report`]) —
|
||||
/// the compose-silence discriminator: presents flowing while the queue starves = the OS
|
||||
/// display path dropped composed frames; both silent = the content stopped presenting.
|
||||
/// `None` when the ETW session is unavailable.
|
||||
|
||||
@@ -57,10 +57,6 @@ 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
|
||||
|
||||
@@ -336,9 +336,6 @@ enum Ctl {
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
/// 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),
|
||||
}
|
||||
@@ -485,18 +482,6 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::KindOverride(pref));
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
@@ -626,11 +611,6 @@ 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;
|
||||
@@ -664,29 +644,6 @@ 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
|
||||
@@ -720,14 +677,6 @@ 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 {
|
||||
@@ -743,8 +692,6 @@ impl Slot {
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
audio_caps: 0,
|
||||
rumble_suppressed_logged: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,10 +725,6 @@ 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<()>,
|
||||
@@ -982,18 +925,11 @@ 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,
|
||||
@@ -1016,27 +952,6 @@ 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,
|
||||
@@ -1050,35 +965,6 @@ 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).
|
||||
@@ -1095,11 +981,6 @@ 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,
|
||||
@@ -1388,7 +1269,6 @@ impl Worker {
|
||||
self.refresh_active();
|
||||
}
|
||||
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1660,20 +1540,6 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -1706,17 +1572,6 @@ 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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1731,8 +1586,6 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1756,7 +1609,6 @@ impl Worker {
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
kind_override: GamepadPref::Auto,
|
||||
pad_audio_prefs: 0,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -2092,43 +1944,5 @@ 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,11 +47,6 @@ 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,14 +44,6 @@ 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,
|
||||
@@ -364,11 +356,6 @@ 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,
|
||||
@@ -392,11 +379,6 @@ 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
|
||||
}),
|
||||
@@ -442,11 +424,31 @@ fn pump(
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC), honoring the
|
||||
// Settings backend preference (auto/vaapi/software).
|
||||
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
// The WIRE codec is the negotiated truth; the FFmpeg id is meaningful only where
|
||||
// FFmpeg decodes it. `ffmpeg_codec_id`'s fallthrough maps every unknown wire bit —
|
||||
// PyroWave included — to HEVC, so logging it unconditionally claimed
|
||||
// `codec_id=HEVC` for wavelet sessions that never touch FFmpeg at all.
|
||||
let codec = match connector.codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "H264",
|
||||
punktfunk_core::quic::CODEC_HEVC => "HEVC",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||
_ => "unknown",
|
||||
};
|
||||
if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
tracing::info!(
|
||||
codec,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
codec,
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
}
|
||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||
// reachable only through the explicit preference above (resolve_codec never
|
||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
||||
@@ -499,20 +501,6 @@ 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.
|
||||
@@ -1078,9 +1066,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -912,21 +912,6 @@ 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
|
||||
@@ -958,10 +943,6 @@ 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).
|
||||
@@ -1034,8 +1015,6 @@ 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,
|
||||
|
||||
@@ -321,6 +321,88 @@ pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a decoder for `codec_id` that can actually drive `hw_pix_fmt` through
|
||||
/// `hw_device_ctx` — the open-time capability check every hardware backend needs.
|
||||
///
|
||||
/// `avcodec_find_decoder(id)` is NOT that: it 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" — allcodecs.c), behind libdav1d/libaom. The ID
|
||||
/// lookup therefore hands every AV1 session a pure software decoder that silently
|
||||
/// ignores `hw_device_ctx` and never calls `get_format`; each frame then fails the
|
||||
/// backend's hw-format guard and the session burns the demotion ladder MID-STREAM
|
||||
/// (~1 s per rung — field-logged as 68 Vulkan fails → D3D11VA → 102 fails → software,
|
||||
/// ~3 s of black) instead of failing here at open in milliseconds. H.264/HEVC never hit
|
||||
/// this only because their native decoders happen to be registered first.
|
||||
///
|
||||
/// The walk mirrors what `avcodec_find_decoder` would do, restricted to decoders whose
|
||||
/// `avcodec_get_hw_config` advertises the wanted surface via
|
||||
/// `AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX` — registry order still wins among those,
|
||||
/// so H.264/HEVC keep selecting exactly the decoder they always did. The error names
|
||||
/// the decoders that WERE found, so a log reader can tell "this build has no AV1
|
||||
/// hwaccel at all" from "no AV1 decoder exists, period".
|
||||
pub(crate) fn find_hw_decoder(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
hw_pix_fmt: ffmpeg::ffi::AVPixelFormat,
|
||||
) -> Result<*const ffmpeg::ffi::AVCodec> {
|
||||
use ffmpeg::ffi;
|
||||
let want: ffi::AVCodecID = codec_id.into();
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
// SAFETY: `av_codec_iterate` walks libav's static codec registry (`opaque` is its
|
||||
// cursor) and returns static `AVCodec`s; `avcodec_get_hw_config` only reads the
|
||||
// codec's own static hw-config table, NULL-terminated by returning null past the end.
|
||||
unsafe {
|
||||
let mut opaque = std::ptr::null_mut();
|
||||
loop {
|
||||
let codec = ffi::av_codec_iterate(&mut opaque);
|
||||
if codec.is_null() {
|
||||
break;
|
||||
}
|
||||
if (*codec).id != want || ffi::av_codec_is_decoder(codec) == 0 {
|
||||
continue;
|
||||
}
|
||||
for i in 0.. {
|
||||
let cfg = ffi::avcodec_get_hw_config(codec, i);
|
||||
if cfg.is_null() {
|
||||
break;
|
||||
}
|
||||
if (*cfg).methods & ffi::AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX as i32 != 0
|
||||
&& (*cfg).pix_fmt == hw_pix_fmt
|
||||
{
|
||||
return Ok(codec);
|
||||
}
|
||||
}
|
||||
found.push(
|
||||
std::ffi::CStr::from_ptr((*codec).name)
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
bail!("no {codec_id:?} decoder in this FFmpeg build");
|
||||
}
|
||||
bail!(
|
||||
"no {codec_id:?} decoder in this FFmpeg build can drive {hw_pix_fmt:?} via \
|
||||
hw_device_ctx (found: {})",
|
||||
found.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
/// The name of a registry `AVCodec` (`(*codec).name`), owned — the field every decode
|
||||
/// log carries so `decoder="av1"` vs `decoder="libdav1d"` is one glance, not a debugger.
|
||||
///
|
||||
/// # Safety
|
||||
/// `codec` must point to a registered `AVCodec` (their `name` is a static NUL-terminated
|
||||
/// string, valid for the process).
|
||||
pub(crate) unsafe fn codec_name(codec: *const ffmpeg::ffi::AVCodec) -> String {
|
||||
// SAFETY: caller guarantees a registered AVCodec; `name` is its static C string.
|
||||
unsafe {
|
||||
std::ffi::CStr::from_ptr((*codec).name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
|
||||
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
|
||||
pub fn decodable_codecs() -> u8 {
|
||||
@@ -435,7 +517,11 @@ impl Decoder {
|
||||
vaapi_tried = true;
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"VAAPI hardware decode active (zero-copy dmabuf)"
|
||||
);
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -470,6 +556,7 @@ impl Decoder {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = d.name(),
|
||||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||||
);
|
||||
return done(Backend::D3d11va(d));
|
||||
@@ -490,6 +577,7 @@ impl Decoder {
|
||||
Ok(v) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"Vulkan Video hardware decode active (presenter-shared device)"
|
||||
);
|
||||
return done(Backend::Vulkan(v));
|
||||
@@ -520,7 +608,11 @@ impl Decoder {
|
||||
if choice != "software" && choice != "vulkan" && !vaapi_tried {
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"VAAPI hardware decode active (zero-copy dmabuf)"
|
||||
);
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -548,6 +640,7 @@ impl Decoder {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = d.name(),
|
||||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||||
);
|
||||
return done(Backend::D3d11va(d));
|
||||
@@ -724,6 +817,7 @@ impl Decoder {
|
||||
match VaapiDecoder::new(self.codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
decoder = v.name(),
|
||||
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
||||
self.backend = Backend::Vaapi(v);
|
||||
self.vaapi_fails = 0;
|
||||
@@ -745,6 +839,7 @@ impl Decoder {
|
||||
) {
|
||||
Ok(d) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
decoder = d.name(),
|
||||
"Vulkan Video decode failing repeatedly — demoting to D3D11VA");
|
||||
self.backend = Backend::D3d11va(d);
|
||||
self.vaapi_fails = 0;
|
||||
|
||||
@@ -552,6 +552,10 @@ pub(crate) struct D3d11vaDecoder {
|
||||
/// ([`crate::video::VulkanDecodeDevice::d3d11_hdr10`]) — PQ streams get the HDR
|
||||
/// pass-through ring; without it they keep the tonemap-to-sRGB ring.
|
||||
hdr10_out: bool,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
}
|
||||
|
||||
// SAFETY: the libav pointers are this decoder's own allocations (freed once in `Drop`) and the COM
|
||||
@@ -609,10 +613,16 @@ impl D3d11vaDecoder {
|
||||
if !d3d11va_decode_supported(hw_device.as_ptr()) {
|
||||
bail!("GPU can't create the D3D11VA decode surface pool");
|
||||
}
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's D3D11-format guard mid-stream,
|
||||
// even when the DXVA profile + pool probes above all passed. Select by
|
||||
// capability instead: the first decoder that can drive AV_PIX_FMT_D3D11
|
||||
// via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_D3D11)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(get_format_d3d11);
|
||||
@@ -638,10 +648,16 @@ impl D3d11vaDecoder {
|
||||
video_context1,
|
||||
ring: None,
|
||||
hdr10_out,
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<D3d11Frame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -830,6 +846,7 @@ impl D3d11vaDecoder {
|
||||
src_desc.Height,
|
||||
index,
|
||||
color.is_pq(),
|
||||
&self.name,
|
||||
);
|
||||
Ok(D3d11Frame {
|
||||
width,
|
||||
@@ -883,7 +900,15 @@ impl Drop for D3d11vaDecoder {
|
||||
/// One-time dump of the first decoded surface's layout — the forensics for a new GPU/driver.
|
||||
/// `tex_*` is the DXVA-aligned decode surface (>= the frame); the gap is the padding the
|
||||
/// stream source rect excludes.
|
||||
fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, pq: bool) {
|
||||
fn log_layout_once(
|
||||
width: u32,
|
||||
height: u32,
|
||||
tex_w: u32,
|
||||
tex_h: u32,
|
||||
index: u32,
|
||||
pq: bool,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
@@ -894,6 +919,7 @@ fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32,
|
||||
tex_h,
|
||||
slice = index,
|
||||
pq,
|
||||
decoder,
|
||||
"D3D11VA first frame"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ impl SoftwareDecoder {
|
||||
(*raw).thread_count = 0; // auto
|
||||
}
|
||||
let decoder = ctx.decoder().video().context("open video decoder")?;
|
||||
// Every construction site (session open, preference, mid-stream demotion) says
|
||||
// which decoder actually opened: for AV1 the ID lookup means libdav1d here —
|
||||
// deliberately (fastest CPU path; the native `av1` decoder has no software
|
||||
// path at all) — and the name in the log is what keeps that distinguishable
|
||||
// from the hardware lanes' capability-selected decoders.
|
||||
tracing::info!(?codec_id, decoder = codec.name(), "software decoder opened");
|
||||
Ok(SoftwareDecoder { decoder, sws: None })
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ pub(crate) struct VaapiDecoder {
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
}
|
||||
|
||||
// SAFETY: the three raw pointers (`ctx`, `packet`, `frame`) are allocations this decoder makes in
|
||||
@@ -80,11 +84,15 @@ impl VaapiDecoder {
|
||||
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
|
||||
// The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id).
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's VAAPI-format guard mid-stream.
|
||||
// Select by capability instead: the first decoder that can drive
|
||||
// AV_PIX_FMT_VAAPI via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_VAAPI)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vaapi);
|
||||
@@ -109,10 +117,16 @@ impl VaapiDecoder {
|
||||
hw_device,
|
||||
packet: ffi::av_packet_alloc(),
|
||||
frame: ffi::av_frame_alloc(),
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<DmabufFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -207,7 +221,7 @@ impl VaapiDecoder {
|
||||
// a single modifier for the texture.
|
||||
let modifier = d.objects[0].format_modifier;
|
||||
|
||||
log_descriptor_once(d, sw_format, fourcc, modifier);
|
||||
log_descriptor_once(d, sw_format, fourcc, modifier, &self.name);
|
||||
|
||||
Ok(DmabufFrame {
|
||||
width: (*self.frame).width as u32,
|
||||
@@ -233,6 +247,7 @@ fn log_descriptor_once(
|
||||
sw: ffmpeg_next::ffi::AVPixelFormat,
|
||||
fourcc: u32,
|
||||
modifier: u64,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
@@ -250,6 +265,7 @@ fn log_descriptor_once(
|
||||
nb_layers = d.nb_layers,
|
||||
?layers,
|
||||
modifier = format_args!("{:#018x}", modifier),
|
||||
decoder,
|
||||
"VAAPI dmabuf descriptor layout (first frame)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ pub(crate) struct VulkanDecoder {
|
||||
/// (resolved through the same get_proc_addr chain FFmpeg uses).
|
||||
wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores,
|
||||
vk_device: pf_ffvk::VkDevice,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
/// Storage `AVVulkanDeviceContext` points into (extension string arrays + the
|
||||
/// feature chain) — FFmpeg reads the extension lists past init (frames-context
|
||||
/// setup keys code paths off them), so this lives exactly as long as `hw_device`.
|
||||
@@ -245,10 +249,15 @@ impl VulkanDecoder {
|
||||
}
|
||||
let vk_device = (*hwctx).act_dev;
|
||||
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's Vulkan-format guard mid-stream.
|
||||
// Select by capability instead: the first decoder that can drive
|
||||
// AV_PIX_FMT_VULKAN via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_VULKAN)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vulkan);
|
||||
@@ -270,11 +279,17 @@ impl VulkanDecoder {
|
||||
frame: ffi::av_frame_alloc(),
|
||||
wait_semaphores,
|
||||
vk_device,
|
||||
name,
|
||||
_ctx_storage: store,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<VkVideoFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -388,6 +403,7 @@ impl VulkanDecoder {
|
||||
(*fc).width,
|
||||
(*fc).height,
|
||||
sw,
|
||||
&self.name,
|
||||
);
|
||||
Ok(VkVideoFrame {
|
||||
vkframe: vkf as usize,
|
||||
@@ -423,6 +439,7 @@ fn log_layout_once(
|
||||
pool_w: i32,
|
||||
pool_h: i32,
|
||||
sw: ffmpeg::ffi::AVPixelFormat,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
@@ -433,6 +450,7 @@ fn log_layout_once(
|
||||
pool_w,
|
||||
pool_h,
|
||||
?sw,
|
||||
decoder,
|
||||
"Vulkan Video first frame"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,7 +365,8 @@ fn detail(id: RowId) -> &'static str {
|
||||
}
|
||||
RowId::Chroma444 => {
|
||||
"Full-colour video: crisp small text and thin lines, at more bandwidth. \
|
||||
HEVC only, and only where the host can encode it."
|
||||
Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders \
|
||||
stream 4:2:0 and the session falls back silently."
|
||||
}
|
||||
RowId::Audio => "The speaker layout requested from the host.",
|
||||
RowId::Mic => {
|
||||
|
||||
@@ -10,20 +10,14 @@ 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` / `AudioCtl`)
|
||||
/// 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`) 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 {
|
||||
@@ -66,25 +60,6 @@ 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
|
||||
@@ -148,28 +123,4 @@ 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,8 +481,7 @@ 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 and the audio-control region are
|
||||
/// forwarded raw for the client.
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
///
|
||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
||||
@@ -541,21 +540,6 @@ 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)]
|
||||
@@ -858,48 +842,6 @@ 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() {
|
||||
|
||||
@@ -475,7 +475,6 @@ mod tests {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
|
||||
@@ -204,6 +204,11 @@ struct StreamState {
|
||||
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
|
||||
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
|
||||
hdr: bool,
|
||||
/// The presented lane was the CPU/software one, where a PQ stream is shown RAW — the
|
||||
/// software path has no tone-map pass at all (the presenter uploads swscale RGBA
|
||||
/// as-is; the CSC mode-1 tonemap is hardware-lane only) — so the OSD badge reads
|
||||
/// `HDR→SDR (raw)` there instead of claiming a tone-map that never ran.
|
||||
hdr_untonemapped: bool,
|
||||
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
|
||||
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
||||
win_e2e_us: Vec<u64>,
|
||||
@@ -308,6 +313,7 @@ impl StreamState {
|
||||
latch_grid,
|
||||
clock_offset: None,
|
||||
hdr: false,
|
||||
hdr_untonemapped: false,
|
||||
win_e2e_us: Vec::with_capacity(256),
|
||||
win_disp_us: Vec::with_capacity(256),
|
||||
win_start: Instant::now(),
|
||||
@@ -1103,6 +1109,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
);
|
||||
if stats_verbosity != StatsVerbosity::Off {
|
||||
@@ -1115,6 +1122,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
);
|
||||
println!("stats: {}", full.replace('\n', " | "));
|
||||
@@ -1296,6 +1304,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// HDR (PQ) pyrowave session presents through the HDR10 path exactly
|
||||
// like the H.26x codecs (design/pyrowave-444-hdr.md Phase 3).
|
||||
st.hdr = f.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::PyroWave(f),
|
||||
@@ -1323,6 +1332,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
DecodedImage::Cpu(c) => {
|
||||
st.hdr = c.color.is_pq();
|
||||
// The software lane shows PQ raw (no tone-map pass exists there)
|
||||
// — the OSD badge must not claim `HDR→SDR` for it.
|
||||
st.hdr_untonemapped = true;
|
||||
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1330,6 +1342,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if presenter.supports_dmabuf() && !st.dmabuf_demoted =>
|
||||
{
|
||||
st.hdr = d.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::Dmabuf(d),
|
||||
@@ -1380,6 +1393,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(d) if presenter.supports_d3d11() && !st.dmabuf_demoted => {
|
||||
st.hdr = d.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::D3d11(d),
|
||||
@@ -1426,6 +1440,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// demotion contract as the dmabuf path.
|
||||
DecodedImage::VkFrame(v) if !st.dmabuf_demoted => {
|
||||
st.hdr = v.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::VkFrame(v),
|
||||
@@ -1910,6 +1925,7 @@ fn bump_stats_tier(
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
),
|
||||
None => String::new(),
|
||||
@@ -2007,11 +2023,15 @@ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
|
||||
///
|
||||
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
|
||||
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
|
||||
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
|
||||
/// format offered, HDR off in the compositor) shows `HDR→SDR`; and a PQ stream on the
|
||||
/// software-decode lane (`hdr_untonemapped`) shows `HDR→SDR (raw)` — that lane has no
|
||||
/// tone-map pass at all, so the washed-out picture is named for what it is rather than
|
||||
/// passed off as a tone-map.
|
||||
///
|
||||
/// `profile` (the session's settings profile, `None` for the global defaults) closes the
|
||||
/// first line at every tier — the cheapest possible answer to "which profile am I on?"
|
||||
/// (design/client-settings-profiles.md §5.2).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stats_text(
|
||||
verbosity: StatsVerbosity,
|
||||
mode_line: &str,
|
||||
@@ -2019,6 +2039,7 @@ fn stats_text(
|
||||
p: &PresentedWindow,
|
||||
hdr_stream: bool,
|
||||
hdr_display: bool,
|
||||
hdr_untonemapped: bool,
|
||||
profile: Option<&str>,
|
||||
) -> String {
|
||||
let profile_tag = profile.map(|n| format!(" · {n}")).unwrap_or_default();
|
||||
@@ -2068,6 +2089,7 @@ fn stats_text(
|
||||
if s.decoder.is_empty() { "-" } else { s.decoder },
|
||||
match (hdr_stream, hdr_display) {
|
||||
(true, true) => " · HDR",
|
||||
(true, false) if hdr_untonemapped => " · HDR→SDR (raw)",
|
||||
(true, false) => " · HDR→SDR",
|
||||
_ => "",
|
||||
},
|
||||
@@ -2380,7 +2402,7 @@ mod tests {
|
||||
#[test]
|
||||
fn stats_text_tiers() {
|
||||
let (s, p) = sample();
|
||||
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, None);
|
||||
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, false, None);
|
||||
|
||||
assert_eq!(text(StatsVerbosity::Off), "");
|
||||
|
||||
@@ -2397,6 +2419,10 @@ mod tests {
|
||||
|
||||
let detailed = text(StatsVerbosity::Detailed);
|
||||
assert!(detailed.contains("vulkan · HDR→SDR"));
|
||||
assert!(
|
||||
!detailed.contains("(raw)"),
|
||||
"the hardware lane tone-maps — no raw tag"
|
||||
);
|
||||
assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
|
||||
assert!(detailed.contains("host: queue 0.3 · encode 0.5 · xfer 0.1 · pace 0.3 ms"));
|
||||
assert!(detailed.contains("lost 3 (0.4%)"));
|
||||
@@ -2406,6 +2432,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT
|
||||
/// tone-mapping (that lane has no PQ→sRGB pass), so its badge must not read as the
|
||||
/// hardware lane's `HDR→SDR` tone-map — and an HDR10 swapchain shows plain `HDR`
|
||||
/// whatever the lane claims (a CPU frame forces the swapchain to SDR anyway).
|
||||
#[test]
|
||||
fn hdr_badge_names_the_untonemapped_cpu_lane() {
|
||||
let (s, p) = sample();
|
||||
let badge = |hdr_display, raw| {
|
||||
stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
true,
|
||||
hdr_display,
|
||||
raw,
|
||||
None,
|
||||
)
|
||||
};
|
||||
assert!(badge(false, true).contains(" · HDR→SDR (raw)"));
|
||||
assert!(!badge(false, false).contains("(raw)"));
|
||||
assert!(badge(false, false).contains(" · HDR→SDR"));
|
||||
assert!(badge(true, false).contains(" · HDR"));
|
||||
assert!(!badge(true, false).contains("HDR→SDR"));
|
||||
}
|
||||
|
||||
/// Detailed shows the negotiated encoder target next to the measured rate — the
|
||||
/// figure whose absence let the settings-drop bug ship four releases — tagged
|
||||
/// `(auto)` when the ABR owns it, plus the honest chroma tag when 4:4:4 was asked.
|
||||
@@ -2413,7 +2465,7 @@ mod tests {
|
||||
fn detailed_shows_target_and_chroma_resolution() {
|
||||
let (mut s, p) = sample();
|
||||
let line1 = |s: &Stats, v| {
|
||||
stats_text(v, "m", s, &p, false, false, None)
|
||||
stats_text(v, "m", s, &p, false, false, false, None)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
@@ -2446,7 +2498,7 @@ mod tests {
|
||||
#[test]
|
||||
fn stats_text_mic_line() {
|
||||
let (mut s, p) = sample();
|
||||
let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, None);
|
||||
let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, false, None);
|
||||
assert!(
|
||||
!text(&s, StatsVerbosity::Detailed).contains("mic"),
|
||||
"no mic line while the mic is off"
|
||||
@@ -2473,7 +2525,16 @@ mod tests {
|
||||
s.lost = 0;
|
||||
let p = PresentedWindow::default();
|
||||
assert_eq!(
|
||||
stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false, None),
|
||||
stats_text(
|
||||
StatsVerbosity::Compact,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
),
|
||||
"120 fps · 24 Mb/s"
|
||||
);
|
||||
}
|
||||
@@ -2491,6 +2552,7 @@ mod tests {
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some("Game")
|
||||
),
|
||||
"120 fps · 6.4 ms · 24 Mb/s · lost 3 · Game"
|
||||
@@ -2502,6 +2564,7 @@ mod tests {
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some("Work"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2515,13 +2578,22 @@ mod tests {
|
||||
&p,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some("Work"),
|
||||
);
|
||||
assert!(detailed.lines().next().unwrap().ends_with("· HDR · Work"));
|
||||
// No profile → the line is exactly what it always was.
|
||||
assert!(
|
||||
!stats_text(StatsVerbosity::Normal, "m", &s, &p, false, false, None).contains(" · ")
|
||||
);
|
||||
assert!(!stats_text(
|
||||
StatsVerbosity::Normal,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
)
|
||||
.contains(" · "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -40,7 +40,27 @@ impl Presenter {
|
||||
// PQ→sRGB pass.
|
||||
let frame_pq = match &input {
|
||||
FrameInput::Redraw => None,
|
||||
FrameInput::Cpu(_) => Some(false),
|
||||
FrameInput::Cpu(f) => {
|
||||
// The swapchain answer stays `false` (above) — but a PQ stream on this
|
||||
// lane is then shown RAW: no PQ→sRGB pass exists here (the CSC mode-1
|
||||
// tonemap is hardware-lane only; CPU frames are a straight RGBA upload),
|
||||
// so the picture is washed out and the pq-downgrade warn below never
|
||||
// fires. Say so once, or the only trace is an OSD badge. (A process-once
|
||||
// latch, same idiom as the decoders' first-frame layout dumps — the
|
||||
// condition is a property of the lane, not of one Presenter.)
|
||||
if f.color.is_pq() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static WARNED: AtomicBool = AtomicBool::new(false);
|
||||
if !WARNED.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"HDR10 (PQ) stream on the software-decode lane — it has no \
|
||||
PQ→sRGB pass, so the picture is shown untonemapped (washed \
|
||||
out). Hardware decode restores correct colour."
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
|
||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||
|
||||
@@ -731,12 +731,17 @@ pub(super) fn pick_formats(
|
||||
}
|
||||
|
||||
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
|
||||
/// fifo|mailbox|immediate` overrides). Both are tear-free, but an arrival-paced
|
||||
/// presenter must not block in FIFO's present queue: when the compositor holds images
|
||||
/// for a vblank pass (gamescope's composite path) or arrival cadence drifts against
|
||||
/// refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms added
|
||||
/// to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
|
||||
/// fifo|mailbox|immediate|fifo_relaxed` overrides). Both defaults are tear-free, but an
|
||||
/// arrival-paced presenter must not block in FIFO's present queue: when the compositor
|
||||
/// holds images for a vblank pass (gamescope's composite path) or arrival cadence drifts
|
||||
/// against refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms
|
||||
/// added to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
|
||||
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
|
||||
///
|
||||
/// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO —
|
||||
/// expected, not a client misconfiguration. FIFO_RELAXED is opt-in only: it tears exactly
|
||||
/// when a stream frame misses the vblank it was pacing for, which on a drifting arrival
|
||||
/// cadence is often — a trade the user must choose, never a silent fallback.
|
||||
fn pick_present_mode(
|
||||
surface_i: &ash::khr::surface::Instance,
|
||||
pdev: vk::PhysicalDevice,
|
||||
@@ -748,7 +753,15 @@ fn pick_present_mode(
|
||||
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
|
||||
Some("fifo") => vk::PresentModeKHR::FIFO,
|
||||
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
|
||||
_ => vk::PresentModeKHR::MAILBOX,
|
||||
Some("fifo_relaxed") => vk::PresentModeKHR::FIFO_RELAXED,
|
||||
Some("mailbox") | None => vk::PresentModeKHR::MAILBOX,
|
||||
Some(other) => {
|
||||
tracing::warn!(
|
||||
value = other,
|
||||
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — using mailbox"
|
||||
);
|
||||
vk::PresentModeKHR::MAILBOX
|
||||
}
|
||||
};
|
||||
Ok(if modes.contains(&want) {
|
||||
want
|
||||
|
||||
@@ -138,7 +138,14 @@ impl KwinDisplay {
|
||||
let kind = match topology {
|
||||
Topology::Exclusive => TopologyKind::Exclusive,
|
||||
Topology::Primary => TopologyKind::Primary,
|
||||
Topology::Extend | Topology::Auto => return Vec::new(),
|
||||
Topology::Extend | Topology::Auto => {
|
||||
// No topology to apply — but the output must still be its OWN desktop rather than a
|
||||
// mirror of someone's panel, and KWin restores a stored `replicationSource` onto our
|
||||
// (stable) output name for whatever monitor set it was saved under. Applies only if
|
||||
// it really is mirroring; nothing else about the user's arrangement is touched.
|
||||
crate::kwin_output_mgmt::clear_replication_source(our_prefix, dims.0, dims.1);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
// In-process over Wayland — immune to whatever wedges the standalone kscreen-doctor.
|
||||
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
|
||||
|
||||
@@ -112,6 +112,34 @@ const POLL_MS: i32 = 100;
|
||||
/// asked — matches `kwin::CVT_H_GRANULARITY`. Used when matching the generated mode back.
|
||||
const CVT_H_GRANULARITY: u32 = 8;
|
||||
|
||||
/// `kde_output_management_v2.set_replication_source` (and the device's `replication_source` event)
|
||||
/// arrived in v13. wayland-rs does not range-check requests, so sending one to a lower-version bind
|
||||
/// would be a protocol error that kills the connection — every call site gates on this.
|
||||
const REPLICATION_SOURCE_SINCE: u32 = 13;
|
||||
|
||||
/// The `source` value that means "this output mirrors nothing" — KWin's `applyMirroring` looks the
|
||||
/// source UUID up among the enabled outputs and treats an EMPTY string as no replication at all.
|
||||
const NO_REPLICATION_SOURCE: &str = "";
|
||||
|
||||
/// Is this output currently a MIRROR of another one?
|
||||
///
|
||||
/// KWin persists output config per *setup* — the exact set of connected outputs, matched by
|
||||
/// EDID/connector — in `kwinoutputconfig.json`, and `replicationSource` is one of the fields it
|
||||
/// stores and restores (`OutputConfigurationStore::storeConfig` / `setupToConfig`). Our virtual
|
||||
/// output carries a STABLE name across sessions (that is deliberate — KWin keys per-output scale by
|
||||
/// it), so a stored `replicationSource` for that name is re-applied to OUR output on every session
|
||||
/// that reproduces the same monitor set. The output then shows the source's viewport instead of
|
||||
/// being its own desktop, which is the whole point of creating it — and per the protocol's own note
|
||||
/// on `priority`, "an output may not be in the output order if it's disabled **or mirroring another
|
||||
/// screen**", so the primary assertion silently stops meaning anything too.
|
||||
///
|
||||
/// The event carries an empty string for the ordinary case, so `Some("")` must read as "not
|
||||
/// mirroring" — treating the mere presence of the event as a mirror would de-mirror every output on
|
||||
/// every apply.
|
||||
fn is_mirroring(replication_source: Option<&str>) -> bool {
|
||||
replication_source.is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Which topology to apply once our output is resolved.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TopologyKind {
|
||||
@@ -154,6 +182,9 @@ struct DeviceState {
|
||||
scale: Option<f64>,
|
||||
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
|
||||
priority: Option<u32>,
|
||||
/// UUID of the output this one MIRRORS, from the `replication_source` event (device ≥ v13).
|
||||
/// Empty / `None` ⇒ it is its own desktop. See [`is_mirroring`] for why this matters to us.
|
||||
replication_source: Option<String>,
|
||||
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
|
||||
current_mode: Option<ObjectId>,
|
||||
/// Every mode this output advertised, in announce order — `(mode object id, proxy)` — so restore
|
||||
@@ -307,6 +338,7 @@ impl Dispatch<OutputDevice, u32> for State {
|
||||
DeviceEvent::Scale { factor } => entry.scale = Some(factor),
|
||||
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
|
||||
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
|
||||
DeviceEvent::ReplicationSource { source } => entry.replication_source = Some(source),
|
||||
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
|
||||
DeviceEvent::Mode { mode } => entry.modes.push((mode.id(), mode)),
|
||||
DeviceEvent::Done => entry.seen_done = true,
|
||||
@@ -626,6 +658,17 @@ pub(crate) fn apply_topology(
|
||||
};
|
||||
let our_uuid = ours.uuid.clone();
|
||||
let our_id = ours.proxy.as_ref().map(|p| p.id());
|
||||
if is_mirroring(ours.replication_source.as_deref()) {
|
||||
// Worth a line of its own: this is the state a user experiences as "the stream just shows my
|
||||
// monitor", and it comes from KWin's stored config for THIS monitor set, so it reproduces
|
||||
// every session until something clears it. The config below does.
|
||||
tracing::warn!(
|
||||
source_uuid = ?ours.replication_source,
|
||||
our_prefix,
|
||||
"KWin had our streamed output MIRRORING another screen (a stored kwinoutputconfig.json \
|
||||
replicationSource for this monitor set) — clearing it so the output is its own desktop"
|
||||
);
|
||||
}
|
||||
|
||||
// First-slot-wins (§6.1): don't steal primary if another managed sibling already holds it
|
||||
// (priority 1) — a 2nd exclusive session joins as a secondary of the shared desktop. A
|
||||
@@ -681,6 +724,17 @@ pub(crate) fn apply_topology(
|
||||
let config = sess.new_config();
|
||||
if let Some(proxy) = ours.proxy.as_ref() {
|
||||
config.enable(proxy, 1);
|
||||
// State that ours is its OWN desktop, not a replica of somebody's panel. A stored
|
||||
// `replicationSource` for our (stable) output name is re-applied by KWin on every session
|
||||
// that reproduces the same monitor set, and it survives everything else this config says:
|
||||
// enabling and prioritising a mirror still leaves it showing the source's viewport, scaled
|
||||
// to the source's size (`OutputConfigurationStore::applyMirroring`). See [`is_mirroring`].
|
||||
// Unconditional rather than conditional on what we enumerated: KWin may apply the stored
|
||||
// setup config between our enumerate and this apply, and clearing a source that is already
|
||||
// empty is exactly what KWin does for a non-mirroring output anyway.
|
||||
if mgmt_version >= REPLICATION_SOURCE_SINCE {
|
||||
config.set_replication_source(proxy, NO_REPLICATION_SOURCE.to_string());
|
||||
}
|
||||
if !sibling_is_primary {
|
||||
config.set_primary_output(proxy);
|
||||
if mgmt_version >= 3 {
|
||||
@@ -781,6 +835,68 @@ pub(crate) fn apply_topology(
|
||||
}
|
||||
}
|
||||
|
||||
/// De-mirror the just-created virtual output (name starts with `our_prefix`, current size
|
||||
/// `our_w`×`our_h`) **without touching the rest of the topology** — the `Extend`/`Auto` counterpart
|
||||
/// to the clear [`apply_topology`] folds into its own config.
|
||||
///
|
||||
/// Those topologies deliberately issue no output-management calls: the streamed output is meant to
|
||||
/// join the desk as one more head, and re-arranging the user's screens would be the rudeness the
|
||||
/// setting exists to avoid. But a stored `replicationSource` (see [`is_mirroring`]) is not an
|
||||
/// arrangement — it makes our output show a *physical panel's* viewport instead of its own desktop,
|
||||
/// which is broken under every topology equally. So this reads the state and applies **only** when
|
||||
/// our output really is mirroring; the ordinary session pays one bounded enumerate and no apply.
|
||||
pub(crate) fn clear_replication_source(our_prefix: &str, our_w: u32, our_h: u32) {
|
||||
let Some(mut sess) = Session::open() else {
|
||||
return;
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
let mgmt_version = sess
|
||||
.state
|
||||
.mgmt_name_version
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or_default();
|
||||
if mgmt_version < REPLICATION_SOURCE_SINCE {
|
||||
return;
|
||||
}
|
||||
// Same resolve as `apply_topology`: managed-prefix name AND the birth size, newest global wins.
|
||||
let Some(ours) = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| {
|
||||
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
|
||||
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
|
||||
})
|
||||
.max_by_key(|d| d.global)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !is_mirroring(ours.replication_source.as_deref()) {
|
||||
return;
|
||||
}
|
||||
let Some(proxy) = ours.proxy.as_ref() else {
|
||||
return;
|
||||
};
|
||||
tracing::warn!(
|
||||
source_uuid = ?ours.replication_source,
|
||||
our_prefix,
|
||||
"KWin had our streamed output MIRRORING another screen (a stored kwinoutputconfig.json \
|
||||
replicationSource for this monitor set) — clearing it so the output is its own desktop"
|
||||
);
|
||||
let config = sess.new_config();
|
||||
config.set_replication_source(proxy, NO_REPLICATION_SOURCE.to_string());
|
||||
let ok = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if !ok {
|
||||
tracing::warn!(
|
||||
reason = ?sess.state.failure_reason,
|
||||
"KWin output management: could not clear the streamed output's replication source — \
|
||||
the stream will show the mirrored screen's content"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install + select a `want_w`×`want_h`@`want_hz` custom mode on the just-created virtual output
|
||||
/// (name starts with `our_prefix`, currently at its sacrificial birth size `birth_w`×`birth_h`) —
|
||||
/// entirely over `kde_output_management_v2`, the in-process replacement for the `kscreen-doctor`
|
||||
@@ -1010,6 +1126,37 @@ fn find_mode(sess: &Session, dev: &DeviceState, spec: &str) -> Option<DeviceMode
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// KWin sends `replication_source` with an EMPTY string for the ordinary, non-mirroring output.
|
||||
/// Reading the event's mere presence as "mirroring" would make every apply issue a pointless
|
||||
/// de-mirror — and, worse, would make the warn fire on every healthy session.
|
||||
#[test]
|
||||
fn an_empty_replication_source_is_not_mirroring() {
|
||||
assert!(!is_mirroring(None));
|
||||
assert!(!is_mirroring(Some("")));
|
||||
}
|
||||
|
||||
/// A real source UUID is the state the field report describes: the streamed output shows a
|
||||
/// physical panel's viewport instead of its own desktop.
|
||||
#[test]
|
||||
fn a_uuid_replication_source_is_mirroring() {
|
||||
assert!(is_mirroring(Some("f7a3c1e2-0b44-4c19-9a1d-6f2b8e0c5d31")));
|
||||
}
|
||||
|
||||
/// The clear we send must be the value KWin reads as "mirrors nothing" — an empty source, which
|
||||
/// its `applyMirroring` fails to resolve to any enabled output and so treats as no replication.
|
||||
#[test]
|
||||
fn the_clear_value_is_the_empty_source() {
|
||||
assert!(!is_mirroring(Some(NO_REPLICATION_SOURCE)));
|
||||
}
|
||||
|
||||
/// The request/event pair is `since 13`; wayland-rs does not range-check requests, so a bind
|
||||
/// below this must never reach `set_replication_source` (it would be a fatal protocol error).
|
||||
#[test]
|
||||
fn replication_source_version_gate_matches_the_protocol() {
|
||||
assert_eq!(REPLICATION_SOURCE_SINCE, 13);
|
||||
const { assert!(MGMT_MAX >= REPLICATION_SOURCE_SINCE) };
|
||||
}
|
||||
|
||||
/// The `WxH@Hz` capture rounds mHz to whole Hz — the shape teardown parses back.
|
||||
#[test]
|
||||
fn mode_spec_rounds_millihertz() {
|
||||
|
||||
@@ -670,12 +670,6 @@ 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;
|
||||
|
||||
@@ -765,16 +759,6 @@ 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)
|
||||
}
|
||||
@@ -1188,25 +1172,6 @@ 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")]
|
||||
@@ -1221,20 +1186,6 @@ 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);
|
||||
@@ -1817,13 +1768,6 @@ 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.
|
||||
@@ -2368,117 +2312,6 @@ 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`].
|
||||
@@ -4283,7 +4116,11 @@ pub struct PunktfunkProbeResult {
|
||||
/// Application goodput bytes / access units the host offered.
|
||||
pub host_bytes: u64,
|
||||
pub host_packets: u32,
|
||||
/// The host's measured burst duration, milliseconds (the throughput denominator).
|
||||
/// The throughput denominator, milliseconds: the client-measured burst receive interval
|
||||
/// (first → last probe-packet arrival) once `done`; the host's measured send-window
|
||||
/// duration when fewer than two probe packets arrived (no interval to measure from). The
|
||||
/// host duration alone overstates throughput — its window closes while the bottleneck
|
||||
/// queue is still draining toward the client.
|
||||
pub elapsed_ms: u32,
|
||||
/// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
|
||||
pub throughput_kbps: u32,
|
||||
@@ -4297,7 +4134,7 @@ pub struct PunktfunkProbeResult {
|
||||
}
|
||||
|
||||
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||
/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||
/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 10 Gbps / ≤ 5 s),
|
||||
/// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
|
||||
/// `done` field is 1. Starting a probe resets any prior measurement.
|
||||
///
|
||||
@@ -4575,36 +4412,3 @@ 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
//! after ~4.5 s clean, ceilinged). Changes are rate-limited (each one costs the IDR the host's
|
||||
//! rebuilt encoder opens with) and the whole controller disables itself against a host that never
|
||||
//! answers [`crate::quic::BitrateChanged`] (an older build that ignores unknown control messages).
|
||||
//! Standing limits are LEARNED rather than re-poked: two identical short host acks latch the
|
||||
//! encoder's ceiling (`host_cap_kbps`), two consecutive decode-severe backoffs at a similar rate
|
||||
//! latch the client decoder's knee (`decode_cap_kbps`) — and both re-probe slowly
|
||||
//! ([`CAP_REPROBE_WINDOWS`]) so neither latch outlives the condition that taught it.
|
||||
//!
|
||||
//! Climbs are additionally **evidence-gated**. The target is only a *promise* to the encoder —
|
||||
//! how many bits it actually emits depends on the content — so on calm content (a menu, an idle
|
||||
@@ -129,7 +133,16 @@ const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole
|
||||
/// session. A still-standing limit just re-teaches itself in two short acks, which the host
|
||||
/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR.
|
||||
/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same clock for the
|
||||
/// same reason: the decoder's knee moves with content and thermals, so its latch must not be
|
||||
/// permanent either.
|
||||
const CAP_REPROBE_WINDOWS: u32 = 80;
|
||||
/// Two consecutive decode-driven backoffs latch the
|
||||
/// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree
|
||||
/// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its
|
||||
/// signature — two unrelated events (a Wi-Fi flush at 300 Mbps, a decode spike at 500) share
|
||||
/// no knee and must not teach one.
|
||||
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -137,6 +150,23 @@ const BASELINE_WINDOWS: usize = 40;
|
||||
/// predates bitrate renegotiation and going quiet for the rest of the session.
|
||||
const MAX_UNACKED: u32 = 3;
|
||||
|
||||
/// Operator escape hatch: `PUNKTFUNK_ABR_MAX_MBPS` (megabits/second, the
|
||||
/// `PUNKTFUNK_PYROWAVE_MAX_MBPS` convention) caps the climb ceiling however it is learned.
|
||||
/// The startup link-capacity probe MEASURES the ceiling, and
|
||||
/// [`set_ceiling`](BitrateController::set_ceiling)'s deliberate monotonicity makes an inflated
|
||||
/// measurement permanent for the session — a link that mis-measures (a bursty middlebox, a
|
||||
/// queue-flattered interval) needs a knob that binds regardless of what any probe claims.
|
||||
/// `PUNKTFUNK_ABR_PROBE_KBPS` is NOT that knob: it only shrinks the burst target, not what the
|
||||
/// measurement may conclude. Unset/0/garbage → no cap. Read once per controller, at
|
||||
/// construction.
|
||||
fn ceiling_cap_from_env() -> Option<u32> {
|
||||
std::env::var("PUNKTFUNK_ABR_MAX_MBPS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&m| m > 0)
|
||||
.map(|m| m.saturating_mul(1_000))
|
||||
}
|
||||
|
||||
/// One decision per report window; `Some(kbps)` = send a [`crate::quic::SetBitrate`].
|
||||
pub(crate) struct BitrateController {
|
||||
/// `false` = permanently off (explicit user bitrate, an old host, or ack silence).
|
||||
@@ -147,6 +177,10 @@ pub(crate) struct BitrateController {
|
||||
/// raises it via [`set_ceiling`](Self::set_ceiling) — that measurement is what lets an
|
||||
/// Automatic session scale past its conservative start.
|
||||
ceiling_kbps: u32,
|
||||
/// The `PUNKTFUNK_ABR_MAX_MBPS` cap in kbps (see [`ceiling_cap_from_env`]), injected at
|
||||
/// construction so tests exercise the clamp without touching the process environment.
|
||||
/// `None` = no cap.
|
||||
ceiling_cap_kbps: Option<u32>,
|
||||
floor_kbps: u32,
|
||||
/// Slow start: true until the first congestion signal — clean windows DOUBLE the rate
|
||||
/// (cooldown-paced) instead of the +6 % additive step.
|
||||
@@ -178,6 +212,24 @@ pub(crate) struct BitrateController {
|
||||
short_acks: u32,
|
||||
/// Clean windows spent parked at the learned cap (the re-probe clock).
|
||||
cap_probe_windows: u32,
|
||||
/// The client-decoder rate cap, mirroring [`host_cap_kbps`](Self::host_cap_kbps) for the
|
||||
/// OTHER end of the pipe: latched when two CONSECUTIVE backoffs carried decode-severe
|
||||
/// evidence (a deep decode-latency excursion, or a jump-to-live flush — in the
|
||||
/// decoder-saturation regime the flushed backlog formed BEHIND a decoder that stopped
|
||||
/// keeping up) at a similar pre-backoff rate. Without it a decoder knee below the link
|
||||
/// ceiling is a permanent 30–60 s sawtooth: every ×0.7 backoff re-climbs toward a ceiling
|
||||
/// the decoder can't hold, and each cycle costs a flush plus a dropped-frame burst (the
|
||||
/// 1440p120 HEVC field case: knee ~490 Mbps under a ~658 Mbps ceiling). Slowly re-probed
|
||||
/// on the [`CAP_REPROBE_WINDOWS`] clock, exactly like the host cap, so a decoder that
|
||||
/// recovers (lighter content, thermal headroom) climbs again — the latch is never
|
||||
/// permanent.
|
||||
decode_cap_kbps: Option<u32>,
|
||||
/// The previous decode-driven backoff's pre-backoff rate (0 = the last backoff wasn't
|
||||
/// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`])
|
||||
/// to latch the cap — one spurious flush teaches nothing.
|
||||
decode_backoff_kbps: u32,
|
||||
/// Clean windows spent parked at the learned decode cap (its re-probe clock).
|
||||
decode_cap_probe_windows: u32,
|
||||
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
||||
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
||||
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
||||
@@ -196,10 +248,17 @@ impl BitrateController {
|
||||
/// to build a permanently-disabled controller (explicit bitrate / an old host that didn't
|
||||
/// echo one — no known ceiling to work against).
|
||||
pub(crate) fn new(start_kbps: u32) -> Self {
|
||||
Self::with_ceiling_cap(start_kbps, ceiling_cap_from_env())
|
||||
}
|
||||
|
||||
/// [`new`](Self::new) with the `PUNKTFUNK_ABR_MAX_MBPS` cap injected — the seam the unit
|
||||
/// tests use so the clamp's behavior never depends on the test process's environment.
|
||||
fn with_ceiling_cap(start_kbps: u32, ceiling_cap_kbps: Option<u32>) -> Self {
|
||||
BitrateController {
|
||||
enabled: start_kbps > 0,
|
||||
current_kbps: start_kbps,
|
||||
ceiling_kbps: start_kbps,
|
||||
ceiling_cap_kbps,
|
||||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||||
probing: true,
|
||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
@@ -210,6 +269,9 @@ impl BitrateController {
|
||||
short_ack_kbps: 0,
|
||||
short_acks: 0,
|
||||
cap_probe_windows: 0,
|
||||
decode_cap_kbps: None,
|
||||
decode_backoff_kbps: 0,
|
||||
decode_cap_probe_windows: 0,
|
||||
proven_kbps: 0,
|
||||
bad_windows: 0,
|
||||
clean_windows: 0,
|
||||
@@ -222,8 +284,12 @@ impl BitrateController {
|
||||
/// delivered throughput with headroom already subtracted by the caller). Without this call
|
||||
/// the ceiling stays the negotiated start rate — exactly the old behavior. Never lowers:
|
||||
/// a congested-moment measurement must not shrink authority below what was negotiated
|
||||
/// (descent is the congestion signals' job).
|
||||
/// (descent is the congestion signals' job). The `PUNKTFUNK_ABR_MAX_MBPS` cap clamps HERE
|
||||
/// — the one funnel every learned ceiling passes through — so it binds no matter how the
|
||||
/// ceiling was learned; monotonicity is precisely why the user needs it (one inflated
|
||||
/// measurement is otherwise permanent for the session).
|
||||
pub(crate) fn set_ceiling(&mut self, kbps: u32) {
|
||||
let kbps = kbps.min(self.ceiling_cap_kbps.unwrap_or(u32::MAX));
|
||||
if self.enabled && kbps > self.ceiling_kbps {
|
||||
self.ceiling_kbps = kbps;
|
||||
}
|
||||
@@ -274,11 +340,16 @@ impl BitrateController {
|
||||
|
||||
/// An accepted mode switch: the encoder's ceiling and compute knee are properties of the
|
||||
/// MODE (4K120 caps where 1080p60 never would) — drop the mode-scoped learned state. The
|
||||
/// probe-measured `ceiling_kbps` (a LINK property) survives.
|
||||
/// decoder's knee is just as mode-scoped (pixel rate drives both ends of the codec), so
|
||||
/// the decode cap goes with it. The probe-measured `ceiling_kbps` (a LINK property)
|
||||
/// survives.
|
||||
pub(crate) fn on_mode_switch(&mut self) {
|
||||
self.host_cap_kbps = None;
|
||||
self.short_acks = 0;
|
||||
self.cap_probe_windows = 0;
|
||||
self.decode_cap_kbps = None;
|
||||
self.decode_backoff_kbps = 0;
|
||||
self.decode_cap_probe_windows = 0;
|
||||
self.encode_means.clear();
|
||||
}
|
||||
|
||||
@@ -427,6 +498,30 @@ impl BitrateController {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The decode cap re-probes on the same clock and for the same reason: the knee is
|
||||
// content- and thermals-dependent evidence, not a spec limit — a decoder that recovers
|
||||
// must get its headroom back, so the latch clears UPWARD through here rather than ever
|
||||
// being permanent. A still-standing knee re-latches from the next pair of
|
||||
// decode-driven backoffs.
|
||||
if let Some(cap) = self.decode_cap_kbps {
|
||||
if bad {
|
||||
self.decode_cap_probe_windows = 0;
|
||||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||
self.decode_cap_probe_windows += 1;
|
||||
if self.decode_cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||
self.decode_cap_probe_windows = 0;
|
||||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||
if lifted > cap {
|
||||
tracing::debug!(
|
||||
from_kbps = cap,
|
||||
to_kbps = lifted,
|
||||
"adaptive bitrate: re-probing above the learned decode cap"
|
||||
);
|
||||
self.decode_cap_kbps = Some(lifted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let cooled = self
|
||||
.last_change
|
||||
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
||||
@@ -436,6 +531,31 @@ impl BitrateController {
|
||||
if (self.bad_windows >= BAD_WINDOWS_TO_DECREASE || (severe && self.bad_windows >= 1))
|
||||
&& self.current_kbps > self.floor_kbps
|
||||
{
|
||||
// Decode-cap learning (see [`decode_cap_kbps`](Self::decode_cap_kbps)): a backoff
|
||||
// with decode-severe evidence — the deep decode excursion, or the flush that
|
||||
// drained the queue behind a stalled decoder — remembers its pre-backoff rate; the
|
||||
// SECOND consecutive one at a similar rate latches that rate as the decoder's
|
||||
// knee. One event never latches (a spurious flush must stay a one-off), and a
|
||||
// backoff without decode evidence in between breaks the streak — whatever it saw,
|
||||
// it wasn't the same knee.
|
||||
if decode_severe || flushed {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
&& rate.abs_diff(self.decode_backoff_kbps)
|
||||
<= self.decode_backoff_kbps / DECODE_CAP_SIMILAR_DIV;
|
||||
if similar && self.decode_cap_kbps.is_none_or(|c| rate < c) {
|
||||
tracing::info!(
|
||||
cap_kbps = rate,
|
||||
"adaptive bitrate: decode cap learned (decoder knee) — climbs stop \
|
||||
here until it lifts"
|
||||
);
|
||||
self.decode_cap_kbps = Some(rate.max(self.floor_kbps));
|
||||
self.decode_cap_probe_windows = 0;
|
||||
}
|
||||
self.decode_backoff_kbps = rate;
|
||||
} else {
|
||||
self.decode_backoff_kbps = 0;
|
||||
}
|
||||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||||
self.bad_windows = 0;
|
||||
return self.request(next, now);
|
||||
@@ -447,11 +567,13 @@ impl BitrateController {
|
||||
// utilized window after a long-enough clean run climbs immediately.
|
||||
let utilized =
|
||||
actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM;
|
||||
// The effective ceiling folds in the host-taught cap: the probe measured the LINK, but
|
||||
// the host's short acks measured the ENCODER — whichever binds first is the limit.
|
||||
// The effective ceiling folds in both learned caps: the probe measured the LINK, the
|
||||
// host's short acks measured the ENCODER, and the decode cap measured the CLIENT
|
||||
// DECODER — whichever binds first is the limit.
|
||||
let eff_ceiling = self
|
||||
.ceiling_kbps
|
||||
.min(self.host_cap_kbps.unwrap_or(u32::MAX));
|
||||
.min(self.host_cap_kbps.unwrap_or(u32::MAX))
|
||||
.min(self.decode_cap_kbps.unwrap_or(u32::MAX));
|
||||
let cap = eff_ceiling
|
||||
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
||||
if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
|
||||
@@ -1447,6 +1569,243 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_max_mbps_caps_every_learned_ceiling() {
|
||||
// PUNKTFUNK_ABR_MAX_MBPS=50 (injected — `new` reads the env exactly once, at
|
||||
// construction): a probe "measuring" 886 Mbps (the divisor bug's field figure) must
|
||||
// not out-rank the user's cap…
|
||||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||||
c.set_ceiling(886_312);
|
||||
assert_eq!(c.ceiling_kbps, 50_000);
|
||||
// …while a measurement under the cap stands untouched.
|
||||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||||
c.set_ceiling(40_000);
|
||||
assert_eq!(c.ceiling_kbps, 40_000);
|
||||
// And the climb honors it: slow start doubles 20→40, the capped ceiling truncates the
|
||||
// next step to 50, then quiet — never a request past the user's limit.
|
||||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||||
c.set_ceiling(886_312);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(40_000));
|
||||
c.on_ack(40_000);
|
||||
assert_eq!(run_clean(&mut c, start, 2, 1), Some(50_000));
|
||||
c.on_ack(50_000);
|
||||
assert_eq!(run_clean(&mut c, start, 4, 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
// link ceiling — nothing ever LEARNED the knee, so every re-climb ended in a flush +
|
||||
// dropped-frame burst. Establish a decode baseline on calm windows, choke twice at the
|
||||
// same rate, and the second decode-severe backoff must latch the knee.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
// Calm baseline windows (2 Mb/s actual: unutilized, so no climb interferes).
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
// First deep decode excursion → immediate ×0.7, but ONE event must not latch.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
// Second consecutive decode-severe backoff at the same pre-backoff rate: latch.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
// The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps
|
||||
// link ceiling the old sawtooth kept re-poking.
|
||||
c.on_ack(350_000);
|
||||
let mut max_req = 0;
|
||||
for i in 8..70 {
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
assert!(k <= 500_000, "climb past the decode cap: {k}");
|
||||
max_req = max_req.max(k);
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
assert_eq!(max_req, 500_000);
|
||||
assert_eq!(c.current_kbps, 500_000);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_flush_or_dissimilar_backoffs_never_latch_a_decode_cap() {
|
||||
// The latch's false-positive guards. A lone jump-to-live flush (a Wi-Fi clump can
|
||||
// flush once at ANY rate) backs off but teaches nothing…
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, None, 490_000, true, 0),
|
||||
Some(350_000)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(350_000);
|
||||
// …a LOSS-driven backoff in between breaks the streak…
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, None, 340_000, false, 0),
|
||||
Some(245_000)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(245_000);
|
||||
// …so the next flush counts as a FIRST decode event again — still no latch…
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 4), 0, 0, None, None, None, 240_000, true, 0),
|
||||
Some(171_500)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(171_500);
|
||||
// …and two consecutive decode events at DISSIMILAR rates (245 vs 171.5 Mbps — no
|
||||
// common knee) must not latch either.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 6), 0, 0, None, None, None, 170_000, true, 0),
|
||||
Some(120_050)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_reprobes_after_a_sustained_clean_run() {
|
||||
// The knee is content/thermals evidence, not a spec limit: after ~60 s parked clean at
|
||||
// the latched cap, it lifts one step (+12.5 %, ceiling-bounded) — the re-probe path is
|
||||
// how the latch clears (never permanent), and a still-standing knee just re-latches
|
||||
// from the next pair of decode-driven backoffs.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for i in [4, 6] {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
// The host's ack parks the session at the knee (its clamp is authoritative).
|
||||
c.on_ack(500_000);
|
||||
for i in 0..CAP_REPROBE_WINDOWS {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 8 + i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 + 500_000 / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_switch_clears_the_decode_cap() {
|
||||
// A 1440p120 knee means nothing at the new mode's pixel rate — the decode cap must
|
||||
// not survive the switch (the probe-measured link ceiling does).
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for i in [4, 6] {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
c.on_mode_switch();
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
assert_eq!(c.ceiling_kbps, 900_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_silence_disables_the_controller() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
|
||||
@@ -16,13 +16,11 @@ 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, PadAudioFrame,
|
||||
ProbeRequest, RfiRequest, RichInput,
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
|
||||
RfiRequest, RichInput,
|
||||
};
|
||||
use crate::session::Frame;
|
||||
use std::sync::atomic::{
|
||||
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -45,7 +43,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, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
||||
};
|
||||
use self::probe::ProbeState;
|
||||
use self::pump::run_pump;
|
||||
@@ -124,14 +122,6 @@ 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
|
||||
@@ -428,10 +418,6 @@ 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);
|
||||
@@ -473,7 +459,6 @@ 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())
|
||||
@@ -517,8 +502,6 @@ 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,
|
||||
@@ -567,8 +550,6 @@ 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),
|
||||
@@ -902,7 +883,7 @@ impl NativeClient {
|
||||
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
||||
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
||||
/// `done` flag is set. Starting a probe resets any prior measurement. The host clamps both
|
||||
/// fields (≤ 3 Gbps, ≤ 5 s).
|
||||
/// fields (≤ 10 Gbps, ≤ 5 s).
|
||||
pub fn request_probe(&self, target_kbps: u32, duration_ms: u32) -> Result<()> {
|
||||
// Reset the accumulator so a fresh run doesn't blend into the previous one.
|
||||
*self.probe.lock().unwrap() = ProbeState {
|
||||
@@ -941,8 +922,12 @@ impl NativeClient {
|
||||
p.rx_bytes_now.saturating_sub(base_b),
|
||||
)
|
||||
};
|
||||
// The host's burst duration is the throughput denominator. bytes × 8 / ms = kilobits/second.
|
||||
let window_ms = p.host_duration_ms;
|
||||
// The throughput denominator: the client-measured receive interval once the report
|
||||
// froze one, the host's send-window duration as the fallback (see
|
||||
// `ProbeState::measured_interval_ms` for why the host window alone overstates the
|
||||
// link). Both are 0 until the report lands, so a partial read reports 0 throughput —
|
||||
// unchanged. bytes × 8 / ms = kilobits/second.
|
||||
let window_ms = p.throughput_window_ms();
|
||||
let throughput_kbps = if window_ms > 0 {
|
||||
(delivered_bytes.saturating_mul(8) / window_ms as u64) as u32
|
||||
} else {
|
||||
@@ -1070,33 +1055,6 @@ 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,12 +20,6 @@ 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;
|
||||
|
||||
@@ -1,34 +1,50 @@
|
||||
//! Speed-test probe state (`ProbeState`, pump-mirrored) and the public `ProbeOutcome`.
|
||||
|
||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||
/// session's packet-level receive counters here; the control task finalizes the delivered figure
|
||||
/// session's probe-scoped receive counters here; the control task finalizes the delivered figure
|
||||
/// and folds in the host's [`ProbeResult`] when it lands. Read by [`NativeClient::probe_result`].
|
||||
///
|
||||
/// Counting at the *packet* level (every delivered wire packet) — not whole reassembled probe AUs —
|
||||
/// is what makes the measurement degrade gracefully: once loss exceeds the FEC budget no AU
|
||||
/// completes, so the old AU-based count cliffed to zero even though most bytes still arrived.
|
||||
/// Counting *probe* packets only (the reassembler stamps dedicated counters at its FLAG_PROBE
|
||||
/// routing) keeps video out of the numerator: the burst pauses video, but frames already in
|
||||
/// flight land during its head, and resumed video lands between the last probe packet and the
|
||||
/// host's report — both used to inflate the all-datagram byte delta this mirrored before.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ProbeState {
|
||||
/// A probe is in progress: set by `request_probe`, cleared when the host's [`ProbeResult`]
|
||||
/// lands (a re-probe just overwrites the whole state — the latest one wins).
|
||||
pub(crate) active: bool,
|
||||
/// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first
|
||||
/// tick while active) and latest, mirrored every pump iteration.
|
||||
/// Probe-scoped receive counters (`Stats::probe_*`) at the burst's start (snapshotted by the
|
||||
/// pump on its first tick while active) and latest, mirrored every pump iteration.
|
||||
pub(crate) base_packets: Option<u64>,
|
||||
pub(crate) base_bytes: Option<u64>,
|
||||
pub(crate) rx_packets_now: u64,
|
||||
pub(crate) rx_bytes_now: u64,
|
||||
/// First / last probe-packet arrival stamps (monotonic ns, 0 = none yet), mirrored from the
|
||||
/// probe-scoped session counters. Their difference is the interval the delivered bytes
|
||||
/// actually arrived in — the honest throughput denominator (see
|
||||
/// [`measured_interval_ms`](Self::measured_interval_ms)).
|
||||
pub(crate) first_arrival_ns: u64,
|
||||
pub(crate) last_arrival_ns: u64,
|
||||
/// Delivered wire packets / plaintext bytes (header + shard), frozen when the host's report lands
|
||||
/// (so resumed video after the burst can't inflate them).
|
||||
pub(crate) delivered_packets: u64,
|
||||
pub(crate) delivered_bytes: u64,
|
||||
/// The client-measured receive interval (ms), frozen alongside the delivered figures; 0 = no
|
||||
/// usable interval (the burst delivered fewer than two probe packets) — consumers fall back
|
||||
/// to [`host_duration_ms`](Self::host_duration_ms) via
|
||||
/// [`throughput_window_ms`](Self::throughput_window_ms).
|
||||
pub(crate) client_interval_ms: u32,
|
||||
/// The host's end-of-burst report.
|
||||
pub(crate) host_goodput_bytes: u64,
|
||||
pub(crate) host_au: u32,
|
||||
/// Wire packets the host actually put on the link, and the ones its send buffer dropped.
|
||||
pub(crate) host_wire_packets: u32,
|
||||
pub(crate) host_send_dropped: u32,
|
||||
/// The host's measured burst duration (the throughput denominator).
|
||||
/// The host's measured burst duration (the throughput denominator's FALLBACK — see
|
||||
/// [`throughput_window_ms`](Self::throughput_window_ms)).
|
||||
pub(crate) host_duration_ms: u32,
|
||||
/// The host's `ProbeResult` arrived → the measurement is final.
|
||||
pub(crate) done: bool,
|
||||
@@ -39,6 +55,40 @@ pub(crate) struct ProbeState {
|
||||
pub(crate) duration_ms: u32,
|
||||
}
|
||||
|
||||
impl ProbeState {
|
||||
/// The client-measured receive interval of a finished burst, in ms: first → last
|
||||
/// probe-packet arrival, floored at 1 (a sub-ms burst divided by 0 ms would read as
|
||||
/// infinite throughput). `None` — the caller falls back to the host's duration — when
|
||||
/// fewer than two probe packets arrived or the stamps are degenerate (unset / identical /
|
||||
/// reversed): a single arrival spans no interval.
|
||||
///
|
||||
/// Why not the host's `duration_ms`: it measures the SEND window, which closes while the
|
||||
/// bottleneck (switch/kernel) queue is still draining toward the client — the tail of the
|
||||
/// bytes lands *after* it. Dividing client-side bytes by the host-side window therefore
|
||||
/// overstates the link: a 1 GbE link under a 2 Gbps burst target "measured" 1266 Mbps and
|
||||
/// handed the ABR an 886 Mbps ceiling it could never deliver — and
|
||||
/// [`set_ceiling`](crate::abr::BitrateController::set_ceiling) never lowers, so the lie
|
||||
/// was permanent for the session.
|
||||
pub(crate) fn measured_interval_ms(first_ns: u64, last_ns: u64, packets: u64) -> Option<u32> {
|
||||
if packets < 2 || first_ns == 0 || last_ns <= first_ns {
|
||||
return None;
|
||||
}
|
||||
let ms = ((last_ns - first_ns) / 1_000_000).max(1);
|
||||
Some(u32::try_from(ms).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
/// The throughput denominator, in ms: the client-measured receive interval when the burst
|
||||
/// produced one, else the host's send-window duration (an old measurement is better than
|
||||
/// none — and strictly conservative territory only when packets were too few to matter).
|
||||
pub(crate) fn throughput_window_ms(&self) -> u32 {
|
||||
if self.client_interval_ms > 0 {
|
||||
self.client_interval_ms
|
||||
} else {
|
||||
self.host_duration_ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished/partial speed-test measurement, returned by [`NativeClient::probe_result`].
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ProbeOutcome {
|
||||
@@ -50,7 +100,11 @@ pub struct ProbeOutcome {
|
||||
/// Application goodput bytes / access units the host offered.
|
||||
pub host_bytes: u64,
|
||||
pub host_packets: u32,
|
||||
/// The burst duration the host measured, in milliseconds (the throughput denominator).
|
||||
/// The throughput denominator, in milliseconds: the client-measured receive interval
|
||||
/// (first → last probe-packet arrival) once `done`; the host's measured send-window
|
||||
/// duration when the burst delivered fewer than two probe packets (no interval to measure
|
||||
/// from). The host duration alone overstates throughput — its window closes while the
|
||||
/// bottleneck queue is still draining toward the client.
|
||||
pub elapsed_ms: u32,
|
||||
/// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second). The figure to
|
||||
/// drive a [`Hello::bitrate_kbps`] choice from (allow headroom for the FEC overhead + loss).
|
||||
@@ -66,3 +120,63 @@ pub struct ProbeOutcome {
|
||||
pub wire_packets_sent: u32,
|
||||
pub send_dropped: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interval_needs_two_packets_and_a_nonzero_span() {
|
||||
// <2 packets: no interval exists — the caller must fall back to the host duration.
|
||||
assert_eq!(ProbeState::measured_interval_ms(0, 0, 0), None);
|
||||
assert_eq!(
|
||||
ProbeState::measured_interval_ms(5_000_000, 5_000_000, 1),
|
||||
None
|
||||
);
|
||||
// Two packets in the same ns / a reversed pair / an unset first stamp: same fallback.
|
||||
assert_eq!(
|
||||
ProbeState::measured_interval_ms(5_000_000, 5_000_000, 2),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ProbeState::measured_interval_ms(9_000_000, 5_000_000, 2),
|
||||
None
|
||||
);
|
||||
assert_eq!(ProbeState::measured_interval_ms(0, 5_000_000, 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_is_floored_at_one_ms() {
|
||||
// Two packets 0.4 ms apart truncate to 0 ms — the floor keeps the division honest
|
||||
// instead of infinite.
|
||||
assert_eq!(ProbeState::measured_interval_ms(1_000, 401_000, 2), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_measures_first_to_last_arrival() {
|
||||
assert_eq!(
|
||||
ProbeState::measured_interval_ms(1_000_000, 801_000_000, 1_000),
|
||||
Some(800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throughput_window_falls_back_to_the_host_duration() {
|
||||
// No client interval frozen (a <2-packet burst) → the host's send window is the
|
||||
// denominator, exactly the old behavior.
|
||||
let p = ProbeState {
|
||||
host_duration_ms: 800,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(p.throughput_window_ms(), 800);
|
||||
// With an interval, the client measurement wins — the 1 GbE field case: the same
|
||||
// bytes over 1010 ms instead of the host's 800 ms is the difference between an
|
||||
// honest ~940 Mbps and an impossible 1266 Mbps.
|
||||
let p = ProbeState {
|
||||
client_interval_ms: 1_010,
|
||||
host_duration_ms: 800,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(p.throughput_window_ms(), 1_010);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ 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,
|
||||
@@ -94,17 +92,9 @@ 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`]). 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.
|
||||
// (see [`input_task`]).
|
||||
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||
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,
|
||||
));
|
||||
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
|
||||
|
||||
// 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
|
||||
@@ -176,7 +166,6 @@ 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(),
|
||||
|
||||
@@ -132,12 +132,24 @@ impl ControlTask {
|
||||
}
|
||||
} else if let Ok(result) = ProbeResult::decode(&msg) {
|
||||
let mut p = probe.lock().unwrap();
|
||||
// Freeze the delivered figures now (the burst is done), before resumed
|
||||
// video can inflate the packet counters.
|
||||
// Freeze the delivered figures now (the burst is done). The mirrored
|
||||
// counters are probe-scoped (stamped at the reassembler's FLAG_PROBE
|
||||
// routing), so video around the burst inflates nothing; the client's
|
||||
// first→last arrival interval is frozen with them — the denominator
|
||||
// that measures when the bytes actually ARRIVED, not when the host
|
||||
// stopped sending (its window closes while the bottleneck queue is
|
||||
// still draining this way, which is how a 1 GbE link once "measured"
|
||||
// 1266 Mbps).
|
||||
let base_p = p.base_packets.unwrap_or(p.rx_packets_now);
|
||||
let base_b = p.base_bytes.unwrap_or(p.rx_bytes_now);
|
||||
p.delivered_packets = p.rx_packets_now.saturating_sub(base_p);
|
||||
p.delivered_bytes = p.rx_bytes_now.saturating_sub(base_b);
|
||||
p.client_interval_ms = ProbeState::measured_interval_ms(
|
||||
p.first_arrival_ns,
|
||||
p.last_arrival_ns,
|
||||
p.delivered_packets,
|
||||
)
|
||||
.unwrap_or(0);
|
||||
p.host_goodput_bytes = result.bytes_sent;
|
||||
p.host_au = result.packets_sent;
|
||||
p.host_wire_packets = result.wire_packets_sent;
|
||||
@@ -151,6 +163,7 @@ impl ControlTask {
|
||||
send_dropped = result.send_dropped,
|
||||
duration_ms = result.duration_ms,
|
||||
delivered_packets = p.delivered_packets,
|
||||
client_interval_ms = p.client_interval_ms,
|
||||
"speed-test probe result"
|
||||
);
|
||||
} else if let Ok(ack) = BitrateChanged::decode(&msg) {
|
||||
|
||||
@@ -200,10 +200,23 @@ impl DataPump {
|
||||
let probe_active = {
|
||||
let mut p = pump_probe.lock().unwrap();
|
||||
if p.active && !p.done {
|
||||
p.rx_packets_now = st.packets_received;
|
||||
p.rx_bytes_now = st.bytes_received;
|
||||
p.base_packets.get_or_insert(st.packets_received);
|
||||
p.base_bytes.get_or_insert(st.bytes_received);
|
||||
// Arm edge (first mirror tick): zero the arrival stamps before the burst can
|
||||
// claim them — the ProbeRequest is still queued locally (the burst starts a
|
||||
// round trip later), so the reset cannot race a probe packet. `st` predates
|
||||
// the reset, so the stamps mirror 0 on this tick and live values after.
|
||||
let arming = p.base_bytes.is_none();
|
||||
if arming {
|
||||
session.reset_probe_arrivals();
|
||||
}
|
||||
p.rx_packets_now = st.probe_packets_received;
|
||||
p.rx_bytes_now = st.probe_bytes_received;
|
||||
(p.first_arrival_ns, p.last_arrival_ns) = if arming {
|
||||
(0, 0)
|
||||
} else {
|
||||
(st.probe_first_arrival_ns, st.probe_last_arrival_ns)
|
||||
};
|
||||
p.base_packets.get_or_insert(st.probe_packets_received);
|
||||
p.base_bytes.get_or_insert(st.probe_bytes_received);
|
||||
}
|
||||
p.active && !p.done
|
||||
};
|
||||
@@ -280,16 +293,23 @@ impl DataPump {
|
||||
if p.done {
|
||||
capacity_probe_deadline = None;
|
||||
// An all-zero reply is a decline (old host / probe-less build) — keep the
|
||||
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7.
|
||||
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7, over the
|
||||
// CLIENT-measured receive interval (the host's send window closes while the
|
||||
// bottleneck queue is still draining toward us, so dividing by ITS duration
|
||||
// overstates the link — a 1 GbE link "measured" 1266 Mbps, and the inflated
|
||||
// ceiling is permanent because set_ceiling never lowers); the host duration
|
||||
// is the fallback when the burst delivered too few packets for an interval.
|
||||
if p.host_duration_ms > 0 && p.delivered_bytes > 0 {
|
||||
let delivered_kbps = (p.delivered_bytes.saturating_mul(8)
|
||||
/ p.host_duration_ms.max(1) as u64)
|
||||
as u32;
|
||||
let window_ms = p.throughput_window_ms();
|
||||
let delivered_kbps =
|
||||
(p.delivered_bytes.saturating_mul(8) / window_ms.max(1) as u64) as u32;
|
||||
let ceiling = delivered_kbps.saturating_mul(7) / 10;
|
||||
abr.set_ceiling(ceiling);
|
||||
tracing::info!(
|
||||
delivered_kbps,
|
||||
ceiling_kbps = ceiling,
|
||||
client_interval_ms = p.client_interval_ms,
|
||||
host_duration_ms = p.host_duration_ms,
|
||||
"adaptive bitrate: link-capacity probe done — climb ceiling set"
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -71,11 +70,6 @@ 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,16 +15,8 @@ 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];
|
||||
@@ -45,17 +37,6 @@ 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 {
|
||||
@@ -100,28 +81,13 @@ pub(super) async fn run(
|
||||
let _ = conn.send_datagram(rem.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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
}
|
||||
@@ -138,7 +104,7 @@ pub(super) async fn run(
|
||||
code: kind as u32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: arrival_flags(idx),
|
||||
flags: idx as u32,
|
||||
};
|
||||
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, PadAudioFrame};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
|
||||
use crate::quic::{HdrMeta, HidOutput};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -43,14 +43,6 @@ 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,11 +64,7 @@ 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 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
|
||||
/// wire byte, `flags` = pad index. 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
|
||||
@@ -101,34 +97,6 @@ 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
|
||||
@@ -380,11 +348,6 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -480,31 +443,6 @@ 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,13 +120,7 @@ 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.
|
||||
/// 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;
|
||||
pub const ABI_VERSION: u32 = 14;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -409,6 +409,27 @@ impl Reassembler {
|
||||
// can neither advance the video anchor nor be dropped as stale against it (and its aged-out
|
||||
// frames never count as `frames_dropped`, which would fire video loss recovery).
|
||||
let is_probe = hdr.user_flags & (FLAG_PROBE as u32) != 0;
|
||||
if is_probe {
|
||||
// Probe-scoped receive accounting (the speed-test numerator + denominator, see
|
||||
// `Stats::probe_first_arrival_ns`), stamped at the routing decision so video in
|
||||
// flight around the burst contaminates neither the byte count nor the arrival
|
||||
// stamps. Byte unit mirrors `bytes_received` (whole plaintext packet). The first
|
||||
// probe packet since the pump armed the probe claims the first-arrival slot (the
|
||||
// pump zeroes it before the burst can reach the host); every probe packet
|
||||
// refreshes the last-arrival stamp.
|
||||
let now_ns = crate::stats::now_monotonic_ns();
|
||||
StatsCounters::add(&stats.probe_packets_received, 1);
|
||||
StatsCounters::add(&stats.probe_bytes_received, pkt.len() as u64);
|
||||
let _ = stats.probe_first_arrival_ns.compare_exchange(
|
||||
0,
|
||||
now_ns,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
stats
|
||||
.probe_last_arrival_ns
|
||||
.store(now_ns, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
let win = if is_probe { probe } else { video };
|
||||
win.advance_window(
|
||||
hdr.frame_index,
|
||||
|
||||
@@ -111,16 +111,6 @@ 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
|
||||
@@ -142,17 +132,6 @@ 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.
|
||||
@@ -335,28 +314,6 @@ 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,15 +1,12 @@
|
||||
//! 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.
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
||||
|
||||
/// 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), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
/// host→client).
|
||||
/// (0xCE, 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
|
||||
@@ -335,7 +332,6 @@ 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
|
||||
@@ -376,16 +372,6 @@ 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 {
|
||||
@@ -418,12 +404,6 @@ 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
|
||||
}
|
||||
@@ -461,11 +441,6 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -724,72 +699,6 @@ 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::*;
|
||||
@@ -1118,12 +1027,6 @@ 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();
|
||||
@@ -1142,47 +1045,6 @@ 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–0xD1 plane codecs,
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF 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
|
||||
|
||||
@@ -218,6 +218,18 @@ impl Session {
|
||||
self.stats.snapshot()
|
||||
}
|
||||
|
||||
/// Re-arm the probe-scoped arrival stamps (see [`Stats::probe_first_arrival_ns`]): zero
|
||||
/// them so the NEXT burst's first packet claims the first-arrival slot. Called by the
|
||||
/// client pump when it arms a probe, strictly before the burst can have reached the host
|
||||
/// (the `ProbeRequest` is still queued locally) — so the reset cannot race a probe packet.
|
||||
/// The cumulative probe byte/packet counters are left alone: per-burst deltas come from
|
||||
/// base snapshots, the same pattern the total counters use.
|
||||
pub fn reset_probe_arrivals(&self) {
|
||||
let l = std::sync::atomic::Ordering::Relaxed;
|
||||
self.stats.probe_first_arrival_ns.store(0, l);
|
||||
self.stats.probe_last_arrival_ns.store(0, l);
|
||||
}
|
||||
|
||||
/// Wrap a packet for the wire: when encrypting, prepend the 8-byte big-endian
|
||||
/// sequence (the receiver derives the GCM nonce from it) then the ciphertext.
|
||||
/// Seal one plaintext packet into the reused `wire` buffer in place (no allocation): the wire is
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
//! Live counters for the frame-pacing / quality logic and the web UI.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Monotonic now, in ns since an arbitrary process-wide epoch — the basis for the probe
|
||||
/// arrival stamps below. Monotonic on purpose: the stamps are only ever DIFFERENCED on this
|
||||
/// machine (the burst's receive interval), and a wall-clock step mid-burst — the exact event
|
||||
/// the clock re-sync machinery exists for — must not corrupt the one measurement the ABR
|
||||
/// ceiling is built from, so the CLOCK_REALTIME basis `pts_ns` uses is wrong here. Floored
|
||||
/// at 1 so a stamp can never collide with the 0 = "unset" sentinel.
|
||||
pub(crate) fn now_monotonic_ns() -> u64 {
|
||||
static EPOCH: OnceLock<Instant> = OnceLock::new();
|
||||
(EPOCH.get_or_init(Instant::now).elapsed().as_nanos() as u64).max(1)
|
||||
}
|
||||
|
||||
/// Immutable snapshot, copied across the C ABI as `PunktfunkStats`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -26,11 +39,29 @@ pub struct Stats {
|
||||
pub fec_late_shards: u64,
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_received: u64,
|
||||
/// Probe-scoped receive counters: wire packets / plaintext bytes carrying
|
||||
/// [`FLAG_PROBE`](crate::packet::FLAG_PROBE) (speed-test filler), counted at the
|
||||
/// reassembler's probe routing decision. `bytes_received` counts EVERY accepted datagram,
|
||||
/// so a speed-test numerator built from it inherits whatever video was in flight around
|
||||
/// the burst — these keep video out of the probe math. Deliberately NOT mirrored into the
|
||||
/// C-ABI `PunktfunkStats` (probe measurements surface via `ProbeOutcome`).
|
||||
pub probe_packets_received: u64,
|
||||
pub probe_bytes_received: u64,
|
||||
/// First / last probe-packet arrival (monotonic ns, see [`now_monotonic_ns`]; 0 = none
|
||||
/// since the last probe arm). Their difference is the burst's client-side receive
|
||||
/// interval — the honest speed-test denominator: the host's send window closes while the
|
||||
/// switch/kernel queue toward the client is still draining, so dividing client bytes by
|
||||
/// the HOST duration overstates the link (a 1 GbE link "measured" 1266 Mbps). The client
|
||||
/// pump zeroes both when it arms a probe (`Session::reset_probe_arrivals`).
|
||||
pub probe_first_arrival_ns: u64,
|
||||
pub probe_last_arrival_ns: u64,
|
||||
}
|
||||
|
||||
/// Atomic accumulators owned by a [`Session`](crate::session::Session). Snapshot to
|
||||
/// [`Stats`] for readers. `Relaxed` ordering is fine: these are monotonic counters
|
||||
/// read for display, never used to synchronize other memory.
|
||||
/// read for display, never used to synchronize other memory. (The two probe arrival
|
||||
/// stamps are the exception — slots, not counters — but they carry no synchronization
|
||||
/// duty either: they are read hundreds of ms after the last write.)
|
||||
#[derive(Default)]
|
||||
pub struct StatsCounters {
|
||||
pub frames_submitted: AtomicU64,
|
||||
@@ -44,6 +75,10 @@ pub struct StatsCounters {
|
||||
pub fec_late_shards: AtomicU64,
|
||||
pub bytes_sent: AtomicU64,
|
||||
pub bytes_received: AtomicU64,
|
||||
pub probe_packets_received: AtomicU64,
|
||||
pub probe_bytes_received: AtomicU64,
|
||||
pub probe_first_arrival_ns: AtomicU64,
|
||||
pub probe_last_arrival_ns: AtomicU64,
|
||||
}
|
||||
|
||||
impl StatsCounters {
|
||||
@@ -66,6 +101,10 @@ impl StatsCounters {
|
||||
fec_late_shards: self.fec_late_shards.load(l),
|
||||
bytes_sent: self.bytes_sent.load(l),
|
||||
bytes_received: self.bytes_received.load(l),
|
||||
probe_packets_received: self.probe_packets_received.load(l),
|
||||
probe_bytes_received: self.probe_bytes_received.load(l),
|
||||
probe_first_arrival_ns: self.probe_first_arrival_ns.load(l),
|
||||
probe_last_arrival_ns: self.probe_last_arrival_ns.load(l),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,17 +259,6 @@ 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,12 +183,6 @@ 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;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::wiring_plan::{plan, Endpoint, Wiring};
|
||||
use super::wiring_plan::{self, plan, Endpoint, Wiring};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::Mutex;
|
||||
@@ -75,15 +75,31 @@ pub(crate) fn host_audio_requested() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
/// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs:
|
||||
/// the endpoint-set fingerprint ([`wiring_plan::fingerprint`] — the wait key while a plan is
|
||||
/// unsatisfiable, snapshotted from the SAME enumeration the plan consumed so a device arriving
|
||||
/// mid-pass can't leave the waiter keyed to a set the plan never saw) and the render inventory
|
||||
/// (the one-shot "why is there no loopback" diagnosis).
|
||||
pub(crate) struct WiredPlan {
|
||||
pub wiring: Wiring,
|
||||
pub fingerprint: u64,
|
||||
pub renders: Vec<Endpoint>,
|
||||
}
|
||||
|
||||
/// Fingerprint of the CURRENT endpoint set (both directions) WITHOUT a wiring pass: enumeration
|
||||
/// and a hash — no plan, no default-device writes, no logs. This is the cheap poll the capture
|
||||
/// loop runs while waiting out a failure; the full [`wire_now`] only runs again once this moves.
|
||||
/// Must run on a COM-initialized thread, like [`wire_now`].
|
||||
pub(crate) fn endpoint_fingerprint() -> u64 {
|
||||
wiring_plan::fingerprint(
|
||||
&list_endpoints(Direction::Render),
|
||||
&list_endpoints(Direction::Capture),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`wire_now_full`] for callers that only need the assignment (the mic paths).
|
||||
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
}
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
@@ -94,24 +110,20 @@ fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
/// Must run on a COM-initialized thread (the WASAPI worker threads all `initialize_mta` first).
|
||||
/// Logged only when the assignment changes, so per-open recomputation stays quiet in the steady
|
||||
/// state.
|
||||
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
recover_orphaned_default();
|
||||
let renders = list_endpoints(Direction::Render);
|
||||
let captures = list_endpoints(Direction::Capture);
|
||||
let fingerprint = wiring_plan::fingerprint(&renders, &captures);
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
// 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 wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
|
||||
let done = |wiring: Wiring| WiredPlan {
|
||||
wiring,
|
||||
fingerprint,
|
||||
renders: renders.clone(),
|
||||
};
|
||||
|
||||
// Log assignment changes exactly once (first plan included).
|
||||
static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
|
||||
@@ -126,14 +138,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
mic_render = wiring.mic_render.as_ref().map(|(n, _)| n.as_str()),
|
||||
mic_capture = wiring.mic_capture.as_ref().map(|(n, _)| n.as_str()),
|
||||
loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()),
|
||||
loopback_last_resort = wiring.loopback_last_resort,
|
||||
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
|
||||
"audio wiring plan"
|
||||
);
|
||||
if wiring.mic_render.is_some() && wiring.loopback_render.is_none() {
|
||||
if wiring.mic_render.is_some() && wiring.loopback_unsatisfiable() {
|
||||
// Inventory + per-endpoint reasons + ONLY the remedies not already taken — the old
|
||||
// static advice here suggested installing the Steam pair to a field box that had it
|
||||
// installed (its Microphone half was exactly what the mic had reserved).
|
||||
tracing::warn!(
|
||||
"the virtual mic reserved the only usable render endpoint — desktop audio will be \
|
||||
unavailable until another output device exists (attach one, or let the host \
|
||||
install the Steam Streaming pair)"
|
||||
"desktop audio unavailable: {}",
|
||||
wiring_plan::describe_no_loopback(&renders, &wiring)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -144,7 +159,7 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"
|
||||
);
|
||||
}
|
||||
return wiring;
|
||||
return done(wiring);
|
||||
}
|
||||
// Default-playback hygiene, on EVERY wire (mic pump at boot included): if the default render
|
||||
// endpoint IS the mic target — VB-CABLE installs have been seen grabbing the default — every
|
||||
@@ -156,7 +171,7 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
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, &pad_ids).loopback_render {
|
||||
match plan(&renders, &captures, want.as_deref(), true).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 \
|
||||
@@ -181,18 +196,26 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
|
||||
// fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
|
||||
// device-graph notification, each). Re-asserting on every wiring pass therefore both
|
||||
// churned the policy store AND silently stomped an operator's own recording-device
|
||||
// choice within one reopen cycle — write only when the plan changed or the default
|
||||
// actually drifted off the target.
|
||||
if changed || default_capture_id().as_deref() != Some(id.as_str()) {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
}
|
||||
wiring
|
||||
done(wiring)
|
||||
}
|
||||
|
||||
/// The operator's default playback endpoint while we have it parked on the loopback sink:
|
||||
@@ -205,10 +228,8 @@ fn park_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default.prev")
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// The current default RENDER endpoint id, if any.
|
||||
fn default_render_id() -> Option<String> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.ok()?
|
||||
.get_default_device(&Direction::Render)
|
||||
@@ -217,6 +238,18 @@ pub(crate) fn default_render_id() -> Option<String> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The current default CAPTURE endpoint id, if any — the recording-side analogue of
|
||||
/// [`default_render_id`], read before asserting the recording default so an already-correct
|
||||
/// default costs zero IPolicyConfig writes.
|
||||
fn default_capture_id() -> Option<String> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.ok()?
|
||||
.get_default_device(&Direction::Capture)
|
||||
.ok()?
|
||||
.get_id()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Once per process: if a crash marker from a previous run exists, the host died while the
|
||||
/// playback default was parked — put the operator's device back, but only if the default still
|
||||
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
|
||||
@@ -355,9 +388,8 @@ 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. 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<()> {
|
||||
/// fails.
|
||||
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
@@ -18,9 +18,14 @@
|
||||
//! device changing under us — the operator picked a different output mid-stream — and reacts:
|
||||
//! a loopback-capturable choice is FOLLOWED (their explicit choice wins; audio then also plays
|
||||
//! on the host), a known-dud choice (cable/Steam Speakers/the mic target) snaps back to the
|
||||
//! plan. Device errors (endpoint invalidated, engine restart) reopen with backoff instead of
|
||||
//! killing audio for the rest of the session. On thread exit (capturer dropped at stream end)
|
||||
//! the parked default playback device is restored.
|
||||
//! plan. Device errors (endpoint invalidated, engine restart) reopen with a capped exponential
|
||||
//! backoff that an endpoint-set change cuts short. A plan with NO loopback endpoint at all is
|
||||
//! never retried: `wiring_plan::plan` is pure in the endpoint set, so that verdict holds until
|
||||
//! the set changes — the thread says why once, then parks on a cheap fingerprint poll and
|
||||
//! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass —
|
||||
//! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
|
||||
//! device is restored.
|
||||
|
||||
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
@@ -131,8 +136,20 @@ enum Next {
|
||||
Reopen(TargetMode),
|
||||
}
|
||||
|
||||
/// Backoff between self-heal reopen attempts after a capture failure.
|
||||
const REOPEN_BACKOFF: Duration = Duration::from_secs(2);
|
||||
/// Reopen backoff after a TRANSIENT capture failure: starts here and doubles per consecutive
|
||||
/// failure up to [`REOPEN_BACKOFF_CAP`], resetting on success or on an endpoint-set change
|
||||
/// (mirrors the mic pump's `PUMP_TUNING` shape). The predecessor was a FLAT 2 s retry whose
|
||||
/// every attempt re-ran the full wiring pass, IPolicyConfig writes included — tolerable for a
|
||||
/// genuinely transient error, an 8-minute hammer in the 2026-08 field case where the failure
|
||||
/// was structural.
|
||||
const REOPEN_BACKOFF_START: Duration = Duration::from_secs(2);
|
||||
const REOPEN_BACKOFF_CAP: Duration = Duration::from_secs(60);
|
||||
/// Endpoint-set poll cadence while waiting out a failure (both the transient backoff sleep and
|
||||
/// the unsatisfiable-plan wait): one enumerate-and-hash per tick, nothing else. A fingerprint
|
||||
/// change ends the wait immediately — a (re)arrived endpoint (the display coming back, plugged
|
||||
/// headphones) is exactly the recovery moment — so recovery stays as fast as the old 2 s hammer
|
||||
/// without its side effects.
|
||||
const ENDPOINT_POLL_EVERY: Duration = Duration::from_secs(2);
|
||||
/// Watchdog cadence for "did the default render device change under us?" checks.
|
||||
const DEFAULT_CHECK_EVERY: Duration = Duration::from_secs(1);
|
||||
/// Total attempts for the FIRST open before its failure surfaces through the `ready` handshake.
|
||||
@@ -168,14 +185,30 @@ fn capture_thread(
|
||||
let mut mode = TargetMode::Assert;
|
||||
let mut failures: u64 = 0;
|
||||
let mut first_attempts: u32 = 0;
|
||||
let mut backoff = REOPEN_BACKOFF_START;
|
||||
// Endpoint-set fingerprint under which an unsatisfiable plan was already error-logged: an
|
||||
// unchanged set means an unchanged verdict (`wiring_plan::plan` is pure), so the diagnosis
|
||||
// is said once per topology — the field log drowned in 256+ copies of the same line.
|
||||
let mut unsat_logged: Option<u64> = None;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match capture_once(&tx, &stop, &mut ready, channels, mode) {
|
||||
Ok(Next::Stopped) => break,
|
||||
Ok(Next::Reopen(m)) => {
|
||||
mode = m;
|
||||
failures = 0;
|
||||
backoff = REOPEN_BACKOFF_START;
|
||||
unsat_logged = None;
|
||||
}
|
||||
Err(e) if ready.is_some() => {
|
||||
// An unsatisfiable PLAN cannot improve within the handshake window — the
|
||||
// once-per-process Steam-pair install already ran inside `capture_once` — so
|
||||
// fail the open now with the full diagnosis instead of spending the transient
|
||||
// retry budget on a structural verdict. The native plane owns first-open
|
||||
// retries and backs off on its own.
|
||||
if e.downcast_ref::<PlanUnsatisfiable>().is_some() {
|
||||
let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}")));
|
||||
break;
|
||||
}
|
||||
first_attempts += 1;
|
||||
if first_attempts >= FIRST_OPEN_ATTEMPTS || stop.load(Ordering::Relaxed) {
|
||||
let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}")));
|
||||
@@ -190,16 +223,43 @@ fn capture_thread(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failures += 1;
|
||||
if failures.is_power_of_two() {
|
||||
tracing::warn!(error = %format!("{e:#}"), count = failures,
|
||||
"audio loopback capture failed — reopening");
|
||||
}
|
||||
mode = TargetMode::Assert;
|
||||
// Backoff in stop-responsive slices.
|
||||
let until = Instant::now() + REOPEN_BACKOFF;
|
||||
while Instant::now() < until && !stop.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
if let Some(unsat) = e.downcast_ref::<PlanUnsatisfiable>() {
|
||||
// Structural: retrying against the same endpoints repeats the same verdict,
|
||||
// and every retry used to re-run the wiring pass — IPolicyConfig writes
|
||||
// included, stomping any operator default-recording change within 2 s. Say
|
||||
// why once per topology, then park on the cheap fingerprint poll; the set
|
||||
// changing IS the recovery moment and re-plans immediately.
|
||||
failures = 0;
|
||||
backoff = REOPEN_BACKOFF_START;
|
||||
if unsat_logged != Some(unsat.fingerprint) {
|
||||
unsat_logged = Some(unsat.fingerprint);
|
||||
tracing::error!(
|
||||
"desktop audio unavailable, and retrying cannot help until the \
|
||||
audio endpoint set changes — waiting for that change. {unsat}"
|
||||
);
|
||||
}
|
||||
if wait_endpoint_change(&stop, unsat.fingerprint, None) == EndpointWait::Stopped
|
||||
{
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
unsat_logged = None;
|
||||
failures += 1;
|
||||
if failures.is_power_of_two() {
|
||||
tracing::warn!(error = %format!("{e:#}"), count = failures,
|
||||
backoff_secs = backoff.as_secs(),
|
||||
"audio loopback capture failed — reopening after backoff");
|
||||
}
|
||||
// Capped exponential backoff, cut short (and reset) the moment the
|
||||
// endpoint set changes — a re-arrived device is the likeliest cure for
|
||||
// whatever killed the capture, and it must not wait out a 60 s sleep.
|
||||
let fp = audio_control::endpoint_fingerprint();
|
||||
match wait_endpoint_change(&stop, fp, Some(Instant::now() + backoff)) {
|
||||
EndpointWait::Stopped => break,
|
||||
EndpointWait::Changed => backoff = REOPEN_BACKOFF_START,
|
||||
EndpointWait::Elapsed => backoff = (backoff * 2).min(REOPEN_BACKOFF_CAP),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +270,75 @@ fn capture_thread(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A wiring plan with NO loopback endpoint, as a typed error: [`wiring_plan::plan`] is pure in
|
||||
/// the enumerated endpoint set, so unlike every other capture error this one is PERMANENT until
|
||||
/// the topology changes — retrying it is guaranteed futile (the 2026-08 field case retried it
|
||||
/// flat-out for 8+ minutes, one full wiring pass per retry). Carries the set's fingerprint
|
||||
/// (what the reopen loop waits on) and the full diagnosis: inventory, per-endpoint rejection
|
||||
/// reasons, and only the remedies not already taken.
|
||||
#[derive(Debug)]
|
||||
struct PlanUnsatisfiable {
|
||||
fingerprint: u64,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl PlanUnsatisfiable {
|
||||
fn from_plan(plan: &audio_control::WiredPlan) -> PlanUnsatisfiable {
|
||||
debug_assert!(plan.wiring.loopback_unsatisfiable());
|
||||
PlanUnsatisfiable {
|
||||
fingerprint: plan.fingerprint,
|
||||
detail: wiring_plan::describe_no_loopback(&plan.renders, &plan.wiring),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlanUnsatisfiable {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlanUnsatisfiable {}
|
||||
|
||||
/// How a [`wait_endpoint_change`] ended.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum EndpointWait {
|
||||
/// `stop` was set — the capturer is being dropped.
|
||||
Stopped,
|
||||
/// The endpoint-set fingerprint moved — re-plan NOW (this is the recovery moment).
|
||||
Changed,
|
||||
/// The deadline passed without a change (backoff waits only; `deadline: None` never ends
|
||||
/// this way).
|
||||
Elapsed,
|
||||
}
|
||||
|
||||
/// Stop-responsive wait that polls the endpoint-set fingerprint every [`ENDPOINT_POLL_EVERY`] —
|
||||
/// an enumerate-and-hash, no wiring pass, no IPolicyConfig writes, no logs — until the set
|
||||
/// changes, `deadline` passes, or `stop` is set. `deadline: None` waits indefinitely: used while
|
||||
/// the plan is unsatisfiable, where ONLY a topology change can alter the verdict.
|
||||
fn wait_endpoint_change(
|
||||
stop: &AtomicBool,
|
||||
fingerprint: u64,
|
||||
deadline: Option<Instant>,
|
||||
) -> EndpointWait {
|
||||
let mut next_poll = Instant::now() + ENDPOINT_POLL_EVERY;
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return EndpointWait::Stopped;
|
||||
}
|
||||
if deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
return EndpointWait::Elapsed;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
if Instant::now() >= next_poll {
|
||||
next_poll = Instant::now() + ENDPOINT_POLL_EVERY;
|
||||
if audio_control::endpoint_fingerprint() != fingerprint {
|
||||
return EndpointWait::Changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current default render endpoint, with its id (`None` on any enumeration failure —
|
||||
/// transient failures must not kill the capture).
|
||||
fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
|
||||
@@ -220,7 +349,7 @@ fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
|
||||
|
||||
/// One endpoint open + capture loop. Returns how to continue ([`Next`]) or an error (first open:
|
||||
/// retried [`FIRST_OPEN_ATTEMPTS`] times, then fatal via the `ready` handshake; later: reopen
|
||||
/// with backoff).
|
||||
/// with capped backoff — or, for a typed [`PlanUnsatisfiable`], an endpoint-set wait).
|
||||
fn capture_once(
|
||||
tx: &SyncSender<Vec<f32>>,
|
||||
stop: &AtomicBool,
|
||||
@@ -233,26 +362,23 @@ fn capture_once(
|
||||
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some();
|
||||
// Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
|
||||
let assert_plan = mode == TargetMode::Assert && !keep_default;
|
||||
let mut wiring = audio_control::wire_now(assert_plan);
|
||||
let mut plan = audio_control::wire_now_full(assert_plan);
|
||||
|
||||
// Client-only audio needs a silent-on-host sink with a working loopback (the Steam Streaming
|
||||
// Microphone's render side). If the plan had to settle for real hardware (or nothing), try —
|
||||
// once per process — to install the Steam pair (present when Steam is), then re-plan.
|
||||
if assert_plan && !audio_control::host_audio_requested() {
|
||||
let have_silent = wiring
|
||||
.loopback_render
|
||||
.as_ref()
|
||||
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase()));
|
||||
static INSTALL_TRIED: AtomicBool = AtomicBool::new(false);
|
||||
if !have_silent && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
|
||||
if super::wasapi_mic::install_steam_audio_pair() {
|
||||
wiring = audio_control::wire_now(true);
|
||||
}
|
||||
if !wiring
|
||||
.loopback_render
|
||||
let have_silent = |w: &wiring_plan::Wiring| {
|
||||
w.loopback_render
|
||||
.as_ref()
|
||||
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase()))
|
||||
{
|
||||
};
|
||||
static INSTALL_TRIED: AtomicBool = AtomicBool::new(false);
|
||||
if !have_silent(&plan.wiring) && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
|
||||
if super::wasapi_mic::install_steam_audio_pair() {
|
||||
plan = audio_control::wire_now_full(true);
|
||||
}
|
||||
if !have_silent(&plan.wiring) {
|
||||
tracing::info!(
|
||||
"no silent virtual sink for client-only audio — desktop audio will also play \
|
||||
on the host (install Steam, whose Remote Play streaming drivers provide one)"
|
||||
@@ -260,6 +386,12 @@ fn capture_once(
|
||||
}
|
||||
}
|
||||
}
|
||||
let wiring = &plan.wiring;
|
||||
// Only the Assert path can knowingly sit on the plan's LAST-RESORT endpoint: Follow captures
|
||||
// the operator's chosen default, and `judge_default` never routes Follow onto the Steam
|
||||
// Speakers (they are `excluded_from_loopback` — a Dud that snaps back to the plan).
|
||||
let last_resort = assert_plan && wiring.loopback_last_resort;
|
||||
let plan_fp = plan.fingerprint;
|
||||
|
||||
let en = DeviceEnumerator::new().context("DeviceEnumerator")?;
|
||||
// Resolve the endpoint to capture. ECHO GUARD (Follow/KEEP_DEFAULT shapes): the wiring plan
|
||||
@@ -268,11 +400,11 @@ fn capture_once(
|
||||
// fall back to the plan's loopback endpoint, or refuse — no desktop audio beats an echo loop.
|
||||
let (device, dev_name, dev_id) = if assert_plan {
|
||||
let Some(ep) = wiring.loopback_render.clone() else {
|
||||
anyhow::bail!(
|
||||
"no loopback-capturable render endpoint (every usable endpoint is reserved for \
|
||||
the virtual mic or has a silent loopback) — attach an output device or install \
|
||||
the Steam Streaming pair to get desktop audio"
|
||||
);
|
||||
// Detected BEFORE any open attempt, and typed: the plan is a pure function of the
|
||||
// endpoint set, so this cannot resolve until the set changes — the reopen loop
|
||||
// waits on the fingerprint instead of retrying (the old untyped bail was retried
|
||||
// flat-out every 2 s, forever, in the 2026-08 field case).
|
||||
return Err(PlanUnsatisfiable::from_plan(&plan).into());
|
||||
};
|
||||
let d = audio_control::open_endpoint(&ep)?;
|
||||
(d, ep.0, ep.1)
|
||||
@@ -285,10 +417,15 @@ fn capture_once(
|
||||
.is_some_and(|(_, mic_id)| *mic_id == id);
|
||||
if default_is_mic {
|
||||
let Some(lb) = wiring.loopback_render.clone() else {
|
||||
// Same inventory shape as the Assert bail, but NOT typed as unsatisfiable:
|
||||
// Follow's inputs include the DEFAULT device, which the operator can change
|
||||
// without a topology change (especially under PUNKTFUNK_KEEP_DEFAULT) — the
|
||||
// capped backoff must keep retrying rather than a fingerprint wait sleeping
|
||||
// through a default-only change.
|
||||
anyhow::bail!(
|
||||
"the only render endpoint is reserved for the virtual mic (capturing it would \
|
||||
echo the client's voice back) — attach another output device or install the \
|
||||
Steam Streaming pair to get desktop audio"
|
||||
"the default render endpoint is reserved for the virtual mic (capturing it \
|
||||
would echo the client's voice back) — {}",
|
||||
wiring_plan::describe_no_loopback(&plan.renders, wiring)
|
||||
);
|
||||
};
|
||||
tracing::warn!(mic = %wiring.mic_render.as_ref().unwrap().0, loopback = %lb.0,
|
||||
@@ -338,6 +475,7 @@ fn capture_once(
|
||||
}
|
||||
tracing::info!(device = %dev_name,
|
||||
follow = matches!(mode, TargetMode::Follow) || keep_default,
|
||||
last_resort,
|
||||
"audio loopback capturing");
|
||||
|
||||
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just
|
||||
@@ -349,7 +487,7 @@ fn capture_once(
|
||||
if assert_plan {
|
||||
if let Some(d) = seen_default.as_deref() {
|
||||
if d != dev_id {
|
||||
match judge_default(&en, &wiring, d) {
|
||||
match judge_default(&en, wiring, d) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
tracing::info!(default = %name, planned = %dev_name,
|
||||
"could not park the default playback on the planned endpoint — \
|
||||
@@ -367,10 +505,12 @@ fn capture_once(
|
||||
|
||||
let mut bytes: VecDeque<u8> = VecDeque::new();
|
||||
let mut last_check = Instant::now();
|
||||
let mut last_fp_check = Instant::now();
|
||||
// Triage breadcrumb: a broken loopback (endpoint renders but its loopback tap delivers
|
||||
// nothing — the Steam Streaming Speakers failure shape) is indistinguishable from a simply
|
||||
// quiet desktop, so after 30 s with zero packets say so ONCE. Info, not warn: an idle host
|
||||
// is legitimately silent.
|
||||
// quiet desktop, so after 30 s with zero packets say so ONCE. Info, not warn — an idle host
|
||||
// is legitimately silent — EXCEPT on a last-resort endpoint, where the plan already knew
|
||||
// the loopback is silent and zero packets all but confirms the quality risk materialized.
|
||||
let opened_at = Instant::now();
|
||||
let mut saw_packets = false;
|
||||
let mut silence_noted = false;
|
||||
@@ -396,10 +536,18 @@ fn capture_once(
|
||||
}
|
||||
if !saw_packets && !silence_noted && opened_at.elapsed() >= Duration::from_secs(30) {
|
||||
silence_noted = true;
|
||||
tracing::info!(device = %dev_name,
|
||||
"no audio captured in the first 30 s — fine if the host is quiet; if it should \
|
||||
be playing audio, this endpoint's loopback may be broken (set \
|
||||
PUNKTFUNK_HOST_AUDIO=1 to prefer real hardware)");
|
||||
if last_resort {
|
||||
tracing::warn!(device = %dev_name,
|
||||
"no audio captured in the first 30 s from the LAST-RESORT loopback — the \
|
||||
Steam Streaming Speakers' loopback is known-silent, so desktop audio is \
|
||||
most likely not reaching the client; attach any output device to give the \
|
||||
plan a working endpoint (it re-plans on the change)");
|
||||
} else {
|
||||
tracing::info!(device = %dev_name,
|
||||
"no audio captured in the first 30 s — fine if the host is quiet; if it \
|
||||
should be playing audio, this endpoint's loopback may be broken (set \
|
||||
PUNKTFUNK_HOST_AUDIO=1 to prefer real hardware)");
|
||||
}
|
||||
}
|
||||
let whole = (bytes.len() / block_align) * block_align;
|
||||
if whole > 0 {
|
||||
@@ -428,7 +576,7 @@ fn capture_once(
|
||||
);
|
||||
return Ok(Next::Reopen(TargetMode::Follow));
|
||||
}
|
||||
return Ok(match judge_default(&en, &wiring, &nid) {
|
||||
return Ok(match judge_default(&en, wiring, &nid) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
tracing::info!(device = %name,
|
||||
"operator changed the output device mid-stream — following \
|
||||
@@ -447,6 +595,24 @@ fn capture_once(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A LAST-RESORT capture is a stopgap, not a steady state: the plan chose the
|
||||
// known-silent Steam Speakers only because nothing better existed, so any endpoint-set
|
||||
// change — the display's audio endpoint re-arriving, headphones plugged in — may unlock
|
||||
// a real plan. Re-plan on the change; without this the session would ride the silent
|
||||
// loopback forever AFTER the real endpoint returned (the original field defect in a
|
||||
// quieter costume). Preferred endpoints don't get this watch: mid-stream re-routing
|
||||
// there is the default-device watchdog's job, on the operator's terms.
|
||||
if last_resort && last_fp_check.elapsed() >= ENDPOINT_POLL_EVERY {
|
||||
last_fp_check = Instant::now();
|
||||
if audio_control::endpoint_fingerprint() != plan_fp {
|
||||
audio_client.stop_stream().ok();
|
||||
tracing::info!(
|
||||
"endpoint set changed while capturing the last-resort loopback — re-planning"
|
||||
);
|
||||
return Ok(Next::Reopen(TargetMode::Assert));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -253,16 +253,25 @@ pub(crate) fn install_steam_audio_pair() -> bool {
|
||||
mic || spk
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// 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::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";
|
||||
@@ -281,33 +290,8 @@ pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
|
||||
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.
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
//! out of the host's speakers. Real hardware is the fallback (audio then plays on both ends).
|
||||
//! With `host_audio` (the `PUNKTFUNK_HOST_AUDIO` opt-in) the order flips back: real hardware
|
||||
//! first, so the operator hears the stream locally.
|
||||
//!
|
||||
//! **Last resort, and the honest failure.** When neither a silent sink nor real hardware
|
||||
//! survives the mic reservation, the Steam Streaming *Speakers* are taken as a flagged LAST
|
||||
//! resort ([`Wiring::loopback_last_resort`]): their loopback is known-silent (validated live) —
|
||||
//! a QUALITY risk the capture side warns about and treats as a stopgap — but holding a parked
|
||||
//! endpoint beats holding none (2026-08 field case: the display isolate invalidated the only
|
||||
//! real render endpoint mid-session, the mic held the Streaming Microphone, and a plan with no
|
||||
//! loopback left the session unrecoverable). Cables, VoiceMeeter strips and generically-
|
||||
//! "virtual" endpoints are never a last resort — capturing them re-captures what the mic writes,
|
||||
//! an echo/feedback CORRECTNESS risk, unlike silence — so with only those left the plan is
|
||||
//! honestly unsatisfiable ([`Wiring::loopback_unsatisfiable`]): a pure verdict on the endpoint
|
||||
//! set that cannot change until the set does. Callers must wait for an endpoint-set change
|
||||
//! ([`fingerprint`]), not retry.
|
||||
|
||||
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
|
||||
pub(crate) type Endpoint = (String, String);
|
||||
@@ -42,6 +55,22 @@ pub(crate) struct Wiring {
|
||||
pub mic_capture: Option<Endpoint>,
|
||||
/// Render endpoint for the desktop-audio loopback; made the default playback device.
|
||||
pub loopback_render: Option<Endpoint>,
|
||||
/// `loopback_render` is the flagged LAST RESORT (the Steam Streaming Speakers, whose
|
||||
/// loopback is known-silent — validated live), taken only because nothing better survived
|
||||
/// the mic reservation. The capture side treats it as a stopgap: it warns when the silence
|
||||
/// materializes and re-plans on any endpoint-set change instead of riding it out.
|
||||
pub loopback_last_resort: bool,
|
||||
}
|
||||
|
||||
impl Wiring {
|
||||
/// This plan has NO loopback endpoint — not even the last resort. Because [`plan`] is pure,
|
||||
/// this is a STRUCTURAL verdict on the endpoint set, not a transient device error:
|
||||
/// reattempting a capture open without an endpoint-set change must fail identically (the
|
||||
/// 2026-08 field case spent 8+ minutes of flat 2 s retries proving exactly that). Callers
|
||||
/// wait for the set's [`fingerprint`] to move instead of retrying.
|
||||
pub(crate) fn loopback_unsatisfiable(&self) -> bool {
|
||||
self.loopback_render.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render-endpoint friendly-name substrings (lowercased) usable as the virtual-mic write target,
|
||||
@@ -102,28 +131,13 @@ fn virtualish(lname: &str) -> bool {
|
||||
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
|
||||
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
|
||||
/// flips the loopback preference to real hardware (audio audible on the host too); the default
|
||||
/// (`false`) prefers the silent sink so audio plays on the client only. `pad_renders` are the
|
||||
/// endpoint IDs of the host's own pad-audio ("DualSense speaker") endpoints — platform data
|
||||
/// collected by `audio_control`, since a pad endpoint is identified by its stamped container /
|
||||
/// devnode, not by any name rule this module could express.
|
||||
/// (`false`) prefers the silent sink so audio plays on the client only.
|
||||
pub(crate) fn plan(
|
||||
renders: &[Endpoint],
|
||||
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). Their names carry no virtual marker —
|
||||
// they are stamped "DualSense Wireless Controller" on purpose — so without this
|
||||
// exclusion the loopback rules would read one as 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()
|
||||
@@ -149,7 +163,8 @@ pub(crate) fn plan(
|
||||
|
||||
// 3. Loopback from the REMAINING renders. Client-only (default): the silent sink (Steam
|
||||
// Streaming Microphone — its loopback works, unlike the Speakers') > real hardware
|
||||
// (audible fallback) > any non-excluded leftover. `host_audio`: real hardware first.
|
||||
// (audible fallback). `host_audio`: real hardware first. Either order can fall through
|
||||
// to the flagged last resort below.
|
||||
let not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id);
|
||||
let real_hw = || {
|
||||
renders.iter().find(|(n, id)| {
|
||||
@@ -162,29 +177,131 @@ pub(crate) fn plan(
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
|
||||
};
|
||||
// `virtualish` here too: a virtual endpoint that slipped past `excluded_from_loopback`'s
|
||||
// name list (a future cable/mixer sibling) is an internal-feedback loop waiting to happen —
|
||||
// no loopback is the honest answer, exactly like the cable-only case.
|
||||
let leftover = || {
|
||||
renders.iter().find(|(n, id)| {
|
||||
let ln = n.to_lowercase();
|
||||
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
|
||||
})
|
||||
// LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent
|
||||
// (validated live): a QUALITY risk, flagged so the capture side can warn when the silence
|
||||
// materializes and re-plan when the endpoint set changes — but a parked endpoint beats none
|
||||
// (2026-08: the display isolate invalidated the only real render endpoint mid-session and a
|
||||
// loopback-less plan left the session unrecoverable). Never a cable, a VoiceMeeter strip, or
|
||||
// a generically-"virtual" endpoint: those re-capture what the mic writes — echo/feedback
|
||||
// CORRECTNESS risks — so "no loopback" stays the honest answer there. NOTE
|
||||
// `excluded_from_loopback` itself stays untouched: it also powers the capture watchdog's
|
||||
// judgement of a NEW operator-chosen default, where admitting the Speakers would change
|
||||
// mid-stream snap-back semantics.
|
||||
let last_resort = || {
|
||||
renders
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers"))
|
||||
};
|
||||
let loopback_render = if host_audio {
|
||||
real_hw().or_else(silent).or_else(leftover)
|
||||
let preferred = if host_audio {
|
||||
real_hw().or_else(silent)
|
||||
} else {
|
||||
silent().or_else(real_hw).or_else(leftover)
|
||||
}
|
||||
.cloned();
|
||||
silent().or_else(real_hw)
|
||||
};
|
||||
let (loopback_render, loopback_last_resort) = match preferred {
|
||||
Some(ep) => (Some(ep.clone()), false),
|
||||
None => match last_resort() {
|
||||
Some(ep) => (Some(ep.clone()), true),
|
||||
None => (None, false),
|
||||
},
|
||||
};
|
||||
|
||||
Wiring {
|
||||
mic_render,
|
||||
mic_capture,
|
||||
loopback_render,
|
||||
loopback_last_resort,
|
||||
}
|
||||
}
|
||||
|
||||
/// Order-independent fingerprint of an enumerated endpoint set. [`plan`] is a pure function of
|
||||
/// these inputs (the env knobs are process-stable), so an unchanged fingerprint PROVES an
|
||||
/// unchanged verdict: re-planning an unsatisfiable set before the fingerprint moves only repeats
|
||||
/// the same answer, with IPolicyConfig default-device writes as the side effect. The capture
|
||||
/// loop polls this instead of re-planning, and treats a change as the recovery moment.
|
||||
pub(crate) fn fingerprint(renders: &[Endpoint], captures: &[Endpoint]) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
// Each direction hashes as a length-prefixed sorted slice, so renders and captures cannot
|
||||
// alias each other and endpoint order (enumeration order churns) never matters.
|
||||
for eps in [renders, captures] {
|
||||
let mut sorted: Vec<&Endpoint> = eps.iter().collect();
|
||||
sorted.sort();
|
||||
sorted.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The one-shot diagnosis for a plan with no loopback endpoint: every enumerated render with WHY
|
||||
/// it was rejected, then ONLY the remedies not already taken. The static advice this replaces
|
||||
/// ("attach one, or let the host install the Steam Streaming pair") was already satisfied in the
|
||||
/// 2026-08 field case — the pair WAS installed, its Microphone half reserved by the mic — so the
|
||||
/// message pointed at a fix the box already had. Pure, like [`plan`]: callers pass the same
|
||||
/// enumeration the plan consumed.
|
||||
pub(crate) fn describe_no_loopback(renders: &[Endpoint], wiring: &Wiring) -> String {
|
||||
debug_assert!(wiring.loopback_unsatisfiable());
|
||||
let mic_id = wiring.mic_render.as_ref().map(|(_, id)| id.as_str());
|
||||
let rejected: Vec<String> = renders
|
||||
.iter()
|
||||
.map(|(name, id)| {
|
||||
let ln = name.to_lowercase();
|
||||
let why = if Some(id.as_str()) == mic_id {
|
||||
"reserved for the virtual mic (its loopback would echo the client's voice back)"
|
||||
} else if ln.contains("cable") {
|
||||
"virtual cable (its loopback re-captures what is written into it)"
|
||||
} else if ln.contains("voicemeeter") {
|
||||
"VoiceMeeter strip (shares the mixer the mic writes into — a feedback loop)"
|
||||
} else if ln.contains("steam streaming speakers") {
|
||||
// Reachable only when the Speakers ARE the mic target (operator override) —
|
||||
// the last-resort tier takes them otherwise.
|
||||
"known-silent loopback (validated live)"
|
||||
} else if ln.contains("virtual") {
|
||||
"unrecognized virtual endpoint (assumed feedback/silence risk)"
|
||||
} else {
|
||||
// `plan` accepts any non-virtual render — reaching this arm means a tier
|
||||
// changed without updating this diagnosis.
|
||||
"rejected by the wiring plan"
|
||||
};
|
||||
format!("{name:?}: {why}")
|
||||
})
|
||||
.collect();
|
||||
let inventory = if rejected.is_empty() {
|
||||
"no render endpoints exist at all".to_string()
|
||||
} else {
|
||||
rejected.join("; ")
|
||||
};
|
||||
let has = |needle: &str| {
|
||||
renders
|
||||
.iter()
|
||||
.any(|(n, _)| n.to_lowercase().contains(needle))
|
||||
};
|
||||
let mut remedies = vec!["attach any output device (headphones, or a monitor/TV with audio)"];
|
||||
// Only useful when the mic would actually vacate a loopback-capable endpoint: with the mic
|
||||
// on the Steam Streaming Microphone, a cable frees that silent sink for the loopback. A mic
|
||||
// on a VoiceMeeter strip frees nothing capturable, so the advice is withheld there.
|
||||
if !has("cable")
|
||||
&& wiring
|
||||
.mic_render
|
||||
.as_ref()
|
||||
.is_some_and(|(n, _)| silent_sink(&n.to_lowercase()))
|
||||
{
|
||||
remedies.push(
|
||||
"install VB-Audio Virtual Cable — the mic then takes the cable and frees the Steam \
|
||||
Streaming Microphone's render side for the loopback",
|
||||
);
|
||||
}
|
||||
if !has("steam streaming microphone") {
|
||||
remedies.push(
|
||||
"install Steam — its Remote Play streaming drivers add a loopback-capable virtual \
|
||||
sink",
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"no loopback-capturable render endpoint: {inventory}. Remedies: {}",
|
||||
remedies.join("; or ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -206,7 +323,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)"
|
||||
@@ -235,7 +352,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)"
|
||||
@@ -255,7 +372,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)"
|
||||
@@ -272,7 +389,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}");
|
||||
}
|
||||
}
|
||||
@@ -284,7 +401,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");
|
||||
}
|
||||
@@ -302,7 +419,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)"
|
||||
@@ -311,6 +428,10 @@ mod tests {
|
||||
w.loopback_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
);
|
||||
assert!(
|
||||
!w.loopback_last_resort,
|
||||
"the silent sink is a PREFERRED pick"
|
||||
);
|
||||
assert_eq!(
|
||||
w.mic_capture.unwrap().0,
|
||||
"CABLE Output (VB-Audio Virtual Cable)"
|
||||
@@ -326,7 +447,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)"
|
||||
@@ -340,21 +461,93 @@ 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());
|
||||
}
|
||||
|
||||
/// Steam Streaming Speakers never become the loopback (silent loopback, validated live) —
|
||||
/// even when they're the only non-mic endpoint.
|
||||
/// Steam Streaming Speakers are never a PREFERRED loopback (their loopback is silent —
|
||||
/// validated live) — but when they are the only non-mic endpoint they ARE taken, flagged as
|
||||
/// the last resort: a silent loopback the capture side can warn about beats a plan with no
|
||||
/// endpoint at all (which is unrecoverable until the topology changes).
|
||||
#[test]
|
||||
fn steam_speakers_never_loopback() {
|
||||
fn steam_speakers_only_as_last_resort() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
let w = plan(&renders, &[], None, false, &[]);
|
||||
assert!(w.loopback_render.is_none());
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &[], None, host_audio);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Speakers)",
|
||||
"host_audio={host_audio}"
|
||||
);
|
||||
assert!(w.loopback_last_resort, "host_audio={host_audio}");
|
||||
}
|
||||
}
|
||||
|
||||
/// THE 2026-08 field case: no cable, only the Steam pair left after the display isolate
|
||||
/// invalidated the monitor's DP audio endpoint. The mic reserves the Streaming Microphone
|
||||
/// (the only mic candidate), and the plan must then take the Speakers as the last resort —
|
||||
/// the old plan yielded no loopback here and the session never recovered.
|
||||
#[test]
|
||||
fn field_case_steam_pair_only_takes_speakers_as_last_resort() {
|
||||
let renders = [
|
||||
ep("Altavoces (Steam Streaming Speakers)"),
|
||||
ep("Altavoces (Steam Streaming Microphone)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Altavoces (Steam Streaming Microphone)"
|
||||
);
|
||||
assert_eq!(
|
||||
w.loopback_render.unwrap().0,
|
||||
"Altavoces (Steam Streaming Speakers)"
|
||||
);
|
||||
assert!(w.loopback_last_resort);
|
||||
}
|
||||
|
||||
/// The last resort never shadows a real pick: with real hardware present the Speakers stay
|
||||
/// unchosen and the flag stays down, in both preference modes.
|
||||
#[test]
|
||||
fn last_resort_never_beats_real_hardware() {
|
||||
let renders = [
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Realtek HD Audio)",
|
||||
"host_audio={host_audio}"
|
||||
);
|
||||
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cables and VoiceMeeter strips are CORRECTNESS risks (they re-capture what the mic
|
||||
/// writes — echo/feedback), not quality risks: never the loopback, not even as a last
|
||||
/// resort. The plan stays honestly unsatisfiable.
|
||||
#[test]
|
||||
fn cable_and_voicemeeter_never_last_resort() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
|
||||
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
|
||||
];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
for host_audio in [false, true] {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator override beats the candidate order.
|
||||
@@ -365,7 +558,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)"
|
||||
@@ -381,7 +574,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)");
|
||||
}
|
||||
@@ -399,7 +592,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)",
|
||||
@@ -422,49 +615,62 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A generically-"virtual" leftover (unknown vendor cable) is refused too: `leftover()`
|
||||
/// applies `virtualish`, so a virtual endpoint that slips past the name list can't become
|
||||
/// the loopback.
|
||||
/// A generically-"virtual" leftover (unknown vendor cable) is refused too: the last resort
|
||||
/// accepts ONLY the Steam Streaming Speakers, so a virtual endpoint that slips past
|
||||
/// `excluded_from_loopback`'s name list still can't become the loopback.
|
||||
#[test]
|
||||
fn unknown_virtual_never_loopback() {
|
||||
let renders = [
|
||||
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());
|
||||
}
|
||||
|
||||
/// A provisioned pad-audio endpoint (stamped "DualSense Wireless Controller") 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 among `renders`.
|
||||
/// The fingerprint keys the capture loop's "wait for an endpoint change" state: it must
|
||||
/// ignore enumeration order (Windows churns it), react to any topology change, and never
|
||||
/// alias the render and capture directions.
|
||||
#[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());
|
||||
fn fingerprint_order_independent_topology_sensitive() {
|
||||
let a = [ep("Speakers (Realtek HD Audio)"), ep("CABLE Input")];
|
||||
let a_rev = [ep("CABLE Input"), ep("Speakers (Realtek HD Audio)")];
|
||||
let caps = [ep("CABLE Output")];
|
||||
assert_eq!(fingerprint(&a, &caps), fingerprint(&a_rev, &caps));
|
||||
assert_ne!(fingerprint(&a, &caps), fingerprint(&a[..1], &caps));
|
||||
assert_ne!(fingerprint(&a, &caps), fingerprint(&caps, &a));
|
||||
}
|
||||
|
||||
/// The unsatisfiable-plan diagnosis must name what the mic reserved and advise ONLY the
|
||||
/// remedies not already taken: in the field case the Steam pair was installed (so "install
|
||||
/// Steam" would point at a fix the box already had) and the cable was missing (so VB-CABLE
|
||||
/// is the advice that actually frees the silent sink).
|
||||
#[test]
|
||||
fn describe_no_loopback_skips_satisfied_remedies() {
|
||||
// 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);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
let msg = describe_no_loopback(&renders, &w);
|
||||
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
|
||||
assert!(msg.contains("VB-Audio Virtual Cable"), "{msg}");
|
||||
assert!(!msg.contains("install Steam"), "{msg}");
|
||||
|
||||
// Cable-only headless box: VB-CABLE is already installed (and freeing it wouldn't help
|
||||
// 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);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +384,6 @@ 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 \
|
||||
@@ -431,7 +430,6 @@ 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 \
|
||||
@@ -488,50 +486,6 @@ 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);
|
||||
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(())
|
||||
}
|
||||
},
|
||||
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,8 +65,6 @@ 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,
|
||||
}
|
||||
@@ -140,7 +138,6 @@ mod tests {
|
||||
index,
|
||||
kind,
|
||||
capabilities,
|
||||
..
|
||||
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
|
||||
else {
|
||||
panic!("expected Arrival");
|
||||
|
||||
@@ -602,10 +602,6 @@ 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,12 +64,6 @@ 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;
|
||||
@@ -350,14 +344,6 @@ 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.
|
||||
@@ -1178,14 +1164,9 @@ 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, pad_audio_on))
|
||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
|
||||
.context("spawn input thread")?
|
||||
};
|
||||
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
||||
|
||||
@@ -338,7 +338,10 @@ pub(super) async fn negotiate(
|
||||
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
|
||||
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
|
||||
// gate is `can_encode_444` (the full-res-chroma CSC variant existing on this OS).
|
||||
let capture_supports_444 = codec == crate::encode::Codec::PyroWave
|
||||
// Named for the whole capture→encoder INGEST chain, not the capturer: on Windows the
|
||||
// deciding fact is the encoder backend (direct NVENC only), and a field report burned
|
||||
// real time hunting a capture problem because the old `capture_supports_444` key said so.
|
||||
let ingest_chain_supports_444 = codec == crate::encode::Codec::PyroWave
|
||||
|| crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444());
|
||||
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
||||
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
||||
@@ -350,7 +353,7 @@ pub(super) async fn negotiate(
|
||||
crate::encode::Codec::H265 | crate::encode::Codec::PyroWave
|
||||
) && host_wants_444
|
||||
&& client_supports_444
|
||||
&& capture_supports_444
|
||||
&& ingest_chain_supports_444
|
||||
{
|
||||
tokio::task::spawn_blocking(move || crate::encode::can_encode_444(codec))
|
||||
.await
|
||||
@@ -358,6 +361,23 @@ pub(super) async fn negotiate(
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// The client's 4:4:4 setting IS the VIDEO_CAP_444 bit — when the user flipped it on and
|
||||
// the session still resolves 4:2:0, name the losing gate. (The PyroWave mode-size gate
|
||||
// below warns for itself.)
|
||||
if host_wants_444 && client_supports_444 && !gpu_supports_444 {
|
||||
let reason = if !matches!(
|
||||
codec,
|
||||
crate::encode::Codec::H265 | crate::encode::Codec::PyroWave
|
||||
) {
|
||||
"the negotiated codec only carries 4:2:0 — 4:4:4 needs HEVC or PyroWave"
|
||||
} else if !ingest_chain_supports_444 {
|
||||
"this host's encoder backend can't ingest full chroma — 4:4:4 needs direct \
|
||||
NVENC (NVIDIA) or the PyroWave codec"
|
||||
} else {
|
||||
"the GPU declined the 4:4:4 encode profile probe"
|
||||
};
|
||||
tracing::info!(reason, "4:4:4 requested but the session negotiates 4:2:0");
|
||||
}
|
||||
let chroma = if gpu_supports_444 {
|
||||
crate::encode::ChromaFormat::Yuv444
|
||||
} else {
|
||||
@@ -383,7 +403,7 @@ pub(super) async fn negotiate(
|
||||
chroma = ?chroma,
|
||||
host_wants_444,
|
||||
client_supports_444,
|
||||
capture_supports_444,
|
||||
ingest_chain_supports_444,
|
||||
"encode chroma"
|
||||
);
|
||||
|
||||
@@ -544,16 +564,6 @@ 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,75 +515,6 @@ 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).
|
||||
@@ -738,13 +669,8 @@ 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.
|
||||
@@ -903,53 +829,16 @@ pub(super) fn input_thread(
|
||||
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 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;
|
||||
// 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;
|
||||
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.
|
||||
@@ -1105,9 +994,6 @@ 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)]
|
||||
|
||||
@@ -1,641 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ pub(super) fn synthetic_stream(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bounds a speed-test [`ProbeRequest`] before bursting: a 3 Gbps / 5 s ceiling keeps a probe from
|
||||
/// Bounds a speed-test [`ProbeRequest`] before bursting: a 10 Gbps / 5 s ceiling keeps a probe from
|
||||
/// monopolizing the link or stalling the stream for too long. The ceiling is set ABOVE the session
|
||||
/// bitrate cap ([`MAX_BITRATE_KBPS`], 2 Gbps) on purpose — a probe should be able to demonstrate
|
||||
/// headroom past the rate a session will actually be configured to use, so the client can pick a
|
||||
|
||||
@@ -241,6 +241,10 @@ A few knobs are read by the native **clients**, not the host:
|
||||
| `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). |
|
||||
| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. |
|
||||
| `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. |
|
||||
| `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. |
|
||||
| `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `900000` | The startup link-capacity probe's burst target (default 2 Gbps — deliberately above any plausible link so the burst measures the link, not itself). Lower it on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. |
|
||||
| `PUNKTFUNK_ABR_PROBE` | `0` | Skip the startup link-capacity probe entirely. The adaptive-bitrate climb ceiling then stays at the negotiated starting rate — a blunt instrument; prefer `PUNKTFUNK_ABR_MAX_MBPS`. |
|
||||
| `PUNKTFUNK_ABR_MAX_MBPS` | Mbps, e.g. `300` | Hard cap on the adaptive bitrate's climb ceiling, whatever the startup probe measured. The escape hatch when adaptive sessions keep climbing past what your client's **decoder** can sustain (periodic hitch + "receive backlog stopped draining" in the client log). An explicit bitrate setting still bypasses ABR entirely. |
|
||||
|
||||
## Bitrate
|
||||
|
||||
|
||||
+9
-160
@@ -58,13 +58,7 @@
|
||||
// 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.
|
||||
// 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
|
||||
#define ABI_VERSION 14
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -88,13 +82,6 @@
|
||||
// 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
|
||||
|
||||
@@ -279,28 +266,6 @@
|
||||
// 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
|
||||
@@ -311,13 +276,6 @@
|
||||
// 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.
|
||||
@@ -384,19 +342,6 @@
|
||||
// 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
|
||||
@@ -682,18 +627,6 @@
|
||||
#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,
|
||||
@@ -719,19 +652,6 @@
|
||||
#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
|
||||
@@ -1019,9 +939,7 @@
|
||||
// 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), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
// host→client).
|
||||
// (0xCE, host→client).
|
||||
#define PUNKTFUNK_AUDIO_MAGIC 201
|
||||
#endif
|
||||
|
||||
@@ -1125,31 +1043,6 @@
|
||||
#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
|
||||
@@ -1464,11 +1357,7 @@ 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 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
|
||||
// wire byte, `flags` = pad index. 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
|
||||
@@ -1878,7 +1767,11 @@ typedef struct {
|
||||
// Application goodput bytes / access units the host offered.
|
||||
uint64_t host_bytes;
|
||||
uint32_t host_packets;
|
||||
// The host's measured burst duration, milliseconds (the throughput denominator).
|
||||
// The throughput denominator, milliseconds: the client-measured burst receive interval
|
||||
// (first → last probe-packet arrival) once `done`; the host's measured send-window
|
||||
// duration when fewer than two probe packets arrived (no interval to measure from). The
|
||||
// host duration alone overstates throughput — its window closes while the bottleneck
|
||||
// queue is still draining toward the client.
|
||||
uint32_t elapsed_ms;
|
||||
// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
|
||||
uint32_t throughput_kbps;
|
||||
@@ -2373,50 +2266,6 @@ 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.
|
||||
@@ -3001,7 +2850,7 @@ PunktfunkStatus punktfunk_connection_wants_decode_latency(const PunktfunkConnect
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 10 Gbps / ≤ 5 s),
|
||||
// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
|
||||
// `done` field is 1. Starting a probe resets any prior measurement.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user