Compare commits

..
Author SHA1 Message Date
enricobuehler e32bd30c85 fix(android): give the renderer its own USB connection, and add a real-world self test
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m21s
ci / rust (pull_request) Successful in 13m11s
**The bug.** The renderer was handed `HidUsbLink`'s file descriptor. That link's
own comment states the hazard exactly — "only one thread may drive a
connection's UsbRequests (requestWait() returns ANY completed request; a second
waiter would steal the reader's completions)" — and it is just as true of the
usbfs reap underneath: the isochronous ring and the HID reader were reaping each
other's URB completions. The standalone harness works because it owns its
descriptor by construction, which is precisely why it could never have caught
this. `DsCapture` now opens a dedicated connection via `openAuxConnection()` and
closes it only after the render thread is joined.

**The test.** Nothing exercised the CLIENT path without a host, so the two things
most likely to be wrong were invisible: whether the descriptor handed over is
exclusively ours, and whether the claim succeeds on this kernel. Neither is
unit-testable and a harness proves neither.

`nativePadAudioSelfTest` drives the voice coils with a tone through the real
path — the same aux connection, claim, sink and write loop the renderer uses —
and is triggered by `adb shell setprop debug.punktfunk.pad_audio_selftest 3`,
matching this repo's existing debug.punktfunk.* convention. It runs INSTEAD of
the renderer for that capture, never alongside it: two engines on one descriptor
is the fault being tested for, and I nearly shipped it into the test itself.

Underruns are deliberately not a failure condition — that is producer pacing.
The pass condition is data reaching the bus.
2026-08-03 00:38:58 +02:00
enricobuehler 2f1ef44191 fix(android): commit the tier-A trade only once the USB stream actually opens
ci / web (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m12s
android / android (pull_request) Successful in 3m36s
ci / rust (pull_request) Canceled after 4m11s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 55s
A real bug, and the worst shape one can take here: it costs the user ALL
haptics rather than degrading.

`pad_audio::start` returned success as soon as the render thread spawned, and
`nativeStartPadAudio` then declared the pad's render capability and took it off
wire rumble. But `sink::open` runs later, on that thread. On a kernel that
refuses the interface claim — the OEM case documented as needing a clean tier-C
fallback — the pad was already suppressed and the host already streaming 0xD1 at
a renderer that never opened. No pad audio, and no rumble either.

The declaration and the suppression now happen inside the renderer, immediately
after a successful open, and are both withdrawn when it stops. A failed open
declares nothing and suppresses nothing, so the session stays on ordinary rumble
— which is what "degrades to tier C" was always supposed to mean. `PadAudio`'s
Drop clears the tier-A bit too, so a thread that dies unexpectedly cannot leave a
pad permanently mute.

The general rule this violated: never give up a working fallback until the thing
replacing it is known to work. Spawning a thread is not evidence that it will.
2026-08-03 00:34:47 +02:00
enricobuehler 8ee224e5db fix(android): advertise CLIENT_CAP_PAD_AUDIO, without which nothing is ever sent
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m35s
ci / docs-site (pull_request) Successful in 1m14s
android / android (pull_request) Successful in 3m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m20s
ci / rust-arm64 (pull_request) Successful in 5m27s
ci / rust (pull_request) Successful in 12m30s
A gap in the previous commits, and the same silent-failure shape as the two they
fixed. There are TWO negotiations, not one: the per-pad render capabilities that
ride a gamepad arrival (bits 8/9), which those commits set, and the SESSION-level
CLIENT_CAP_PAD_AUDIO in the Hello, which they did not. Without the latter the
host never sets HOST_CAP_PAD_AUDIO and emits no 0xD1 at all — so the per-pad bits
would have had nothing to gate, and the renderer would have sat on a permanently
empty plane with every other piece looking correct.

Threaded as an explicit `padAudioOk` on nativeConnect rather than advertised
unconditionally: the cap makes a Windows host provision pad endpoints at startup,
and a user who has pad audio switched off should not pay for that.

Found by tracing what an on-glass run against a real host would actually need,
not by a test — there is no test that could have caught it, since both halves are
individually well-formed.
2026-08-03 00:04:14 +02:00
enricobuehler e8499e6131 feat(android): wire tier-A pad audio through the capture lifecycle and settings
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 5m27s
ci / rust (pull_request) Canceled after 3m50s
ci / rust-arm64 (pull_request) Canceled after 3m50s
ci / docs-site (pull_request) Canceled after 31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
The Kotlin half. Turns out Android needs to claim nothing extra: `uac-host`
claims the pad's audio interface itself through usbfs on the fd, and usbfs
claims are per interface, so the HID claim `HidUsbLink` already holds is
untouched. The link therefore surrenders only its file descriptor.

Two orderings carry the whole design, and both are easy to get wrong:

- **Start on the first report, not at claim time.** The wire pad index does not
  exist until the router opens a slot, and the host addresses the 0xD1 stream by
  that index — starting earlier would declare capabilities for a pad that has no
  index yet.
- **Stop before the link closes.** `usb.stop()` closes the connection whose
  descriptor the render thread borrows, so `padAudio.stop()` runs first, at the
  top of `DsCapture.stop()`. `nativeStopPadAudio` does not return until the
  thread is joined, which is what makes the borrow sound rather than merely
  usually-fine.

`DsCapture` decides WHEN (it owns the wire index and the link lifetime);
`StreamScreen` decides WHETHER (it owns the session handle and the settings).
The capture stays ignorant of sessions.

Settings: `padHaptics` defaults on — it is the whole point, and this client's
rumble already drives the same actuators, so tier A is a strict improvement.
`padSpeaker` defaults OFF: it is a small loudspeaker in the user's hands playing
audio they can already hear, and surprising someone with that is worse than
making them opt in.

Verified: APK builds, and both JNI entry points are exported in the shipped
arm64 .so — a missing one would be an UnsatisfiedLinkError only at runtime.
12 Rust tests, 0 clippy findings, fmt clean.
2026-08-02 23:51:28 +02:00
enricobuehler a10bde39bb feat(android): declare pad-audio caps and take tier-A pads off wire rumble
The two things that decide whether WP9 does anything at all on a device, both
failing silently rather than loudly if missed.

**Capability bits.** The host emits 0xD1 only toward pads that declared they can
render it (arrival flags 8/9). Without `set_pad_audio_caps` the renderer would
sit on a permanently empty plane and look like a decode bug. Declared when the
stream opens, withdrawn when it stops.

**Rumble arbitration.** `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. One replayed rumble command would
mute the voice coils the 0xD1 stream is driving, for the rest of the session.
Tier A and tier C are mutually exclusive in the pad's firmware, so the
arbitration selects and never blends.

Suppression sits at `nativeNextRumble`, the pull point, rather than in Kotlin:
it keeps the rule next to the reason and covers every caller. The registry is an
atomic bitmask because the reader is the rumble poll thread and must not block
behind a start/stop on the JNI thread.

Order matters on teardown: the capability is withdrawn before the pad returns to
wire rumble, so the host has stopped sending 0xD1 before tier C resumes and the
two never overlap.

`nativeStartPadAudio`/`nativeStopPadAudio` now take the wire pad index, since
both the capability and the arbitration are per-pad. Out-of-range indices are
rejected rather than wrapped into another pad's slot.

12 host tests (2 new, including one pinning that an out-of-range index cannot
shift the mask into undefined territory), 0 clippy findings, check clean on all
three Android ABIs.
2026-08-02 23:44:51 +02:00
enricobuehler b5f91d50bb feat(android): tier-A pad audio — the 0xD1 plane on the pad's USB endpoint (WP9)
The Android twin of `pf-client-core`'s pad_audio: drain the host's per-pad
DualSense streams, Opus-decode haptics (kind 0) and speaker (kind 1), interleave
into the pad's own 4-channel layout, and render on the pad itself.

Every other client hands that stream to the platform's audio graph. Android
cannot: AOSP's UsbAlsaManager denylists the DualSense's output by VID/PID, so
the kernel enumerates the pad's playback node and the framework discards it —
`hasOutput: false`, nothing for setPreferredDevice to target, /dev/snd closed by
SELinux, and UsbRequest rejects non-bulk/interrupt endpoints. So this drives the
pad's isochronous endpoint directly via uac-host on the descriptor Java owns.

That is measured, not assumed. 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 — holding under eight-core load with the SoC in
severe thermal throttling. The renderer runs at 6 ms, one step of headroom,
because the same measurement found transient events that are not depth-dependent.

Structured to the crate's own convention: the mixer and PLC are ungated so they
compile and unit-test in the host workspace (8 tests), while everything touching
an Android-only dependency is cfg'd to android. Two details worth review:

- The kinds arrive on different cadences (5 ms vs 10 ms), so each has its own
  write cursor and both shift together on overflow — a haptics-only session
  renders with a silent speaker pair instead of stalling on a kind that will
  never arrive, and the two can never skew.
- An unrecognised kind is dropped rather than folded into the coil pair. A
  `min(1)` clamp would have rendered a future kind straight into the actuators.

Lifecycle mirrors MicCapture: dropping the handle joins the thread, and
nativeStopPadAudio returns only once it has, so Kotlin may close the
UsbDeviceConnection as soon as it returns and not before.

usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport
under a real-time deadline should move when we choose. They become version
dependencies once published to crates.io.
2026-08-02 23:39:25 +02:00
enricobuehlerandClaude Fable 5 ed3d236ab8 feat(pad-audio): DualSense audio haptics + speaker, host->client end to end
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.

Wire (punktfunk-core, ABI v15): PAD_AUDIO_MAGIC 0xD1 [pad][kind][seq][pts]
[opus]; CLIENT_CAP_PAD_AUDIO 0x04 / HOST_CAP_PAD_AUDIO 0x20; per-pad render
capability rides GamepadArrival flags bits 8/9, sent only toward a host that
advertised its cap so old hosts see byte-identical arrivals; silence is a
frozen seq (mic-mute discipline), loss is a seq gap concealed via
AudioGapTracker. HidOutput::AudioCtl (0xCD kind 0x06) forwards the 0x02
report's audio-control bytes 5..=10 change-only, value-deduped, with a
once-per-pad "title asserted haptics-select" diagnosis log.

Windows host endpoint provider (audio/windows/pad_endpoint.rs): per-pad
render endpoints are additional devnode instances of Valve's Steam Streaming
Speakers driver (SetupDiRegisterDeviceInfo, NOT the class installer - it
needs an interactive window station), stamped with DualSense identity: desc
"Wireless Controller", device name "DualSense Wireless Controller",
ContainerId = the virtual pad's PFDS GUID, 4ch/48k format triplet.
IPropertyStore route first, ACL-repaired registry fallback (the MMDevices
keys deny writes even to SYSTEM; the owner's implicit WRITE_DAC + an ACE for
S-1-5-18 resolved by SID is the way in). Provisioned at host startup
(PUNKTFUNK_PAD_AUDIO, PUNKTFUNK_PAD_AUDIO_SLOTS, default 1), idempotent via
a persisted PunktfunkPadIndex marker; pad endpoints are structurally
ineligible for the mic/loopback wiring plan and guarded against default-
device theft; capture is WASAPI loopback on the stamped endpoint. Devtest:
punktfunk-host pad-endpoint ensure|remove|status.

Host service (native/pad_audio.rs): per-(session,pad) thread, loopback 4ch
-> pair splitter -> per-kind stereo Opus (48k LowDelay CBR 64k) -> per-kind
silence gate (opens at peak>=1e-3, 250 ms hangover, gated = no send + frozen
seq) -> datagrams. Spawned from the native input pump when a DualSense/Edge
arrival carries audio bits and both caps negotiated; idempotent re-arrivals;
reaped on remove and teardown.

Client tier A (pf-client-core/pad_audio.rs): settings pad_haptics (default
on) and pad_speaker (default "pad"); tier A = wired USB DS5/Edge via SDL
connection state with an audio-sibling fallback; correlation maps the SDL
HID path to the pad's own render endpoint (Windows: ContainerId match +
4ch gate via registry; Linux: Sony sink signature); renderer decodes both
kinds into a quad interleave and plays it on the pad's endpoint (WASAPI
autoconvert / PipeWire target.object, 240-2400 frame ring floor,
dont-reconnect so an unplug never re-routes haptics to the desktop
speakers). SDL's DualSense driver sets "disable audio haptics" whenever it
drives rumble emulation, so tier-A pads suppress wire rumble and send one
cleared-enable-bits effects packet to keep the actuators live; AudioCtl
bytes fold back into the effects packet at report-minus-one offsets.

Verification: punktfunk-core 265 tests (macOS) + clippy -D warnings (mac +
Linux docker); pf-inject 85 tests (Linux docker); punktfunk-host cargo
check + clippy + 19 pad tests + 46 audio-module tests (Windows box);
pf-client-core 30 tests + clippy (Linux docker CI image) + cargo check
(Windows box); punktfunk-client-session clippy (Linux) + check (Windows);
cargo fmt --all --check clean on the final tree. NOT yet verified: any
on-glass run (host deploy + real title + physical pad), the stamp-route
split at runtime, exclusive-mode Initialize isolation, Linux-host emission
(the per-pad PipeWire sink is not in this change - Windows hosts only).
Scope excluded deliberately: tier B (Apple CoreHaptics) and tier C
(haptics->rumble derivation), pad_speaker="mix", Android leg, settings UI
surfaces (keys are serde-defaulted), GameStream-plane arrivals (audio_caps
always 0 there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00
131 changed files with 6897 additions and 7339 deletions
Generated
+19
View File
@@ -2893,6 +2893,7 @@ dependencies = [
"ureq",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
]
[[package]]
@@ -3346,6 +3347,8 @@ dependencies = [
"opus",
"punktfunk-core",
"tracing",
"uac-host",
"usbfs-iso",
]
[[package]]
@@ -4985,6 +4988,14 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uac-host"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2"
dependencies = [
"usbfs-iso",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5064,6 +5075,14 @@ dependencies = [
"serde",
]
[[package]]
name = "usbfs-iso"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2"
dependencies = [
"libc",
]
[[package]]
name = "usbip-sim"
version = "0.8.0"
@@ -401,15 +401,8 @@ private fun buildSettingsRows(
s.echoCancel,
) { update(s.copy(echoCancel = it)) },
toggle(
"padForward", "Controllers", "Forward controllers",
"Send this device's controllers to the host. Turn it off when your controller " +
"already reaches the host another way — USB passthrough such as VirtualHere — " +
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
choice(
"padType", null, "Controller type",
"padType", "Controllers", "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
) { update(s.copy(gamepad = it)) },
@@ -84,6 +84,9 @@ suspend fun connectToHost(
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
)
}
}
@@ -43,7 +43,6 @@ data class SettingsOverlay(
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
@@ -77,7 +76,6 @@ data class SettingsOverlay(
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
@@ -112,9 +110,6 @@ data class SettingsOverlay(
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
@@ -141,7 +136,6 @@ data class SettingsOverlay(
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
@@ -165,7 +159,6 @@ data class SettingsOverlay(
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
@@ -197,7 +190,6 @@ data class SettingsOverlay(
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
@@ -213,8 +205,7 @@ data class SettingsOverlay(
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"stats_verbosity",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
@@ -236,7 +227,6 @@ data class SettingsOverlay(
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
@@ -34,17 +34,6 @@ data class Settings(
val hdrEnabled: Boolean = true,
val compositor: Int = 0,
val gamepad: Int = 0,
/**
* Forward this device's controllers to the host at all. Default on — that was the
* unconditional behaviour before this became a setting.
*
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
* there gives the host two controllers for one pair of hands, and games read both. It also
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
* bind — which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -156,6 +145,26 @@ data class Settings(
*/
val dsCapture: Boolean = true,
/**
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
*
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
* audio framework denylists that device by VID/PID, so there is no supported route to it. 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
@@ -227,7 +236,6 @@ class SettingsStore(context: Context) {
hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
@@ -255,6 +263,8 @@ class SettingsStore(context: Context) {
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
@@ -274,7 +284,6 @@ class SettingsStore(context: Context) {
.putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
@@ -290,6 +299,8 @@ class SettingsStore(context: Context) {
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -304,7 +315,6 @@ class SettingsStore(context: Context) {
const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
@@ -335,6 +345,8 @@ class SettingsStore(context: Context) {
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_PAD_HAPTICS = "pad_haptics"
const val K_PAD_SPEAKER = "pad_speaker"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -818,23 +818,11 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
@Composable
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
SettingsGroup(footer = "Applies from the next session.") {
// The master switch, above everything it governs. Profileable, so it shows in both
// scopes: a "Work" profile can decline to forward what "Game" forwards.
ToggleRow(
title = "Forward controllers",
subtitle = "Send this device's controllers to the host. Turn it off when your " +
"controller already reaches the host another way — USB passthrough such as " +
"VirtualHere, or a pad plugged into the host — so games don't see two of them",
checked = s.gamepadForwarding,
field = "gamepad_forwarding",
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
)
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS,
selected = s.gamepad,
field = "gamepad",
enabled = s.gamepadForwarding,
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
@@ -864,7 +852,6 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
"trackpads, gyro and haptics directly",
checked = s.sc2Capture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
@@ -874,7 +861,6 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
"plus adaptive triggers, lightbar and gyro",
checked = s.dsCapture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
}
@@ -1027,7 +1013,6 @@ private fun <T> SettingDropdown(
selected: T,
field: String? = null,
caption: String? = null,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
@@ -1035,25 +1020,18 @@ private fun <T> SettingDropdown(
?: options.firstOrNull()?.second.orEmpty()
Column {
OverrideBadge(field)
ExposedDropdownMenuBox(
expanded = expanded && enabled,
onExpandedChange = { if (enabled) expanded = it },
) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded && enabled,
onDismissRequest = { expanded = false },
) {
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
@@ -321,9 +321,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
)
val router = GamepadRouter(context, handle, initialSettings.gamepad)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
@@ -444,11 +442,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
// claim the interfaces; it resumes in onDispose once the stream releases them.
activity?.stopSc2MenuNav()
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
Sc2Capture(context, router)
} else {
null
}
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
var sc2UsbReceiver: BroadcastReceiver? = null
if (sc2 != null) {
feedback.onHidRaw = sc2::onHidRaw
@@ -498,14 +492,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
DsCapture(context, router)
} else {
null
}
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
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,6 +78,34 @@ 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. */
@@ -111,6 +139,17 @@ 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
@@ -133,6 +172,42 @@ 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)
@@ -33,24 +33,7 @@ import java.util.concurrent.ConcurrentHashMap
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
* threads, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(
context: Context,
private val handle: Long,
private val setting: Int,
/**
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
* default true). Off is for a couch whose controller reaches the host another way — USB
* passthrough such as VirtualHere, or a pad plugged into the host itself — where forwarding
* as well would give the host two pads for one pair of hands.
*
* Off still opens slots and tracks held state; it only stops the wire sends. That is
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
) {
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
@@ -140,9 +123,7 @@ class GamepadRouter(
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
if (down) {
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
val wasHeld = slot.held
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
@@ -155,9 +136,7 @@ class GamepadRouter(
onMicChord?.invoke()
}
} else {
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
@@ -207,7 +186,7 @@ class GamepadRouter(
val dev = event.device ?: return false
if (!isForwardable(dev)) return false
val slot = slotFor(dev) ?: return false
if (forwarding) slot.mapper.onMotion(event)
slot.mapper.onMotion(event)
return true
}
@@ -242,26 +221,24 @@ class GamepadRouter(
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
fun axis(id: Int, value: Int) {
if (slot != null && forwarding) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
}
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null && forwarding) {
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units — the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null && forwarding) {
if (slot != null) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
@@ -283,7 +260,7 @@ class GamepadRouter(
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
val syntheticId = EXTERNAL_ID_BASE - index
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index)
}
@@ -340,7 +317,7 @@ class GamepadRouter(
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
// to that type (a single global choice — matches the handshake's session-default pref).
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
slots[dev.id] = slot
return slot
@@ -353,7 +330,7 @@ class GamepadRouter(
private fun closeSlot(deviceId: Int) {
val slot = slots.remove(deviceId) ?: return
releaseHeld(slot)
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
// Release this controller's feedback bindings (close its lights session / cancel rumble).
@@ -365,11 +342,11 @@ class GamepadRouter(
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
bits = bits and bit.inv()
}
slot.held = 0
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
}
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
@@ -92,6 +92,40 @@ class HidUsbLink(
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
*
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
* returns *any* completed request on that connection, and the same is true of the usbfs reap
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
* other's completions. This link's reader owns its connection exclusively (see the note on
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
* renderer — must open its own.
*
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
* interface), so a claim made on this connection does not conflict with one made on that.
*
* The caller owns the returned connection and must close it.
*/
fun openAuxConnection(): UsbDeviceConnection? {
val dev = device ?: return null
return usb.openDevice(dev)
}
/**
* The open connection's usbfs file descriptor, or -1 when the link is not running.
*
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
* HID claim untouched.
*
* **The borrower must stop using it before [stop] runs**: closing the connection while a
* transfer is in flight pulls the descriptor out from under the kernel.
*/
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
@@ -69,6 +69,10 @@ object NativeBridge {
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
* the host falls back to a fingerprint-derived "device abcd1234" label. */
deviceName: String?,
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
* so a captured pad's own render capabilities would have nothing to gate. */
padAudioOk: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -332,6 +336,46 @@ object NativeBridge {
*/
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
/**
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
* 4-channel USB audio device.
*
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
* claim on the same device alone) and never closes the descriptor. The caller must keep the
* connection open until [nativeStopPadAudio] returns.
*
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
*
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
* NOT reported here — the renderer discovers that on its own thread and the session simply
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
*/
external fun nativeStartPadAudio(
handle: Long,
pad: Int,
fd: Int,
haptics: Boolean,
speaker: Boolean,
): Boolean
/**
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
*
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
* as this returns, and not before.
*/
external fun nativeStopPadAudio(handle: Long, pad: Int)
/**
* Drive the pad with a test tone through the real render path — no host, no session.
*
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
* the main thread. Returns sample frames written, or negative on failure.
*/
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
/**
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
+8
View File
@@ -64,6 +64,14 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
# should move when we choose to. Becomes a plain version dependency once the crates are published.
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" }
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" }
[lints]
workspace = true
@@ -392,7 +392,7 @@ pub(super) fn run_async(
// even when the choreographer clock is absent.
if let Some(p) = presenter.as_mut() {
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
rendered += 1;
}
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
@@ -822,21 +822,8 @@ fn feed_ready(
}
}
let Some(dst) = codec.input_buffer(idx) else {
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
// here leaked one of the codec's input buffers per occurrence — we forget it and the
// codec never frees what it never received, so the pipeline quietly runs out of input
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
// keyframe request behind it, unlike every sibling path here.
//
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
// advertised is in no state to be fed the rest of the parked queue this pass, and
// retrying the same index against every parked AU would burn the whole backlog. The
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
free_inputs.push_front(idx);
pending_aus.push_front(frame);
break;
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
continue;
};
let au = &frame.data;
if au.len() > dst.len() {
+3 -8
View File
@@ -115,14 +115,9 @@ pub(crate) struct DecodeOptions {
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
/// Only meaningful with `present_priority` = smooth.
pub smooth_buffer: i32,
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
///
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
/// stream is down-rated below the panel (see `vsync.rs`).
pub panel_hz: i32,
}
+20 -156
View File
@@ -4,12 +4,10 @@
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
//! queueing behind the display;
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
//! could be wrong in one direction forever;
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
@@ -22,7 +20,6 @@
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Mutex;
use std::time::Instant;
@@ -39,9 +36,9 @@ use super::vsync::VsyncShared;
///
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
/// as a measured latch beyond one panel period (see the adaptation in
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
/// signal to widen, not stutter.
const LATCH_MARGIN_NS: i64 = 2_500_000;
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
@@ -74,26 +71,6 @@ fn latch_margin_ns() -> Option<i64> {
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
const STALE_REOPEN_NS: i64 = 100_000_000;
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
/// SurfaceFlinger more work.
///
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
/// budget before the display has consumed anything, and the presenter then releases faster than
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
/// ground truth about what actually reached glass, so it bounds the prediction.
///
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
/// exactly as before — best-effort, never worse than not having it.
const UNDISPLAYED_CAP: i32 = 6;
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
@@ -144,14 +121,6 @@ struct InFlight {
/// a HUD-off wireless A/B readable from logcat.
pub(super) struct PresentMeter {
inner: Mutex<PresentMeterInner>,
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
/// confirm side on the codec's callback thread, once per frame each.
undisplayed: AtomicI32,
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
confirms: AtomicBool,
}
struct PresentMeterInner {
@@ -178,23 +147,11 @@ impl PresentMeter {
codec_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
}),
undisplayed: AtomicI32::new(0),
confirms: AtomicBool::new(false),
}
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
///
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
self.confirms.store(true, Ordering::Relaxed);
let _ = self
.undisplayed
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some((v - 1).max(0))
});
let mut g = self
.inner
.lock()
@@ -207,26 +164,6 @@ impl PresentMeter {
}
}
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
fn note_released(&self) {
self.undisplayed.fetch_add(1, Ordering::Relaxed);
}
/// Releases still unconfirmed, and whether confirms happen on this device at all.
fn outstanding(&self) -> (i32, bool) {
(
self.undisplayed.load(Ordering::Relaxed),
self.confirms.load(Ordering::Relaxed),
)
}
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
/// them. Never stall the stream on a ledger we cannot audit.
fn forgive_outstanding(&self) {
self.undisplayed.store(0, Ordering::Relaxed);
}
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
@@ -302,13 +239,6 @@ pub(super) struct Presenter {
no_budget: u64,
forced: u64,
dry: u64,
/// Pump passes that held a frame back because too many earlier releases were still
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
/// the signature of a presenter out-running its display.
queue_waits: u64,
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
/// simply stopped coming. `None` while the rail is down.
backed_up_since: Option<i64>,
pace_us: Vec<u64>,
last_flush: Instant,
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
@@ -350,8 +280,6 @@ impl Presenter {
no_budget: 0,
forced: 0,
dry: 0,
queue_waits: 0,
backed_up_since: None,
pace_us: Vec::with_capacity(256),
last_flush: Instant::now(),
margin_ns,
@@ -406,7 +334,6 @@ impl Presenter {
codec: &MediaCodec,
clock: Option<&VsyncShared>,
tracker: &DisplayTracker,
meter: &PresentMeter,
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
@@ -419,10 +346,6 @@ impl Presenter {
self.inflight = None;
}
}
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
// rather than how often a frame happened to be ready.
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
// Pick the frame this pump may release.
let frame = if self.fifo_capacity == 0 {
self.frames.pop_back() // submit() kept it a single slot; back == the newest
@@ -450,12 +373,9 @@ impl Presenter {
self.frames.pop_front()
};
let Some(frame) = frame else { return false };
if self.inflight.is_some() || backlogged {
if self.inflight.is_some() {
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
// vsync tick / loop pass retries the pairing.
if backlogged {
self.queue_waits += 1;
}
self.no_budget += 1;
match self.fifo_capacity {
0 => self.frames.push_back(frame),
@@ -492,7 +412,6 @@ impl Presenter {
released_at_ns: now_mono_ns,
});
self.released += 1;
meter.note_released();
let release_real_ns = now_realtime_ns();
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
if self.pace_us.len() < 4096 {
@@ -503,33 +422,6 @@ impl Presenter {
true
}
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
///
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
/// rather than allowed to wedge the stream.
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
let (outstanding, confirms_live) = meter.outstanding();
if !confirms_live || outstanding < UNDISPLAYED_CAP {
self.backed_up_since = None;
return false;
}
match self.backed_up_since {
Some(t) if now_ns - t > STALE_REOPEN_NS => {
meter.forgive_outstanding();
self.backed_up_since = None;
self.forced += 1;
false
}
_ => {
self.backed_up_since.get_or_insert(now_ns);
true
}
}
}
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
while let Some(f) = self.frames.pop_front() {
@@ -542,9 +434,7 @@ impl Presenter {
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
/// `pace` (decoded→release) / `latch` (release→displayed) /
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
@@ -572,15 +462,14 @@ impl Presenter {
let circ = clock.and_then(|c| {
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
});
let latch_samples = latch.len();
let (latch_p50, latch_max) = p50_max_ms(latch);
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
let (outstanding, _) = meter.outstanding();
let panel_ms = clock
.map(|c| c.panel_period_ns() as f64 / 1e6)
.unwrap_or(0.0);
log::info!(
target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
qWait={} unconfirmed={} \
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
@@ -591,8 +480,6 @@ impl Presenter {
self.no_budget,
self.forced,
self.dry,
self.queue_waits,
outstanding,
pace_p50,
pace_max,
latch_p50,
@@ -606,48 +493,25 @@ impl Presenter {
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ns as f64 / 1e6,
panel_ms,
);
self.released = 0;
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
//
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
// evictions — a second frame decoding while one is held — which happen whenever the
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
// of pure display latency the P2e sweep had just measured away.
let latch_p50_ns = (latch_p50 * 1e6) as i64;
if !self.margin_pinned
&& self.margin_ns < LATCH_MARGIN_NS
&& panel_ns > 0
&& latch_samples >= 8
&& latch_p50_ns > panel_ns + self.margin_ns
{
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
// late and coalesces the next frame into `paced`) mean this device's SF does need
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
log::warn!(
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
latch_p50,
panel_ns as f64 / 1e6,
"presenter: {} latch misses in 1s — margin widened to {}us",
self.paced_drops,
self.margin_ns / 1_000
);
}
if self.queue_waits > 0 {
log::warn!(
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
OnFrameRendered (the display is not keeping up with the release rate)",
self.queue_waits,
outstanding
);
}
self.paced_drops = 0;
self.no_budget = 0;
self.forced = 0;
self.dry = 0;
self.queue_waits = 0;
circ
}
}
+22 -32
View File
@@ -58,10 +58,8 @@ pub(super) struct VsyncShared {
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
/// [`Self::next_target`].
period_ns: AtomicI64,
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
panel_period_ns: AtomicI64,
/// Callback count, for the one-shot cadence diagnostic log.
ticks: std::sync::atomic::AtomicU32,
@@ -233,11 +231,6 @@ struct CallbackCtx {
choreographer: *mut c_void,
shared: Arc<VsyncShared>,
on_tick: Box<dyn Fn() + Send>,
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
/// doc) — and its streak state is nobody else's business; only the settled period is
/// published, to `shared.panel_period_ns`.
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
}
impl CallbackCtx {
@@ -247,25 +240,22 @@ impl CallbackCtx {
.shared
.last_vsync_ns
.swap(frame_time_ns, Ordering::Relaxed);
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
// only honest witness to what the panel is doing — the configured mode is not (under a
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
// spacing ever observed is the panel's true period — trustworthy where the configured
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
// valid, widening on a later down-rated window never is.
if timelines.len() >= 2 {
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
let mut grid = self.panel.get();
if grid.observe(spacing) {
self.shared
.panel_period_ns
.store(grid.period_ns(), Ordering::Relaxed);
log::info!(
"vsync: panel grid now {:.2}ms",
grid.period_ns() as f64 / 1e6
);
if (2_000_000..=42_000_000).contains(&spacing) {
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
if cur == 0 || spacing < cur - 200_000 {
self.shared
.panel_period_ns
.store(spacing, Ordering::Relaxed);
}
}
self.panel.set(grid);
}
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
// panel period is exactly the down-rating question, and this line answers it on-glass.
@@ -382,9 +372,8 @@ pub(super) struct VsyncClock {
impl VsyncClock {
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
/// budget).
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
@@ -394,9 +383,11 @@ impl VsyncClock {
stop: AtomicBool::new(false),
last_vsync_ns: AtomicI64::new(0),
period_ns: AtomicI64::new(0),
panel_period_ns: AtomicI64::new(
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
),
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
1_000_000_000 / panel_hz as i64
} else {
0
}),
ticks: std::sync::atomic::AtomicU32::new(0),
timelines: Mutex::new(Vec::new()),
});
@@ -417,7 +408,6 @@ impl VsyncClock {
choreographer,
shared: thread_shared,
on_tick,
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
};
ctx.repost();
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
+11
View File
@@ -54,6 +54,12 @@ 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)
@@ -156,6 +162,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return -1;
}
};
n as jint
})
+2
View File
@@ -37,6 +37,8 @@ mod discovery;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
mod pad_audio;
mod session;
mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
+770
View File
@@ -0,0 +1,770 @@
//! 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);
}
}
+13 -1
View File
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
timeout_ms: jint,
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
// should say what the client does).
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
// so declaring a pad's render caps later would have nothing to gate. Gated on the
// settings so a user with pad audio off does not make the host provision endpoints.
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
| if pad_audio_ok != 0 {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
},
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
#[cfg(target_os = "android")]
pad_audio: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
+15
View File
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
/// `Option` because a session may have no wired DualSense at all, which is the common case.
#[cfg(target_os = "android")]
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
@@ -99,6 +104,14 @@ impl SessionHandle {
fn stop_mic(&self) {
let _ = self.mic.lock().unwrap().take();
}
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
/// `UsbDeviceConnection`. Idempotent.
#[cfg(target_os = "android")]
pub(crate) fn stop_pad_audio(&self) {
let _ = self.pad_audio.lock().unwrap().take();
}
}
impl Drop for SessionHandle {
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
self.stop_audio();
#[cfg(target_os = "android")]
self.stop_mic();
#[cfg(target_os = "android")]
self.stop_pad_audio();
}
}
@@ -460,6 +460,110 @@ 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
@@ -672,11 +672,7 @@ final class SessionModel: ObservableObject {
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
// when a pad's virtual device is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt.
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
let capture = GamepadCapture(connection: conn, manager: .shared)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
@@ -26,7 +26,6 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@@ -324,15 +323,8 @@ struct GamepadSettingsView: View {
+ "speaker setups feeding the game back to the host.",
value: $echoCancel),
toggleRow(
id: "padForward", header: "Controller", icon: "gamecontroller",
label: "Forward controllers",
detail: "Send this device's controllers to the host. Turn it off when your "
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
choiceRow(
id: "pad", icon: "gamecontroller", label: "Use controller",
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
) { gamepads.preferredID = $0 },
@@ -122,10 +122,6 @@ enum SettingsFields {
.init(name: "gamepad", key: DefaultsKey.gamepadType,
overlay: \.gamepadType, effective: \.gamepadType)
}
static var gamepadForwarding: SettingsField<Bool> {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -185,7 +181,6 @@ extension SettingsView {
base.micEnabled = micEnabled
base.echoCancel = echoCancel
base.gamepadType = gamepadType
base.gamepadForwarding = gamepadForwarding
base.statsVerbosity = statsVerbosityRaw
base.fullscreenWhileStreaming = fullscreenWhileStreaming
base.presentPriority = presentPriority
@@ -641,15 +641,6 @@ extension SettingsView {
@ViewBuilder var controllersSection: some View {
Section {
// The master switch, above everything it governs. Profileable, so it renders in
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
described("Sends controllers connected to this device to the host. Turn it off when "
+ "your controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere, or a pad plugged into the host itself — so games don't see "
+ "two of them.",
field: "gamepad_forwarding") {
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
}
// Which physical pad this device forwards, and what its own haptics do, are facts
// about THIS device (tier G) only the virtual pad the host creates is profileable.
if !inProfileScope {
@@ -668,7 +659,6 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
}
described("The virtual pad created on the host. Automatic matches your controller "
@@ -679,7 +669,6 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
@@ -49,7 +49,6 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
SettingsOptions.presentPriorityDefault
@@ -98,27 +98,9 @@ public final class GamepadCapture {
/// gameplay can't end it (see ContentView's tvOS session branch).
public var onDisconnectRequest: (() -> Void)?
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
/// default true). Off is for a couch whose controller reaches the host another way USB
/// passthrough such as VirtualHere, or a pad plugged into the host itself where
/// forwarding as well would give the host two pads for one pair of hands.
///
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
/// is the ONLY controller way out of a stream a session that silently lost its exit
/// because a forwarding preference was off would be a worse bug than the one this fixes.
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
/// open costs the host nothing and blocks no passthrough tool.
public let forwarding: Bool
/// The connection, or nil while forwarding is off every wire send goes through this, so
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection
self.manager = manager
self.forwarding = forwarding
}
public func start() {
@@ -223,8 +205,8 @@ public final class GamepadCapture {
// core re-sends it a few times against datagram loss; an older host ignores it and uses
// the session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then).
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
sync(slot, ext)
if let tp = Self.touchpad(ext) {
@@ -251,7 +233,7 @@ public final class GamepadCapture {
flush(slot)
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
wire?.send(.gamepadRemove(pad: slot.pad))
connection.send(.gamepadRemove(pad: slot.pad))
let c = slot.controller
if let ext = c.extendedGamepad {
ext.valueChangedHandler = nil
@@ -293,7 +275,7 @@ public final class GamepadCapture {
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
}
slot.buttons = newButtons
}
@@ -306,7 +288,7 @@ public final class GamepadCapture {
Int32(g.rightTrigger.value * 255),
]
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
slot.axes[i] = v
}
updateEscapeChord()
@@ -320,7 +302,7 @@ public final class GamepadCapture {
let bit = GamepadWire.guide
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
guard now != slot.buttons else { return }
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
slot.buttons = now
}
@@ -383,13 +365,13 @@ public final class GamepadCapture {
if lifted {
if slot.fingerActive[finger] {
slot.fingerActive[finger] = false
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
}
return
}
slot.fingerActive[finger] = true
let w = GamepadWire.touchpad(x: x, y: y)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
}
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
@@ -412,7 +394,7 @@ public final class GamepadCapture {
}
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
wire?.sendMotion(
connection.sendMotion(
pad: UInt8(slot.pad),
gyro: (
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
@@ -450,15 +432,15 @@ public final class GamepadCapture {
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
slot.buttons = 0
for (i, v) in slot.axes.enumerated() where v != 0 {
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
slot.axes[i] = 0
}
for (f, active) in slot.fingerActive.enumerated() where active {
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
slot.fingerActive[f] = false
}
}
@@ -175,46 +175,6 @@ public final class StreamViewController: StreamViewControllerBase {
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
private var matchFollower: MatchWindowFollower?
// MARK: Escape-drop re-lock
//
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape the platform's
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
// (, Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
// is already false when their drop is observed and none of them are fought here.
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
/// never having been granted one means the scene doesn't qualify, not that Esc took it.
/// Cleared when capture ends, so each capture starts from a clean slate.
private var pointerLockWasEngaged = false
/// Attempts spent in the current re-lock burst, and when the burst began.
private var pointerRelockAttempt = 0
private var pointerRelockBurstStart: CFTimeInterval = 0
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
/// teleporting the host's to the pointer's absolute position.
private var pointerRelockPending = false
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
/// presents the system with a genuine falsetrue transition instead of re-asserting a value it
/// already holds. See `requestPointerRelock()`.
private var pointerLockForcedOff = false
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
/// to today's click-to-recapture, rather than retrying forever.
private static let pointerRelockAttemptLimit = 3
private static let pointerRelockBurstWindow: CFTimeInterval = 2
/// Gap between attempts in a burst long enough for the system to answer the previous
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
/// next attempt evaluates.
private static let pointerRelockRetryDelay: TimeInterval = 0.2
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
/// so the system observes a real transition instead of coalescing the flip away.
private static let pointerLockForcedOffHold: TimeInterval = 0.05
#endif
/// Reads whether the scene's pointer is actually locked right now; nil = state
@@ -300,7 +260,7 @@ public final class StreamViewController: StreamViewControllerBase {
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
}
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
public override var prefersPointerLocked: Bool { wantsPointerLock }
public override var prefersHomeIndicatorAutoHidden: Bool { true }
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
@@ -423,11 +383,6 @@ public final class StreamViewController: StreamViewControllerBase {
// is the exact mirror of the GCMouse handlers, which fire only while locked.
streamView.onPointerMoveAbs = { [weak self] p in
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
// resume. Motion only BUTTONS still forward (they carry no position, so a click during
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
guard !self.pointerRelockPending else { return }
self.inputCapture?.sendMouseAbs(
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
}
@@ -738,24 +693,6 @@ public final class StreamViewController: StreamViewControllerBase {
/// change and capture toggle. Main queue.
private func syncPointerLock() {
let locked = pointerLockEngaged() == true
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
// duration to win a lock that isn't coming. A first grant is already driven by the chain
// engage in setCaptured/viewDidAppear.
if locked {
pointerLockWasEngaged = true
pointerRelockPending = false
pointerRelockAttempt = 0
} else if wantsPointerLock, pointerLockWasEngaged {
requestPointerRelock()
} else {
// Capture is gone (or the lock was never ours) settle, and let the next capture
// start from a clean "never held" slate.
if !wantsPointerLock { pointerLockWasEngaged = false }
pointerRelockPending = false
pointerRelockAttempt = 0
}
let useGCMouse = captured && locked
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
// gcMouseForwarding flips false its release handler is gated off, so flush any held
@@ -767,83 +704,7 @@ public final class StreamViewController: StreamViewControllerBase {
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
if iosInputDebug {
iosInputLog.debug(
"""
pointer lock isLocked=\(locked, privacy: .public) \
captured=\(self.captured, privacy: .public) \
relockPending=\(self.pointerRelockPending, privacy: .public) \
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
""")
}
}
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
private func requestPointerRelock() {
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
// saying we don't qualify, not the Esc key re-asking would be noise, and the qualifying
// states (foreground, appearance, reparent) each re-resolve on their own already.
guard view.window?.windowScene?.activationState == .foregroundActive else {
pointerRelockPending = false
return
}
let now = CACurrentMediaTime()
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
// is the backstop for the pathological case where a grant is immediately revoked again and
// re-arms us. Even then this stays timer-driven at a few Hz never a spin.
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
pointerRelockBurstStart = now
pointerRelockAttempt = 0
}
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
// Out of budget: fall back to exactly today's behavior the iPadOS cursor comes back
// and a click into the video re-captures. The caller invalidates the interaction, so
// the cursor can never stay hidden on a lock the system won't grant.
pointerRelockPending = false
return
}
pointerRelockAttempt += 1
pointerRelockPending = true
let escalate = pointerRelockAttempt > 1
// Deferred a turn so a whose GC keystroke lands after the system's unlock notification
// has already cleared `captured` then the guard below drops this attempt instead of
// fighting the user's own release.
DispatchQueue.main.async { [weak self] in
guard let self, self.pointerRelockPending else { return }
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
// The grant landed, or the capture went away under us ( / Q / resign).
// Settle through the one decision point rather than returning with `pending` still
// set that flag hides the cursor, so it must never outlive the burst.
self.syncPointerLock()
return
}
if escalate {
// Re-asserting a value the system already holds didn't take. Present a real
// falsetrue transition instead the documented way to change your mind about the
// lock and re-anchor the chain in case a reparent broke the downward walk to us.
// Held for a beat rather than cleared on the next turn: the system resolves the
// property asynchronously, and a same-turn flip back to true can be coalesced into
// no transition at all. We are already unlocked, so the false pass costs nothing.
self.pointerLockForcedOff = true
self.setNeedsUpdateOfPrefersPointerLocked()
self.updatePointerLockChain()
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
[weak self] in
guard let self else { return }
self.pointerLockForcedOff = false
self.setNeedsUpdateOfPrefersPointerLocked()
}
} else {
self.setNeedsUpdateOfPrefersPointerLocked()
}
// A GRANT arrives as a didChange syncPointerLock, which settles the burst and makes
// this retry a no-op. Routed back through syncPointerLock (not straight into another
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
// place that does it.
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
[weak self] in
guard let self, self.pointerRelockPending else { return }
self.syncPointerLock()
}
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
}
}
#endif
@@ -863,11 +724,7 @@ extension StreamViewController: UIPointerInteractionDelegate {
// host renders its own cursor from GCMouse deltas and a visible local one would just
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
// pointer is forwarded as an absolute position, both cursors tracking together.
// except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
captured && pointerLockEngaged() == true ? .hidden() : nil
}
}
#endif
@@ -32,12 +32,6 @@ public enum DefaultsKey {
public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID"
/// Forward this device's controllers to the host at all (default true). Off is for a
/// couch whose controller reaches the host another way USB passthrough such as
/// VirtualHere, or a pad plugged into the host where forwarding as well would give the
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
public static let bitrateKbps = "punktfunk.bitrateKbps"
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
@@ -34,7 +34,6 @@ public struct EffectiveSettings: Equatable, Sendable {
public var mouseMode = "capture"
public var invertScroll = false
public var gamepadType = 0
public var gamepadForwarding = true
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
@@ -94,7 +93,6 @@ public struct EffectiveSettings: Equatable, Sendable {
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
@@ -142,7 +140,6 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.mouseMode { s.mouseMode = v }
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
@@ -110,7 +110,6 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var mouseMode: String?
public var invertScroll: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
@@ -152,7 +151,6 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
@@ -186,7 +184,6 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
@@ -222,8 +219,6 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
@@ -276,7 +271,6 @@ public enum OverlayField {
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
@@ -312,7 +306,6 @@ public enum OverlayField {
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
+3 -10
View File
@@ -24,15 +24,8 @@ the panel looks and feels native to Gaming Mode.
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
(matched by certificate fingerprint).
5. **Settings**the client's whole settings store, written to its config. Laid out like SteamOS's
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
scrolling. The categories and their order are the console settings screen's — Stream (resolution
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
library / fullscreen). The device pickers are populated
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
there is more than one adapter.
5. **Settings**resolution / refresh / bitrate / gamepad type / host compositor / mic, written
to the client's config.
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
a force-stop for a wedged stream client.
@@ -100,7 +93,7 @@ restart is required for an out-of-band install to appear.
| --- | --- |
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
+4 -152
View File
@@ -21,10 +21,6 @@ The backend's jobs are the things Steam can't do:
the frontend so it can create/point the Steam shortcut.
* **get_settings() / set_settings()** read/write the flatpak client's stream settings JSON
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
``set_settings`` MERGES onto the file: it is shared with the desktop client and the console.
* **list_devices() / refresh_devices()** the GPUs and audio endpoints the settings tab's
device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``)
and cached, since enumerating them costs a Vulkan + PipeWire init.
* **kill_stream()** force-stop a wedged stream (``flatpak kill``).
* **check_update()** report pending updates for BOTH the plugin and the client. The plugin's
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
@@ -347,9 +343,6 @@ def _flatpak() -> str | None:
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
# home), so nothing else in this file has to care which one answered.
NATIVE_BIN = "punktfunk-client"
# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate
# this device's GPUs and audio endpoints for the settings pickers.
SESSION_BIN = "punktfunk-session"
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
@@ -405,25 +398,6 @@ def _client_argv() -> list[str] | None:
return [native] if native else None
def _session_argv() -> list[str] | None:
"""The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there.
The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on
`punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and
shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The
flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native
install puts them in the same bindir, so the session is the client's sibling.
"""
prefix = _client_argv()
if not prefix:
return None
if prefix[0] == _flatpak():
# `flatpak run --command=<bin> <app>` — the app id must stay LAST.
return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]]
sibling = Path(prefix[0]).with_name(SESSION_BIN)
return [str(sibling)] if sibling.exists() else None
def _client_is_flatpak() -> bool:
"""Is the client this plugin actually drives the FLATPAK one?
@@ -537,63 +511,6 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
return -1, "", ""
def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]:
"""Split `punktfunk-session --list-audio` into ``(sinks, sources)``.
Its format is one endpoint per line, ``sink|source<TAB>node.name<TAB>description``. The
node.name is what gets STORED (it is the stable id the client resolves against), so a line
without one is unusable and dropped; a missing description falls back to the name rather than
rendering a picker entry with no label. Anything else on the line is ignored, so an extra
trailing column in a future client can't break this.
"""
sinks: list[dict] = []
sources: list[dict] = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) < 3 or not parts[1].strip():
continue
kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip()
entry = {"name": name, "description": description or name}
if kind == "sink":
sinks.append(entry)
elif kind == "source":
sources.append(entry)
return sinks, sources
async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]:
"""Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when
it isn't installed or the call errors/times out.
Only ever used for the two read-only device enumerations the launch path goes through the
Steam shortcut and the wrapper script, never through here. The timeout is generous because
`--list-adapters` initialises Vulkan on a cold flatpak."""
prefix = _session_argv()
if not prefix:
return -1, ""
proc = None
try:
proc = await asyncio.create_subprocess_exec(
*prefix, *session_args,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
env=_flatpak_env(),
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
rc = proc.returncode if proc.returncode is not None else -1
return rc, (out or b"").decode("utf-8", "replace")
except asyncio.TimeoutError:
decky.logger.warning("session %s timed out", " ".join(session_args))
if proc:
try:
proc.kill()
except ProcessLookupError:
pass
return -1, ""
except Exception: # noqa: BLE001
decky.logger.exception("session %s failed", " ".join(session_args))
return -1, ""
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
@@ -601,11 +518,6 @@ async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[
_HOSTS_TTL_S = 12.0
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading
# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices`
# clears it for the user who just plugged a headset in.
_devices_cache: dict = {"data": None}
def _invalidate_hosts_cache() -> None:
_hosts_cache["data"] = None
@@ -1132,84 +1044,24 @@ class Plugin:
try:
return json.loads(_settings_path().read_text())
except (OSError, json.JSONDecodeError):
# The client's own defaults (native display, host-default bitrate, auto pad,
# stats overlay at Normal — `Settings::default` is `show_stats: true`).
# The client's own defaults (native display, host-default bitrate, auto pad).
return {
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto",
"gamepad_forwarding": True, "compositor": "auto",
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
"inhibit_shortcuts": True, "mic_enabled": False,
"stats_verbosity": "normal", "show_stats": True,
}
async def set_settings(self, settings: dict) -> dict:
"""Write the stream settings JSON the (sandboxed) client reads on launch.
MERGED onto whatever is on disk, never a wholesale replace: this file is shared with
the desktop client and the console's settings screen, and it holds far more keys than
this panel models (decoder, GPU, profiles, touch/mouse model). The panel reads it once
when it mounts, so a straight write would post a snapshot that predates anything those
other editors stored in the meantime silently reverting it.
"""
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
try:
d = _client_config_dir()
d.mkdir(parents=True, exist_ok=True)
try:
on_disk = json.loads(_settings_path().read_text())
if not isinstance(on_disk, dict):
on_disk = {}
except (OSError, json.JSONDecodeError):
on_disk = {} # no file yet (or an unreadable one): this write creates it
on_disk.update(settings)
_settings_path().write_text(json.dumps(on_disk, indent=2))
_settings_path().write_text(json.dumps(settings, indent=2))
return {"ok": True}
except OSError as exc:
decky.logger.exception("could not write settings")
return {"ok": False, "error": str(exc)}
async def list_devices(self) -> dict:
"""GPUs + audio endpoints for the settings tab's device pickers.
Two subprocesses that initialise Vulkan and PipeWire, so the result is cached for the
Decky session: hardware doesn't come and go often enough to justify paying that on every
remount of the page, and a stale entry is harmless a picked device that has since
vanished falls back to the OS default in the client anyway. `refresh_devices` clears it.
Best-effort in the same way every other client call here is: no session binary (an old
flatpak that predates the two-binary split, or a native install missing its sibling) just
means empty lists and `ok: false`, which the UI shows as "couldn't read" rather than as
"you have no devices".
"""
if _devices_cache["data"] is not None:
return _devices_cache["data"]
adapters: list[str] = []
sinks: list[dict] = []
sources: list[dict] = []
rc_a, out_a = await _run_session(["--list-adapters"])
if rc_a == 0:
adapters = [ln.strip() for ln in out_a.splitlines() if ln.strip()]
rc_d, out_d = await _run_session(["--list-audio"])
if rc_d == 0:
sinks, sources = _parse_audio_endpoints(out_d)
result = {
"ok": rc_a == 0 or rc_d == 0,
"adapters": adapters,
"sinks": sinks,
"sources": sources,
}
# Only a run that actually answered is worth remembering — caching a failure would make
# a client installed after the page was first opened stay invisible until a Decky restart.
if result["ok"]:
_devices_cache["data"] = result
return result
async def refresh_devices(self) -> dict:
"""Drop the cached enumeration and read it again (a headset was just plugged in)."""
_devices_cache["data"] = None
return await self.list_devices()
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
async def list_hosts(self, probe: bool = True) -> dict:
-27
View File
@@ -144,33 +144,6 @@ got = asyncio.run(plugin.get_pins())["pins"]
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
sinks, sources = main._parse_audio_endpoints(
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
)
check("audio: sinks parsed", [d["name"] for d in sinks] == [
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
])
check("audio: sources parsed", len(sources) == 1)
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
# short line is malformed, and an unknown kind belongs to neither list. A blank description
# falls back to the name so no entry renders unlabelled.
sinks, sources = main._parse_audio_endpoints(
"sink\t\tNo node name\n"
"sink\tonly-two-columns\n"
"monitor\tsome.monitor\tNot a sink or source\n"
"source\tbare.node\t\n"
"\n"
)
check("audio: junk lines dropped", sinks == [])
check("audio: blank description falls back to the node name", sources == [
{"name": "bare.node", "description": "bare.node"}
])
print()
if failures:
print(f"{failures} check(s) FAILED")
+10 -88
View File
@@ -101,97 +101,24 @@ export interface RunnerInfo {
client_bin?: string;
}
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
// and the console's settings screen own, so a value changed in any of them shows in the others.
//
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
// cannot be answered from a plugin backend or aren't settings at all:
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
// the client process has; there is no CLI that enumerates pads.
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
// preference anyone sets.
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
// spreads, and set_settings merges onto what's on disk.
//
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
// that key existed simply lacks it. Read those through the same fallback the client uses —
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
// while the stream runs with it on.
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
// because get_settings returns the whole parsed file and patches are object spreads.
export interface StreamSettings {
// ---- Stream mode ----
width: number; // 0 = native
height: number; // 0 = native
refresh_hz: number; // 0 = native
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
bitrate_kbps: number; // 0 = host default
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
// Stream mode follows the session window instead of width/height, renegotiating on resize.
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
match_window?: boolean;
// ---- Video ----
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
enable_444?: boolean; // default off — ask for full chroma
adapter?: string; // decode/present GPU by marketing name; "" = automatic
// ---- Presentation ----
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
present_priority?: string;
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 13
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
// ---- Audio ----
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
speaker_device?: string; // PipeWire node.name for playback; "" = system default
mic_enabled: boolean;
mic_device?: string; // PipeWire node.name for capture; "" = system default
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
// ---- Controllers ----
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
gamepad_forwarding?: boolean;
// ---- Touchscreen, mouse & keyboard ----
touch_mode?: string; // "trackpad" | "pointer" | "touch"
mouse_mode?: string; // "capture" | "desktop"
invert_scroll?: boolean;
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
// here would be a dead one. The desktop client's row still edits this same file.
inhibit_shortcuts: boolean;
// ---- Interface & behaviour ----
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
// which resolves through `show_stats` — read both the way the client's
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
stats_verbosity?: string;
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
// tiers still honours an Off chosen here.
show_stats?: boolean;
fullscreen_on_stream?: boolean;
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
}
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
// human name to show.
export interface AudioDevice {
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
description: string; // human label ("Steam Deck Speakers")
}
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
// pickers stay on their stored value rather than pretending the device is gone.
export interface DeviceLists {
ok: boolean;
adapters: string[]; // Vulkan physical devices, discrete first
sinks: AudioDevice[]; // playback endpoints
sources: AudioDevice[]; // capture endpoints
mic_enabled: boolean;
}
export interface UpdateInfo {
@@ -258,11 +185,6 @@ export const getSettings = callable<[], StreamSettings>("get_settings");
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
"set_settings",
);
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
export const listDevices = callable<[], DeviceLists>("list_devices");
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
export const killStream = callable<[], { ok: boolean }>("kill_stream");
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
+1 -7
View File
@@ -334,14 +334,8 @@ const HostsTab: FC<{
</div>
);
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
const SettingsTab: FC = () => (
<div style={settingsPane}>
<div style={tabScroll}>
<SettingsSection />
</div>
);
+152 -608
View File
@@ -1,59 +1,10 @@
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
// launch (main.py set_settings, merged onto what's on disk). This is the same
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
// changed in any of the three shows in the other two.
//
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
//
// The categories, their order, and the wording of the rows are the console's settings screen
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
// trusting either. It shows them as one steppable list because it has no pointer and no room for
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
//
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
// the one above it is a moving target for a thumbstick.
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
// one-GPU Deck). A dead control is worse than an absent one.
// • Anything that behaves differently *here* than it does on a desktop says so in its own
// description, rather than being silently dropped from the screen.
//
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
// `MouseMode` enums, which serialize lowercase.
import {
DialogButton,
Dropdown,
Field,
SidebarNavigation,
SliderField,
Spinner,
ToggleField,
} from "@decky/ui";
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
import {
FaDesktop,
FaGamepad,
FaHandPointer,
FaSlidersH,
FaTv,
FaVideo,
FaVolumeUp,
} from "react-icons/fa";
import {
AudioDevice,
DeviceLists,
getSettings,
listDevices,
refreshDevices,
setSettings,
StreamSettings,
} from "./backend";
import { actionButton, RowActions } from "./ui";
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend";
import { RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
@@ -66,543 +17,50 @@ const selectShell: CSSProperties = {
maxWidth: "24em",
};
// ----------------------------------------------------------------------------------------
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
// ----------------------------------------------------------------------------------------
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
// Match window is offered even though this plugin's launches are always fullscreen (where it
// degenerates to the display's native mode) — leaving it out would make the row lie about a
// store the desktop client can set it in.
const MATCH_WINDOW = "match";
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1280, 800, "1280 × 800 (Deck)"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
[3840, 2160, "3840 × 2160"],
];
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
const REFRESH = [0, 30, 60, 90, 120];
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const renderScaleLabel = (x: number): string =>
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
const COMPOSITORS: [string, string][] = [
["auto", "Automatic"],
["kwin", "KDE Plasma (KWin)"],
["wlroots", "Sway (wlroots)"],
["mutter", "GNOME (Mutter)"],
["gamescope", "gamescope"],
];
const CODECS: [string, string][] = [
["auto", "Automatic"],
["hevc", "HEVC (H.265)"],
["h264", "H.264 (AVC)"],
["av1", "AV1"],
// Opt-in wired-LAN low-latency codec (100400 Mbit/s class, 8-bit SDR). Only ever selected
// when the host advertises it too; anything else falls back to HEVC.
["pyrowave", "PyroWave (wired LAN)"],
];
const DECODERS: [string, string][] = [
["auto", "Automatic"],
["vulkan", "Vulkan Video"],
["vaapi", "VAAPI"],
["software", "Software"],
];
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
// one profile reads the same on every device.
const PRESENT_PRIORITIES: [string, string][] = [
["latency", "Lowest latency"],
["smooth", "Smoothness"],
];
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
const SMOOTH_BUFFERS: [number, string][] = [
[0, "Automatic"],
[1, "1 frame"],
[2, "2 frames"],
[3, "3 frames"],
];
const AUDIO_CHANNELS: [number, string][] = [
[2, "Stereo"],
[6, "5.1 surround"],
[8, "7.1 surround"],
];
const GAMEPADS: [string, string][] = [
["auto", "Automatic"],
["xbox360", "Xbox 360"],
["xboxone", "Xbox One"],
["dualsense", "DualSense"],
["dualshock4", "DualShock 4"],
["steamdeck", "Steam Deck"],
];
const TOUCH_MODES: [string, string][] = [
["trackpad", "Trackpad"],
["pointer", "Direct pointer"],
["touch", "Touch passthrough"],
];
const MOUSE_MODES: [string, string][] = [
["capture", "Capture (games)"],
["desktop", "Desktop (absolute)"],
];
const STATS_TIERS: [string, string][] = [
["off", "Off"],
["compact", "Compact"],
["normal", "Normal"],
["detailed", "Detailed"],
];
// ----------------------------------------------------------------------------------------
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
// twelve of them below stay one line each and can't drift apart.
// ----------------------------------------------------------------------------------------
const SelectRow = <T extends string | number>({
label,
description,
options,
value,
onChange,
formatUnknown,
disabled,
indent,
}: {
label: string;
description?: ReactNode;
options: [T, string][];
value: T;
onChange: (v: T) => void;
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
formatUnknown?: (v: T) => string;
disabled?: boolean;
indent?: boolean;
}): ReactElement => {
// A Dropdown can only display a value that is one of its options, and this store has four other
// writers — the desktop client, the console, a settings profile, a newer client with presets
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
// different value than the stream will actually use), carry the stored one as its own entry.
const shown: [T, string][] = options.some(([v]) => v === value)
? options
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
return (
<Field
label={label}
description={description}
disabled={disabled}
indentLevel={indent ? 1 : undefined}
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
disabled={disabled}
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
selectedOption={value}
onChange={(o) => onChange(o.data as T)}
/>
</div>
</RowActions>
</Field>
);
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
xboxone: "Xbox One",
dualsense: "DualSense",
dualshock4: "DualShock 4",
steamdeck: "Steam Deck",
};
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
// a real preference that simply isn't plugged in right now, and dropping it would silently
// re-point the next stream at the default without ever showing the user why.
const DeviceRow: FC<{
label: string;
description: string;
devices: AudioDevice[] | null;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
indent?: boolean;
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
const options: [string, string][] = [["", "System default"]];
for (const d of devices ?? []) options.push([d.name, d.description]);
if (value && !options.some(([name]) => name === value)) {
options.push([value, `${value} (not connected)`]);
}
return (
<SelectRow
label={label}
description={devices === null ? "Reading this device's audio endpoints…" : description}
options={options}
value={value}
onChange={onChange}
disabled={disabled || devices === null}
indent={indent}
/>
);
// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host
// falls back from when its GPU can't encode it.
const CODECS = ["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: Record<string, string> = {
auto: "Automatic",
hevc: "HEVC (H.265)",
h264: "H.264 (AVC)",
av1: "AV1",
};
// ----------------------------------------------------------------------------------------
// The pages. One settings object, seven views on it — every page takes the same context rather
// than fetching or holding state of its own, so a change on one page is visible on the others
// the moment you switch.
// ----------------------------------------------------------------------------------------
interface PageCtx {
s: StreamSettings;
patch: (p: Partial<StreamSettings>) => void;
devices: DeviceLists | null;
reading: boolean;
readDevices: (again: boolean) => void;
}
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
// inset the tabs use).
const pageBody: CSSProperties = { paddingBottom: "80px" };
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
const renderScale = s.render_scale ?? 1;
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
return (
<div style={pageBody}>
<SelectRow
label="Resolution"
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
options={[
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
[MATCH_WINDOW, "Match window"] as [string, string],
]}
value={resolution}
// A size set from a desktop profile that isn't one of these presets, spelled the way the
// presets are rather than left as the raw "1600x900" key.
formatUnknown={(v) => v.replace("x", " × ")}
onChange={(v) => {
if (v === MATCH_WINDOW) {
// The tri-state the console stores: the flag on, the explicit size cleared.
patch({ match_window: true, width: 0, height: 0 });
return;
}
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
}}
/>
<SelectRow
label="Refresh rate"
description="Native follows the display the stream is on."
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
value={s.refresh_hz}
formatUnknown={(v) => `${v} Hz`}
onChange={(v) => patch({ refresh_hz: v })}
/>
<SelectRow
label="Render scale"
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
value={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
)}
onChange={(v) => patch({ render_scale: v })}
/>
<SliderField
label="Bitrate"
description="0 = the host's own default (20 Mbit/s)."
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<SelectRow
label="Host compositor"
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
options={COMPOSITORS}
value={s.compositor}
onChange={(v) => patch({ compositor: v })}
/>
</div>
);
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
const COMPOSITOR_LABELS: Record<string, string> = {
auto: "Automatic",
kwin: "KDE Plasma (KWin)",
wlroots: "Sway (wlroots)",
mutter: "GNOME (Mutter)",
gamescope: "gamescope",
};
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
// picker with a single option is a control that can't do anything.
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
return (
<div style={pageBody}>
<SelectRow
label="Video codec"
description="A preference — the host falls back when its GPU can't encode this one."
options={CODECS}
value={s.codec ?? "auto"}
onChange={(v) => patch({ codec: v })}
/>
<SelectRow
label="Video decoder"
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
options={DECODERS}
value={s.decoder ?? "auto"}
onChange={(v) => patch({ decoder: v })}
/>
{showGpuRow && (
<SelectRow
label="Decode GPU"
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
options={[
["", "Automatic"],
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
]}
value={s.adapter ?? ""}
onChange={(v) => patch({ adapter: v })}
/>
)}
<ToggleField
label="10-bit HDR"
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
checked={s.hdr_enabled ?? true}
onChange={(v) => patch({ hdr_enabled: v })}
/>
<ToggleField
label="Full chroma (4:4:4)"
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
checked={s.enable_444 ?? false}
onChange={(v) => patch({ enable_444: v })}
/>
</div>
);
};
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
const smooth = (s.present_priority ?? "latency") === "smooth";
return (
<div style={pageBody}>
<SelectRow
label="Prioritize"
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
options={PRESENT_PRIORITIES}
value={s.present_priority ?? "latency"}
onChange={(v) => patch({ present_priority: v })}
/>
<SelectRow
label="Smoothness buffer"
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
options={SMOOTH_BUFFERS}
value={s.smooth_buffer ?? 0}
formatUnknown={(v) => `${v} frames`}
onChange={(v) => patch({ smooth_buffer: v })}
disabled={!smooth}
indent
/>
<ToggleField
label="V-Sync"
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
checked={s.vsync ?? true}
onChange={(v) => patch({ vsync: v })}
/>
<ToggleField
label="Follow variable refresh"
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
checked={s.allow_vrr ?? true}
onChange={(v) => patch({ allow_vrr: v })}
/>
</div>
);
};
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
const micOn = s.mic_enabled;
// What the pickers get: null while the enumeration is in flight (they show a loading state),
// [] when it answered but couldn't read the endpoints (System default plus whatever is
// stored), and the real list otherwise.
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
reading || !devices ? null : devices.ok ? (list ?? []) : [];
return (
<div style={pageBody}>
<SelectRow
label="Audio channels"
description="The speaker layout requested from the host, which clamps it to what it can capture."
options={AUDIO_CHANNELS}
value={s.audio_channels ?? 2}
formatUnknown={(v) => `${v} channels`}
onChange={(v) => patch({ audio_channels: v })}
/>
<DeviceRow
label="Output device"
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
devices={endpoints(devices?.sinks)}
value={s.speaker_device ?? ""}
onChange={(v) => patch({ speaker_device: v })}
/>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
checked={micOn}
onChange={(v) => patch({ mic_enabled: v })}
/>
<DeviceRow
label="Microphone device"
description="Which input the mic uplink captures from."
devices={endpoints(devices?.sources)}
value={s.mic_device ?? ""}
onChange={(v) => patch({ mic_device: v })}
disabled={!micOn}
indent
/>
<ToggleField
label="Echo cancellation"
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
checked={s.echo_cancel ?? true}
onChange={(v) => patch({ echo_cancel: v })}
disabled={!micOn}
indentLevel={1}
/>
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
answer when the enumeration failed outright (a client too old to ship the session
binary). Rendered unconditionally, including while it is reading: a row that comes and
goes under a thumbstick is a moving target, so only its wording changes. */}
<Field
label={
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
}
description={
reading
? "Reading this device's audio endpoints and GPUs…"
: devices && !devices.ok
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
: "Plugged something in just now? Read the audio endpoints and GPUs again."
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
</DialogButton>
</RowActions>
</Field>
</div>
);
};
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
const forwarding = s.gamepad_forwarding ?? true;
return (
<div style={pageBody}>
<ToggleField
label="Forward controllers"
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
checked={forwarding}
onChange={(v) => patch({ gamepad_forwarding: v })}
/>
<SelectRow
label="Controller type"
description="The virtual pad the host creates. Automatic matches the controller you're holding."
options={GAMEPADS}
value={s.gamepad}
onChange={(v) => patch({ gamepad: v })}
disabled={!forwarding}
indent
/>
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
label="⚠ Disable Steam Input"
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
indentLevel={1}
/>
)}
</div>
);
};
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
<div style={pageBody}>
<SelectRow
label="Touch mode"
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
options={TOUCH_MODES}
value={s.touch_mode ?? "trackpad"}
onChange={(v) => patch({ touch_mode: v })}
/>
<SelectRow
label="Mouse mode"
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
options={MOUSE_MODES}
value={s.mouse_mode ?? "capture"}
onChange={(v) => patch({ mouse_mode: v })}
/>
<ToggleField
label="Invert scroll direction"
description="Reverses the wheel and trackpad scroll direction sent to the host."
checked={s.invert_scroll ?? false}
onChange={(v) => patch({ invert_scroll: v })}
/>
<ToggleField
label="Capture system shortcuts"
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
checked={s.inhibit_shortcuts}
onChange={(v) => patch({ inhibit_shortcuts: v })}
/>
</div>
);
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
// which itself defaults to true.
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
return (
<div style={pageBody}>
<SelectRow
label="Statistics overlay"
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
options={STATS_TIERS}
value={statsTier}
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
// client too old for the tiers still honours an Off chosen here.
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
/>
<ToggleField
label="Wake hosts automatically"
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
checked={s.auto_wake ?? true}
onChange={(v) => patch({ auto_wake: v })}
/>
<ToggleField
label="Show game library in the client"
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
checked={s.library_enabled ?? false}
onChange={(v) => patch({ library_enabled: v })}
/>
<ToggleField
label="Start streams fullscreen"
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
checked={s.fullscreen_on_stream ?? true}
onChange={(v) => patch({ fullscreen_on_stream: v })}
/>
</div>
);
};
// ----------------------------------------------------------------------------------------
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
// null until the enumeration answers — the pickers show a loading state rather than briefly
// claiming this device has no endpoints.
const [devices, setDevices] = useState<DeviceLists | null>(null);
const [reading, setReading] = useState(true);
const readDevices = (again: boolean) => {
setReading(true);
void (again ? refreshDevices() : listDevices())
.then(setDevices)
.finally(() => setReading(false));
};
useEffect(() => {
void getSettings().then(setS);
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
// takes seconds, and the rest of the screen must not wait for it.
readDevices(false);
}, []);
const patch = (p: Partial<StreamSettings>) => {
@@ -616,42 +74,128 @@ export const SettingsSection: FC = () => {
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<SidebarNavigation
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
// reporting would have this nav push entries of its own onto the router and fight the
// page for the back gesture; the pages are addressed by `identifier` instead.
disableRouteReporting
pages={[
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
{
title: "Presentation",
identifier: "presentation",
icon: <FaTv />,
content: <PresentationPage {...ctx} />,
},
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
{
title: "Controllers",
identifier: "controllers",
icon: <FaGamepad />,
content: <ControllersPage {...ctx} />,
},
{
title: "Touch & mouse",
identifier: "pointer",
icon: <FaHandPointer />,
content: <PointerPage {...ctx} />,
},
{
title: "Interface",
identifier: "interface",
icon: <FaSlidersH />,
content: <InterfacePage {...ctx} />,
},
]}
/>
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</div>
</RowActions>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</div>
</RowActions>
</Field>
<Field
label="Render scale"
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
selectedOption={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
)}
onChange={(o) => patch({ render_scale: o.data as number })}
/>
</div>
</RowActions>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field
label="Video codec"
description="Preferred stream codec — the host falls back when its GPU can't encode it"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={CODECS.map((c) => ({ data: c, label: CODEC_LABELS[c] ?? c }))}
selectedOption={s.codec ?? "auto"}
onChange={(o) => patch({ codec: o.data as string })}
/>
</div>
</RowActions>
</Field>
<Field
label="Gamepad type"
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</div>
</RowActions>
</Field>
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
label="⚠ Disable Steam Input"
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<Field
label="Host compositor"
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</div>
</RowActions>
</Field>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
-163
View File
@@ -156,20 +156,6 @@ mod index {
pub fn gamepad(s: &Settings) -> u32 {
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
}
pub fn present_priority(s: &Settings) -> u32 {
// Unknown values (a newer client's intent) read as the default, exactly as
// `PresentPriority::resolve` treats them.
PRESENT_PRIORITIES
.iter()
.position(|&p| p == s.present_priority)
.unwrap_or(0) as u32
}
pub fn smooth_buffer(s: &Settings) -> u32 {
// The index IS the stored value: 0 = Automatic, 1..3 = frames.
u32::from(s.smooth_buffer).min(SMOOTH_BUFFER_LABELS.len() as u32 - 1)
}
}
/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
@@ -639,27 +625,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("gamepad") {
o.gamepad = Some(values.gamepad.clone());
}
if touched.has("gamepad_forwarding") {
o.gamepad_forwarding = Some(values.gamepad_forwarding);
}
if touched.has("stats_verbosity") {
o.stats_verbosity = Some(values.stats_verbosity());
}
if touched.has("fullscreen_on_stream") {
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
}
if touched.has("present_priority") {
o.present_priority = Some(values.present_priority.clone());
}
if touched.has("smooth_buffer") {
o.smooth_buffer = Some(values.smooth_buffer);
}
if touched.has("vsync") {
o.vsync = Some(values.vsync);
}
if touched.has("allow_vrr") {
o.allow_vrr = Some(values.allow_vrr);
}
// Resets are not handled here: they clear the field and re-seed their row the moment the
// user asks, so by the time this runs the catalog already reflects them and the row is no
// longer marked touched.
@@ -713,20 +684,6 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
"The cursor jumps to your finger — a tap clicks there",
"Real multi-touch reaches the host — for touch-native apps",
];
/// Presentation-intent values (persisted under the `present_priority` key the Apple and
/// Android clients share) + labels + dynamic captions. Captions stay ONE line, like the
/// touch/mouse rows.
const PRESENT_PRIORITIES: &[&str] = &["latency", "smooth"];
const PRESENT_PRIORITY_LABELS: &[&str] = &["Lowest latency", "Smoothness"];
const PRESENT_PRIORITY_CAPTIONS: &[&str] = &[
"Each frame shows the moment the display can take it",
"Buffers a little to even out network hiccups",
];
/// Smoothness buffer depth, in frames — the index IS the stored `smooth_buffer` value
/// (0 = Automatic, which resolves to 2). No millisecond hints: the cost is one refresh
/// per frame, and the session's refresh isn't known here when the mode is Native.
const SMOOTH_BUFFER_LABELS: &[&str] = &["Automatic", "1 frame", "2 frames", "3 frames"];
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
@@ -1256,50 +1213,6 @@ pub fn show_scoped(
row
});
// ---- Display: Presentation ----
// The intent pair the Apple and Android clients already carry. The buffer row only
// means anything under Smoothness, so it hides itself the rest of the time rather
// than sitting there inert.
let present_row = ChoiceRow::new(
&dialog,
inline,
"Prioritize",
PRESENT_PRIORITY_CAPTIONS[0],
PRESENT_PRIORITY_LABELS,
);
let buffer_row = ChoiceRow::new(
&dialog,
inline,
"Smoothness buffer",
"Each frame held absorbs one refresh of hiccup and adds one of delay",
SMOOTH_BUFFER_LABELS,
);
{
let w = present_row.widget().clone();
let buffer = buffer_row.widget().clone();
present_row.connect_changed(move |i| {
let i = (i as usize).min(PRESENT_PRIORITY_CAPTIONS.len() - 1);
set_row_subtitle(&w, PRESENT_PRIORITY_CAPTIONS[i]);
buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth");
});
}
let vsync_row = adw::SwitchRow::builder()
.title("V-Sync")
.subtitle(
"Tear-free. Turning it off removes the wait for the screen's refresh — the \
lowest possible delay, at the cost of visible tearing. Not every driver \
offers it; the stats overlay names the mode actually in use",
)
.build();
let vrr_row = adw::SwitchRow::builder()
.title("Follow variable refresh rate")
.subtitle(
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with the \
stream instead of on a fixed cadence. Applies to fullscreen sessions; \
harmless on a fixed-refresh screen",
)
.build();
// ---- Display: Host output ----
let compositor_row = ChoiceRow::new(
&dialog,
@@ -1463,17 +1376,6 @@ pub fn show_scoped(
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
// instead of silently snapping back to Automatic.
// Off = this device's controllers are not sent at all, because they reach the host
// another way (USB passthrough such as VirtualHere, or a pad plugged into the host).
// It also stops the session OPENING the pad, which is what frees the device for a
// passthrough tool to bind — so the two rows below have nothing to act on while it is
// off, and are desensitised to say so.
let pad_forward_row = adw::SwitchRow::builder()
.title("Forward controllers")
.subtitle(
"Send this device's controllers to the host — off if it already has them another way",
)
.build();
let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
@@ -1542,18 +1444,6 @@ pub fn show_scoped(
"Steam Deck",
],
);
// Both pad rows only mean something while something is being forwarded (the same
// relationship mic → echo cancellation draws just above, initial state included: the
// seed's `set_active` fires this only when it CHANGES the switch).
{
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
f.set_sensitive(seed.gamepad_forwarding);
t.set_sensitive(seed.gamepad_forwarding);
pad_forward_row.connect_active_notify(move |r| {
f.set_sensitive(r.is_active());
t.set_sensitive(r.is_active());
});
}
// ---- Seed from the effective settings for this scope ----
{
@@ -1564,7 +1454,6 @@ pub fn show_scoped(
hz_row.set_selected(index::refresh(s));
scale_row.set_selected(index::render_scale(s));
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
pad_forward_row.set_active(s.gamepad_forwarding);
pad_row.set_selected(index::gamepad(s));
let touch_i = index::touch(s);
touch_row.set_selected(touch_i);
@@ -1590,19 +1479,6 @@ pub fn show_scoped(
let codec_i = index::codec(s);
codec_row.set_selected(codec_i);
set_row_subtitle(codec_row.widget(), codec_caption(codec_i));
let present_i = index::present_priority(s);
present_row.set_selected(present_i);
set_row_subtitle(
present_row.widget(),
PRESENT_PRIORITY_CAPTIONS[present_i as usize],
);
buffer_row.set_selected(index::smooth_buffer(s));
// `set_selected` never fires the changed hook, so mirror its visibility rule here.
buffer_row
.widget()
.set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth");
vsync_row.set_active(s.vsync);
vrr_row.set_active(s.allow_vrr);
}
// ---- Override markers, per-row reset, and the touch that creates an override ----
@@ -1795,26 +1671,6 @@ pub fn show_scoped(
index::surround
);
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
toggle!(
pad_forward_row,
"gamepad_forwarding",
o.gamepad_forwarding.is_some(),
gamepad_forwarding
);
choice!(
present_row,
"present_priority",
o.present_priority.is_some(),
index::present_priority
);
choice!(
buffer_row,
"smooth_buffer",
o.smooth_buffer.is_some(),
index::smooth_buffer
);
toggle!(vsync_row, "vsync", o.vsync.is_some(), vsync);
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
toggle!(
@@ -1919,11 +1775,6 @@ pub fn show_scoped(
if let (Some(r), false) = (&gpu_row, profile_mode) {
quality_group.add(r.widget());
}
let presentation_group = group("Presentation", "");
presentation_group.add(present_row.widget());
presentation_group.add(buffer_row.widget());
presentation_group.add(&vsync_row);
presentation_group.add(&vrr_row);
// The one form-level note (deliberately not repeated on every row).
let output_group = group(
"Host output",
@@ -1932,7 +1783,6 @@ pub fn show_scoped(
output_group.add(compositor_row.widget());
display.add(&resolution_group);
display.add(&quality_group);
display.add(&presentation_group);
display.add(&output_group);
let input = page("Input", "input-keyboard-symbolic");
@@ -1993,10 +1843,6 @@ pub fn show_scoped(
controllers_group.add(&row);
}
}
// Profileable, so it shows in both scopes — unlike the pin below it, which is about
// which of THIS device's pads goes first: a "Work" profile can decline to forward
// controllers to a host that a "Game" profile forwards them to.
controllers_group.add(&pad_forward_row);
if !profile_mode {
controllers_group.add(forward_row.widget());
}
@@ -2069,7 +1915,6 @@ pub fn show_scoped(
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.gamepad_forwarding = pad_forward_row.is_active();
s.mic_enabled = mic_row.is_active();
s.echo_cancel = echo_row.is_active();
s.hdr_enabled = hdr_row.is_active();
@@ -2080,14 +1925,6 @@ pub fn show_scoped(
_ => 2,
};
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.present_priority = PRESENT_PRIORITIES
[(present_row.selected() as usize).min(PRESENT_PRIORITIES.len() - 1)]
.to_string();
// The index IS the value (0 = Automatic).
s.smooth_buffer =
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
s.vsync = vsync_row.is_active();
s.allow_vrr = vrr_row.is_active();
s.library_enabled = library_row.is_active();
};
+1 -2
View File
@@ -61,7 +61,6 @@ 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|fifo|immediate|fifo_relaxed` (default MAILBOX, FIFO where the surface offers no
MAILBOX — AMD on Windows), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
mailbox|immediate` (default FIFO), `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).
-5
View File
@@ -169,11 +169,6 @@ pub fn run(target: Option<&str>) -> u8 {
mouse_mode: settings_at_start.mouse_mode(),
invert_scroll: settings_at_start.invert_scroll,
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
// Presentation-tier like the rows above: latched at console start, a per-host
// profile cannot move it in this mode (the documented P4 gap).
present_priority: settings_at_start.present_priority(),
vsync: settings_at_start.vsync,
allow_vrr: settings_at_start.allow_vrr,
json_status,
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
let fp_hex = trust::hex(&fingerprint);
+11 -9
View File
@@ -188,12 +188,12 @@ mod session_main {
if !settings.forward_pad.is_empty() {
gamepad.set_pinned(Some(settings.forward_pad.clone()));
}
// Whether to forward controllers AT ALL (off = the pad reaches the host by some other
// route — VirtualHere and friends). Set unconditionally, not only when off: browse mode
// reuses one service across launches, so a stream that follows one with it off must put
// it back. It goes on before the attach below, so a non-forwarding session never opens
// — never grabs — the device.
gamepad.set_forwarding(settings.gamepad_forwarding);
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
// slots declare their render caps at open time, which happens on attach — after this.
gamepad.set_pad_audio_prefs(
settings.pad_haptics,
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
);
let mode = Mode {
width: if settings.width == 0 {
native.width
@@ -297,6 +297,11 @@ mod session_main {
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
mic_enabled: settings.mic_enabled,
echo_cancel: settings.echo_cancel,
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
// service learns the same prefs below so tier-A slots declare their render caps
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
pad_haptics: settings.pad_haptics,
pad_speaker: settings.pad_speaker.clone(),
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
@@ -623,9 +628,6 @@ mod session_main {
mouse_mode: settings.mouse_mode(),
invert_scroll: settings.invert_scroll,
inhibit_shortcuts: settings.inhibit_shortcuts,
present_priority: settings.present_priority(),
vsync: settings.vsync,
allow_vrr: settings.allow_vrr,
json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now.
+2 -8
View File
@@ -623,14 +623,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
actions.push(
icon_btn("Settings", Symbol::Setting)
.on_click({
let (c, ss) = (ctx.clone(), set_screen.clone());
move || {
// Re-base the settings snapshot on the file before the page
// renders — this process is not its only writer (see
// settings::refresh_snapshot).
super::settings::refresh_snapshot(&c);
ss.call(Screen::Settings)
}
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
})
.into(),
);
+4 -10
View File
@@ -2,8 +2,7 @@
//! Settings).
use super::style::*;
use super::{AppCtx, Screen};
use std::sync::Arc;
use super::Screen;
use windows_reactor::*;
/// punktfunk's own license (MIT OR Apache-2.0).
@@ -16,15 +15,10 @@ const APP_LICENSE: &str = concat!(
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt");
pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
let (c, ss) = (ctx.clone(), set_screen.clone());
move || {
// Back RE-ENTERS the settings page — re-base its snapshot on the file, same
// as the hosts page's Settings button (see settings::refresh_snapshot).
super::settings::refresh_snapshot(&c);
ss.call(Screen::Settings)
}
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
});
let app_card = card(
+1 -5
View File
@@ -172,10 +172,6 @@ pub(crate) struct Shared {
pub struct AppCtx {
pub(crate) identity: (String, String),
/// The settings snapshot the UI renders from. Loaded once at startup, and RE-BASED on
/// the file when the settings page is (re)entered (`settings::refresh_snapshot`) and
/// inside every `commit` — this process is not the file's only writer (session resize,
/// console UI, Decky), so a plain process-lifetime snapshot goes stale on screen.
pub(crate) settings: Mutex<Settings>,
pub(crate) gamepad: GamepadService,
pub(crate) shared: Arc<Shared>,
@@ -692,7 +688,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
&set_settings_rev,
nav_progress,
),
Screen::Licenses => licenses::licenses_page(ctx, &set_screen),
Screen::Licenses => licenses::licenses_page(&set_screen),
Screen::Help => help::help_page(&set_screen),
Screen::Pair => component(pair::pair_page, svc),
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
+5 -191
View File
@@ -101,19 +101,6 @@ const MOUSE_MODES: &[(&str, &str)] = &[
("capture", "Capture (games)"),
("desktop", "Desktop (absolute)"),
];
/// Presentation intent: `(stored value, display label)` — the `present_priority` key the
/// Apple and Android clients share, so one profile means the same thing everywhere.
const PRESENT_PRIORITIES: &[(&str, &str)] =
&[("latency", "Lowest latency"), ("smooth", "Smoothness")];
/// Smoothness buffer depth in frames: `(stored value, display label)`. `0` = Automatic,
/// which resolves to 2 (`PresentPriority::resolve`). No millisecond hints — the cost is
/// one refresh per frame, and the refresh isn't known here when the mode is Native.
const SMOOTH_BUFFERS: &[(u8, &str)] = &[
(0, "Automatic"),
(1, "1 frame"),
(2, "2 frames"),
(3, "3 frames"),
];
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
const COMPOSITORS: &[(&str, &str)] = &[
@@ -424,16 +411,7 @@ fn commit(
return;
}
let mut catalog = ProfilesFile::load();
// The same rebase as the global arm above: `base` is what `absorb`'s before/after
// effective settings derive from, and the snapshot is not the file — another process
// (session resize, console UI, Decky) may have moved a global under us. The historical
// rebase fix ("settings saves stop reverting each other") covered the whole-file
// writers but missed this arm.
let base = {
let mut s = ctx.settings.lock().unwrap();
*s = Settings::load();
s.clone()
};
let base = ctx.settings.lock().unwrap().clone();
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
return; // deleted from under us; the next render falls back to the defaults scope
};
@@ -447,17 +425,6 @@ fn commit(
rev.1.call(rev.0 + 1);
}
/// Re-base the process-lifetime settings snapshot on the file — called from the navigation
/// handlers that (re)enter this page, NOT per render pass. `ctx.settings` is loaded once at
/// process start and this process is not the file's only writer (a spawned session persists
/// its match-window size, the console UI and Decky save too — profiles.rs documents the
/// family), so without this the page opens showing values another process already replaced,
/// which then visibly "jump" the moment a row is touched and `commit`'s rebase pulls the
/// file in. The field report this fixes: a codec setting that "changed by itself".
pub(crate) fn refresh_snapshot(ctx: &Arc<AppCtx>) {
*ctx.settings.lock().unwrap() = Settings::load();
}
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
#[derive(Default)]
@@ -478,13 +445,8 @@ struct OverrideFlags {
invert_scroll: bool,
inhibit_shortcuts: bool,
gamepad: bool,
gamepad_forwarding: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
present_priority: bool,
smooth_buffer: bool,
vsync: bool,
allow_vrr: bool,
}
impl OverrideFlags {
@@ -511,13 +473,8 @@ impl OverrideFlags {
invert_scroll: o.invert_scroll.is_some(),
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
gamepad_forwarding: o.gamepad_forwarding.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
present_priority: o.present_priority.is_some(),
smooth_buffer: o.smooth_buffer.is_some(),
vsync: o.vsync.is_some(),
allow_vrr: o.allow_vrr.is_some(),
}
}
}
@@ -894,32 +851,6 @@ pub(crate) fn settings_page(
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
s.enable_444 = on
});
// Presentation intent (design/desktop-presentation-rebuild.md). The buffer row is
// rendered only under Smoothness — `commit` bumps the revision, so flipping the
// intent re-renders the section and the row appears/disappears with it.
let (present_names, present_i) = presets(PRESENT_PRIORITIES, |v| *v == s.present_priority);
let present_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
present_names,
present_i,
|s, i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string(),
);
let smoothing = s.present_priority == "smooth";
let (buffer_names, buffer_i) = presets(SMOOTH_BUFFERS, |v| *v == s.smooth_buffer);
let buffer_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
buffer_names,
buffer_i,
|s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0,
);
let vsync_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.vsync, |s, on| s.vsync = on);
let vrr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.allow_vrr, |s, on| {
s.allow_vrr = on
});
// --- Input -----------------------------------------------------------------------------
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
@@ -967,10 +898,6 @@ pub(crate) fn settings_page(
s.save();
})
};
let pad_forward_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
s.gamepad_forwarding = on
});
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
});
@@ -1045,16 +972,6 @@ pub(crate) fn settings_page(
let ss = set_screen.clone();
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
};
// The client log's home (%LOCALAPPDATA%\punktfunk\logs) — the file every "check the
// client log" message means, which until this row had no way in from the UI at all.
// The folder rather than the file so the rotated `.old` generation is in reach too.
// Best-effort, like the log itself: a missing dir or a failed spawn stays silent.
let logs_button = button("Open log folder").on_click(|| {
if let Some(dir) = crate::logfile::log_dir() {
let _ = std::fs::create_dir_all(&dir);
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
}
});
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
s.library_enabled = on
});
@@ -1148,9 +1065,8 @@ pub(crate) fn settings_page(
"HDR10, when the host has HDR content and this display supports it. \
HEVC only; otherwise the stream stays SDR.",
),
// First sentence shared with the GTK client (its chroma_row); the
// constraint sentence names the real gate (host: PyroWave || NVENC) —
// "where the host can encode it" cost field users the discovery time.
// Wording shared with the GTK client (its chroma_row) — same setting,
// same constraints.
described_overridable(
(rev, set_rev),
scope,
@@ -1159,8 +1075,7 @@ pub(crate) fn settings_page(
over.enable_444,
chroma_toggle,
"Full-colour video: crisp small text and thin lines, at more \
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
codec \u{2014} other encoders stream 4:2:0.",
bandwidth. HEVC only, and only where the host can encode it.",
),
],
None,
@@ -1190,60 +1105,6 @@ pub(crate) fn settings_page(
},
None,
));
out.extend(group(
Some("Presentation"),
{
let mut fields = vec![described_overridable(
(rev, set_rev),
scope,
"present_priority",
"Prioritize",
over.present_priority,
present_combo,
"Lowest latency shows each frame the moment the display can take \
it \u{2014} a network hiccup becomes an occasional repeated or \
skipped frame. Smoothness buffers a little to even those out.",
)];
if smoothing {
fields.push(described_overridable(
(rev, set_rev),
scope,
"smooth_buffer",
"Smoothness buffer",
over.smooth_buffer,
buffer_combo,
"Frames held back before showing. Each one absorbs about a \
refresh of network hiccup and adds a refresh of delay. \
Automatic holds two.",
));
}
fields.push(described_overridable(
(rev, set_rev),
scope,
"vsync",
"V-Sync",
over.vsync,
vsync_toggle,
"Tear-free. Turning it off removes the wait for the screen\u{2019}s \
refresh \u{2014} the lowest possible delay, at the cost of visible \
tearing. Not every driver offers it; the stats overlay names the \
mode actually in use.",
));
fields.push(described_overridable(
(rev, set_rev),
scope,
"allow_vrr",
"Follow variable refresh rate",
over.allow_vrr,
vrr_toggle,
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with \
the stream instead of on a fixed cadence. Applies to fullscreen \
sessions; harmless on a fixed-refresh screen.",
));
fields
},
None,
));
out.extend(group(
Some("Host output"),
vec![described_overridable(
@@ -1362,23 +1223,6 @@ pub(crate) fn settings_page(
"Plug in or pair a controller and it appears here.",
)
}),
// Whether ANY controller is forwarded — profileable, so it renders in
// both scopes (a "Work" profile can decline what "Game" forwards),
// unlike the device-fact picker below it.
Some(described_overridable(
(rev, set_rev),
scope,
"gamepad_forwarding",
"Forward controllers",
over.gamepad_forwarding,
pad_forward_toggle,
"Sends controllers connected to this PC to the host. Turn it off when \
your controller already reaches the host another way \u{2014} USB \
passthrough such as VirtualHere, or a pad plugged into the host \
itself \u{2014} so games don't see two of them. Off, this PC never \
opens the controller at all, which is what leaves it free for a \
passthrough tool to claim.",
)),
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
// Which physical pad this device forwards is a device fact (tier G), so it
@@ -1481,16 +1325,7 @@ pub(crate) fn settings_page(
"About",
group(
None,
vec![
about_identity.into(),
described_labeled(
"Diagnostics",
logs_button,
"The client log (client.log, plus the session\u{2019}s whole \
receive/decode/present trail) \u{2014} attach it to a bug report.",
),
licenses_button.into(),
],
vec![about_identity.into(), licenses_button.into()],
None,
),
),
@@ -1892,26 +1727,5 @@ mod tests {
let f3 = OverrideFlags::of(Some(&p3));
assert!(f3.echo_cancel);
assert!(!f3.mic_enabled);
// The presentation pair, likewise independent: pinning the intent doesn't claim
// the buffer (a "Smoothness, whatever the global buffer is" profile is valid).
let mut p4 = StreamProfile::new("t4".to_string());
p4.overrides = SettingsOverlay {
present_priority: Some("smooth".into()),
..Default::default()
};
let f4 = OverrideFlags::of(Some(&p4));
assert!(f4.present_priority);
assert!(!f4.smooth_buffer);
// V-Sync and VRR are independent of each other and of the intent pair.
let mut p5 = StreamProfile::new("t5".to_string());
p5.overrides = SettingsOverlay {
vsync: Some(false),
..Default::default()
};
let f5 = OverrideFlags::of(Some(&p5));
assert!(f5.vsync);
assert!(!f5.allow_vrr && !f5.present_priority);
}
}
+2 -3
View File
@@ -21,12 +21,11 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
/// The log directory — Settings ▸ About's "Open log folder" opens it in Explorer.
pub(crate) fn log_dir() -> Option<PathBuf> {
fn log_dir() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
}
/// The log file's path, for the "logs land here" startup line and the failed-spawn banner.
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
pub(crate) fn path() -> Option<PathBuf> {
Some(log_dir()?.join("client.log"))
}
+1 -8
View File
@@ -105,14 +105,7 @@ fn parse_line(line: &str) -> Option<ChildLine> {
/// connect that silently drops back to the host list.
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
(code != 0 && code != -1).then(|| {
// Name the log's actual location — "check the client log" without a path is a
// scavenger hunt (Settings ▸ About's "Open log folder" reaches it too).
let log = crate::logfile::path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "the client log".into());
format!(
"The session didn't start (punktfunk-session exited with code {code}). Check {log}."
)
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
})
}
+1 -6
View File
@@ -612,10 +612,7 @@ pub fn open_portal_monitor(
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
/// "pointer hidden" the composited/forwarded cursor must honor.
/// session that negotiated PQ cannot fall back to SDR afterwards.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn open_virtual_output(
@@ -628,7 +625,6 @@ pub fn open_virtual_output(
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
cursor_id0_hides: bool,
) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::from_virtual_output(
remote_fd,
@@ -640,7 +636,6 @@ pub fn open_virtual_output(
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
policy,
expect_exact_dims,
cursor_id0_hides,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
+1 -14
View File
@@ -72,11 +72,6 @@ struct CaptureOpts {
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
/// gamescope fixates its own — gating those would starve legitimate first frames).
expect_exact_dims: bool,
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
cursor_id0_hides: bool,
}
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
@@ -306,10 +301,6 @@ impl PortalCapturer {
want_444: false,
want_hdr,
expect_exact_dims: false,
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
// one through here yet; the virtual-output path below carries the real flag.
cursor_id0_hides: false,
},
policy,
)?
@@ -325,8 +316,7 @@ impl PortalCapturer {
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
/// [`crate::open_virtual_output`] for who is allowed to pass it.
#[allow(clippy::too_many_arguments)]
pub fn from_virtual_output(
remote_fd: Option<OwnedFd>,
@@ -338,7 +328,6 @@ impl PortalCapturer {
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
cursor_id0_hides: bool,
) -> Result<PortalCapturer> {
tracing::info!(
node_id,
@@ -346,7 +335,6 @@ impl PortalCapturer {
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
"connecting PipeWire to virtual output"
);
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
@@ -362,7 +350,6 @@ impl PortalCapturer {
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
},
policy,
)?
+1 -2
View File
@@ -811,7 +811,6 @@ pub fn pipewire_thread(
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
..
} = opts;
crate::pwinit::ensure_init();
@@ -986,7 +985,7 @@ pub fn pipewire_thread(
yuv444: want_444,
linear_nv12_failed: false,
dbg_log_n: 0,
cursor: CursorState::new(cursor_id0_hides),
cursor: CursorState::default(),
expect_dims: if expect_exact_dims {
preferred.map(|(w, h, _)| (w, h))
} else {
+8 -75
View File
@@ -39,23 +39,9 @@ pub(super) struct CursorState {
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
/// process-wide latch made the second session's triage read as "no meta".
seen_meta: bool,
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
/// [`note_cursor_id`].
id0_hides: bool,
}
impl CursorState {
/// The per-stream state, declaring which `id == 0` contract the producer follows
/// ([`Self::id0_hides`]).
pub(super) fn new(id0_hides: bool) -> CursorState {
CursorState {
id0_hides,
..CursorState::default()
}
}
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
@@ -93,31 +79,6 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
}
}
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
/// meta region (position, bitmap) is worth parsing.
///
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
/// Windows has its own CURSOR_SUPPRESSED source).
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
if id == 0 {
if cursor.id0_hides {
cursor.visible = false;
}
return false;
}
cursor.visible = true;
true
}
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
@@ -160,9 +121,16 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
(*cur).bitmap_offset,
)
};
if !note_cursor_id(cursor, id) {
if id == 0 {
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
return;
}
cursor.visible = true;
cursor.x = pos_x - hot_x;
cursor.y = pos_y - hot_y;
cursor.hot_x = hot_x;
@@ -399,44 +367,9 @@ mod tests {
hot_x: 0,
hot_y: 0,
seen_meta: true,
id0_hides: false,
}
}
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
#[test]
fn id_zero_hides_only_on_a_rewriting_producer() {
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
// a game or Big Picture hiding the pointer must reach the stream.
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
kwin.id0_hides = true;
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
let o = kwin.overlay().expect("bitmap stays cached across a hide");
assert!(!o.visible, "KWin id 0 must hide the overlay");
// The pointer coming back re-shows the SAME cached bitmap.
assert!(note_cursor_id(&mut kwin, 1));
assert!(kwin.overlay().expect("still cached").visible);
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
// (honoring them flickered the cursor off between hovers, on-glass round 5).
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
assert!(!note_cursor_id(&mut mutter, 0));
assert!(
mutter.overlay().expect("cached").visible,
"a stale-meta producer's id 0 must NOT hide"
);
}
#[test]
fn id_zero_before_any_bitmap_yields_no_overlay() {
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
let mut c = CursorState::new(true);
assert!(!note_cursor_id(&mut c, 0));
assert!(c.overlay().is_none());
}
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
#[test]
+14 -32
View File
@@ -1670,22 +1670,6 @@ 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
@@ -1698,14 +1682,24 @@ impl IddPushCapturer {
}
}),
max_heartbeat_age_ms: self.max_hb_age_us / 1_000,
// 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).
// 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).
probes: now
.checked_sub(stall.gap + Duration::from_millis(300))
.zip(self.probes.as_deref())
.map(|(from, p)| p.window(from, now)),
etw,
etw_counts,
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))
}),
};
self.stall_watch.report(&stall, now, &evidence);
}
@@ -2459,18 +2453,6 @@ 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_report`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
//! ([`EtwWatch::window_counts`]): 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,8 +48,7 @@ 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_RAW_TIMESTAMP, PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION,
WNODE_FLAG_TRACED_GUID,
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
};
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
use windows::Win32::System::Threading::{
@@ -123,13 +122,8 @@ fn qpc_freq() -> i64 {
})
}
/// 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.
/// 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.
unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
if record.is_null() {
return;
@@ -156,10 +150,10 @@ pub(super) struct EtwWatch {
}
// SAFETY: both fields are plain kernel handle VALUES (u64 wrappers) owned by this watch; every
// 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>`.
// 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>`.
unsafe impl Send for EtwWatch {}
// SAFETY: as above — `&EtwWatch` exposes only `window_report` (static-ring reads).
// SAFETY: as above — `&EtwWatch` exposes only `summary` (static-ring reads).
unsafe impl Sync for EtwWatch {}
static WATCH: Mutex<Weak<EtwWatch>> = Mutex::new(Weak::new());
@@ -207,10 +201,8 @@ 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 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).
// appended name space (ETW copies the name there itself). ClientContext 1 = QPC clock —
// what makes event timestamps comparable to our probe windows.
let rc = unsafe {
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
(*props).Wnode.BufferSize = buf.len() as u32;
@@ -232,11 +224,6 @@ 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
@@ -253,7 +240,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_report` then reports
// refusal only costs the per-process present counts — `window_counts` then reports
// no present history and classification stays honest (Unattributed, never a guess).
if !enable_provider(session, &DXGI, &DXGI_FILTER_IDS) {
tracing::debug!(
@@ -265,12 +252,8 @@ impl EtwWatch {
LoggerName: PWSTR(name.as_ptr() as *mut _),
..Default::default()
};
// 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.Anonymous1.ProcessTraceMode =
PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
log.Anonymous2.EventRecordCallback = Some(on_event);
// SAFETY: `log` is a fully-initialized local; `name` outlives the call (OpenTrace copies
// what it needs before returning).
@@ -320,34 +303,14 @@ impl EtwWatch {
Some(Self { session, consumer })
}
/// 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.
/// 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.
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_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 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 events: Vec<(i64, u16, u32)> = {
let ring = RING.lock().unwrap();
ring.iter()
@@ -355,7 +318,6 @@ 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 [
@@ -373,7 +335,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 >= summary_from_q {
if s <= to_q && ts >= from_q {
count += 1;
max_ms = max_ms.max(ms(ts - s));
}
@@ -400,34 +362,43 @@ impl EtwWatch {
] {
let count = events
.iter()
.filter(|(ts, i, _)| *i == id && *ts >= summary_from_q && *ts <= to_q)
.filter(|(ts, i, _)| *i == id && *ts >= 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 summary window plus the top presenters, NAMED — the line that splits a
// inside the 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 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.
// 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.
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 => {
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
Some((_, c)) => *c += 1,
None => per_pid.push((pid, 1)),
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;
}
}
}
BLT_ADD_ID => adds += 1,
BLT_COMPLETE_ID => completes += 1,
_ => {}
}
}
@@ -444,80 +415,61 @@ impl EtwWatch {
.collect::<Vec<_>>()
.join(",");
parts.push(format!("Present×{total}({top})"));
} else if counts.present_history {
} else if have_present_history {
parts.push("Present×0".to_string());
}
if counts.queue_history || adds > 0 || completes > 0 {
if have_queue_history {
parts.push(format!("blt-queue add×{adds} complete×{completes}"));
}
let summary = if parts.is_empty() {
if parts.is_empty() {
"none".to_string()
} else {
parts.join(" ")
};
(summary, counts)
}
}
/// 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
/// 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
}
}
/// [`EtwWatch::window_report`]'s structured half: the compose-silence discriminator's evidence.
/// [`EtwWatch::window_counts`]'s read: the compose-silence discriminator's structured 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 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.
/// The present stream has produced at least one event EVER (witness known-working).
pub(super) present_history: bool,
/// Queue-stream liveness inside [`LOOKBACK`] before the hole (`BltQueueAddEntry` or
/// `BltQueueCompleteIndirectPresent` — either proves the witness works).
/// The queue stream has produced at least one event EVER (witness known-working).
pub(super) queue_history: bool,
}
@@ -609,72 +561,3 @@ 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_report`]) —
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_counts`]) —
/// 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.
+4
View File
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
[target.'cfg(windows)'.dependencies]
wasapi = "0.23"
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
# property stores entirely (the same version the host pins).
winreg = "0.56"
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
+188 -64
View File
@@ -336,7 +336,9 @@ enum Ctl {
Detach,
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
PadAudioPrefs(u8),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -483,24 +485,16 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::KindOverride(pref));
}
/// Forward this device's controllers to the host at all ([`Settings::gamepad_forwarding`],
/// default on). Off is for a couch whose pad reaches the host another way — a USB
/// passthrough tool like VirtualHere, or a controller plugged into the host itself —
/// where forwarding as well would give the host two pads for one pair of hands.
///
/// Off holds no slot open, so nothing is sent AND nothing is *grabbed*: no arrival, no
/// virtual pad host-side, and the hidraw node stays free for the passthrough tool to
/// bind (SDL's HIDAPI drivers take it at open — a held device cannot be bound away).
/// It follows that the escape chord, which only listens on forwarded pads, is not
/// available while off; the keyboard chord and the client's own UI still end a session.
///
/// Menu navigation is untouched: the launcher still opens the active pad to drive its
/// UI, and a session — which supersedes menu mode whether it forwards or not — releases
/// it again, so the pad is free for the whole time a stream is up.
///
/// [`Settings::gamepad_forwarding`]: crate::trust::Settings::gamepad_forwarding
pub fn set_forwarding(&self, on: bool) {
let _ = self.ctl.send(Ctl::Forwarding(on));
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
let bits = (haptics as u8) | ((speaker as u8) << 1);
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
@@ -632,6 +626,11 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
struct Ds5Feedback;
impl Ds5Feedback {
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`, struct offsets 4..=9).
/// The 47-byte effect struct is the USB report 0x02 minus its report-id byte, so struct
/// offset 4 = report byte 5 (the same 1 shift that maps report offset 11 to
/// [`Self::RIGHT_TRIGGER`] = 10 in [`trigger_packet`](Self::trigger_packet)).
const AUDIO: usize = 4;
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
@@ -665,6 +664,29 @@ impl Ds5Feedback {
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
fn audio_haptics_packet() -> [u8; 47] {
[0u8; 47]
}
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
let mut p = [0u8; 47];
p[0] = (flags & 0x1E) << 3;
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
p
}
}
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
@@ -698,6 +720,14 @@ struct Slot {
/// close lift a click held across detach/unplug.
held_clicks: [bool; 2],
last_accel: [i16; 3],
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
/// disable-bit trap — see [`Worker::render_feedback`]).
audio_caps: u8,
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
rumble_suppressed_logged: bool,
}
impl Slot {
@@ -713,6 +743,8 @@ impl Slot {
surface_last: [(0, 0, false); 2],
held_clicks: [false; 2],
last_accel: [0; 3],
audio_caps: 0,
rumble_suppressed_logged: false,
}
}
@@ -742,14 +774,14 @@ struct Worker {
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
/// (an explicit single-player choice); Automatic forwards every real controller.
pinned: Option<String>,
/// Forward controllers to an attached session at all ([`GamepadService::set_forwarding`]).
/// Off makes [`Self::forwarded_ids`] empty, so a session opens no slot — the whole point
/// being that the hardware stays ungrabbed for a USB passthrough tool.
forwarding: bool,
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
/// `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<()>,
@@ -840,11 +872,6 @@ impl Worker {
/// back to the single most-recent pad when only a Steam-virtual pad is present (the Deck
/// game-mode case — otherwise its gyro/paddles/input would have nowhere to land).
fn forwarded_ids(&self) -> Vec<u32> {
// Forwarding off: nothing is forwarded, so nothing is opened either — the device stays
// free for whatever route the user's controller actually takes to the host.
if !self.forwarding {
return Vec::new();
}
if let Some(key) = &self.pinned {
if let Some(id) = self
.order
@@ -955,11 +982,18 @@ impl Worker {
Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad);
Self::set_slot_sensors(&mut slot, true);
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
// re-sends it a few times against datagram loss; an older host ignores it and
// uses the session-default kind.
if let Some(c) = &self.attached {
// Pad-audio render caps go in FIRST — the core ORs them into this (and
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
// set (0 for non-tier-A): wire indices are reused within a connection, so
// a tier-A slot that closes must not leave its bits behind for the next
// pad on the same index (the set_rumble_quirks rule).
c.set_pad_audio_caps(index, slot.audio_caps);
send(
c,
InputKind::GamepadArrival,
@@ -982,6 +1016,27 @@ impl Worker {
};
c.set_rumble_quirks(index as u16, quirks);
}
if slot.audio_caps != 0 {
if slot.audio_caps & 0x01 != 0 {
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
// "disable audio haptics") whenever its rumble path runs — which
// would MUTE the voice coils the 0xD1 stream drives. One effects
// packet with those bits CLEARED puts the pad back on audio haptics
// ("Leaving emulated rumble bits off will restore audio haptics" —
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
// render_feedback so SDL never re-arms them.
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
}
// Hand the pad to the session's renderer worker. Windows correlation
// needs the HID interface path; Linux matches the sink by signature.
crate::pad_audio::register_tier_a(index, slot.pad.path());
tracing::info!(
index,
caps = slot.audio_caps,
"tier-A DualSense: pad-audio render caps declared"
);
}
tracing::info!(
id,
index,
@@ -995,6 +1050,35 @@ impl Worker {
}
}
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
if self.pad_audio_prefs == 0 {
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
}
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
return 0; // not a DualSense/Edge — no wired check needed
}
use sdl3::joystick::ConnectionState;
let wired = match pad.connection_state() {
Ok(ConnectionState::Wired) => true,
Ok(ConnectionState::Wireless) => false,
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
};
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
self.pad_audio_prefs
} else {
0
}
}
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
/// already gone (unplug).
@@ -1011,6 +1095,11 @@ impl Worker {
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
}
let slot = self.slots.remove(i);
if slot.audio_caps != 0 {
// Take the pad back from the pad-audio renderer (its device-gone path then
// re-correlates — and finds nothing until a tier-A pad registers again).
crate::pad_audio::unregister_tier_a(slot.index);
}
tracing::info!(
id = slot.id,
index = slot.index,
@@ -1273,16 +1362,10 @@ impl Worker {
Ok(Ctl::Attach(c)) => {
self.attached = Some(c);
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
// Not with forwarding off: this session opens no slot, and the drivers'
// mere enumeration both kills the Deck's trackpad-mouse and is the
// opposite of leaving the hardware alone for a passthrough tool.
if self.forwarding {
set_valve_hidapi(true);
}
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
set_valve_hidapi(true);
self.sync_open();
}
Ok(Ctl::Detach) => {
@@ -1305,31 +1388,7 @@ impl Worker {
self.refresh_active();
}
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::Forwarding(on)) => {
if self.forwarding == on {
continue;
}
self.forwarding = on;
self.reset_chord(); // no forwarded pad can be mid-chord across the flip
// Applied live rather than at attach only, so a mid-session flip (an
// in-stream settings screen) takes effect on the pad in your hands.
//
// The Valve HIDAPI drivers are an in-session-only thing (see
// set_valve_hidapi), and forwarding off is — for their purpose — not in
// session. Order matters and differs by direction: ON must enable them
// BEFORE `sync_open`, or a Deck's built-in pad opens under its old
// identity; OFF must disable them AFTER, so no slot outlives the driver
// that opened it.
let attached = self.attached.is_some();
if on && attached {
set_valve_hidapi(true);
}
self.sync_open();
if !on && attached {
set_valve_hidapi(false);
}
}
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -1601,6 +1660,20 @@ impl Worker {
// first; the physical silence backstop is in `close_slot_at`).
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
// so a slot with tier-A haptics active never issues wire rumble (the stream
// carries the feedback; the game's rumble is in its haptics mix).
if slot.audio_caps & 0x01 != 0 {
if !slot.rumble_suppressed_logged {
slot.rumble_suppressed_logged = true;
tracing::info!(
pad = slot.index,
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
);
}
continue;
}
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
}
}
@@ -1633,6 +1706,17 @@ impl Worker {
.pad
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
// The audio-control region of a DS5 output report a game wrote host-side
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
// pad's effects packet, but only where a tier-A renderer is actually live
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
// nothing streams to would just mute/blast a future session's start state.
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
let _ = slot
.pad
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
}
_ => {}
}
}
@@ -1647,6 +1731,8 @@ fn hidout_pad(h: &HidOutput) -> u8 {
| HidOutput::Trigger { pad, .. }
| HidOutput::TrackpadHaptic { pad, .. }
| HidOutput::HidRaw { pad, .. } => *pad,
// AudioCtl's pad is u16 on the wire; the index space is 0..MAX_PADS end to end.
HidOutput::AudioCtl { pad, .. } => *pad as u8,
}
}
@@ -1669,8 +1755,8 @@ impl Worker {
menu_open: None,
order: Vec::new(),
pinned: None,
forwarding: true,
kind_override: GamepadPref::Auto,
pad_audio_prefs: 0,
attached: None,
escape_tx,
disconnect_tx,
@@ -2006,5 +2092,43 @@ mod slot_tests {
}),
6
);
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
assert_eq!(
hidout_pad(&HidOutput::AudioCtl {
pad: 7,
flags: 0,
raw: [0; 6]
}),
7
);
}
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
#[test]
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
// bits1..4 (0b1011) → flag0 bits 4..7.
assert_eq!(p[0], 0b1011_0000);
assert_eq!(
p[0] & 0x03,
0,
"haptics-select must NOT replay into p[0] bits 0/1"
);
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
assert!(p[1..4].iter().all(|&b| b == 0));
assert!(p[10..].iter().all(|&b| b == 0));
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
assert_eq!(p[0], 0);
assert_eq!(&p[4..10], &raw);
// The tier-A activation packet is the all-clear: every enable bit off — per
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
}
}
+5
View File
@@ -47,6 +47,11 @@ pub mod os;
// Client settings profiles: the override catalog + the one connect-time resolver
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
// the bindings live on.
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
// the tier-A pad registry the gamepad worker feeds it through.
#[cfg(any(target_os = "linux", windows))]
pub mod pad_audio;
#[cfg(any(target_os = "linux", windows))]
pub mod profiles;
#[cfg(any(target_os = "linux", windows))]
-4
View File
@@ -982,10 +982,6 @@ mod tests {
height: 1440,
bitrate_kbps: 55000,
codec: "av1".into(),
present_priority: "smooth".into(),
smooth_buffer: 2,
vsync: false,
allow_vrr: false,
..Default::default()
},
clipboard: true,
File diff suppressed because it is too large Load Diff
-139
View File
@@ -74,23 +74,9 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad_forwarding: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fullscreen_on_stream: Option<bool>,
/// The presentation cluster — the keys the Apple client already writes into this
/// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`;
/// Android carries the first two). First-class here so a profile authored on any
/// client applies on all of them instead of riding `extra` unapplied.
#[serde(skip_serializing_if = "Option::is_none")]
pub present_priority: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smooth_buffer: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vsync: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_vrr: Option<bool>,
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
/// load→save round-trip untouched.
#[serde(flatten)]
@@ -156,9 +142,6 @@ impl SettingsOverlay {
if let Some(v) = &self.gamepad {
s.gamepad = v.clone();
}
if let Some(v) = self.gamepad_forwarding {
s.gamepad_forwarding = v;
}
if let Some(v) = self.stats_verbosity {
// Through the setter so the legacy `show_stats` bool stays coherent for
// pre-tier binaries reading the same settings file.
@@ -167,18 +150,6 @@ impl SettingsOverlay {
if let Some(v) = self.fullscreen_on_stream {
s.fullscreen_on_stream = v;
}
if let Some(v) = &self.present_priority {
s.present_priority = v.clone();
}
if let Some(v) = self.smooth_buffer {
s.smooth_buffer = v;
}
if let Some(v) = self.vsync {
s.vsync = v;
}
if let Some(v) = self.allow_vrr {
s.allow_vrr = v;
}
s
}
@@ -249,27 +220,12 @@ impl SettingsOverlay {
if after.gamepad != before.gamepad {
self.gamepad = Some(after.gamepad.clone());
}
if after.gamepad_forwarding != before.gamepad_forwarding {
self.gamepad_forwarding = Some(after.gamepad_forwarding);
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
if after.fullscreen_on_stream != before.fullscreen_on_stream {
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
}
if after.present_priority != before.present_priority {
self.present_priority = Some(after.present_priority.clone());
}
if after.smooth_buffer != before.smooth_buffer {
self.smooth_buffer = Some(after.smooth_buffer);
}
if after.vsync != before.vsync {
self.vsync = Some(after.vsync);
}
if after.allow_vrr != before.allow_vrr {
self.allow_vrr = Some(after.allow_vrr);
}
}
/// Drop one override by its overlay field name, putting the row back to inheriting. The
@@ -301,13 +257,8 @@ impl SettingsOverlay {
"invert_scroll" => self.invert_scroll = None,
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"gamepad_forwarding" => self.gamepad_forwarding = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
"present_priority" => self.present_priority = None,
"smooth_buffer" => self.smooth_buffer = None,
"vsync" => self.vsync = None,
"allow_vrr" => self.allow_vrr = None,
_ => return false,
}
true
@@ -482,10 +433,6 @@ mod tests {
assert_eq!((out.width, out.height), (1920, 1080));
assert_eq!(out.bitrate_kbps, 20000);
assert_eq!(out.codec, "hevc");
assert!(
out.gamepad_forwarding,
"default on, and an empty overlay leaves it alone"
);
assert!(empty.is_empty());
let overlay = SettingsOverlay {
@@ -505,14 +452,9 @@ mod tests {
invert_scroll: Some(true),
inhibit_shortcuts: Some(false),
gamepad: Some("dualsense".into()),
gamepad_forwarding: Some(false),
match_window: Some(true),
fullscreen_on_stream: Some(false),
stats_verbosity: Some(StatsVerbosity::Detailed),
present_priority: Some("smooth".into()),
smooth_buffer: Some(3),
vsync: Some(false),
allow_vrr: Some(false),
..Default::default()
};
assert!(!overlay.is_empty());
@@ -531,14 +473,9 @@ mod tests {
assert!(out.invert_scroll);
assert!(!out.inhibit_shortcuts);
assert_eq!(out.gamepad, "dualsense");
assert!(!out.gamepad_forwarding);
assert!(out.match_window);
assert!(!out.fullscreen_on_stream);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
assert_eq!(out.present_priority, "smooth");
assert_eq!(out.smooth_buffer, 3);
assert!(!out.vsync);
assert!(!out.allow_vrr);
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
// stays coherent with it.
assert!(out.show_stats);
@@ -636,59 +573,6 @@ mod tests {
assert!(o.is_empty());
}
/// The presentation cluster is first-class, not `extra` passengers: it applies,
/// absorbs, clears, and serialises under the exact keys the Apple client already
/// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog
/// has to round-trip through every platform, and a mismatched key would be carried
/// but never applied.
#[test]
fn presentation_cluster_is_first_class() {
let base = Settings::default();
let mut o = SettingsOverlay::default();
let before = o.apply(&base);
let mut after = before.clone();
after.present_priority = "smooth".into();
o.absorb(&before, &after);
let before = o.apply(&base);
let mut after = before.clone();
after.smooth_buffer = 1;
o.absorb(&before, &after);
assert_eq!(o.present_priority.as_deref(), Some("smooth"));
assert_eq!(o.smooth_buffer, Some(1));
assert!(
o.extra.is_empty(),
"modelled fields must never land in the passthrough"
);
let out = o.apply(&base);
assert_eq!(
out.present_priority(),
crate::trust::PresentPriority::Smooth { buffer: 1 }
);
// Serialised under the shared keys, and read back from a foreign client's file.
let text = serde_json::to_string(&o).unwrap();
assert!(text.contains("\"present_priority\":\"smooth\""), "{text}");
assert!(text.contains("\"smooth_buffer\":1"), "{text}");
let from_apple: SettingsOverlay = serde_json::from_str(
r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#,
)
.unwrap();
assert_eq!(from_apple.present_priority.as_deref(), Some("latency"));
assert_eq!(from_apple.smooth_buffer, Some(2));
assert_eq!(from_apple.vsync, Some(true));
assert_eq!(from_apple.allow_vrr, Some(false));
assert!(from_apple.extra.is_empty());
assert!(o.clear("present_priority"));
assert!(o.clear("smooth_buffer"));
assert_eq!(o.present_priority, None);
assert!(o.is_empty());
let mut vrr = from_apple;
assert!(vrr.clear("vsync"));
assert!(vrr.clear("allow_vrr"));
assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None));
}
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
#[test]
fn clear_drops_one_override() {
@@ -707,29 +591,6 @@ mod tests {
assert!(!o.clear("no_such_field"));
}
/// Controller forwarding defaults ON, so its interesting override is the FALSE one — and a
/// `false` that `apply` dropped would silently forward a pad the profile said not to.
/// `absorb` must record it, `clear` must undo it, and the serialized name both carry is the
/// one every client's reset button sends.
#[test]
fn gamepad_forwarding_overrides_off_and_resets_back() {
let base = Settings::default();
assert!(base.gamepad_forwarding, "the shipped default");
let mut o = SettingsOverlay::default();
let mut after = base.clone();
after.gamepad_forwarding = false;
o.absorb(&base, &after);
assert_eq!(o.gamepad_forwarding, Some(false));
assert!(!o.apply(&base).gamepad_forwarding);
assert!(o.clear("gamepad_forwarding"));
assert_eq!(o.gamepad_forwarding, None);
assert!(o.is_empty());
// Back to inheriting: the global's live value, not a remembered false.
assert!(o.apply(&base).gamepad_forwarding);
}
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
#[test]
+40 -25
View File
@@ -44,6 +44,14 @@ pub struct SessionParams {
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
pub echo_cancel: bool,
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
pub pad_haptics: bool,
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
/// off — see [`crate::pad_audio::speaker_active`]).
pub pad_speaker: String,
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
pub clipboard: bool,
@@ -356,6 +364,11 @@ fn pump(
);
}
}
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
let pad_speaker_on = crate::pad_audio::speaker_active(&params.pad_speaker);
let pad_audio_on = params.pad_haptics || pad_speaker_on;
let connector = match NativeClient::connect(
&params.host,
params.port,
@@ -379,6 +392,11 @@ fn pump(
0
}) | (if params.phase_lock {
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
} else {
0
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
}) | (if pad_audio_on {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
}),
@@ -424,31 +442,11 @@ 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);
// 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"
);
}
tracing::info!(
?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.
@@ -501,6 +499,20 @@ fn pump(
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
// thread (one puller per plane), blocking on the audio queue like the Apple client.
let audio_thread = spawn_audio(connector.clone(), stop.clone());
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
// the settings could render. The output device is opened LAZILY once frames actually
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
// a session without a wired DualSense costs one idle 10 ms poll loop.
let pad_audio_thread = pad_audio_on
.then(|| {
crate::pad_audio::spawn(
connector.clone(),
stop.clone(),
params.pad_haptics,
pad_speaker_on,
)
})
.flatten();
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
// away when the host has no clipboard capability, so spawning is unconditional.
@@ -1066,6 +1078,9 @@ fn pump(
if let Some(t) = audio_thread {
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
}
if let Some(t) = pad_audio_thread {
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
}
if let Some(t) = clipboard_thread {
let _ = t.join(); // exits within its next_clip wait once `stop` is set
}
+21 -164
View File
@@ -14,7 +14,6 @@ use anyhow::{anyhow, Context, Result};
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub fn config_dir() -> Result<PathBuf> {
@@ -788,45 +787,6 @@ impl MouseMode {
}
}
/// Presentation intent — what the presenter optimizes for
/// (design/desktop-presentation-rebuild.md; the Apple/Android clients' shared
/// `present_priority`/`smooth_buffer` pair). Stored stringly in
/// [`Settings::present_priority`] + [`Settings::smooth_buffer`]; resolved with
/// [`PresentPriority::resolve`], whose rules match the Android reference
/// (`decode/presenter.rs`): anything but an explicit `"smooth"` is latency, and a
/// smooth buffer outside 1..=3 (including 0 = Automatic) becomes 2.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PresentPriority {
/// Every frame presents the moment the display can take it; a network hiccup is an
/// occasional repeated or skipped frame. The default.
Latency,
/// A small frame buffer (13 frames) evens out network/decode jitter, at the
/// buffer's worth of added display latency.
Smooth { buffer: u8 },
}
impl PresentPriority {
/// The shared cross-client resolution rule — pure, so every embedder agrees on what
/// a foreign profile's values mean.
pub fn resolve(name: &str, buffer: u8) -> PresentPriority {
if name == "smooth" {
PresentPriority::Smooth {
buffer: if (1..=3).contains(&buffer) { buffer } else { 2 },
}
} else {
PresentPriority::Latency
}
}
/// Frames the smoothing store holds; `0` = newest-wins (the latency intent).
pub fn fifo_capacity(self) -> u8 {
match self {
PresentPriority::Latency => 0,
PresentPriority::Smooth { buffer } => buffer,
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -848,21 +808,6 @@ pub struct Settings {
/// container `#[serde(default)]`.
pub render_scale: f64,
pub gamepad: String,
/// Forward this device's controllers to the host at all. Default ON — that was the
/// unconditional behaviour before this became a setting.
///
/// Off is for the couch whose controller reaches the host by some *other* route: a USB
/// passthrough tool (VirtualHere and friends), or a pad simply plugged into the host
/// itself. Leaving forwarding on there gives the host two controllers for one pair of
/// hands, and games read both.
///
/// It is deliberately stronger than "send no input": with it off the client never
/// *opens* the controller, and opening is what grabs the hardware (SDL's HIDAPI drivers
/// take the hidraw node) — a held device is one a passthrough tool cannot bind. Menu
/// navigation in the launcher still opens the active pad, and the session releases it;
/// see [`crate::gamepad::GamepadService::set_forwarding`].
#[serde(default = "default_true")]
pub gamepad_forwarding: bool,
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
@@ -929,32 +874,6 @@ pub struct Settings {
/// `default = true`: the Linux stores never carried this and always advertised.
#[serde(default = "default_true")]
pub hdr_enabled: bool,
/// Presentation intent: `"latency"` (default) or `"smooth"` — the Apple/Android
/// clients' shared `present_priority` profile key, resolved with
/// [`PresentPriority::resolve`] (via [`Settings::present_priority`]). Anything
/// unknown reads as latency, so a newer client's future value degrades safely.
#[serde(default = "default_present_priority")]
pub present_priority: String,
/// Smoothness buffer size in frames: `0` = Automatic (resolves to 2), else 13.
/// Only meaningful under `present_priority = "smooth"` (the shared `smooth_buffer`
/// key). Each buffered frame absorbs about one refresh of jitter and adds one
/// refresh of display latency.
#[serde(default)]
pub smooth_buffer: u8,
/// Tear-free presentation (default ON = today's behavior: MAILBOX, FIFO fallback).
/// Off asks for a tearing present mode (IMMEDIATE) for the lowest possible latch
/// latency — best-effort: platforms/drivers without tearing silently stay tear-free
/// and the active mode is visible in the detailed stats. The shared `vsync` profile
/// key; the desktop default differs from macOS's (`false` there) deliberately —
/// sync-off means something different on each platform, the key is the contract.
#[serde(default = "default_true")]
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence: prefers the present
/// mode that drives VRR panels directly when fullscreen. Inert on fixed-refresh
/// displays (detection is measured from on-glass timestamps, not queried). The
/// shared `allow_vrr` profile key. Default ON, like the Apple client.
#[serde(default = "default_true")]
pub allow_vrr: bool,
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
@@ -993,6 +912,21 @@ pub struct Settings {
/// `PUNKTFUNK_AUDIO_SOURCE`).
#[serde(default)]
pub mic_device: String,
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
/// a wired DS5. `default` so pre-existing stores load with it on.
#[serde(default = "default_true")]
pub pad_haptics: bool,
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
#[serde(default = "default_pad_speaker")]
pub pad_speaker: String,
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
/// stream mode follows the session window — the connect asks for the window's pixel
/// size and a mid-session resize renegotiates the host's virtual display + encoder
@@ -1006,14 +940,6 @@ pub struct Settings {
/// the user will be looking at. `0` = never stored → the 1280×720 default.
pub last_window_w: u32,
pub last_window_h: u32,
/// Settings keys this build doesn't model (a newer client's field), carried through a
/// load→save round-trip untouched — [`crate::profiles::SettingsOverlay`]'s `extra`
/// pattern extended to the globals. Without it, every whole-file writer of this store
/// (two shells, the console settings screen, the session's resize callback, Decky)
/// running as an OLDER binary silently drops what a newer one persisted. Empty on
/// every existing store, and an empty map serializes to nothing, so files don't churn.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
fn default_codec() -> String {
@@ -1028,14 +954,14 @@ fn default_mouse_mode() -> String {
"capture".into()
}
fn default_present_priority() -> String {
"latency".into()
}
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).
@@ -1063,12 +989,6 @@ impl Settings {
MouseMode::from_name(&self.mouse_mode)
}
/// The presentation intent for this session (the resolved
/// `present_priority` × `smooth_buffer` pair).
pub fn present_priority(&self) -> PresentPriority {
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() {
@@ -1093,7 +1013,6 @@ impl Default for Settings {
bitrate_kbps: 0,
render_scale: 1.0,
gamepad: "auto".into(),
gamepad_forwarding: true,
forward_pad: String::new(),
compositor: "auto".into(),
touch_mode: "trackpad".into(),
@@ -1107,10 +1026,6 @@ impl Default for Settings {
adapter: String::new(),
enable_444: false,
hdr_enabled: true,
present_priority: "latency".into(),
smooth_buffer: 0,
vsync: true,
allow_vrr: true,
show_stats: true,
stats_verbosity: None,
fullscreen_on_stream: true,
@@ -1119,10 +1034,11 @@ 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,
extra: BTreeMap::new(),
}
}
}
@@ -1249,43 +1165,6 @@ mod tests {
}
}
/// A settings file predating the presentation cluster loads with the shipped
/// defaults (latency intent, Automatic buffer, tear-free, VRR allowed), and the
/// resolution rules match the Apple/Android reference: anything but an explicit
/// `"smooth"` is latency, and a smooth buffer outside 1..=3 becomes 2.
#[test]
fn settings_presentation_defaults_and_resolution() {
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
let s: Settings = serde_json::from_str(old).unwrap();
assert_eq!(s.present_priority, "latency");
assert_eq!(s.smooth_buffer, 0);
assert!(s.vsync);
assert!(s.allow_vrr);
assert_eq!(s.present_priority(), PresentPriority::Latency);
assert_eq!(
PresentPriority::resolve("smooth", 0),
PresentPriority::Smooth { buffer: 2 },
"Automatic resolves to 2"
);
assert_eq!(
PresentPriority::resolve("smooth", 3),
PresentPriority::Smooth { buffer: 3 }
);
assert_eq!(
PresentPriority::resolve("smooth", 9),
PresentPriority::Smooth { buffer: 2 },
"out-of-range pins to the Automatic resolution"
);
assert_eq!(
PresentPriority::resolve("balanced-from-the-future", 2),
PresentPriority::Latency,
"unknown intents degrade to latency"
);
assert_eq!(PresentPriority::Latency.fifo_capacity(), 0);
assert_eq!(PresentPriority::Smooth { buffer: 3 }.fifo_capacity(), 3);
}
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
#[test]
fn settings_forward_pad_defaults_empty() {
@@ -1334,28 +1213,6 @@ mod tests {
assert!(s.echo_cancel);
}
/// A key this build doesn't model (a newer client's setting) survives a load→save
/// round trip instead of being dropped by the next whole-file write — the same
/// contract `SettingsOverlay.extra` gives profiles. And when there are no unknown
/// keys, the flatten map adds nothing, so existing files don't churn.
#[test]
fn settings_unknown_keys_survive_round_trip() {
let newer = r#"{"width":1920,"height":1080,"frob_mode":"fancy","frob_level":3}"#;
let s: Settings = serde_json::from_str(newer).unwrap();
assert_eq!((s.width, s.height), (1920, 1080));
assert_eq!(
s.extra.get("frob_mode").and_then(|v| v.as_str()),
Some("fancy")
);
let out = serde_json::to_string(&s).unwrap();
assert!(out.contains(r#""frob_mode":"fancy""#), "{out}");
assert!(out.contains(r#""frob_level":3"#), "{out}");
// No unknown keys → no artifact of the passthrough field in the file.
let plain = serde_json::to_string(&Settings::default()).unwrap();
assert!(!plain.contains("extra"), "{plain}");
assert!(!plain.contains("frob"), "{plain}");
}
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
+2 -97
View File
@@ -321,88 +321,6 @@ 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 {
@@ -517,11 +435,7 @@ impl Decoder {
vaapi_tried = true;
match VaapiDecoder::new(codec_id) {
Ok(v) => {
tracing::info!(
?codec_id,
decoder = v.name(),
"VAAPI hardware decode active (zero-copy dmabuf)"
);
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
return done(Backend::Vaapi(v));
}
Err(e) => {
@@ -556,7 +470,6 @@ impl Decoder {
Ok(d) => {
tracing::info!(
?codec_id,
decoder = d.name(),
"D3D11VA hardware decode active (shared-texture hand-off)"
);
return done(Backend::D3d11va(d));
@@ -577,7 +490,6 @@ impl Decoder {
Ok(v) => {
tracing::info!(
?codec_id,
decoder = v.name(),
"Vulkan Video hardware decode active (presenter-shared device)"
);
return done(Backend::Vulkan(v));
@@ -608,11 +520,7 @@ impl Decoder {
if choice != "software" && choice != "vulkan" && !vaapi_tried {
match VaapiDecoder::new(codec_id) {
Ok(v) => {
tracing::info!(
?codec_id,
decoder = v.name(),
"VAAPI hardware decode active (zero-copy dmabuf)"
);
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
return done(Backend::Vaapi(v));
}
Err(e) => {
@@ -640,7 +548,6 @@ impl Decoder {
Ok(d) => {
tracing::info!(
?codec_id,
decoder = d.name(),
"D3D11VA hardware decode active (shared-texture hand-off)"
);
return done(Backend::D3d11va(d));
@@ -817,7 +724,6 @@ 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;
@@ -839,7 +745,6 @@ 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;
+5 -31
View File
@@ -552,10 +552,6 @@ 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
@@ -613,16 +609,10 @@ impl D3d11vaDecoder {
if !d3d11va_decode_supported(hw_device.as_ptr()) {
bail!("GPU can't create the D3D11VA decode surface pool");
}
// 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 codec = ffi::avcodec_find_decoder(codec_id.into());
if codec.is_null() {
bail!("no {codec_id:?} decoder");
}
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);
@@ -648,16 +638,10 @@ 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
@@ -846,7 +830,6 @@ impl D3d11vaDecoder {
src_desc.Height,
index,
color.is_pq(),
&self.name,
);
Ok(D3d11Frame {
width,
@@ -900,15 +883,7 @@ 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,
decoder: &str,
) {
fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, pq: bool) {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
@@ -919,7 +894,6 @@ fn log_layout_once(
tex_h,
slice = index,
pq,
decoder,
"D3D11VA first frame"
);
}
@@ -34,12 +34,6 @@ 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 })
}
+6 -22
View File
@@ -46,10 +46,6 @@ 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
@@ -84,15 +80,11 @@ 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")?;
// 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);
// 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");
}
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);
@@ -117,16 +109,10 @@ 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
@@ -221,7 +207,7 @@ impl VaapiDecoder {
// a single modifier for the texture.
let modifier = d.objects[0].format_modifier;
log_descriptor_once(d, sw_format, fourcc, modifier, &self.name);
log_descriptor_once(d, sw_format, fourcc, modifier);
Ok(DmabufFrame {
width: (*self.frame).width as u32,
@@ -247,7 +233,6 @@ 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);
@@ -265,7 +250,6 @@ fn log_descriptor_once(
nb_layers = d.nb_layers,
?layers,
modifier = format_args!("{:#018x}", modifier),
decoder,
"VAAPI dmabuf descriptor layout (first frame)"
);
}
+4 -22
View File
@@ -33,10 +33,6 @@ 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`.
@@ -249,15 +245,10 @@ impl VulkanDecoder {
}
let vk_device = (*hwctx).act_dev;
// 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 codec = ffi::avcodec_find_decoder(codec_id.into());
if codec.is_null() {
bail!("no {codec_id:?} decoder");
}
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);
@@ -279,17 +270,11 @@ 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
@@ -403,7 +388,6 @@ impl VulkanDecoder {
(*fc).width,
(*fc).height,
sw,
&self.name,
);
Ok(VkVideoFrame {
vkframe: vkf as usize,
@@ -439,7 +423,6 @@ 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);
@@ -450,7 +433,6 @@ fn log_layout_once(
pool_w,
pool_h,
?sw,
decoder,
"Vulkan Video first frame"
);
}
+7 -151
View File
@@ -26,14 +26,9 @@ enum RowId {
Decoder,
Hdr,
Chroma444,
PresentPriority,
SmoothBuffer,
Vsync,
AllowVrr,
Audio,
Mic,
EchoCancel,
PadForward,
Pad,
PadType,
Touch,
@@ -51,7 +46,7 @@ enum RowId {
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
const ROWS: [RowId; 27] = [
const ROWS: [RowId; 22] = [
RowId::Resolution,
RowId::Refresh,
RowId::RenderScale,
@@ -61,14 +56,9 @@ const ROWS: [RowId; 27] = [
RowId::Decoder,
RowId::Hdr,
RowId::Chroma444,
RowId::PresentPriority,
RowId::SmoothBuffer,
RowId::Vsync,
RowId::AllowVrr,
RowId::Audio,
RowId::Mic,
RowId::EchoCancel,
RowId::PadForward,
RowId::Pad,
RowId::PadType,
RowId::Touch,
@@ -127,17 +117,6 @@ const DECODERS: [(&str, &str); 4] = [
("software", "Software"),
];
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
/// Presentation intent — the `present_priority` key shared with the Apple and Android
/// clients, so one profile reads the same on every device.
const PRESENT_PRIORITIES: [(&str, &str); 2] =
[("latency", "Lowest latency"), ("smooth", "Smoothness")];
/// Smoothness buffer depth in frames; `0` = Automatic (resolves to 2).
const SMOOTH_BUFFERS: [(u8, &str); 4] = [
(0, "Automatic"),
(1, "1 frame"),
(2, "2 frames"),
(3, "3 frames"),
];
const PAD_TYPES: [(&str, &str); 6] = [
("auto", "Automatic"),
("xbox360", "Xbox 360"),
@@ -243,18 +222,9 @@ impl SettingsScreen {
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
let s = &ctx.settings;
// Several rows follow another: echo cancellation only means anything while the mic
// streams, the pad rows only while any controller is forwarded at all, and the
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
// — the same relationship the desktop shells draw by greying a row out (they hide the
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
// move everything under the cursor).
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
// Echo cancellation only means anything while the mic streams — dimmed and inert while it
// doesn't, the same relationship the desktop shells draw with a greyed-out row.
let enabled = !matches!(id, RowId::EchoCancel) || s.mic_enabled;
let (header, label, value): (Option<&'static str>, &str, String) = match id {
RowId::Resolution => (
Some("Stream"),
@@ -309,22 +279,6 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
RowId::PresentPriority => (
Some("Presentation"),
"Prioritize",
label_for(&PRESENT_PRIORITIES, &s.present_priority).into(),
),
RowId::SmoothBuffer => (
None,
"Smoothness buffer",
SMOOTH_BUFFERS
.iter()
.find(|(v, _)| *v == s.smooth_buffer)
.map_or("Automatic", |(_, l)| l)
.into(),
),
RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()),
RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()),
RowId::Audio => (
Some("Audio"),
"Audio channels",
@@ -336,13 +290,8 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
),
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
RowId::PadForward => (
Some("Controller"),
"Forward controllers",
on_off(s.gamepad_forwarding).into(),
),
RowId::Pad => (
None,
Some("Controller"),
"Use controller",
if s.forward_pad.is_empty() {
"Automatic".into()
@@ -416,26 +365,7 @@ fn detail(id: RowId) -> &'static str {
}
RowId::Chroma444 => {
"Full-colour video: crisp small text and thin lines, at more bandwidth. \
Needs an NVIDIA host (NVENC) or the PyroWave codec other encoders \
stream 4:2:0 and the session falls back silently."
}
RowId::PresentPriority => {
"Lowest latency shows each frame the moment the display can take it — a \
network hiccup becomes an occasional repeated or skipped frame. Smoothness \
buffers a little to even those out."
}
RowId::SmoothBuffer => {
"Frames held back before showing. Each one absorbs about a refresh of network \
hiccup and adds a refresh of delay. Automatic holds two."
}
RowId::Vsync => {
"Tear-free. Off removes the wait for the screen's refresh — the lowest \
possible delay, at the cost of visible tearing. Not every driver offers it; \
the stats overlay names the mode actually in use."
}
RowId::AllowVrr => {
"On a VRR screen, let the panel refresh in step with the stream instead of on \
a fixed cadence. Applies to fullscreen sessions; harmless on a fixed screen."
HEVC only, and only where the host can encode it."
}
RowId::Audio => "The speaker layout requested from the host.",
RowId::Mic => {
@@ -446,11 +376,6 @@ fn detail(id: RowId) -> &'static str {
"Stops the host's audio, playing from this device's speakers, being picked up \
and sent back. Turn it off if your microphone already runs its own processing."
}
RowId::PadForward => {
"Send controllers connected to this device to the host. Turn it off when your \
controller already reaches the host another way USB passthrough such as \
VirtualHere, or a pad plugged into the host so games don't see two of them."
}
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::Touch => {
@@ -537,27 +462,6 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
RowId::PresentPriority => {
let cur = PRESENT_PRIORITIES
.iter()
.position(|(v, _)| *v == s.present_priority);
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
}
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
RowId::SmoothBuffer => {
if s.present_priority == "smooth" {
let cur = SMOOTH_BUFFERS
.iter()
.position(|(v, _)| *v == s.smooth_buffer);
step_option(cur, SMOOTH_BUFFERS.len(), delta, wrap)
.map(|i| s.smooth_buffer = SMOOTH_BUFFERS[i].0)
} else {
None
}
}
RowId::Vsync => toggle(&mut s.vsync, delta, wrap),
RowId::AllowVrr => toggle(&mut s.allow_vrr, delta, wrap),
RowId::Audio => {
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
@@ -571,11 +475,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
None
}
}
RowId::PadForward => toggle(&mut s.gamepad_forwarding, delta, wrap),
RowId::Pad => {
if !s.gamepad_forwarding {
return false;
}
// Automatic first, then every connected pad by stable key.
let keys: Vec<String> = std::iter::once(String::new())
.chain(ctx.pads.iter().map(|p| p.key.clone()))
@@ -583,12 +483,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
let cur = keys.iter().position(|c| *c == s.forward_pad);
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
}
RowId::PadType => {
if !s.gamepad_forwarding {
return false;
}
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
}
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)
@@ -752,45 +647,6 @@ mod tests {
assert!(ctx.settings.echo_cancel);
}
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
/// row list dims it, because a row vanishing mid-list would shift everything under the
/// cursor.
#[test]
fn smoothness_buffer_follows_the_intent() {
let (mut settings, pads) = ctx_parts();
assert_eq!(settings.present_priority, "latency", "the shipped default");
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
);
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
// Stepping the intent to Smoothness brings the buffer row to life.
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "smooth");
assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled);
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
assert_eq!(ctx.settings.smooth_buffer, 1);
// The intent wraps back and the row goes inert again.
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "latency");
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
}
#[test]
fn touch_mode_steps_and_wraps() {
let (mut settings, pads) = ctx_parts();
+51 -2
View File
@@ -10,14 +10,20 @@ use punktfunk_core::quic::HidOutput;
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
/// fire).
#[derive(Clone, Default)]
pub struct HidoutDedup {
led: Option<(u8, u8, u8)>,
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
audio_ctl: Option<(u8, [u8; 6])>,
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
haptics_select_logged: bool,
}
impl HidoutDedup {
@@ -60,6 +66,25 @@ impl HidoutDedup {
}
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true,
HidOutput::AudioCtl { pad, flags, raw } => {
let v = Some((*flags, *raw));
if self.audio_ctl == v {
false
} else {
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
// audio haptics (not plain rumble emulation, whose all-zero audio region
// never reaches here) — the trace that tells "the game does audio haptics"
// apart from "the client just doesn't render them".
if flags & 0x01 != 0 && !self.haptics_select_logged {
self.haptics_select_logged = true;
tracing::info!(
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
);
}
self.audio_ctl = v;
true
}
}
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
@@ -123,4 +148,28 @@ mod tests {
assert!(d.should_forward(&pl(0b101)));
assert!(d.should_forward(&trig(0, 2)));
}
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
#[test]
fn audio_ctl_dedups_by_value() {
let mut d = HidoutDedup::default();
let audio = |flags, vol| HidOutput::AudioCtl {
pad: 0,
flags,
raw: [vol, 0, 0, 0, 0, 0],
};
// Identical twice → exactly one emission.
assert!(d.should_forward(&audio(0x17, 0x50)));
assert!(!d.should_forward(&audio(0x17, 0x50)));
// Either half changing (flags, or the raw region) forwards again.
assert!(d.should_forward(&audio(0x16, 0x50)));
assert!(d.should_forward(&audio(0x16, 0x60)));
// The other kinds' state is untouched by audio traffic.
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }));
// `clear` (pad re-plug) re-arms the value dedup.
d.clear();
assert!(d.should_forward(&audio(0x16, 0x60)));
}
}
@@ -481,7 +481,8 @@ pub struct DsFeedback {
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
/// player LEDs) are surfaced — adaptive-trigger blocks and the audio-control region are
/// forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
@@ -540,6 +541,21 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
});
}
}
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
// whenever an audio-valid flag is present or the region carries data; downstream dedup
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
let raw: [u8; 6] = data[5..11].try_into().unwrap();
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
fb.hidout.push(HidOutput::AudioCtl {
pad: pad.into(),
flags,
raw,
});
}
}
#[cfg(test)]
@@ -842,6 +858,48 @@ mod tests {
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
}
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
#[test]
fn parse_output_surfaces_audio_ctl() {
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
data[5] = 0x50; // headphone volume
data[6] = 0x60; // speaker volume
data[7] = 0x70; // mic volume
data[8] = 0x05; // audio routing / enable bits
let mut fb = DsFeedback::default();
parse_ds_output(3, &data, &mut fb);
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
assert_eq!(
fb.hidout,
vec![HidOutput::AudioCtl {
pad: 3,
flags: 0b1_0111,
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
}]
);
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
// the honest bytes either way.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[9] = 0x01;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(
fb.hidout,
vec![HidOutput::AudioCtl {
pad: 0,
flags: 0,
raw: [0, 0, 0, 0, 0x01, 0],
}]
);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
@@ -475,6 +475,7 @@ mod tests {
index: 2,
kind: 1,
capabilities: 0,
audio_caps: 0,
});
assert!(m.slots.get(2).is_some());
}
-2
View File
@@ -52,8 +52,6 @@ pub mod keymap_sdl;
#[cfg(any(target_os = "linux", windows))]
pub mod overlay;
#[cfg(any(target_os = "linux", windows))]
mod present_pace;
#[cfg(any(target_os = "linux", windows))]
mod run;
#[cfg(any(target_os = "linux", windows))]
pub mod touch;
-751
View File
@@ -1,751 +0,0 @@
//! The presentation intent engine (design/desktop-presentation-rebuild.md WP2): the
//! store, clock, and gate the run loop composes into the two intents.
//!
//! * [`FrameStore`] — newest-wins slot (latency) or smoothing FIFO with preroll
//! (smoothness), ported from the Apple `FrameStore` / Android `presenter.rs` so all
//! three clients agree on what the intents mean.
//! * [`LatchClock`] — the panel latch grid, learned from `VK_KHR_present_wait` on-glass
//! stamps (measured, never queried — the Android refresh-rate lie and VRR both punish
//! trusting a reported rate). Without present-wait it degrades to a grid rooted at the
//! last submit on the mode's refresh period.
//! * [`PresentGate`] — the FIFO glass budget: one undisplayed present in flight, so the
//! swapchain's own queue can never become a standing queue (+1 refresh per slot,
//! forever — the law every bounded-FIFO pacing rediscovered on Apple). MAILBOX cannot
//! queue and never needs it.
//!
//! Everything here is pure state + arithmetic on `CLOCK_REALTIME` ns (the
//! `pf_client_core::session::now_ns` domain the on-glass stamps live in); the run loop
//! owns all clocks and Vulkan calls, which is what keeps this testable.
use std::collections::VecDeque;
/// Stale-present force-open: an undisplayed present older than this is presumed lost
/// (occluded window, wedged compositor) and the gate opens anyway, counted as `forced`
/// — reads 0 on healthy systems. The Apple/Android presenters use the same 100 ms.
const STALE_REOPEN_NS: u64 = 100_000_000;
/// The adaptive slot-pick margin's ceiling and step (Android's measured values: start
/// at 0 — a fixed lead was pure display tax on the reference device — and widen only
/// when measured misses demand it).
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
/// The decoded-frame store between the wake channel and the present call.
///
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
/// `capacity 1..=3` = smoothing FIFO: preroll-to-capacity, drop-oldest on overflow,
/// an underflow after preroll re-arms the preroll (the previous frame persists on
/// glass — a repeat by omission) while headroom rebuilds.
pub(crate) struct FrameStore<T> {
capacity: usize,
frames: VecDeque<T>,
prerolled: bool,
/// Newest-wins displacements (normal operation under latency, not a fault signal).
replaced: u32,
/// FIFO drop-oldest evictions — the Apple debug line's `qDrop`.
overflow_drops: u32,
/// FIFO dry-after-preroll events — `qDry`.
underflows: u32,
}
impl<T> FrameStore<T> {
pub(crate) fn new(capacity: usize) -> FrameStore<T> {
FrameStore {
capacity,
frames: VecDeque::with_capacity(capacity.max(1) + 1),
prerolled: false,
replaced: 0,
overflow_drops: 0,
underflows: 0,
}
}
pub(crate) fn is_smoothing(&self) -> bool {
self.capacity > 0
}
pub(crate) fn is_empty(&self) -> bool {
self.frames.is_empty()
}
pub(crate) fn submit(&mut self, f: T) {
if self.capacity == 0 {
if self.frames.pop_front().is_some() {
self.replaced += 1;
}
self.frames.push_back(f);
} else {
self.frames.push_back(f);
// Drop the OLDEST past capacity: bounded added latency, the newest keeps
// flowing. Also trims a transient capacity+1 a put_back left behind.
while self.frames.len() > self.capacity {
self.frames.pop_front();
self.overflow_drops += 1;
}
}
}
pub(crate) fn take(&mut self) -> Option<T> {
if self.capacity == 0 {
return self.frames.pop_front();
}
if !self.prerolled {
// Preroll gate: without it a steady stream drains every frame on arrival
// and jitter headroom never builds (the Apple store's lesson).
if self.frames.len() < self.capacity {
return None;
}
self.prerolled = true;
}
match self.frames.pop_front() {
Some(f) => Some(f),
None => {
self.underflows += 1;
self.prerolled = false;
None
}
}
}
/// A frame taken but not presented (gate closed, present failed before consuming
/// it). Newest-wins reinserts only into an empty slot — a fresher decode wins;
/// FIFO puts it back at the front (it is the oldest).
pub(crate) fn put_back(&mut self, f: T) {
if self.capacity == 0 {
if self.frames.is_empty() {
self.frames.push_back(f);
}
} else {
self.frames.push_front(f);
}
}
/// Collapse to newest-wins for the rest of the stream (PyroWave: its plane-ring
/// retirement accounting assumes the depth-2 newest-wins hand-off, and its all-intra
/// frames make buffering pointless anyway).
///
/// Gated with its only caller: the power-user build (`--no-default-features`, which
/// the Windows ARM64 leg ships) has no PyroWave decode path, and an ungated helper
/// is dead code there.
#[cfg(feature = "pyrowave")]
pub(crate) fn force_latency(&mut self) {
if self.capacity == 0 {
return;
}
self.capacity = 0;
self.prerolled = false;
while self.frames.len() > 1 {
self.frames.pop_front();
}
}
/// Drain the window's counters: `(replaced, overflow_drops, underflows)`.
pub(crate) fn take_counters(&mut self) -> (u32, u32, u32) {
let c = (self.replaced, self.overflow_drops, self.underflows);
self.replaced = 0;
self.overflow_drops = 0;
self.underflows = 0;
c
}
}
/// The panel latch grid: a recent on-glass instant + the latch period, extrapolated
/// forward for slot targeting.
///
/// The period learner is the SHARED [`punktfunk_core::phase::PanelGrid`], not a local
/// rule. An earlier version of this clock capped the learned period at the display
/// mode's refresh, on the reasoning that a stream running below panel rate spaces its
/// presents at k×period and the cap stops a 30 fps stream claiming a 30 Hz panel. That
/// cap is the same defect the Android presenter shipped in 0.23.0: the seed is only what
/// the *mode* claims, and when the real panel is slower (a refused mode switch, a
/// compositor running its own rate) a downward-only learner pins a grid that never
/// arrives, for the whole session, with no way back. `PanelGrid` moves both ways —
/// narrowing at once, widening only after eight consecutive agreeing observations and
/// then to the narrowest of them.
///
/// What is fed to it is still the window's MIN spacing: within one window that resists
/// the k×period inflation the old cap was aimed at, while the streak requirement means a
/// genuinely slower panel is still discovered. Same grid the host-facing `LatchGrid`
/// publish reads, so the phase-lock report and the local scheduler cannot disagree.
pub(crate) struct LatchClock {
anchor_ns: u64,
/// The previous stamp, kept ACROSS calls. The run loop drains present-wait samples
/// every pass, so a "batch" is very often a single stamp — computing spacings only
/// within a batch (`windows(2)`) observed nothing at all on glass, and the learner
/// silently ran on its seed forever.
last_ns: u64,
/// Narrowest spacing seen since the last handoff to the grid, and how many have
/// accumulated. The grid is fed the MIN of a run rather than every spacing: our
/// observations are the spacing of OUR presents, which is k×period whenever the
/// stream runs below panel rate, and the min over a run is the best available
/// estimate of the true grid step.
pending_min_ns: u64,
pending_count: u32,
grid: punktfunk_core::phase::PanelGrid,
fallback_period_ns: u64,
}
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
/// mode change is picked up in well under a second at any sane frame rate.
const GRID_OBSERVE_EVERY: u32 = 16;
impl LatchClock {
pub(crate) fn new(refresh_hz: u32) -> LatchClock {
LatchClock {
anchor_ns: 0,
last_ns: 0,
pending_min_ns: 0,
pending_count: 0,
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
}
}
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
/// the learner.
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
for &s in stamps {
if self.last_ns != 0 && s > self.last_ns {
let d = s - self.last_ns;
// < 1 ms apart = a queued pair, not a grid step.
if d > 1_000_000 {
self.pending_min_ns = if self.pending_min_ns == 0 {
d
} else {
self.pending_min_ns.min(d)
};
self.pending_count += 1;
if self.pending_count >= GRID_OBSERVE_EVERY {
self.grid.observe(self.pending_min_ns as i64);
self.pending_min_ns = 0;
self.pending_count = 0;
}
}
}
self.last_ns = s;
}
if let Some(&last) = stamps.last() {
self.anchor_ns = last;
}
}
pub(crate) fn period_ns(&self) -> u64 {
let learned = self.grid.period_ns();
if learned > 0 {
learned as u64
} else {
self.fallback_period_ns
}
}
pub(crate) fn anchor_ns(&self) -> u64 {
self.anchor_ns
}
/// The first predicted latch strictly after `after_ns` (`anchor + k·period`). With
/// no anchor yet: one period out — callers get a usable, if unanchored, deadline.
pub(crate) fn next_slot_after(&self, after_ns: u64) -> u64 {
let p = self.period_ns();
if self.anchor_ns == 0 || after_ns < self.anchor_ns {
return after_ns.saturating_add(p);
}
let k = (after_ns - self.anchor_ns) / p + 1;
self.anchor_ns + k * p
}
}
/// Whether the panel is refreshing on a fixed grid or following our cadence.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum Cadence {
/// Not enough evidence yet — say nothing rather than guess.
#[default]
Unknown,
/// On-glass instants land on multiples of the panel period: a fixed-refresh panel.
Fixed,
/// On-glass instants track our present spacing instead: variable refresh is live.
Variable,
}
impl Cadence {
pub(crate) fn label(self) -> &'static str {
match self {
Cadence::Unknown => "",
Cadence::Fixed => "no",
Cadence::Variable => "yes",
}
}
}
/// Is variable refresh actually live? **Measured, never queried** — no portable query
/// exists (SDL exposes none, Wayland does not report adaptive-sync state, and Windows
/// surfaces nothing through Vulkan), and the platforms that *do* answer have been caught
/// lying before (Android reports a game-uid's down-rated refresh as the panel's).
///
/// The discriminator is quantization. On a fixed-refresh panel every on-glass instant
/// lands on the vblank grid, so the spacing between consecutive presents is always
/// ~k×period for whole k — even when the stream runs slower than the panel, where it just
/// picks a larger k. Under real VRR the panel refreshes *when we present*, so the spacing
/// follows our own cadence and sits wherever it likes relative to the grid.
///
/// So: fold each delta to its distance from the nearest multiple of the period. Tight
/// against the grid ⇒ Fixed; consistently off it ⇒ Variable. A stream running exactly at
/// panel rate is indistinguishable either way (both give delta ≈ period), which is
/// harmless — at that rate VRR has nothing to do.
pub(crate) struct CadenceProbe {
/// Off-grid distances as a fraction of the period, in thousandths.
off_grid_milli: Vec<u32>,
/// Previous stamp, kept across calls for the same reason [`LatchClock`] does: the
/// live drain hands over one sample at a time.
last_ns: u64,
/// The last round's raw reading and how many rounds have agreed — a verdict is only
/// published once [`CADENCE_STABLE_ROUNDS`] agree.
candidate: Cadence,
agree_rounds: u8,
verdict: Cadence,
}
/// Enough deltas to distinguish jitter from a real off-grid cadence.
const CADENCE_MIN_SAMPLES: usize = 24;
/// Consecutive agreeing rounds before a verdict is published.
///
/// ⭐ On glass (GNOME/Wayland, .21, 2026-08-02) the raw per-round verdict FLAPPED between
/// runs with VRR provably disabled. The cause is structural, not a tuning miss: under a
/// compositor our on-glass stamp is the compositor's release, so anything that perturbs
/// delivery — an occluded or unfocused surface being throttled, a distressed pipeline
/// missing vblanks — smears the spacings exactly the way real VRR does. This probe can
/// therefore only ever say "presents are not landing on the grid", so it demands
/// agreement across rounds and refuses evidence from a distressed window (see
/// [`CadenceProbe::note`]'s `healthy` flag) before claiming anything.
const CADENCE_STABLE_ROUNDS: u8 = 2;
/// Median off-grid distance under this fraction of a period reads as grid-locked. Present
/// stamps carry real measurement jitter (the wait returns, then we read the clock), so
/// this is deliberately loose — the two regimes differ by far more than this in practice.
const CADENCE_FIXED_MILLI: u32 = 150;
impl CadenceProbe {
pub(crate) fn new() -> CadenceProbe {
CadenceProbe {
off_grid_milli: Vec::with_capacity(64),
last_ns: 0,
candidate: Cadence::Unknown,
agree_rounds: 0,
verdict: Cadence::Unknown,
}
}
/// Fold on-glass stamps against the learned panel period. Spacings are measured
/// against the previous stamp whatever the batching.
///
/// `healthy` is the caller's statement that this window's presents were flowing
/// normally (no stale force-opens). A distressed pipeline smears spacings for reasons
/// that have nothing to do with the panel, so its evidence is dropped — the timeline
/// continuity is still advanced, it simply does not count as a sample.
pub(crate) fn note(&mut self, stamps: &[u64], period_ns: u64, healthy: bool) {
if period_ns == 0 || !healthy {
self.last_ns = stamps.last().copied().unwrap_or(self.last_ns);
return;
}
for &s in stamps {
let prev = std::mem::replace(&mut self.last_ns, s);
if prev == 0 || s <= prev {
continue;
}
let delta = s - prev;
let rem = delta % period_ns;
// Distance to the NEAREST multiple, so a delta just under k×period reads as
// close to the grid rather than a whole period away from k-1.
let off = rem.min(period_ns - rem);
self.off_grid_milli
.push((off.saturating_mul(1000) / period_ns) as u32);
// A round closes on the SAMPLE count, inside the loop — not once per call.
// Evaluating per call would make the verdict depend on how the caller happens
// to batch its stamps (one big batch = one round, forever short of the
// agreement requirement), and the live drain and the tests batch differently.
self.close_round_if_ready();
}
}
/// Publish a verdict once a round's worth of spacings agree with the previous round.
fn close_round_if_ready(&mut self) {
if self.off_grid_milli.len() >= CADENCE_MIN_SAMPLES {
self.off_grid_milli.sort_unstable();
let median = self.off_grid_milli[self.off_grid_milli.len() / 2];
let round = if median <= CADENCE_FIXED_MILLI {
Cadence::Fixed
} else {
Cadence::Variable
};
if round == self.candidate {
self.agree_rounds = self.agree_rounds.saturating_add(1);
} else {
self.candidate = round;
self.agree_rounds = 1;
}
if self.agree_rounds >= CADENCE_STABLE_ROUNDS {
self.verdict = round;
}
self.off_grid_milli.clear();
}
}
pub(crate) fn verdict(&self) -> Cadence {
self.verdict
}
/// A mode switch / display change invalidates the evidence.
pub(crate) fn reset(&mut self) {
self.off_grid_milli.clear();
self.last_ns = 0;
self.candidate = Cadence::Unknown;
self.agree_rounds = 0;
self.verdict = Cadence::Unknown;
}
}
/// The FIFO glass budget: at most one undisplayed present in flight, measured by the
/// present-wait waiter's outstanding count. Never consulted under MAILBOX/IMMEDIATE
/// (they cannot queue) or without present-wait (nothing to count with — behavior is
/// then exactly the shipped arrival pacing).
#[derive(Default)]
pub(crate) struct PresentGate {
/// Submit stamp of the newest tracked present; 0 = none yet.
last_present_ns: u64,
gated: u32,
forced: u32,
}
impl PresentGate {
/// May a new present go out? Open when nothing undisplayed is in flight; a stale
/// in-flight present (occlusion, wedged compositor) force-opens after 100 ms so the
/// stream survives, counted as `forced`.
pub(crate) fn open(&mut self, outstanding: usize, now_ns: u64) -> bool {
if outstanding == 0 {
return true;
}
if self.last_present_ns != 0
&& now_ns.saturating_sub(self.last_present_ns) > STALE_REOPEN_NS
{
self.forced += 1;
return true;
}
self.gated += 1;
false
}
pub(crate) fn note_present(&mut self, now_ns: u64) {
self.last_present_ns = now_ns;
}
/// Drain the window's counters: `(gated, forced)`.
pub(crate) fn take_counters(&mut self) -> (u32, u32) {
let c = (self.gated, self.forced);
self.gated = 0;
self.forced = 0;
c
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Newest-wins: submit replaces, take clears, put_back only fills an empty slot.
#[test]
fn newest_wins_replaces_and_putback_never_clobbers() {
let mut s: FrameStore<u32> = FrameStore::new(0);
assert!(!s.is_smoothing());
assert_eq!(s.take(), None);
s.submit(1);
s.submit(2);
s.submit(3);
assert_eq!(s.take(), Some(3), "only the newest survives");
assert_eq!(s.take(), None);
// A taken-but-unpresented frame returns — unless a fresher one arrived.
s.submit(4);
let f = s.take().unwrap();
s.put_back(f);
assert_eq!(s.take(), Some(4));
let f = s.take();
assert_eq!(f, None);
s.submit(5);
let f = s.take().unwrap();
s.submit(6);
s.put_back(f); // 6 arrived while 5 was out — 6 wins
assert_eq!(s.take(), Some(6));
assert_eq!(
s.take_counters(),
(2, 0, 0),
"two displacements, no fifo counters"
);
}
/// FIFO: preroll to capacity, drop-oldest overflow, underflow re-arms the preroll.
#[test]
fn fifo_prerolls_overflows_oldest_and_rearms_on_dry() {
let mut s: FrameStore<u32> = FrameStore::new(2);
assert!(s.is_smoothing());
s.submit(1);
assert_eq!(s.take(), None, "prerolling: below capacity, nothing vends");
s.submit(2);
assert_eq!(s.take(), Some(1), "preroll reached — FIFO order");
assert_eq!(
s.take(),
Some(2),
"once prerolled the buffer drains normally"
);
// Dry after preroll = one underflow, preroll re-arms.
assert_eq!(s.take(), None);
s.submit(3);
assert_eq!(s.take(), None, "re-armed preroll holds again");
s.submit(4);
assert_eq!(s.take(), Some(3));
// Overflow drops the OLDEST: [4] → [4,5] → 6 evicts 4 → 7 evicts 5.
s.submit(5);
s.submit(6);
s.submit(7);
assert_eq!(s.take(), Some(6));
assert_eq!(s.take(), Some(7));
let (replaced, drops, dry) = s.take_counters();
assert_eq!(replaced, 0);
assert_eq!(drops, 2, "6 evicted 4, 7 evicted 5");
assert_eq!(dry, 1);
}
/// put_back under FIFO goes to the FRONT (it is the oldest), and the transient
/// capacity+1 is trimmed by the next submit.
#[test]
fn fifo_putback_restores_order() {
let mut s: FrameStore<u32> = FrameStore::new(2);
s.submit(1);
s.submit(2);
let f = s.take().unwrap();
s.put_back(f);
assert_eq!(s.take(), Some(1), "the put-back frame is still first");
}
/// force_latency collapses a smoothing store to a newest-wins slot mid-stream.
#[cfg(feature = "pyrowave")]
#[test]
fn force_latency_collapses_to_one_slot() {
let mut s: FrameStore<u32> = FrameStore::new(3);
s.submit(1);
s.submit(2);
s.submit(3);
s.force_latency();
assert!(!s.is_smoothing());
assert_eq!(s.take(), Some(3), "only the newest survives the collapse");
s.submit(4);
s.submit(5);
assert_eq!(s.take(), Some(5));
}
/// The clock learns the min positive spacing (capped at the mode refresh), anchors
/// on the newest stamp, and extrapolates the next slot; sub-ms pairs (a queued
/// double-present) never become the period.
#[test]
fn latch_clock_learns_and_extrapolates() {
const P: u64 = 16_666_666; // 60 Hz
let mut c = LatchClock::new(60);
assert_eq!(c.period_ns(), P, "fallback = the mode refresh");
// No anchor: a usable deadline one period out.
assert_eq!(c.next_slot_after(1_000), 1_000 + P);
c.note_batch(&[1_000_000_000, 1_000_000_000 + P, 1_000_000_000 + 2 * P]);
assert_eq!(c.period_ns(), P);
assert_eq!(c.anchor_ns(), 1_000_000_000 + 2 * P);
let next = c.next_slot_after(c.anchor_ns());
assert_eq!(next, 1_000_000_000 + 3 * P);
// Mid-slot query lands on the same boundary; a later one steps whole periods.
assert_eq!(c.next_slot_after(next - 1), next);
assert_eq!(c.next_slot_after(next), next + P);
// A queued pair (< 1 ms apart) must not poison the period.
c.note_batch(&[2_000_000_000, 2_000_000_500]);
assert_eq!(c.period_ns(), P);
assert_eq!(c.anchor_ns(), 2_000_000_500, "the anchor still advances");
// A stream presenting every OTHER refresh spaces its glass stamps at 2×P. One
// such window must NOT move the grid — the shared learner needs a streak before
// it will widen, which is what keeps a briefly-slow stream from claiming a slow
// panel while still allowing a genuinely slower display to be discovered.
c.note_batch(&[3_000_000_000, 3_000_000_000 + 2 * P]);
assert_eq!(c.period_ns(), P, "one wide window is not a slower panel");
// A single stamp re-anchors without touching the period.
c.note_batch(&[5_000_000_000]);
assert_eq!(c.anchor_ns(), 5_000_000_000);
assert_eq!(c.period_ns(), P);
// A faster panel learns its own finer grid.
let mut fast = LatchClock::new(120);
fast.note_batch(&[1_000_000_000, 1_008_333_333]);
assert_eq!(fast.period_ns(), 8_333_333);
}
/// ⭐ The live loop drains present-wait samples EVERY pass, so stamps arrive one at a
/// time. Measuring spacings only within a batch meant the learner observed nothing on
/// glass and silently ran on its seed (found on .21, 2026-08-02: `period_us` read back
/// exactly the 60 Hz fallback while the panel really was 60 Hz — correct by luck, and
/// wrong the moment the mode lies).
#[test]
fn latch_clock_learns_from_one_sample_at_a_time() {
const REAL: u64 = 16_666_666;
let mut c = LatchClock::new(120); // seeded too fast, as a refused mode switch would
let mut t = 1_000_000_000u64;
for _ in 0..(GRID_OBSERVE_EVERY * 8 + 8) {
t += REAL;
c.note_batch(&[t]); // ONE stamp per call — the live shape
}
assert_eq!(
c.period_ns(),
REAL,
"single-stamp batches must still feed the grid learner"
);
assert_eq!(c.anchor_ns(), t);
}
/// The mode's refresh is a CLAIM, not a measurement — a refused mode switch or a
/// compositor running its own rate leaves the seed too fast. The old downward-only
/// cap pinned that wrong grid for the session (the Android 0.23.0 defect); the
/// shared learner climbs back out once the evidence is consistent.
#[test]
fn latch_clock_recovers_from_a_seed_faster_than_the_real_panel() {
const REAL: u64 = 16_666_666; // the panel is really 60 Hz…
let mut c = LatchClock::new(120); // …but the mode claimed 120
assert_eq!(c.period_ns(), 8_333_333, "seeded from the claim");
// Consistent 60 Hz evidence. The grid is fed the MIN of every
// GRID_OBSERVE_EVERY spacings, and PanelGrid widens only after 8 agreeing
// observations, so a real widen needs 8 × GRID_OBSERVE_EVERY spacings — the
// deliberate cost of not letting one slow patch redefine the panel.
let mut t = 1_000_000_000u64;
for _ in 0..(GRID_OBSERVE_EVERY * 8 + GRID_OBSERVE_EVERY) {
t += REAL;
c.note_batch(&[t]);
}
assert_eq!(
c.period_ns(),
REAL,
"a sustained slower grid is adopted instead of aimed past forever"
);
}
/// The VRR discriminator: presents landing on the vblank grid read Fixed, presents
/// landing wherever our own cadence puts them read Variable — including the case that
/// matters most, a stream SLOWER than the panel, where a fixed panel still quantizes
/// to a larger whole multiple.
#[test]
fn cadence_probe_separates_grid_locked_from_variable() {
const P: u64 = 8_333_333; // 120 Hz
// Enough spacings for CADENCE_STABLE_ROUNDS full rounds: a verdict is published
// only once consecutive rounds agree (on glass a single round FLAPPED).
const ROUNDS: u64 = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
// Fixed panel, stream at panel rate: every delta is exactly one period.
let mut probe = CadenceProbe::new();
assert_eq!(probe.verdict(), Cadence::Unknown, "no evidence yet");
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * P).collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Fixed);
// Fixed panel, stream at HALF panel rate: deltas are 2×P — still grid-locked.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * 2 * P).collect();
probe.note(&stamps, P, true);
assert_eq!(
probe.verdict(),
Cadence::Fixed,
"a slower stream on a fixed panel picks a larger k, it does not leave the grid"
);
// Fixed panel with realistic measurement jitter (±0.5 ms on an 8.3 ms period)
// must not read as variable.
let mut probe = CadenceProbe::new();
let jitter = [0i64, 300_000, -250_000, 120_000, -400_000, 80_000];
let stamps: Vec<u64> = (0..ROUNDS as usize)
.map(|i| (1_000_000_000 + i as i64 * P as i64 + jitter[i % jitter.len()]) as u64)
.collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Fixed, "jitter is not VRR");
// VRR live: a 100 fps stream on a 120 Hz-max panel. 10 ms is not a multiple of
// 8.33 ms, so every present sits off the grid.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS)
.map(|i| 1_000_000_000 + i * 10_000_000)
.collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Variable);
// A display change throws the evidence away rather than carrying a stale verdict.
probe.reset();
assert_eq!(probe.verdict(), Cadence::Unknown);
// Below the sample floor nothing is claimed.
let mut probe = CadenceProbe::new();
probe.note(&[1_000_000_000, 1_010_000_000, 1_020_000_000], P, true);
assert_eq!(probe.verdict(), Cadence::Unknown);
// ⭐ THE SHAPE THE LIVE LOOP ACTUALLY PRODUCES: the run loop drains present-wait
// samples every pass, so stamps arrive ONE AT A TIME. Measuring spacings only
// within a batch observed nothing at all on glass — `vrr` stayed Unknown and the
// latch clock ran on its seed forever. Found on .21, 2026-08-02.
let mut probe = CadenceProbe::new();
for i in 0..ROUNDS {
probe.note(&[1_000_000_000 + i * 10_000_000], P, true); // 100 fps, off a 120 Hz grid
}
assert_eq!(
probe.verdict(),
Cadence::Variable,
"one-sample batches must still yield spacings"
);
// A period we never learned can't discriminate anything.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS)
.map(|i| 1_000_000_000 + i * 10_000_000)
.collect();
probe.note(&stamps, 0, true);
assert_eq!(probe.verdict(), Cadence::Unknown);
}
/// ⭐ Batching must not change the verdict. The same spacings delivered as one big
/// batch, or one stamp at a time, must reach the same conclusion — the live loop
/// drains one at a time while tests hand over vectors, and an evaluation keyed to
/// call boundaries silently made the two disagree.
#[test]
fn cadence_verdict_is_independent_of_batching() {
const P: u64 = 8_333_333;
let n = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
let stamps: Vec<u64> = (0..n).map(|i| 1_000_000_000 + i * P).collect();
let mut bulk = CadenceProbe::new();
bulk.note(&stamps, P, true);
let mut drip = CadenceProbe::new();
for s in &stamps {
drip.note(&[*s], P, true);
}
assert_eq!(bulk.verdict(), Cadence::Fixed);
assert_eq!(drip.verdict(), bulk.verdict(), "batching must not matter");
}
/// Gate: open at zero outstanding, closed at one, force-open past the stale bound.
#[test]
fn gate_budgets_one_undisplayed_present() {
let mut g = PresentGate::default();
let t0 = 1_000_000_000u64;
assert!(g.open(0, t0));
g.note_present(t0);
assert!(!g.open(1, t0 + 8_000_000), "one in flight — hold");
assert!(
g.open(1, t0 + STALE_REOPEN_NS + 1),
"stale in-flight present force-opens"
);
let (gated, forced) = g.take_counters();
assert_eq!((gated, forced), (1, 1));
assert_eq!(g.take_counters(), (0, 0), "counters drain");
}
}
+52 -571
View File
@@ -18,15 +18,12 @@
use crate::input::{Capture, FingerPhase};
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
use crate::present_pace::{
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
use pf_client_core::trust::{MouseMode, PresentPriority, StatsVerbosity, TouchMode};
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
use pf_client_core::video::VulkanDecodeDevice;
use pf_client_core::video::{DecodedFrame, DecodedImage};
use punktfunk_core::client::NativeClient;
@@ -66,20 +63,6 @@ pub struct SessionOpts {
/// work profile that streams on a second screen and still Alt-Tabs here. Never applies
/// under the `desktop` mouse model, which is something you Alt-Tab *away* from.
pub inhibit_shortcuts: bool,
/// Presentation intent ([`Settings::present_priority`] resolved): `Latency` keeps the
/// shipped arrival pacing (newest-wins, present the moment a frame can go out);
/// `Smooth { buffer }` runs the smoothing FIFO drained one frame per latch slot
/// (design/desktop-presentation-rebuild.md). `PUNKTFUNK_PRESENTER=arrival` overrides
/// the whole engine back to the legacy drain for field A/B without a rebuild.
pub present_priority: PresentPriority,
/// Tear-free presentation ([`Settings::vsync`], default on). Off asks for a tearing
/// present mode for the lowest possible latch — best-effort, and the mode that
/// actually took is named in the stats line.
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence ([`Settings::allow_vrr`],
/// default on) — prefers the present mode that drives VRR panels directly when the
/// session starts fullscreen.
pub allow_vrr: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
@@ -221,56 +204,12 @@ 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>,
win_disp_us: Vec<u64>,
/// The display stage's two halves (present-timing sessions only): decoded→submit and
/// submit→on-glass. See [`PresentedWindow::pace_ms`].
win_pace_us: Vec<u64>,
win_latch_us: Vec<u64>,
win_start: Instant,
presented: PresentedWindow,
/// The intent engine (design/desktop-presentation-rebuild.md WP2): the decoded-frame
/// store between the wake channel and the present call — a newest-wins slot under
/// the latency intent (behaviorally the shipped drain), the smoothing FIFO under
/// smoothness. NOTE: a smoothing store holds decoder-pool frames (Vulkan-Video
/// AVFrames) up to `buffer` deep on top of the depth-2 wake channels — within pool
/// headroom for 1..=3, but any deeper store must revisit pool sizing.
store: FrameStore<DecodedFrame>,
/// The panel latch grid (present-wait glass stamps; submit-anchored fallback) — the
/// smoothness slot clock, and the values published to the host-facing `latch_grid`.
clock: LatchClock,
/// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes
/// or without present timing.
gate: PresentGate,
/// Is variable refresh actually live? Measured from the same on-glass stamps (no
/// portable query exists) — see [`CadenceProbe`].
cadence: CadenceProbe,
/// The DISPLAY MODE's refresh period — the vblank grid presents quantize to when
/// VRR is off, and so the cadence probe's reference. Deliberately not the learned
/// period (see the probe's call site).
mode_period_ns: u64,
/// The latch slot the last smoothness present served (one present per slot); 0 =
/// none yet.
last_target_ns: u64,
/// Smoothness slot-pick margin: starts 0 (a fixed lead is pure display tax —
/// measured on Android), widens +500 µs per >2-miss window toward 2.5 ms.
margin_ns: u64,
/// This window's latch misses (a present that reached glass > 1.5 latch periods
/// after submit) — the adaptive margin's error signal.
win_misses: u32,
/// This window's peak undisplayed-presents-in-flight (present timing only).
win_out_max: usize,
/// One-shot log latch: smoothness was requested but a PyroWave stream collapsed the
/// store to latency (its plane-ring retirement assumes the newest-wins hand-off).
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_latency_forced: bool,
// Hardware-path health: a failure streak (or a device with no import support at
// all) demotes the decoder to software via the shared flag — once per session.
dmabuf_demoted: bool,
@@ -335,8 +274,6 @@ impl StreamState {
params: SessionParams,
force_software: Arc<AtomicBool>,
wake: sdl3::event::EventSender,
priority: PresentPriority,
native_refresh_hz: u32,
) -> StreamState {
let profile = params.profile.clone();
// The presenter's half of phase-locked capture: it writes the latch grid the
@@ -371,24 +308,10 @@ 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_pace_us: Vec::with_capacity(256),
win_latch_us: Vec::with_capacity(256),
win_start: Instant::now(),
presented: PresentedWindow::default(),
store: FrameStore::new(usize::from(priority.fifo_capacity())),
clock: LatchClock::new(native_refresh_hz),
gate: PresentGate::default(),
cadence: CadenceProbe::new(),
mode_period_ns: 1_000_000_000 / u64::from(native_refresh_hz.max(1)),
last_target_ns: 0,
margin_ns: 0,
win_misses: 0,
win_out_max: 0,
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_latency_forced: false,
dmabuf_demoted: false,
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_present_warned: false,
@@ -427,25 +350,6 @@ impl StreamState {
}
self.handle.stop.store(true, Ordering::SeqCst);
}
/// The event-loop wait bound: a smoothness stream with buffered frames sleeps only
/// to its next latch-slot deadline; everything else keeps the 15 ms housekeeping
/// tick (frames, input, and present completions all wake the loop early anyway).
fn wake_timeout(&self) -> Duration {
const TICK: Duration = Duration::from_millis(15);
if !self.store.is_smoothing() || self.store.is_empty() {
return TICK;
}
let now = session::now_ns();
let mut target = self
.clock
.next_slot_after(now.saturating_add(self.margin_ns));
if target == self.last_target_ns {
// This slot is already served — the next boundary is the deadline.
target += self.clock.period_ns();
}
Duration::from_nanos(target.saturating_sub(now)).clamp(Duration::from_millis(1), TICK)
}
}
/// Whether a present error is `VK_ERROR_DEVICE_LOST` anywhere in its chain. A lost
@@ -528,43 +432,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let instance_exts = window
.vulkan_instance_extensions()
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
let mut presenter = Presenter::new(
&window,
&instance_exts,
crate::vk::PresentPref {
vsync: opts.vsync,
allow_vrr: opts.allow_vrr,
fullscreen: opts.fullscreen,
// `vrr_fifo_opt_in` (env) and `fifo_latest_ready` (device capability) are
// both resolved inside `Presenter::new` — the swapchain owns those, so every
// caller gets the same answer. `..Default` keeps this site from breaking each
// time the struct learns another one.
..Default::default()
},
)
.context("vulkan presenter")?;
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
// A valid black frame immediately — the window is honest while the connect runs.
presenter.present(&window, FrameInput::Redraw, None)?;
// `PUNKTFUNK_PRESENTER=arrival` — the legacy drain, the intent engine's field-A/B
// kill switch (the Android sysprop pattern: no rebuild to bisect a pacing suspicion).
let arrival_override = std::env::var("PUNKTFUNK_PRESENTER").ok().as_deref() == Some("arrival");
let present_priority = if arrival_override {
tracing::info!("PUNKTFUNK_PRESENTER=arrival — presentation pacing disabled");
PresentPriority::Latency
} else {
opts.present_priority
};
let pacing_active = !arrival_override;
let present_debug = std::env::var_os("PUNKTFUNK_PRESENT_DEBUG").is_some();
// Present completions wake the loop exactly like decoded frames: a glass-gate
// reopen or a smoothness slot must not wait out the event timeout.
{
let sender = events.event_sender();
presenter.set_present_wake(Box::new(move || {
let _ = sender.push_custom_event(FrameWake);
}));
}
// Browse mode is "ready" the moment the library window presents — there may never be
// a stream. (Single mode announces on the first VIDEO frame instead, further down, so
// a shell only yields to a window that actually shows the stream.)
@@ -641,8 +511,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
params,
force_software,
events.event_sender(),
present_priority,
native.refresh_hz,
))
}
ModeCtl::Browse(_) => None,
@@ -670,11 +538,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// forwarder's FrameWake) all land in this one queue, so the loop wakes exactly
// when there is work — a short-timeout poll here burned a full core (measured;
// the timeout only bounds stop-flag/pump-tick latency now). In browse-idle the
// per-iteration FIFO present vsync-throttles the loop anyway. A smoothness
// stream tightens the bound to its next latch-slot deadline.
let timeout = stream
.as_ref()
.map_or(Duration::from_millis(15), |st| st.wake_timeout());
// per-iteration FIFO present vsync-throttles the loop anyway.
let timeout = Duration::from_millis(15);
let first = event_pump.wait_event_timeout(timeout);
let mut queued: Vec<Event> = Vec::new();
if let Some(e) = first {
@@ -737,29 +602,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
}
// Dragged to another monitor (or the mode changed under us): the
// latch grid and the VRR verdict both belong to the OLD panel. The
// refresh rate used to be read once at startup and never revisited,
// so a 60 Hz-seeded clock would keep pacing a 144 Hz panel.
WindowEvent::DisplayChanged(..) => {
let hz = window
.get_display()
.and_then(|d| d.get_mode())
.map(|m| m.refresh_rate.round().max(0.0) as u32)
.unwrap_or(0);
if let Some(st) = stream.as_mut() {
if hz > 0 {
st.clock = LatchClock::new(hz);
st.mode_period_ns = 1_000_000_000 / u64::from(hz);
}
st.cadence.reset();
st.last_target_ns = 0;
tracing::info!(
refresh_hz = hz,
"display changed — relearning the latch grid"
);
}
}
WindowEvent::Exposed => {
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
@@ -1184,8 +1026,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
*params,
force_software,
events.event_sender(),
present_priority,
native.refresh_hz,
));
if let Some(o) = overlay.as_mut() {
o.session_phase(SessionPhase::Connecting);
@@ -1263,7 +1103,6 @@ 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 {
@@ -1276,7 +1115,6 @@ 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', " | "));
@@ -1433,148 +1271,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
presenter.set_hdr_metadata(m);
}
}
// Present-wait completions drive the latch clock, the glass gate, and the
// host-facing grid — drained every pass (a 1 Hz batch would starve all
// three; the waiter's SDL wake pairs with this so completions never wait
// out the event timeout).
if presenter.present_timing_active() {
let samples = presenter.take_presented_samples();
if !samples.is_empty() {
let clock_offset_ns = st
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
let period = st.clock.period_ns();
let mut stamps = Vec::with_capacity(samples.len());
for s in &samples {
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128
- s.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
st.win_e2e_us.push(e2e / 1000);
}
st.win_disp_us
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
// The display split (WP4): our pipeline vs the vsync latch. Only
// meaningful with true glass stamps, which is exactly when this
// branch runs.
st.win_pace_us
.push(s.submitted_ns.saturating_sub(s.decoded_ns) / 1000);
st.win_latch_us
.push(s.displayed_ns.saturating_sub(s.submitted_ns) / 1000);
// Latch miss (the adaptive margin's error signal): glass later
// than one panel period past submit, PLUS the lead we already
// applied — i.e. the slot we aimed at was missed. Measuring the
// real latch rather than the store's own evictions is the
// Android 0.23.0 correction: policy drops happen whenever the
// stream out-runs the panel and say nothing about the latch, and
// widening on them walked the margin to its ceiling on healthy
// devices, re-imposing the very display latency it had removed.
if st.store.is_smoothing()
&& s.displayed_ns.saturating_sub(s.submitted_ns) > period + st.margin_ns
{
st.win_misses += 1;
}
stamps.push(s.displayed_ns);
}
st.clock.note_batch(&stamps);
// Same stamps answer "is VRR live" — the panel either quantizes them
// to its grid or follows our cadence. Evidence only counts from a
// window whose presents were flowing normally: a distressed pipeline
// (stale force-opens) smears spacings for reasons that have nothing
// to do with the panel, and on glass that flapped the verdict.
//
// ⚠ The reference is the DISPLAY MODE's period, NOT the learned one.
// The learned grid comes from our own present spacings, and a stream
// running below panel rate only ever produces multiples ≥ its frame
// interval — so the learner adopts our cadence as "the grid" and every
// delta then looks on-grid by construction. Measured on .21
// (2026-08-02): a 40-50 fps stream on a 60 Hz panel learned 18-22 ms
// and the probe reported VRR on a display with VRR provably disabled.
// The vblank grid is the mode's refresh; that is what presents
// quantize to when VRR is off.
//
// ⚠⚠ And it is only asked under a FIFO-family mode. The whole test
// rests on "with VRR off, a present waits for vblank" — MAILBOX and
// IMMEDIATE deliberately break that, so their stamps are never
// grid-quantized and the probe would call every mailbox session VRR.
// Measured on .21: same panel, same second — fifo read `no`
// (correct, period 16.56 ms), mailbox read `yes` (wrong). Outside
// FIFO the honest answer is "cannot tell", i.e. Unknown.
let healthy = st.presented.forced == 0;
if presenter.vblank_locked() {
st.cadence.note(&stamps, st.mode_period_ns, healthy);
}
// Phase-locked capture, the presenter's half: publish the grid the
// local clock just learned — a recent TRUE on-glass instant plus
// the latch period — for the pump's ~1 Hz PhaseReport. One learner
// feeds both, so the report and the scheduler cannot disagree.
if let Some(grid) = &st.latch_grid {
grid.period_ns
.store(st.clock.period_ns(), Ordering::Relaxed);
grid.anchor_ns
.store(st.clock.anchor_ns(), Ordering::Relaxed);
}
}
}
// Intake into the intent store: a newest-wins slot under latency (the
// shipped drain, now with displacement counters), the smoothing FIFO under
// smoothness. PyroWave collapses smoothness to latency for the stream: its
// plane-ring retirement accounting assumes the newest-wins hand-off
// (`video_pyrowave::RETIRE_HANDOVERS`), and all-intra frames make
// buffering moot anyway.
let mut newest: Option<DecodedFrame> = None;
while let Ok(f) = st.frames.try_recv() {
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
if st.store.is_smoothing() && matches!(f.image, DecodedImage::PyroWave(_)) {
st.store.force_latency();
if !st.pyro_latency_forced {
st.pyro_latency_forced = true;
tracing::info!(
"PyroWave stream — smoothness buffering does not apply \
(latency pacing)"
);
}
}
st.store.submit(f);
newest = Some(f);
}
// One frame out, by intent: latency takes the newest whenever the glass
// gate allows; smoothness serves at most one frame per latch slot (the
// preroll/underflow behavior lives in the store).
let now_ns = session::now_ns();
let mut slot_target = 0u64;
let mut to_present = if st.store.is_smoothing() {
let target = st
.clock
.next_slot_after(now_ns.saturating_add(st.margin_ns));
if target != st.last_target_ns {
slot_target = target;
st.store.take()
} else {
None
}
} else {
st.store.take()
};
// The FIFO glass budget: one undisplayed present in flight, so the
// swapchain's own FIFO can never become a standing queue (a measured
// 11-13 ms at 60 Hz on MAILBOX-less drivers). Only FIFO modes queue and
// only present timing can count, so everywhere else this stays inert and
// behavior is the shipped arrival pacing.
if pacing_active && presenter.needs_glass_gate() && presenter.present_timing_active() {
if let Some(f) = to_present.take() {
if st.gate.open(presenter.presents_outstanding(), now_ns) {
to_present = Some(f);
} else {
// Parked: a newest-wins store replaces it if a fresher frame
// lands; the waiter's wake (or the 100 ms stale force-open)
// retries.
st.store.put_back(f);
}
}
}
if let Some(f) = to_present {
if let Some(f) = newest {
// Resize END: a frame at the steered target size means the sharp new-mode
// picture is here — lift the scrim. A no-op unless a switch is in flight.
let (fw, fh) = f.image.dimensions();
@@ -1595,7 +1296,6 @@ 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),
@@ -1623,9 +1323,6 @@ 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")]
@@ -1633,7 +1330,6 @@ 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),
@@ -1684,7 +1380,6 @@ 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),
@@ -1731,7 +1426,6 @@ 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),
@@ -1763,12 +1457,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
};
if did_present {
presented_video = true;
// Smoothness: this latch slot is served — one present per slot.
// (Set only on success: a gated or failed present leaves the slot
// open for the retry.)
if slot_target != 0 {
st.last_target_ns = slot_target;
}
if opts.json_status && !st.ready_announced {
st.ready_announced = true;
println!("{{\"ready\":true}}");
@@ -1778,8 +1466,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// e2e/display samples arrive via `take_presented_samples` with a
// TRUE on-glass stamp instead of the submit-time one below.
presenter.note_presented(pts_ns, decoded_ns);
st.gate.note_present(now_ns);
st.win_out_max = st.win_out_max.max(presenter.presents_outstanding());
} else {
let displayed_ns = session::now_ns();
// The `displayed` stamp (same clamp rules as the pump's windows).
@@ -1794,81 +1480,59 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
st.win_disp_us
.push(displayed_ns.saturating_sub(decoded_ns) / 1000);
// No glass stamps on this stack: the submit instant anchors an
// approximate grid on the mode's refresh period, so smoothness
// still drains one frame per (approximate) slot.
st.clock.note_batch(&[displayed_ns]);
}
}
}
// Fold the presenter window into the shared stats line once per second.
// (The on-glass samples themselves are drained every pass above — they
// drive the latch clock and glass gate, not just this fold.)
if st.win_start.elapsed() >= Duration::from_secs(1) {
// On-glass samples the present-wait waiter completed this window (empty
// when timing is inactive — the legacy submit-time pushes fill in then).
let clock_offset_ns = st
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
let samples = presenter.take_presented_samples();
// Phase-locked capture, the presenter's half: publish this window's latch
// grid — a recent TRUE on-glass instant plus the panel period — for the
// pump's ~1 Hz PhaseReport. The period is the min positive spacing of
// consecutive on-glass stamps (Apple's method: honest under VRR), capped
// by the display mode's refresh — under arrival-paced MAILBOX a stream
// running below the panel rate spaces its presents at k×period, and the
// cap keeps a 30 fps stream from claiming a 30 Hz panel grid.
if let Some(grid) = &st.latch_grid {
if let Some(last) = samples.last() {
let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1));
let min_delta = samples
.windows(2)
.map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns))
.filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step
.min()
.unwrap_or(refresh_period);
grid.period_ns
.store(min_delta.min(refresh_period), Ordering::Relaxed);
grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed);
}
}
for s in samples {
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
st.win_e2e_us.push(e2e / 1000);
}
st.win_disp_us
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
}
let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us);
let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us);
let (pace_p50, _) = session::window_percentiles(&mut st.win_pace_us);
let (latch_p50, _) = session::window_percentiles(&mut st.win_latch_us);
// Drained ONCE per window and shared by the HUD and the log line below —
// a second `take_counters` would read zeros.
let (replaced, q_drop, q_dry) = st.store.take_counters();
let (gated, forced) = st.gate.take_counters();
st.presented = PresentedWindow {
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
display_ms: disp_p50 as f32 / 1000.0,
pace_ms: pace_p50 as f32 / 1000.0,
latch_ms: latch_p50 as f32 / 1000.0,
mode: presenter.present_mode_name(),
vrr: st.cadence.verdict(),
smoothing: st.store.is_smoothing(),
q_drop,
q_dry,
gated,
forced,
};
st.win_e2e_us.clear();
st.win_disp_us.clear();
st.win_pace_us.clear();
st.win_latch_us.clear();
st.win_start = Instant::now();
// Adaptive slot margin (the Android presenter's measured recipe):
// start at 0 — a fixed lead is pure display tax — and widen one step
// per window whose measured latch misses demand it. One-way per
// stream; the next stream restarts at 0.
if st.store.is_smoothing() && st.win_misses > 2 && st.margin_ns < MARGIN_MAX_NS {
st.margin_ns = (st.margin_ns + MARGIN_STEP_NS).min(MARGIN_MAX_NS);
tracing::info!(
margin_us = st.margin_ns / 1000,
misses = st.win_misses,
"smoothness slot margin widened (measured latch misses)"
);
}
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
// the field-triage instrument for the intent engine.
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
tracing::info!(
smoothing = st.presented.smoothing,
mode = st.presented.mode,
vrr = st.presented.vrr.label(),
replaced,
q_drop,
q_dry,
gated,
forced,
misses = st.win_misses,
out_max = st.win_out_max,
pace_ms = st.presented.pace_ms,
latch_ms = st.presented.latch_ms,
period_us = st.clock.period_ns() / 1000,
margin_us = st.margin_ns / 1000,
"presenter window"
);
}
st.win_misses = 0;
st.win_out_max = 0;
}
}
@@ -2246,7 +1910,6 @@ fn bump_stats_tier(
&st.presented,
st.hdr,
presenter.hdr_active(),
st.hdr_untonemapped,
st.profile.as_deref(),
),
None => String::new(),
@@ -2328,32 +1991,6 @@ struct PresentedWindow {
e2e_p50_ms: f32,
e2e_p95_ms: f32,
display_ms: f32,
/// The display stage split (design/desktop-presentation-rebuild.md WP4):
/// `pace` = decoded → present-submit (our own pipeline), `latch` = submit → on-glass
/// (the presentation engine's queue + the vblank wait). Both `0` without
/// `VK_KHR_present_wait`, where the two are not separable — the HUD then shows the
/// unsplit figure rather than inventing a zero latch.
///
/// This split is what makes a high `display` self-diagnosing: latch dominating means
/// the vsync/queue floor (or a standing queue), pace dominating means us.
/// `pace` is also the honest cross-platform twin of the Apple client's shaved
/// number — Apple subtracts its measured OS present floor, and the latch IS our
/// floor, so `pace` is what remains on both sides of that comparison.
pace_ms: f32,
latch_ms: f32,
/// The live swapchain present mode (`mailbox`/`fifo`/…). Shown because a mode is
/// chosen from what the surface offers, so "why is my latch a refresh long" is
/// usually answered by a MAILBOX request having landed on FIFO.
mode: &'static str,
/// Whether variable refresh is measurably live (never claimed without evidence).
vrr: Cadence,
/// Presenter-engine counters for the window: the smoothing FIFO's overflow drops and
/// post-preroll underflows, and the FIFO glass gate's holds/stale force-opens.
smoothing: bool,
q_drop: u32,
q_dry: u32,
gated: u32,
forced: u32,
}
/// The capture hints (`ui_stream` parity — the words the user reads while released).
@@ -2370,15 +2007,11 @@ 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`; 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.
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
///
/// `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,
@@ -2386,7 +2019,6 @@ 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();
@@ -2436,7 +2068,6 @@ 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",
_ => "",
},
@@ -2459,15 +2090,6 @@ fn stats_text(
" · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms
));
// The display split (WP4). Only with true on-glass stamps — without them the
// two halves are not separable and the unsplit figure stands alone rather than
// implying a zero latch.
if p.latch_ms > 0.0 || p.pace_ms > 0.0 {
text.push_str(&format!(
" (pace {:.1} + latch {:.1})",
p.pace_ms, p.latch_ms
));
}
// Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution
// (queue → encode → seal/xfer → pace) reads as the host pipeline in order.
if s.staged {
@@ -2476,32 +2098,6 @@ fn stats_text(
s.host_queue_ms, s.host_encode_ms, s.host_xfer_ms, s.host_pace_ms
));
}
// The presenter line: the swapchain mode that is actually live, the chosen
// intent, and the engine's own counters. Present-mode alone answers most
// "why is my latch a whole refresh" questions; the counters only render when
// they are non-zero, so a healthy latency session shows just the mode.
if !p.mode.is_empty() {
text.push_str(&format!("\npresent: {}", p.mode));
// Only once measured — an unproven "vrr no" would be a claim, not a reading.
if p.vrr != Cadence::Unknown {
text.push_str(&format!(" · vrr {}", p.vrr.label()));
}
if p.smoothing {
text.push_str(" · smoothing");
}
if p.q_drop > 0 {
text.push_str(&format!(" · qdrop {}", p.q_drop));
}
if p.q_dry > 0 {
text.push_str(&format!(" · qdry {}", p.q_dry));
}
if p.gated > 0 {
text.push_str(&format!(" · gated {}", p.gated));
}
if p.forced > 0 {
text.push_str(&format!(" · forced {}", p.forced));
}
}
}
if s.lost > 0 {
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
@@ -2775,7 +2371,6 @@ mod tests {
e2e_p50_ms: 6.4,
e2e_p95_ms: 9.1,
display_ms: 1.1,
..Default::default()
},
)
}
@@ -2785,7 +2380,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, false, None);
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, None);
assert_eq!(text(StatsVerbosity::Off), "");
@@ -2802,10 +2397,6 @@ 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%)"));
@@ -2813,96 +2404,6 @@ mod tests {
!normal.contains("queue"),
"host-stage split is Detailed-only"
);
assert!(
!detailed.contains("pace 1.1"),
"no glass stamps in this sample — the display stage stays unsplit"
);
}
/// WP4: with true on-glass stamps the display stage reads as its two halves, the
/// live present mode is named, and the engine counters render only when non-zero —
/// so a healthy latency session shows the mode and nothing else. Without glass
/// stamps (no `VK_KHR_present_wait`) the split is absent rather than a zero latch.
#[test]
fn detailed_splits_display_into_pace_and_latch() {
let (s, mut p) = sample();
p.display_ms = 12.4;
p.pace_ms = 1.1;
p.latch_ms = 11.3;
p.mode = "fifo";
let split = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(split.contains("display 12.4 ms (pace 1.1 + latch 11.3)"));
assert!(split.contains("\npresent: fifo"));
assert!(
!split.contains("qdrop") && !split.contains("gated") && !split.contains("smoothing"),
"quiet counters stay off the HUD: {split}"
);
// The smoothing FIFO and the glass gate surface once they actually do something.
p.smoothing = true;
p.q_drop = 2;
p.q_dry = 1;
p.gated = 7;
p.forced = 1;
let busy = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(busy.contains("present: fifo · smoothing · qdrop 2 · qdry 1 · gated 7 · forced 1"));
// A tier below Detailed never carries any of it.
let normal = stats_text(
StatsVerbosity::Normal,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(!normal.contains("present:") && !normal.contains("pace"));
}
/// 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
@@ -2912,7 +2413,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, false, None)
stats_text(v, "m", s, &p, false, false, None)
.lines()
.next()
.unwrap()
@@ -2945,7 +2446,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, false, None);
let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, None);
assert!(
!text(&s, StatsVerbosity::Detailed).contains("mic"),
"no mic line while the mic is off"
@@ -2972,16 +2473,7 @@ mod tests {
s.lost = 0;
let p = PresentedWindow::default();
assert_eq!(
stats_text(
StatsVerbosity::Compact,
"m",
&s,
&p,
false,
false,
false,
None
),
stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false, None),
"120 fps · 24 Mb/s"
);
}
@@ -2999,7 +2491,6 @@ mod tests {
&p,
false,
false,
false,
Some("Game")
),
"120 fps · 6.4 ms · 24 Mb/s · lost 3 · Game"
@@ -3011,7 +2502,6 @@ mod tests {
&p,
false,
false,
false,
Some("Work"),
);
assert_eq!(
@@ -3025,22 +2515,13 @@ 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,
false,
None
)
.contains(" · "));
assert!(
!stats_text(StatsVerbosity::Normal, "m", &s, &p, false, false, None).contains(" · ")
);
}
#[test]
+2 -67
View File
@@ -33,7 +33,7 @@ mod reconfig;
mod resources;
mod setup;
pub use setup::{list_adapters, PresentPref};
pub use setup::list_adapters;
/// One presenter iteration's video input.
pub enum FrameInput<'a> {
@@ -247,75 +247,10 @@ impl Presenter {
/// (the presenter itself never sees them). No-op when timing is inactive.
pub(crate) fn note_presented(&mut self, pts_ns: u64, decoded_ns: u64) {
if let (Some(t), Some((sc, id))) = (&self.present_timer, self.last_presented.take()) {
// The submit stamp: `present()` already returned, so "now" is within the
// present-call tail — the pace/latch split point.
t.enqueue(
sc,
id,
pts_ns,
decoded_ns,
pf_client_core::session::now_ns(),
);
t.enqueue(sc, id, pts_ns, decoded_ns);
}
}
/// Undisplayed id-carrying presents in flight (0 when timing is inactive) — the
/// FIFO glass gate's budget count.
pub(crate) fn presents_outstanding(&self) -> usize {
self.present_timer.as_ref().map_or(0, |t| t.outstanding())
}
/// Install the run loop's wake for present completions (an SDL event push). No-op
/// without present timing — there is nothing to wake on then.
pub(crate) fn set_present_wake(&self, cb: Box<dyn Fn() + Send>) {
if let Some(t) = &self.present_timer {
t.set_wake(cb);
}
}
/// The live swapchain present mode, for the stats overlay: a mode is picked from
/// what the surface actually offers, so the requested one and this can differ (a
/// MAILBOX request lands on FIFO wherever the driver has no mailbox — AMD's Windows
/// driver, notably). Showing it is what makes that visible instead of puzzling.
pub(crate) fn present_mode_name(&self) -> &'static str {
match self.present_mode {
vk::PresentModeKHR::MAILBOX => "mailbox",
vk::PresentModeKHR::FIFO => "fifo",
vk::PresentModeKHR::FIFO_RELAXED => "fifo-relaxed",
vk::PresentModeKHR::IMMEDIATE => "immediate",
setup::fifo_latest_ready::MODE => "fifo-latest-ready",
_ => "other",
}
}
/// The active present mode QUEUES presents — the only modes where the swapchain
/// itself can become a standing queue, and so the only ones the glass gate governs.
///
/// MAILBOX and IMMEDIATE replace/flip and never queue. Nor does
/// `FIFO_LATEST_READY`, which retires stale images in the driver: gating on top of it
/// would hold frames back to emulate something the presentation engine is already
/// doing, paying the serialisation twice.
pub(crate) fn needs_glass_gate(&self) -> bool {
matches!(
self.present_mode,
vk::PresentModeKHR::FIFO | vk::PresentModeKHR::FIFO_RELAXED
)
}
/// The active present mode shows images ON THE VBLANK GRID — the premise the VRR
/// cadence probe rests on ("with VRR off, a present waits for vblank"). The whole
/// FIFO family qualifies, `FIFO_LATEST_READY` included: it drops stale images but
/// still presents on the refresh boundary. MAILBOX/IMMEDIATE do not, and under them
/// the probe reports Unknown rather than calling every session VRR.
pub(crate) fn vblank_locked(&self) -> bool {
matches!(
self.present_mode,
vk::PresentModeKHR::FIFO
| vk::PresentModeKHR::FIFO_RELAXED
| setup::fifo_latest_ready::MODE
)
}
/// Take the window's completed on-glass samples (empty when timing is inactive).
pub(crate) fn take_presented_samples(&self) -> Vec<present_timing::PresentedSample> {
self.present_timer
+1 -21
View File
@@ -40,27 +40,7 @@ impl Presenter {
// PQ→sRGB pass.
let frame_pq = match &input {
FrameInput::Redraw => None,
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 \
PQsRGB pass, so the picture is shown untonemapped (washed \
out). Hardware decode restores correct colour."
);
}
}
Some(false)
}
FrameInput::Cpu(_) => Some(false),
#[cfg(target_os = "linux")]
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
+2 -38
View File
@@ -26,9 +26,6 @@ pub(crate) struct PresentedSample {
pub pts_ns: u64,
/// Decode-complete stamp (client clock) — the display-stage anchor.
pub decoded_ns: u64,
/// `vkQueuePresentKHR`-return stamp (client clock) — the pace/latch split point:
/// `submitted decoded` is our pipeline, `displayed submitted` the vsync latch.
pub submitted_ns: u64,
/// `vkWaitForPresentKHR` completion = the image is visible (client clock).
pub displayed_ns: u64,
}
@@ -38,24 +35,15 @@ struct Job {
present_id: u64,
pts_ns: u64,
decoded_ns: u64,
submitted_ns: u64,
}
/// The run loop's wake callback (an SDL event push), shared with the waiter thread.
type WakeSlot = Arc<Mutex<Option<Box<dyn Fn() + Send>>>>;
/// The waiter: a channel-fed thread turning (swapchain, present-id) pairs into
/// [`PresentedSample`]s. One frame in flight upstream keeps the queue depth ~1.
pub(crate) struct PresentTimer {
tx: Option<mpsc::Sender<Job>>,
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown,
/// and the glass gate's "undisplayed presents in flight" count.
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown.
pending: Arc<AtomicUsize>,
results: Arc<Mutex<Vec<PresentedSample>>>,
/// Called by the waiter after each completed wait (sample or not) — the run loop
/// installs an SDL wake here so a gate reopen / smoothness slot never waits out the
/// event-loop timeout.
wake: WakeSlot,
join: Option<std::thread::JoinHandle<()>>,
}
@@ -64,8 +52,7 @@ impl PresentTimer {
let (tx, rx) = mpsc::channel::<Job>();
let pending = Arc::new(AtomicUsize::new(0));
let results = Arc::new(Mutex::new(Vec::with_capacity(256)));
let wake: WakeSlot = Arc::new(Mutex::new(None));
let (pending_t, results_t, wake_t) = (pending.clone(), results.clone(), wake.clone());
let (pending_t, results_t) = (pending.clone(), results.clone());
let join = std::thread::Builder::new()
.name("pf-present-wait".into())
.spawn(move || {
@@ -82,20 +69,12 @@ impl PresentTimer {
results_t.lock().unwrap().push(PresentedSample {
pts_ns: job.pts_ns,
decoded_ns: job.decoded_ns,
submitted_ns: job.submitted_ns,
displayed_ns,
});
}
// SUBOPTIMAL/TIMEOUT/DEVICE_LOST: no sample; the frame still showed
// (or the loop is about to find out) — never poison the window.
pending_t.fetch_sub(1, Ordering::AcqRel);
// Wake the run loop AFTER the count dropped: what it observes on
// wake is the post-completion state (the gate may now be open).
// Called under the slot lock — the callback is a bare SDL event
// push and never reenters this type.
if let Some(cb) = wake_t.lock().unwrap().as_ref() {
cb();
}
}
})
.expect("spawn pf-present-wait");
@@ -103,23 +82,10 @@ impl PresentTimer {
tx: Some(tx),
pending,
results,
wake,
join: Some(join),
}
}
/// Install the run loop's wake callback (an SDL event push — thread-safe by design).
pub(crate) fn set_wake(&self, cb: Box<dyn Fn() + Send>) {
*self.wake.lock().unwrap() = Some(cb);
}
/// Presents handed to the waiter and not yet resolved to glass — the glass gate's
/// budget count. (Also counts a wait that will end SUBOPTIMAL/TIMEOUT; those resolve
/// within the 250 ms cap, far past the gate's own 100 ms stale force-open.)
pub(crate) fn outstanding(&self) -> usize {
self.pending.load(Ordering::Acquire)
}
/// Hand a successfully submitted present to the waiter.
pub(crate) fn enqueue(
&self,
@@ -127,7 +93,6 @@ impl PresentTimer {
present_id: u64,
pts_ns: u64,
decoded_ns: u64,
submitted_ns: u64,
) {
if let Some(tx) = &self.tx {
self.pending.fetch_add(1, Ordering::AcqRel);
@@ -137,7 +102,6 @@ impl PresentTimer {
present_id,
pts_ns,
decoded_ns,
submitted_ns,
})
.is_err()
{
+18 -342
View File
@@ -13,55 +13,10 @@ use ash::vk;
use ash::vk::Handle as _;
use std::ffi::{c_char, CString};
/// `VK_EXT_present_mode_fifo_latest_ready`, hand-declared: it postdates the Vulkan headers
/// ash 0.38 is generated from (1.3.281), so there is no binding for it — which is also why
/// an unenabled driver reports the mode back as the bare number `1000361000`.
///
/// The mode is FIFO's tear-free vblank pacing that presents the **latest ready** image at
/// each refresh and retires the older ones, instead of draining a queue. That is precisely
/// what [`super::super::present_pace::PresentGate`] emulates in software, done by the
/// driver — and it matters most exactly where the gate does: on a surface that offers no
/// MAILBOX, this restores newest-wins behaviour without the app holding frames back.
pub(crate) mod fifo_latest_ready {
use ash::vk;
/// `VK_EXT_present_mode_fifo_latest_ready` (extension 361).
pub(super) const NAME: &std::ffi::CStr = c"VK_EXT_present_mode_fifo_latest_ready";
/// `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`.
pub(crate) const MODE: vk::PresentModeKHR = vk::PresentModeKHR::from_raw(1000361000);
/// `VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_MODE_FIFO_LATEST_READY_FEATURES_EXT`.
const S_TYPE: vk::StructureType = vk::StructureType::from_raw(1000361000);
/// `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT`. The mode is usable only
/// when this feature is enabled at device creation, so the surface advertising the
/// mode is NOT on its own permission to request it.
#[repr(C)]
#[derive(Clone, Copy)]
pub(super) struct Features {
pub s_type: vk::StructureType,
pub p_next: *mut std::ffi::c_void,
pub present_mode_fifo_latest_ready: vk::Bool32,
}
impl Default for Features {
fn default() -> Features {
Features {
s_type: S_TYPE,
p_next: std::ptr::null_mut(),
present_mode_fifo_latest_ready: vk::FALSE,
}
}
}
}
impl Presenter {
/// Bring up instance → surface → device → swapchain over an SDL window.
/// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`.
pub fn new(
window: &sdl3::video::Window,
instance_extensions: &[String],
pref: PresentPref,
) -> Result<Presenter> {
pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result<Presenter> {
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by
// the value being built here.
@@ -221,21 +176,6 @@ impl Presenter {
// structs through its pNext chain, so any later use of it would pin those borrows —
// every read of a chained struct below must come after this, have_f2's last use.
let have_shader_int16 = have_f2.features.shader_int16;
// FIFO_LATEST_READY: the surface may list the mode even with the extension
// disabled, so the device feature is the real gate on using it.
let flr_ok = if has(fifo_latest_ready::NAME) {
let mut feat = fifo_latest_ready::Features::default();
let mut probe = vk::PhysicalDeviceFeatures2 {
p_next: (&mut feat) as *mut _ as *mut std::ffi::c_void,
..Default::default()
};
// SAFETY: per the Vulkan contract above - a read-only query on the live
// instance/device, filling locals returned by value; `feat` outlives the call.
unsafe { instance.get_physical_device_features2(pdev, &mut probe) };
feat.present_mode_fifo_latest_ready == vk::TRUE
} else {
false
};
let present_wait_ok = present_wait_exts
&& have_pid.present_id == vk::TRUE
&& have_pwait.present_wait == vk::TRUE;
@@ -333,13 +273,6 @@ impl Presenter {
dev_exts.push(ash::khr::present_id::NAME.as_ptr());
dev_exts.push(ash::khr::present_wait::NAME.as_ptr());
}
if flr_ok {
dev_exts.push(fifo_latest_ready::NAME.as_ptr());
}
let mut en_flr = fifo_latest_ready::Features {
present_mode_fifo_latest_ready: vk::TRUE,
..Default::default()
};
let mut en_pid = vk::PhysicalDevicePresentIdFeaturesKHR::default().present_id(true);
let mut en_pwait = vk::PhysicalDevicePresentWaitFeaturesKHR::default().present_wait(true);
@@ -362,11 +295,6 @@ impl Presenter {
if present_wait_ok {
en_f2 = en_f2.push_next(&mut en_pid).push_next(&mut en_pwait);
}
if flr_ok {
// Hand-rolled struct, so chain it by hand: splice into the pNext list head.
en_flr.p_next = en_f2.p_next;
en_f2.p_next = (&mut en_flr) as *mut _ as *mut std::ffi::c_void;
}
en_f2.features.shader_int16 = if pyrowave_ok { vk::TRUE } else { vk::FALSE };
let priorities = [1.0f32];
@@ -522,17 +450,11 @@ impl Presenter {
if let Some(v) = video_export.as_mut() {
v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some();
}
let mut pref = pref;
pref.vrr_fifo_opt_in = vrr_fifo_opt_in();
pref.fifo_latest_ready = flr_ok;
let present_mode = pick_present_mode(&surface_i, pdev, surface, pref)?;
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
tracing::info!(
?format,
?hdr10_format,
?present_mode,
vsync = pref.vsync,
allow_vrr = pref.allow_vrr,
fifo_latest_ready = flr_ok,
hdr_metadata = has_hdr_metadata,
"swapchain config"
);
@@ -808,275 +730,29 @@ pub(super) fn pick_formats(
Ok((sdr, hdr10))
}
/// What the user asked the presentation to be, resolved into a swapchain present mode by
/// [`present_mode_chain`] (design/desktop-presentation-rebuild.md WP3).
#[derive(Clone, Copy, Debug, Default)]
pub struct PresentPref {
/// Tear-free presentation (the `vsync` setting, default on).
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence (`allow_vrr`, default on).
pub allow_vrr: bool,
/// Opt-in for the VRR FIFO-first ladder (`PUNKTFUNK_VRR_FIFO=1`). Off by default on
/// measured evidence — see [`present_mode_chain`].
pub vrr_fifo_opt_in: bool,
/// `VK_EXT_present_mode_fifo_latest_ready` is enabled on the device, so the mode may
/// be requested. Resolved during device creation; never set by callers.
pub fifo_latest_ready: bool,
/// The session STARTED fullscreen. The mode is chosen once, at swapchain creation, so
/// this is the starting state and an F11 mid-session does not re-pick — consistent
/// with the shells' "Display changes apply from the next session" footer, and why
/// live present-mode switching is an explicit non-goal.
pub fullscreen: bool,
}
/// The preference ladder, most to least wanted. The caller takes the first entry the
/// surface actually offers; FIFO ends every chain because the spec guarantees it.
///
/// * **V-Sync off** — IMMEDIATE (tears, no wait at all), then FIFO_RELAXED (tears only on
/// a late frame), then the tear-free modes. Asking for tearing and silently getting
/// vsync is a lie the stats line now exposes, but the ladder still degrades safely.
/// * **V-Sync on + VRR allowed + fullscreen + `PUNKTFUNK_VRR_FIFO=1`** — FIFO first. On a
/// variable-refresh panel with direct scanout the FIFO present IS the flip, so the panel
/// follows the stream's cadence; MAILBOX would decouple presents from scanout and
/// re-quantize to the compositor's clock.
///
/// **Automatic where a queue-free vblank mode exists, opt-in otherwise.** The history is
/// worth keeping: this was default-on, then measured on glass (.21, GNOME/Wayland,
/// NVIDIA, *non*-VRR 60 Hz panel, 2026-08-02) to cost ~27 ms of display stage against
/// MAILBOX — `28.4 ms (pace 11.8 + latch 16.6)` versus `1.4 ms (0.2 + 1.2)` — because a
/// plain-FIFO present's on-glass confirmation lands a whole refresh later and the
/// presenter serialises behind it. It became opt-in on that evidence.
///
/// `FIFO_LATEST_READY` removes the cause rather than working around it: the driver
/// retires stale images, so the vblank-locked path measured **2.6 ms** on the same box —
/// 0.6 ms over MAILBOX instead of 27. So where the device offers it, following the panel
/// is cheap enough to be the default again; where it does not, the ladder would fall
/// back to plain FIFO and the regression returns, so it stays behind
/// `PUNKTFUNK_VRR_FIFO=1` there. The win on a genuine VRR panel is still UNMEASURED —
/// no VRR display was available — but the cost of trying is now small and bounded.
/// * **Otherwise** — MAILBOX, then FIFO: the shipped default. MAILBOX never queues more
/// than the newest frame, so an arrival-paced presenter doesn't block in the present
/// queue (a measured 11-13 ms standing wait at 60 Hz when the compositor holds images
/// for a vblank pass, or when arrival cadence drifts against refresh).
///
/// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO —
/// expected, not a misconfiguration, and now visible in the `present:` stats line.
fn present_mode_chain(pref: PresentPref) -> Vec<vk::PresentModeKHR> {
use vk::PresentModeKHR as M;
let flr = pref.fifo_latest_ready.then_some(fifo_latest_ready::MODE);
let mut chain: Vec<M> = if !pref.vsync {
vec![M::IMMEDIATE, M::FIFO_RELAXED, M::MAILBOX]
} else if pref.allow_vrr && pref.fullscreen && (pref.fifo_latest_ready || pref.vrr_fifo_opt_in)
{
// The VRR ladder wants the vblank-locked family; LATEST_READY is that with the
// queue removed, so it outranks plain FIFO here too.
vec![]
.into_iter()
.chain(flr)
.chain([M::FIFO, M::MAILBOX, M::FIFO_RELAXED, M::IMMEDIATE])
.collect()
} else {
// MAILBOX first (measured good), then LATEST_READY — which is what gives a
// MAILBOX-less surface the same newest-wins behaviour, in the driver instead of
// in our glass gate.
vec![M::MAILBOX]
.into_iter()
.chain(flr)
.chain([M::FIFO_RELAXED, M::IMMEDIATE])
.collect()
};
if !pref.vsync {
chain.extend(flr);
}
// FIFO ends every chain: the spec guarantees it exists, so there is always a landing.
chain.push(M::FIFO);
chain
}
/// `PUNKTFUNK_VRR_FIFO=1` — opt into the FIFO-first ladder for variable-refresh panels.
/// See [`present_mode_chain`] for the measurement that made this opt-in rather than
/// default.
fn vrr_fifo_opt_in() -> bool {
std::env::var("PUNKTFUNK_VRR_FIFO").is_ok_and(|v| v != "0")
}
/// Resolve the present mode: `PUNKTFUNK_PRESENT_MODE` pins one outright (the debug lever,
/// unchanged), otherwise the first entry of [`present_mode_chain`] the surface offers.
/// 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
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
fn pick_present_mode(
surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice,
surface: vk::SurfaceKHR,
pref: PresentPref,
) -> Result<vk::PresentModeKHR> {
// SAFETY: per the Vulkan contract above - a read-only query on the live instance/device,
// filling locals returned by value.
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
let pinned = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("fifo") => Some(vk::PresentModeKHR::FIFO),
Some("immediate") => Some(vk::PresentModeKHR::IMMEDIATE),
Some("fifo_relaxed") => Some(vk::PresentModeKHR::FIFO_RELAXED),
Some("mailbox") => Some(vk::PresentModeKHR::MAILBOX),
None => None,
Some(other) => {
tracing::warn!(
value = other,
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — following the settings"
);
None
}
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,
};
if let Some(want) = pinned {
if modes.contains(&want) {
return Ok(want);
}
tracing::warn!(
?want,
"PUNKTFUNK_PRESENT_MODE not offered by this surface — falling back"
);
}
// What the surface ACTUALLY offers, logged unconditionally. "AMD's Windows driver
// has no MAILBOX" is the premise the FIFO glass gate is built on, and it has been
// carried in comments rather than measured — present modes are a property of the
// (surface, device) pair, so they vary by platform surface, driver version and
// fullscreen state, and the only way to settle it is to read it back from real
// machines. One line here makes every field log answer the question.
tracing::info!(
available = ?modes,
"surface present modes"
);
let chain = present_mode_chain(pref);
let chosen = chain
.iter()
.copied()
.find(|m| modes.contains(m))
.unwrap_or(vk::PresentModeKHR::FIFO); // always available per spec
// The one line that answers "did V-Sync off actually take?" — a request the surface
// can't serve is a fact about the driver, and it must not look like our choice.
if chosen != chain[0] {
tracing::info!(
requested = ?chain[0],
active = ?chosen,
vsync = pref.vsync,
allow_vrr = pref.allow_vrr,
"the surface does not offer the preferred present mode"
);
}
Ok(chosen)
}
#[cfg(test)]
mod tests {
use super::*;
use vk::PresentModeKHR as M;
/// The preference ladders (WP3). Every chain must end at FIFO, which the spec
/// guarantees exists — a chain whose entries a surface all refuses would otherwise
/// have no landing.
#[test]
fn present_mode_chains_rank_by_intent() {
let pref = |vsync, allow_vrr, fullscreen| PresentPref {
vsync,
allow_vrr,
fullscreen,
vrr_fifo_opt_in: true, // the ladder under test; the DEFAULT is off (see below)
fifo_latest_ready: false,
};
let flr = fifo_latest_ready::MODE;
// V-Sync off asks to tear, hardest first, and outranks the VRR rule (tearing
// already gives a VRR-like latch, so the two never fight).
assert_eq!(present_mode_chain(pref(false, true, true))[0], M::IMMEDIATE);
assert_eq!(
present_mode_chain(pref(false, false, false))[0],
M::IMMEDIATE
);
assert_eq!(
present_mode_chain(pref(false, true, true))[1],
M::FIFO_RELAXED,
"tears only on a late frame — the gentler tearing rung"
);
// Tear-free + VRR allowed + fullscreen prefers the vblank-locked family — but
// ONLY when opted in.
assert_eq!(present_mode_chain(pref(true, true, true))[0], M::FIFO);
// Without the opt-in the shipped MAILBOX-first default stands: measured on glass
// to be ~27 ms of display stage better on a non-VRR panel.
assert_eq!(
present_mode_chain(PresentPref {
vsync: true,
allow_vrr: true,
fullscreen: true,
vrr_fifo_opt_in: false,
fifo_latest_ready: false,
})[0],
M::MAILBOX,
"without a queue-free vblank mode the VRR ladder would lead with plain FIFO, \
which measured ~27 ms worse so it stays opt-in there"
);
assert_eq!(
present_mode_chain(PresentPref {
vsync: true,
allow_vrr: true,
fullscreen: true,
vrr_fifo_opt_in: false,
fifo_latest_ready: true,
})[0],
fifo_latest_ready::MODE,
"with LATEST_READY available, following the panel costs 0.6 ms over MAILBOX \
instead of 27 cheap enough to be automatic"
);
// FIFO_LATEST_READY only appears where the device enabled it, and it outranks
// plain FIFO everywhere: it is FIFO's vblank pacing WITHOUT the queue, which is
// what a MAILBOX-less surface otherwise needs the software glass gate for.
let with_flr = |vsync, allow_vrr, fullscreen| PresentPref {
vsync,
allow_vrr,
fullscreen,
vrr_fifo_opt_in: true,
fifo_latest_ready: true,
};
for p in [
pref(true, false, false),
pref(true, true, true),
pref(false, true, true),
] {
assert!(
!present_mode_chain(p).contains(&flr),
"never requested unless the device enabled the extension"
);
}
let default_flr = present_mode_chain(with_flr(true, false, false));
assert_eq!(
default_flr[0],
M::MAILBOX,
"MAILBOX still leads by measurement"
);
assert_eq!(default_flr[1], flr, "then the driver-native newest-wins");
assert!(
default_flr.iter().position(|m| *m == flr)
< default_flr.iter().position(|m| *m == M::FIFO),
"LATEST_READY must outrank plain FIFO — it is FIFO minus the standing queue"
);
assert_eq!(
present_mode_chain(with_flr(true, true, true))[0],
flr,
"the VRR ladder takes the queue-free vblank mode first"
);
// Every ladder can land: FIFO appears in all of them.
for p in [
pref(true, true, true),
pref(true, true, false),
pref(true, false, true),
pref(false, true, true),
pref(false, false, false),
with_flr(true, false, false),
] {
assert!(
present_mode_chain(p).contains(&M::FIFO),
"FIFO is the guaranteed landing"
);
}
}
Ok(if modes.contains(&want) {
want
} else {
vk::PresentModeKHR::FIFO // always available per spec
})
}
+7 -15
View File
@@ -407,21 +407,13 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
let _ = compositor;
// `ensure_available` waits out a devnode that is merely coming up (the wake-from-sleep case:
// the adapter re-enters D0 and re-registers its interface while a reconnecting client is
// already knocking) and self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
// devnode present, interface gone) by reloading the adapter.
//
// `context`, not a replacement message: it reports WHY — how long it waited, whether a reload
// ran, how many interface instances were seen and in what state. A flat "the driver is not
// installed" is what a field report carried from a box whose driver was installed, started,
// and simply mid-resume, and it pointed every reader at the wrong problem.
use anyhow::Context as _;
driver::ensure_available().context(
"pf-vdisplay driver interface not available — the pf-vdisplay IddCx driver is not \
installed, not loaded, or did not finish coming back up (the host installer bundles \
it; reinstall or check the driver state)",
)?;
// `ensure_available` self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
// devnode present, interface gone): one device cycle + re-probe before giving up.
anyhow::ensure!(
driver::ensure_available(),
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
not loaded (the host installer bundles it; reinstall or check the driver state)"
);
Ok(Box::new(driver::PfVdisplayDisplay::new()?))
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
@@ -138,14 +138,7 @@ impl KwinDisplay {
let kind = match topology {
Topology::Exclusive => TopologyKind::Exclusive,
Topology::Primary => TopologyKind::Primary,
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();
}
Topology::Extend | Topology::Auto => 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,34 +112,6 @@ 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 {
@@ -182,9 +154,6 @@ 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
@@ -338,7 +307,6 @@ 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,
@@ -658,17 +626,6 @@ 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
@@ -724,17 +681,6 @@ 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 {
@@ -835,68 +781,6 @@ 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`
@@ -1126,37 +1010,6 @@ 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() {
@@ -425,20 +425,6 @@ pub fn control_device_handle() -> Option<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle)
}
/// Retire the cached control handle from OUTSIDE the manager, for a caller that KNOWS the device
/// died — the adapter-reload recovery in [`crate::driver`], which tears the driver stack down and
/// back up. Without it the stale handle survives into the next session's `IOCTL_ADD` and is only
/// recovered by the gone-classified retry one failed IOCTL later.
///
/// Takes the `device` mutex, so it must NOT be called from inside it (notably not from
/// `VdisplayDriver::open`, which `ensure_device` invokes while holding it). No-op before any backend
/// opened the device.
pub(crate) fn invalidate_cached_device(why: &str) {
if let Some(m) = VDM.get() {
m.invalidate_device(&anyhow::anyhow!("{why}"));
}
}
/// Re-commit the CURRENT display config under the manager `state` lock (the sole-topology-mutator
/// contract of [`force_mode_reenumeration`]). The secure-desktop guard's actuator: the OS only
/// reverts a path to its software-cursor default ON a mode commit, so standing the hardware-cursor
@@ -21,7 +21,6 @@ use std::ffi::c_void;
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use windows::core::{GUID, PCWSTR};
@@ -144,70 +143,31 @@ fn reap_ghost_monitors() -> u32 {
}
}
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
/// woken host logged `cycled … status=OK` and then failed the session for a missing interface).
enum AdapterCycle {
/// The driver stack was genuinely reloaded. `how` names the lever that worked.
Reloaded { how: &'static str, status: String },
/// No punktfunk adapter devnode exists at all — the driver is not installed and retrying is
/// pointless.
NotInstalled,
/// A devnode exists but could not be reloaded; carries the reason (already whitespace-collapsed).
Refused(String),
}
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
/// reloads; on-glass, only a device reload recovered it.
///
/// Two levers, in order. `Disable-PnpDevice` + `Enable-PnpDevice` is the one `reset-pf-vdisplay.ps1`
/// uses — but that script stops the host service FIRST, precisely because the host holds the driver's
/// control device open (its step 1), and a disable can be refused for a device in use. This runs
/// INSIDE the host, so it structurally cannot take that step: the retired-but-never-closed handles in
/// [`DeviceSlot`](super::manager) are still open on the very device being disabled. So a refusal is
/// the expected case here, not the exotic one, and `pnputil /restart-device` — which reloads a device
/// that is in use — is the fallback. Whichever runs, the failure paths re-enable, so a half-completed
/// cycle can never leave the adapter DISABLED.
///
/// Best-effort + bounded (~6 s inside the script).
fn reload_vdisplay_adapter() -> AdapterCycle {
/// Kick the pf-vdisplay ADAPTER device (disable → enable) — the in-process equivalent of
/// `reset-pf-vdisplay.ps1` step 3. A crashed/killed WUDFHost can leave the devnode "started" yet
/// HOSTLESS (PnP Status OK, no WUDFHost process, zero device-interface instances) — a zombie no
/// session can open until the stack reloads; on-glass, only a device cycle recovered it. Called by
/// [`VdisplayDriver::open`] when `open_device` finds no openable interface; the caller retries the
/// open afterwards. Best-effort + bounded (~7 s inside the script). Returns whether a punktfunk
/// adapter devnode was found (and therefore cycled) — `false` means the driver genuinely is not
/// installed and a retry is pointless.
fn restart_vdisplay_device() -> bool {
// Mirrors reset-pf-vdisplay.ps1's Get-PfAdapter selector ('punktfunk Virtual Display' is the INF
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
// reported tokens are ours, so parsing them is locale-invariant too.
//
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
// reading `OK`, so the host logged a successful recovery it had never performed.
//
// `$LASTEXITCODE = 1` before the pnputil call for the same reason: no native command runs before
// it, so an unlaunchable pnputil would otherwise leave the variable holding whatever it held and
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
// System32.
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above.
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
$id = $ad.InstanceId; $err = ''; \
try { \
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
catch { Start-Sleep -Seconds 2; Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop }; \
Start-Sleep -Seconds 2; \
Write-Output ('RELOADED cycle ' + (Get-PnpDevice -InstanceId $id).Status); exit \
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
if ($ad) { \
Disable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status; \
if ($st -ne 'OK') { Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 2; \
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status }; \
Write-Output $st \
} else { Write-Output 'ABSENT' }";
let ps = std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
.unwrap_or_else(|_| "powershell.exe".to_string());
let out = match std::process::Command::new(&ps)
match std::process::Command::new(&ps)
.args([
"-NoProfile",
"-NonInteractive",
@@ -218,65 +178,22 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
])
.output()
{
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
Err(e) => {
tracing::warn!(error = %e, "pf-vdisplay: adapter reload could not spawn powershell");
return AdapterCycle::Refused(format!("could not spawn powershell: {e}"));
}
};
let outcome = classify_reload_output(&out);
match &outcome {
AdapterCycle::NotInstalled => {
tracing::warn!("pf-vdisplay: no adapter devnode to reload — driver not installed");
}
AdapterCycle::Reloaded { how, status } => tracing::warn!(
how,
%status,
"pf-vdisplay: reloaded the adapter device (hostless-zombie recovery)"
),
AdapterCycle::Refused(why) => tracing::warn!(
reason = %why,
"pf-vdisplay: the adapter devnode exists but could NOT be reloaded — a session cannot \
recover from this without a host-service restart or a reboot"
),
}
outcome
}
/// Parse [`reload_vdisplay_adapter`]'s script output. Split out to be testable without a box: the
/// bug this whole change answers was a recovery that MISreported its own outcome, so the decoding of
/// that outcome is worth pinning down.
fn classify_reload_output(out: &str) -> AdapterCycle {
let out = out.trim();
let (verb, rest) = out.split_once(char::is_whitespace).unwrap_or((out, ""));
match verb {
"ABSENT" => AdapterCycle::NotInstalled,
"RELOADED" => {
let (how, status) = rest
.trim()
.split_once(char::is_whitespace)
.unwrap_or((rest.trim(), ""));
// Held as `&'static str` so the two levers stay distinguishable in a field report:
// `restart` means the disable was refused, i.e. something still holds the device open —
// worth knowing when a reload does not fix the box.
let how: &'static str = if how == "restart" {
"pnputil /restart-device"
Ok(o) => {
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
if status == "ABSENT" {
tracing::warn!("pf-vdisplay: no adapter devnode to cycle — driver not installed");
} else {
"disable+enable"
};
AdapterCycle::Reloaded {
how,
status: status.trim().to_string(),
tracing::warn!(
%status,
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
);
}
status != "ABSENT"
}
Err(e) => {
tracing::warn!(error = %e, "pf-vdisplay: adapter cycle could not spawn powershell");
false
}
// Covers `REFUSED <reason>` and anything unrecognised, including an empty stdout (powershell
// died before writing). All of them mean an un-reloaded devnode, which is the only thing
// callers act on; the text rides along for the log.
_ => AdapterCycle::Refused(if rest.trim().is_empty() {
format!("unexpected adapter-reload output: {out:?}")
} else {
rest.trim().to_string()
}),
}
}
@@ -408,55 +325,6 @@ impl Drop for DevInfoList {
}
}
/// What a device-interface enumeration found. The counts are what let [`ensure_available`] tell a
/// devnode that is MID-TRANSITION (present, interface registered, not started yet — resuming from
/// sleep, restarting, reloading) apart from one that is genuinely gone. Only the second is worth
/// answering with device surgery; cycling the first only lengthens the outage it is waiting out.
struct Probe {
/// The control handle, if any interface instance opened.
handle: Option<OwnedHandle>,
/// Instances seen with `SPINT_ACTIVE` set — the owning device is started.
active: u32,
/// Instances seen with `SPINT_ACTIVE` clear — registered, but the owning device is not started.
inactive: u32,
/// The last enumeration/open failure, kept for the diagnostic.
last_err: Option<anyhow::Error>,
}
impl Probe {
/// No interface instance of ANY kind. With an adapter devnode present this is the hostless-zombie
/// state a WUDFHost crash leaves; with none, the driver is not installed. Either way, waiting
/// alone will not fix it.
fn is_absent(&self) -> bool {
self.handle.is_none() && self.active == 0 && self.inactive == 0
}
/// Why no handle came back, NAMING what was seen — "0 interfaces" and "1 inactive interface" are
/// completely different diagnoses (not installed vs. still coming up), and the old message
/// collapsed both into "is the driver installed?". Call only on a miss; a hit reports as much.
fn into_error(self) -> anyhow::Error {
let seen = format!("{} active, {} inactive", self.active, self.inactive);
if self.handle.is_some() {
return anyhow::anyhow!("pf-vdisplay device interface opened ({seen})");
}
match self.last_err {
Some(e) => e.context(format!("no openable pf-vdisplay device interface ({seen})")),
None => anyhow::anyhow!(
"no pf-vdisplay device interface found ({seen}) — is the pf-vdisplay driver \
installed and its device started?"
),
}
}
/// Consume into the [`open_device`] result.
fn into_result(mut self) -> Result<OwnedHandle> {
match self.handle.take() {
Some(h) => Ok(h),
None => Err(self.into_error()),
}
}
}
/// Open the pf-vdisplay control device.
///
/// SAFE, and owning. It has no caller obligation — it takes no arguments and every precondition is
@@ -465,40 +333,26 @@ impl Probe {
/// this file has already leaked from once (see the wrap-IMMEDIATELY comment in `open`). Returning an
/// `OwnedHandle` makes the close a `Drop`, so there is exactly one way to get it wrong: not at all.
fn open_device() -> Result<OwnedHandle> {
probe_device().into_result()
}
/// [`open_device`], reporting WHAT it found rather than only whether it succeeded.
fn probe_device() -> Probe {
let mut probe = Probe {
handle: None,
active: 0,
inactive: 0,
last_err: None,
};
// SAFETY: plain SetupAPI enumeration call; the returned list is solely owned by the RAII wrapper.
let hdev = match unsafe {
SetupDiGetClassDevsW(
Some(&PF_VDISPLAY_INTERFACE),
PCWSTR::null(),
None,
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
)
}
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")
{
Ok(h) => DevInfoList(h),
Err(e) => {
probe.last_err = Some(e);
return probe;
let hdev = DevInfoList(
unsafe {
SetupDiGetClassDevsW(
Some(&PF_VDISPLAY_INTERFACE),
PCWSTR::null(),
None,
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
)
}
};
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?,
);
// Enumerate EVERY interface instance, not just index 0: after a driver upgrade a present-but-
// failed devnode (Code 10) can hold index 0 while the LIVE node's interface sits at a later
// index — the old single-index read then failed every session with "driver not installed"
// even though a working interface existed. `SPINT_ACTIVE` filters dead interfaces (an interface
// is active only while its owning device is started); the first active + openable one wins.
let mut inactive = 0u32;
let mut last_err: Option<anyhow::Error> = None;
for index in 0..64u32 {
let mut idata = SP_DEVICE_INTERFACE_DATA {
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
@@ -513,10 +367,9 @@ fn probe_device() -> Probe {
break; // ERROR_NO_MORE_ITEMS — no further candidates
}
if idata.Flags & SPINT_ACTIVE == 0 {
probe.inactive += 1;
inactive += 1;
continue;
}
probe.active += 1;
let mut required = 0u32;
// SAFETY: sizing call — null buffer plus a valid `required` out-param; the expected
// ERROR_INSUFFICIENT_BUFFER "failure" is ignored and only `required` is consumed.
@@ -556,18 +409,20 @@ fn probe_device() -> Probe {
})
};
match opened {
Ok(h) => {
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing
// else holds it, so transferring it into the `OwnedHandle` gives it a single owner
// that closes it exactly once on drop.
probe.handle = Some(unsafe { OwnedHandle::from_raw_handle(h.0 as _) });
return probe;
}
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing else
// holds it, so transferring it into the `OwnedHandle` gives it a single owner that
// closes it exactly once on drop.
Ok(h) => return Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) }),
// A raced-away or wedged device — remember the error, try the next interface.
Err(e) => probe.last_err = Some(e),
Err(e) => last_err = Some(e),
}
}
probe
Err(last_err.unwrap_or_else(|| {
anyhow::anyhow!(
"no ACTIVE pf-vdisplay device interface found ({inactive} inactive) — is the \
pf-vdisplay driver installed and its device started?"
)
}))
}
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
@@ -580,14 +435,29 @@ impl VdisplayDriver for PfVdisplayDriver {
}
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
// A short re-probe, and deliberately NO adapter reload — this replaces the second, impatient
// copy of the recovery that used to live here. Session bring-up already ran the full
// `ensure_available` before constructing the backend, so anything left for this open to
// absorb is a race, not a wedge. `hw_cursor_capable` also lands here, mid client handshake,
// where a reload's tens of seconds would be entirely the wrong trade for one capability bool
// — and where reloading would deadlock besides, since `ensure_device` calls us holding the
// manager's `device` mutex (see the `RECOVERY` ordering contract).
let device = wait_for_interface(BRIEF_RETRY, false).0?;
let device = match open_device() {
Ok(d) => d,
Err(first) => {
// No openable interface. If a WUDFHost crash left the devnode a hostless zombie
// (validated on-glass: PnP Status OK, zero interface instances), a device cycle
// reloads the stack — kick it once and retry the open over a short arrival window.
if !restart_vdisplay_device() {
return Err(first); // no adapter devnode at all — genuinely not installed
}
let mut reopened = Err(first);
for _ in 0..8 {
std::thread::sleep(std::time::Duration::from_millis(500));
match open_device() {
Ok(d) => {
reopened = Ok(d);
break;
}
Err(e) => reopened = Err(e),
}
}
reopened.context("pf-vdisplay interface still absent after an adapter cycle")?
}
};
// `open_device` hands back an `OwnedHandle`, so every `?` below closes the device exactly
// once by construction — the shape this used to reach by wrapping the raw handle here, and
// which leaked whenever GET_INFO itself failed before that wrap was moved up.
@@ -1009,159 +879,25 @@ pub fn is_available() -> bool {
open_device().is_ok()
}
/// How often the interface is re-probed while waiting.
const PROBE_INTERVAL: Duration = Duration::from_millis(500);
/// How long a devnode whose interface exists but is NOT-READY (no active instance, or `CreateFileW`
/// refused) is given to come up on its own before the adapter is reloaded.
///
/// This is the wake-from-sleep window. Resuming re-enters D0 and re-registers the interface while
/// the rest of the resume storm is still running, and a client reconnecting a second after wake
/// arrives inside that gap — which the old code, probing exactly ONCE, answered by disabling and
/// re-enabling a display adapter that was seconds from being ready anyway.
const NOT_READY_GRACE: Duration = Duration::from_secs(15);
/// How long a fully ABSENT interface is given before the adapter is reloaded. Short — a hostless
/// devnode does not heal itself, and that is the case this recovery exists for — but non-zero, so a
/// resume that briefly de-registers the interface is not met with device surgery either.
const ABSENT_SETTLE: Duration = Duration::from_secs(3);
/// How long the interface is given to ARRIVE after a reload.
///
/// Was 4 s, which a quiet box meets and a box still finishing a resume does not: PnP is contended
/// right after wake. Field report 2026-08-02 — a woken host logged a successful adapter cycle and
/// then failed the session 4 s later for a missing interface, and the client could not connect.
const ARRIVAL_AFTER_RELOAD: Duration = Duration::from_secs(15);
/// Hard ceiling on the whole wait, so display prep can never block for an unbounded sum of the
/// windows above. Without it a devnode wedged NOT-READY costs the full grace, then the reload, then
/// the full arrival window before failing — the pathological case paying nearly a minute per session.
/// Patience for a device that is coming back is the point; patience for one that never will is not.
const TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// The budget a caller that must NOT stall gives the interface: no adapter reload, just a short
/// re-probe to ride out a race. [`VdisplayDriver::open`] uses it — by the time the manager opens,
/// session bring-up has already run the full [`ensure_available`] above, and the OTHER path that
/// reaches it (`manager::hw_cursor_capable`, a best-effort capability answer during the client
/// handshake) must never hold the Welcome for tens of seconds to decide one bool.
const BRIEF_RETRY: Duration = Duration::from_secs(3);
/// Serializes the recovery so N sessions racing in after a wake perform ONE adapter reload between
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
/// on. The second caller through typically finds the interface already up and returns at once.
///
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
/// wake from sleep.
///
/// Returns the reason on failure instead of a bare `false`: the caller used to replace it with a
/// flat "the driver is not installed", which is what a field report showed on a box whose driver was
/// installed, started, and merely mid-resume.
pub fn ensure_available() -> Result<()> {
// Poisoning carries no meaning here — the guard protects a `()`, not state a panic could leave
// inconsistent — so a previous panic must not wedge every later session out of recovery.
let (result, reloaded) = {
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
wait_for_interface(NOT_READY_GRACE, true)
};
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
// stack down and back up, so any control handle a previous session cached is dead by
// construction — retire it while we know that for certain, rather than leaving the next session
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
if reloaded {
super::manager::invalidate_cached_device(
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
);
/// [`is_available`], with self-heal: an interface-less driver whose adapter devnode EXISTS is the
/// hostless-zombie state a WUDFHost crash leaves behind (validated on-glass — PnP reports Status OK
/// with no WUDFHost process and zero interface instances, and every session fails at this gate until
/// the device reloads). Cycle the adapter once and re-probe over a short arrival window. A genuinely
/// uninstalled driver (no adapter devnode) fails fast without the wait.
pub fn ensure_available() -> bool {
if is_available() {
return true;
}
result.map(|_| ())
}
/// Wait for an openable control interface, reloading the adapter if `reload` and the devnode looks
/// genuinely hostless. Returns the handle (so the manager's own open can keep it) alongside whether
/// a reload ran.
///
/// Two distinguishable states hide behind "cannot open the interface", and they want opposite
/// treatment:
///
/// * **Not ready** — instances are registered but none is active (or the open is refused). The
/// devnode is THERE and coming up: resuming from sleep, restarting, reloading. It heals itself;
/// reloading the adapter underneath it only lengthens the outage.
/// * **Absent** — no instance at all. With an adapter devnode present this is the hostless-zombie
/// state a WUDFHost crash leaves (validated on-glass: PnP Status OK, no WUDFHost process, zero
/// interface instances). Only a reload clears it.
///
/// So: probe, wait out a not-ready device, reload an absent one after a short settle, and give the
/// interface a real arrival window afterwards. A reload is still attempted once at the end of
/// `not_ready_grace`, so a devnode wedged not-ready (a failed start) recovers exactly as it did
/// before. A genuinely uninstalled driver — no adapter devnode — still fails FAST, with no wait.
fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedHandle>, bool) {
let started = Instant::now();
let mut deadline = started + not_ready_grace;
let mut absent_since: Option<Instant> = None;
let mut reloaded = false;
loop {
let mut probe = probe_device();
if let Some(h) = probe.handle.take() {
if reloaded || started.elapsed() > PROBE_INTERVAL {
tracing::info!(
waited_ms = started.elapsed().as_millis() as u64,
reloaded,
"pf-vdisplay: control interface available"
);
}
return (Ok(h), reloaded);
}
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
// between absent and not-ready is treated as the transition it is.
if probe.is_absent() {
absent_since.get_or_insert_with(Instant::now);
} else {
absent_since = None;
}
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
match reload_vdisplay_adapter() {
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
// burning the arrival window on a box that simply does not have it installed.
AdapterCycle::NotInstalled => {
let e = Err(probe.into_error()).context(
"no punktfunk virtual-display adapter devnode exists — the driver is not \
installed",
);
return (e, reloaded);
}
AdapterCycle::Refused(why) => {
let e = Err(probe.into_error()).context(format!(
"the pf-vdisplay adapter devnode could not be reloaded ({why})"
));
return (e, reloaded);
}
AdapterCycle::Reloaded { .. } => {
reloaded = true;
absent_since = None;
deadline = (Instant::now() + ARRIVAL_AFTER_RELOAD).min(started + TOTAL_BUDGET);
}
}
}
if Instant::now() >= deadline {
let e = Err(probe.into_error()).context(format!(
"the pf-vdisplay control interface did not appear within {:?}{}",
started.elapsed(),
if reloaded {
" (including an adapter reload)"
} else {
""
}
));
return (e, reloaded);
}
std::thread::sleep(PROBE_INTERVAL);
if !restart_vdisplay_device() {
return false;
}
for _ in 0..8 {
std::thread::sleep(std::time::Duration::from_millis(500));
if is_available() {
return true;
}
}
false
}
#[cfg(test)]
@@ -1170,96 +906,6 @@ mod tests {
use std::thread;
use std::time::Duration;
/// The recovery must not be able to claim success it did not achieve. This is the whole bug:
/// the old script ran the cycle under `SilentlyContinue` and reported `(Get-PnpDevice).Status`,
/// so a device whose disable had been REFUSED — untouched, still started — reported `OK`, and
/// the host logged `cycled the adapter device … status=OK` while nothing had been cycled at all
/// (field report 2026-08-02). A refusal must decode as a refusal, carrying its reason.
#[test]
fn a_refused_reload_is_not_reported_as_a_reload() {
let refused =
classify_reload_output("REFUSED This device cannot be disabled because it is in use.");
match refused {
AdapterCycle::Refused(why) => {
assert!(why.contains("in use"), "the reason must survive: {why:?}")
}
other => panic!("a refused reload decoded as {}", variant(&other)),
}
// A bare device status — what the OLD script emitted on every path — must NEVER decode as a
// successful reload now, however healthy it looks.
for stale in ["OK", "Error", "Unknown"] {
assert!(
matches!(classify_reload_output(stale), AdapterCycle::Refused(_)),
"{stale:?} is a device status, not a reload outcome"
);
}
}
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
/// disable was refused and something still holds the device open).
#[test]
fn reload_outcomes_decode() {
assert!(matches!(
classify_reload_output("ABSENT"),
AdapterCycle::NotInstalled
));
match classify_reload_output("RELOADED cycle OK") {
AdapterCycle::Reloaded { how, status } => {
assert_eq!(how, "disable+enable");
assert_eq!(status, "OK");
}
other => panic!("expected Reloaded, got {}", variant(&other)),
}
match classify_reload_output("RELOADED restart OK\r\n") {
AdapterCycle::Reloaded { how, status } => {
assert_eq!(how, "pnputil /restart-device");
assert_eq!(status, "OK");
}
other => panic!("expected Reloaded, got {}", variant(&other)),
}
// powershell died before writing anything — an un-reloaded devnode, so `Refused`, not a
// silent success.
assert!(matches!(
classify_reload_output(" "),
AdapterCycle::Refused(_)
));
}
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
/// the outage it is already recovering from.
#[test]
fn only_a_total_absence_counts_as_absent() {
let probe = |active, inactive| Probe {
handle: None,
active,
inactive,
last_err: None,
};
assert!(probe(0, 0).is_absent(), "no instances at all = absent");
assert!(
!probe(0, 1).is_absent(),
"a registered-but-inactive instance is a device coming up, not a missing one"
);
assert!(
!probe(1, 0).is_absent(),
"an active instance we merely failed to open is not a missing device"
);
// And the diagnostic names what was seen — the old message collapsed every one of these
// into "is the driver installed?", which sent a field report down the wrong path.
assert!(probe(0, 2).into_error().to_string().contains("2 inactive"));
}
fn variant(c: &AdapterCycle) -> &'static str {
match c {
AdapterCycle::Reloaded { .. } => "Reloaded",
AdapterCycle::NotInstalled => "NotInstalled",
AdapterCycle::Refused(_) => "Refused",
}
}
/// Live hardware round trip — `#[ignore]`d (needs the pf-vdisplay driver installed); run with
/// `cargo test -p pf-vdisplay -- --ignored live_create_drop`. Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
#[test]
+202 -6
View File
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
@@ -759,6 +765,16 @@ impl PunktfunkHidOutput {
out.effect_len = 6;
}
HidOutput::HidRaw { .. } => return None,
HidOutput::AudioCtl { pad, flags, raw } => {
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly —
// pads are 0..16 (`input::MAX_PADS`) end to end.
out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
out.pad = *pad as u8;
out.which = *flags;
out.effect[0..6].copy_from_slice(raw);
out.effect_len = 6;
}
}
Some(out)
}
@@ -1172,6 +1188,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
/// design/pen-tablet-input.md.)
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x20;
/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
/// stream (a real DualSense's voice coils).
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
/// stream.
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
#[cfg(feature = "quic")]
@@ -1186,6 +1221,20 @@ const _: () = {
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
// `input::encode_gamepad_arrival` applies).
assert!(
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
);
assert!(
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
);
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
@@ -1768,6 +1817,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
/// forward-compatible.
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x04;
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
@@ -2312,6 +2368,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
})
}
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
/// thread (one puller, may run alongside the other planes' pullers).
///
/// # Safety
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
/// `buf` is writable for `buf_len` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
c: *mut PunktfunkConnection,
out_pad: *mut u8,
out_kind: *mut u8,
out_seq: *mut u32,
out_pts_ns: *mut u64,
buf: *mut u8,
buf_len: usize,
timeout_ms: u32,
) -> i32 {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return -1,
};
if buf.is_null() && buf_len != 0 {
return -1;
}
match c
.inner
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
{
Some(f) => {
if f.opus.is_empty() || f.opus.len() > buf_len {
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
// as loss would synthesize concealment) or doesn't fit — report "nothing
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
// be undecodable anyway).
return 0;
}
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
// checked before it is written; `buf` is a caller-owned writable region of
// `buf_len` bytes and the copy length was just bounds-checked against it.
unsafe {
if !out_pad.is_null() {
*out_pad = f.pad;
}
if !out_kind.is_null() {
*out_kind = f.kind;
}
if !out_seq.is_null() {
*out_seq = f.seq;
}
if !out_pts_ns.is_null() {
*out_pts_ns = f.pts_ns;
}
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
}
f.opus.len() as i32
}
// `None` folds timeout and closed; the shutdown flag tells them apart so the
// embedder's plane loop can exit instead of polling a dead session forever.
None if c.inner.is_session_ended() => -1,
None => 0,
}
}));
r.unwrap_or(-1)
}
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
/// before. Latest-wins per pad; unknown bits are masked off.
///
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
c: *mut PunktfunkConnection,
pad: u8,
audio_caps: u8,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
c.inner.set_pad_audio_caps(pad, audio_caps);
PunktfunkStatus::Ok
})
}
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
@@ -4116,11 +4283,7 @@ pub struct PunktfunkProbeResult {
/// Application goodput bytes / access units the host offered.
pub host_bytes: u64,
pub host_packets: u32,
/// 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.
/// The host's measured burst duration, milliseconds (the throughput denominator).
pub elapsed_ms: u32,
/// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
pub throughput_kbps: u32,
@@ -4134,7 +4297,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 ≤ 10 Gbps / ≤ 5 s),
/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 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.
///
@@ -4412,3 +4575,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
PunktfunkStatus::Ok
})
}
#[cfg(all(test, feature = "quic"))]
mod tests {
use super::*;
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
/// packing idiom — no struct growth, so the size guard above stays at 19).
#[test]
fn hidout_abi_maps_audio_ctl() {
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
pad: 3,
flags: 0x17,
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
})
.unwrap();
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
assert_eq!(out.pad, 3);
assert_eq!(out.which, 0x17);
assert_eq!(out.effect_len, 6);
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
assert_eq!(out.effect[6..], [0; 5]);
// A raw passthrough report still has no C representation (skipped at the pull site).
assert!(
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
pad: 0,
kind: 0,
data: vec![0x80],
})
.is_none()
);
}
}
+5 -364
View File
@@ -31,10 +31,6 @@
//! 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
@@ -133,16 +129,7 @@ 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;
@@ -150,23 +137,6 @@ 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).
@@ -177,10 +147,6 @@ 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.
@@ -212,24 +178,6 @@ 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 3060 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
@@ -248,17 +196,10 @@ 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),
@@ -269,9 +210,6 @@ 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,
@@ -284,12 +222,8 @@ 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). 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).
/// (descent is the congestion signals' job).
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;
}
@@ -340,16 +274,11 @@ 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
/// 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.
/// 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();
}
@@ -498,30 +427,6 @@ 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);
@@ -531,31 +436,6 @@ 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);
@@ -567,13 +447,11 @@ 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 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.
// 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.
let eff_ceiling = self
.ceiling_kbps
.min(self.host_cap_kbps.unwrap_or(u32::MAX))
.min(self.decode_cap_kbps.unwrap_or(u32::MAX));
.min(self.host_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 {
@@ -1569,243 +1447,6 @@ 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);
+53 -11
View File
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::{PunktfunkError, Result};
use crate::input::InputEvent;
use crate::quic::{
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
RfiRequest, RichInput,
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
ProbeRequest, RfiRequest, RichInput,
};
use crate::session::Frame;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
};
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
use self::planes::{
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
};
use self::probe::ProbeState;
use self::pump::run_pump;
@@ -122,6 +124,14 @@ pub struct NativeClient {
rumble_sched: Arc<rumble::RumbleShared>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
hidout: Mutex<Receiver<HidOutput>>,
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
pad_audio: Mutex<Receiver<PadAudioFrame>>,
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
hdr_meta: Mutex<Receiver<HdrMeta>>,
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
@@ -418,6 +428,10 @@ impl NativeClient {
let rumble_sched = Arc::new(rumble::RumbleShared::new());
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
let (pad_audio_tx, pad_audio_rx) =
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
let (host_timing_tx, host_timing_rx) =
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
@@ -459,6 +473,7 @@ impl NativeClient {
let clock_offset_w = clock_offset.clone();
let decode_lat_w = decode_lat.clone();
let live_bitrate_w = live_bitrate.clone();
let pad_audio_caps_w = pad_audio_caps.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new()
.name("punktfunk-client".into())
@@ -502,6 +517,8 @@ impl NativeClient {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
pad_audio_caps: pad_audio_caps_w,
hdr_meta_tx,
host_timing_tx,
cursor_shape_tx,
@@ -550,6 +567,8 @@ impl NativeClient {
rumble: Mutex::new(rumble_rx),
rumble_sched,
hidout: Mutex::new(hidout_rx),
pad_audio: Mutex::new(pad_audio_rx),
pad_audio_caps,
hdr_meta: Mutex::new(hdr_meta_rx),
host_timing: Mutex::new(host_timing_rx),
cursor_shape: Mutex::new(cursor_shape_rx),
@@ -883,7 +902,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 (≤ 10 Gbps, ≤ 5 s).
/// fields (≤ 3 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 {
@@ -922,12 +941,8 @@ impl NativeClient {
p.rx_bytes_now.saturating_sub(base_b),
)
};
// 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();
// The host's burst duration is the throughput denominator. bytes × 8 / ms = kilobits/second.
let window_ms = p.host_duration_ms;
let throughput_kbps = if window_ms > 0 {
(delivered_bytes.saturating_mul(8) / window_ms as u64) as u32
} else {
@@ -1055,6 +1070,33 @@ impl NativeClient {
}
}
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
/// puller per the plane contract.
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
}
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
/// Call at controller attach, BEFORE the pad's arrival is sent (like
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
slot.store(audio_caps & 0x03, Ordering::Relaxed);
}
}
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
/// the host sent for an HDR session; same timeout/closed semantics as
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
pub(crate) const HIDOUT_QUEUE: usize = 32;
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
/// newest frame (the renderer conceals the gap).
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
pub(crate) const HDR_META_QUEUE: usize = 8;
+5 -119
View File
@@ -1,50 +1,34 @@
//! 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 probe-scoped receive counters here; the control task finalizes the delivered figure
/// session's packet-level 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,
/// 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.
/// `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.
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's FALLBACK — see
/// [`throughput_window_ms`](Self::throughput_window_ms)).
/// The host's measured burst duration (the throughput denominator).
pub(crate) host_duration_ms: u32,
/// The host's `ProbeResult` arrived → the measurement is final.
pub(crate) done: bool,
@@ -55,40 +39,6 @@ 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 {
@@ -100,11 +50,7 @@ pub struct ProbeOutcome {
/// Application goodput bytes / access units the host offered.
pub host_bytes: u64,
pub host_packets: u32,
/// 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.
/// The burst duration the host measured, in milliseconds (the throughput denominator).
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).
@@ -120,63 +66,3 @@ 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);
}
}
+13 -2
View File
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
pad_audio_caps,
hdr_meta_tx,
host_timing_tx,
cursor_shape_tx,
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
// (see [`input_task`]).
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
tokio::spawn(input_task::run(
conn.clone(),
input_rx,
gamepad_snapshots,
pad_audio_arrivals,
pad_audio_caps,
));
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
// Self-healing latency bound: every frame still queued once this task catches up is standing
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
hdr_meta_tx,
host_timing_tx,
encode_lat.clone(),
@@ -132,24 +132,12 @@ 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). 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).
// Freeze the delivered figures now (the burst is done), before resumed
// video can inflate the packet counters.
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;
@@ -163,7 +151,6 @@ 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) {
+8 -28
View File
@@ -200,23 +200,10 @@ impl DataPump {
let probe_active = {
let mut p = pump_probe.lock().unwrap();
if p.active && !p.done {
// 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.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);
}
p.active && !p.done
};
@@ -293,23 +280,16 @@ 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, 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.
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7.
if p.host_duration_ms > 0 && p.delivered_bytes > 0 {
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 delivered_kbps = (p.delivered_bytes.saturating_mul(8)
/ p.host_duration_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,6 +12,7 @@ pub(super) async fn run(
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
rumble_feed: super::super::rumble::RumbleFeed,
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
@@ -70,6 +71,11 @@ pub(super) async fn run(
let _ = hidout_tx.try_send(h);
}
}
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
let _ = pad_audio_tx.try_send(f);
}
}
Some(&crate::quic::HDR_META_MAGIC) => {
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
let _ = hdr_meta_tx.try_send(m);
@@ -15,8 +15,16 @@ pub(super) async fn run(
conn: quinn::Connection,
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
gamepad_snapshots: bool,
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
// so unexpected high bits would make it drop the kind declaration entirely.
pad_audio: bool,
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
) {
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
use std::sync::atomic::Ordering;
// Touched pads only: an entry appears on the first gamepad event for that index, so the
// refresh never conjures a virtual pad the embedder didn't drive.
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
@@ -37,6 +45,17 @@ pub(super) async fn run(
const ARRIVAL_RESENDS: u8 = 2;
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
// byte-identical to the plain index — the pre-pad-audio wire.
let arrival_flags = |idx: usize| -> u32 {
let caps = if pad_audio {
pad_audio_caps[idx].load(Ordering::Relaxed)
} else {
0
};
crate::input::encode_gamepad_arrival(idx as u8, caps)
};
let mut refresh = tokio::time::interval(Duration::from_millis(100));
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
@@ -81,13 +100,28 @@ pub(super) async fn run(
let _ = conn.send_datagram(rem.encode().to_vec().into());
continue;
}
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
// Remember the declared kind (`code`) and forward it, arming a re-send burst
// so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
let _ = conn.send_datagram(ev.encode().to_vec().into());
continue;
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
// the usual source). Fold event-carried bits into the registry so the re-send
// burst keeps them, then send with the negotiation-gated flags word.
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
if idx < MAX_PADS {
if ev_caps != 0 {
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
}
// Remember the declared kind (`code`) and forward it, arming a re-send
// burst so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
let arr = crate::input::InputEvent {
flags: arrival_flags(idx),
..ev
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
continue;
}
}
let _ = conn.send_datagram(ev.encode().to_vec().into());
}
@@ -104,7 +138,7 @@ pub(super) async fn run(
code: kind as u32,
x: 0,
y: 0,
flags: idx as u32,
flags: arrival_flags(idx),
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
} else {
+10 -2
View File
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::Result;
use crate::input::InputEvent;
use crate::quic::{HdrMeta, HidOutput};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, Mutex};
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
/// closed, so the command API always observes connection teardown.
pub(crate) rumble_feed: super::rumble::RumbleFeed,
pub(crate) hidout_tx: SyncSender<HidOutput>,
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
/// [`NativeClient::next_pad_audio`].
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
+63 -1
View File
@@ -64,7 +64,11 @@ pub enum InputKind {
GamepadRemove = 13,
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
/// Sent when the client opens a pad slot — before that pad's
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
(flags as u8, (flags >> 24) as u8)
}
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
/// forwards to) a real DualSense whose voice-coil actuators can play the
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
/// it drop the declaration).
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
/// pre-pad-audio wire bytes exactly.
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
}
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
/// An old-format word (index only) yields `audio_caps = 0`.
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
(flags as u8, ((flags >> 8) & 0x03) as u8)
}
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
///
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
kind: u8,
/// LI_CCAP_* bits (0x02 = rumble).
capabilities: u16,
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
/// express pad audio and always sets `0`, as does an old client.
audio_caps: u8,
},
}
@@ -443,6 +480,31 @@ mod tests {
assert_eq!((pad, seq), (9, 123));
}
#[test]
fn gamepad_arrival_flags_roundtrip() {
// The capability bits ride bits 8/9; the index stays the low byte.
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
let flags = encode_gamepad_arrival(pad, caps);
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
assert_eq!(flags & 0xFF, pad as u32);
}
assert_eq!(
encode_gamepad_arrival(2, 0b11),
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
);
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
// old host) is byte-identical to the plain index, and decodes with caps 0.
assert_eq!(encode_gamepad_arrival(5, 0), 5);
assert_eq!(decode_gamepad_arrival(5), (5, 0));
// Undefined high bits (a future extension) never leak into the index OR the caps.
assert_eq!(
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
(9, 1)
);
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
}
#[test]
fn gamepad_snapshot_roundtrip() {
let s = GamepadSnapshot {
+7 -1
View File
@@ -120,7 +120,13 @@ pub use stats::Stats;
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 14;
/// v15: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 15;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+4 -15
View File
@@ -73,9 +73,9 @@ pub struct StreamedAu {
pts_ns: u64,
user_flags: u32,
/// Bytes not yet sealed into a block: the sub-shard remainder plus anything below the
/// slice-flush threshold. The final block always has ≥ 1 byte flushes emit only whole
/// shards, and a flush that WOULD empty this keeps one shard back (see `push_streamed`),
/// so `finish_streamed` always has something real to seal.
/// slice-flush threshold. The final block always has ≥ 1 byte (flushes emit only whole
/// shards and never drain to empty on a slice that ends the AU — `finish_streamed` seals
/// whatever remains).
pending: Vec<u8>,
/// Sentinel blocks already emitted.
blocks_out: u16,
@@ -418,18 +418,7 @@ impl Packetizer {
"streamed AU exceeds the negotiated max_frame_bytes",
));
}
// Never drain `pending` to EMPTY. [`finish_streamed`] must have bytes left to seal,
// or the final block degenerates to a single zero-padded filler shard whose derived
// base (`total_data 1`) overlaps the block flushed just now — which the receiver's
// retro-validation correctly reads as a lying header and kills the whole AU. It bites
// exactly when the AU's length is a multiple of `shard_payload` (~1 in 1408 frames on
// a 1500-MTU link), and only on the slice arm: the legacy `must_flush` is a strict
// `>`, so its remainder is never empty. Keeping one whole shard back costs nothing —
// it rides out in the final block, which has to exist regardless.
let mut k = whole.min(self.fec.max_data_per_block as usize);
if k > 1 && k == whole && au.pending.len() == whole * payload {
k -= 1;
}
let k = whole.min(self.fec.max_data_per_block as usize);
let sof = !au.opened;
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
let fi = au.frame_index;
+7 -62
View File
@@ -409,27 +409,6 @@ 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,
@@ -467,33 +446,14 @@ impl Reassembler {
return Ok(None);
}
// How many shards of frame buffer THIS packet proves the frame needs. A sentinel carries
// no total, but it does pin its own block's extent — a slice sentinel by its wire base,
// a legacy one by its full-K position — and that is what the buffer must cover to place
// the shard. The frame grows as later blocks reveal more, and the final (non-sentinel)
// block's totals settle it.
//
// ⚠ NOT `total_data_max` (= the negotiated `max_frame_bytes`, 8-64 MiB): that shape
// shipped in 0.23.0 and was survivable only while sentinels were rare — the streamed
// path emitted one solely for an AU exceeding a whole FEC block (~281 KB). The slice
// wire flushes at `MIN_STREAM_BLOCK_SHARDS`, so EVERY ordinary AU became sentinel-opened
// and every one of them committed the full ceiling: a multi-megabyte zeroed allocation
// per access unit, and an in-flight budget (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`)
// exhausted after ~3 concurrent frames — beyond which every packet of every further
// frame was dropped outright. On a jittery link that is a permanent loss storm.
let need_shards = if sentinel && slice_stream {
frame_bytes / shard_bytes + data_shards
} else if sentinel {
// Legacy sentinels are full-K uniform blocks (firewall-enforced), so the block's
// index alone gives its end.
(block_idx + 1).saturating_mul(lim.max_data_shards)
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
// the limits' maximum — its real size doesn't exist yet.
let buf_len = if sentinel {
total_data_max * shard_bytes
} else {
total_data
}
.min(total_data_max);
// First packet of a frame allocates its (zeroed) buffer, budget-gated; later packets must
// agree with its geometry.
let buf_len = need_shards * shard_bytes;
total_data * shard_bytes
};
let frame = match win.frames.entry(hdr.frame_index) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
@@ -621,21 +581,6 @@ impl Reassembler {
drop(stats);
return Ok(None);
}
// Grow to this packet's proven extent. A streamed frame opens at whichever block arrived
// first and learns its real size from the final block's totals (or a later, higher
// sentinel base) — reorder means either can come first, so the buffer is sized by
// whatever the frame has proven so far. Never shrinks: the totals only settle the frame's
// END, and completion truncates to `frame_bytes` anyway. The budget is re-checked here
// for exactly the reason it is checked at open — growth commits memory too.
if buf_len > frame.buf.len() {
let delta = buf_len - frame.buf.len();
if *in_flight_bytes + delta > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
drop(stats);
return Ok(None);
}
*in_flight_bytes += delta;
frame.buf.resize(buf_len, 0);
}
let FrameBuf {
buf,
blocks,
+10 -167
View File
@@ -941,9 +941,8 @@ fn slice_config() -> Config {
/// Slice chunks chosen to exercise every packetizer path: an exact-shard slice, a slice with
/// a sub-shard remainder, a slice below [`MIN_STREAM_BLOCK_SHARDS`] that must accumulate,
/// and a finish tail. 1023 B total → blocks (K, base-shard): (19, 0), (26, 19), (18, 45),
/// final (1, 63) with block_count 4. Chunk 0 is an exact 20-shard multiple and flushes 19:
/// a flush never drains `pending` to empty, so `finish_streamed` always seals real bytes.
/// and a finish tail. 1023 B total → blocks (K, base-shard): (20, 0), (25, 20), (18, 45),
/// final (1, 63) with block_count 4.
fn slice_chunks() -> Vec<Vec<u8>> {
[320usize, 403, 100, 200]
.iter()
@@ -1008,8 +1007,7 @@ fn slice_streamed_wire_shape_and_roundtrip() {
assert_eq!(src.len(), 1023);
// (block_index, K, base bytes) — chunk 2 (100 B) accumulated instead of flushing (6
// whole shards < MIN_STREAM_BLOCK_SHARDS) and rode into block 2 with chunk 3's bytes.
// Block 0 keeps one shard back (chunk 0 is an exact multiple), which rides into block 1.
let expect = [(0u16, 19u16, 0u32), (1, 26, 304), (2, 18, 720)];
let expect = [(0u16, 20u16, 0u32), (1, 25, 320), (2, 18, 720)];
for p in &pkts {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_ne!(
@@ -1480,24 +1478,15 @@ fn parts_flow_for_legacy_streamed_frames() {
assert!(got.last().unwrap().complete);
}
/// A one-datagram open commits only the buffer its OWN header proves it needs, and the
/// in-flight budget still bounds the ones that claim a lot.
///
/// Both halves matter. A sentinel that claims little must cost little: sizing every
/// sentinel-opened frame at `max_frame_bytes` (the 0.23.0 shape) was survivable only while
/// sentinels were rare, and the slice wire made every ordinary AU one — after which the budget
/// was spent on ~3 frames and everything else on the link was dropped. A sentinel that claims a
/// lot must still be bounded: its wire base can point near the frame ceiling, which is the
/// amplification this budget exists for.
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
#[test]
fn streamed_open_commits_its_own_extent_and_stays_bounded() {
let coder = coder_for(FecScheme::Gf8);
// limits(): shard 16 B, max_data_shards 8, max_frame_bytes 4096 → budget = 4 × 4096.
// Modest legacy sentinels (block 0, full K = 8 → 128 B each): far more than
// IN_FLIGHT_BUF_FACTOR of them must fit, because none of them claims the ceiling.
fn streamed_open_amplification_is_budget_bounded() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
for fi in 0..32u32 {
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
for fi in 0..5u32 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
@@ -1509,35 +1498,10 @@ fn streamed_open_commits_its_own_extent_and_stays_bounded() {
.unwrap()
.is_none());
}
assert_eq!(
stats.snapshot().packets_dropped,
0,
"ordinary one-datagram opens must not exhaust the in-flight budget"
);
// A SLICE sentinel whose wire base sits just under the ceiling really does commit a
// max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B) — four fit the budget, the
// fifth must be refused.
let mut r = Reassembler::new(limits());
let stats = StatsCounters::default();
for fi in 0..5u32 {
let mut h = base_header();
h.user_flags = USER_FLAG_SLICE_STREAM;
h.block_count = 0;
h.frame_bytes = 4096 - 8 * 16;
h.block_index = 1;
h.data_shards = 8;
h.recovery_shards = 0;
h.frame_index = fi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
assert_eq!(
stats.snapshot().packets_dropped,
1,
"the fifth ceiling-claiming open must be refused by the in-flight budget"
"the fifth max-sized open must be refused by the in-flight budget"
);
}
@@ -1648,124 +1612,3 @@ fn streamed_second_final_with_different_totals_is_rejected() {
.expect("frame completes under the first pinned totals");
assert_eq!(got.data.len(), 160);
}
/// Production-shaped slice geometry: a 1500-MTU shard payload and the smallest frame ceiling
/// the QUIC handshake ever negotiates (`max_frame_bytes` is clamped to ≥ 8 MiB there).
fn prod_slice_config() -> Config {
use crate::config::{FecConfig, ProtocolPhase, Role};
Config {
role: Role::Host,
phase: ProtocolPhase::P2Punktfunk,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 20,
max_data_per_block: 200,
},
shard_payload: crate::config::mtu1500_shard_payload(),
max_frame_bytes: 8 << 20,
encrypt: false,
key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4],
loopback_drop_period: 0,
}
}
/// Packetize one streamed AU of `chunks`, each chunk an encoder slice boundary.
fn streamed_packets_with(
cfg: &Config,
frame_index: u32,
pts_ns: u64,
slice: bool,
chunks: &[usize],
) -> (Vec<Vec<u8>>, Vec<u8>) {
let coder = coder_for(cfg.fec.scheme);
let mut pk = Packetizer::new(cfg);
let uf = if slice { USER_FLAG_SLICE_STREAM } else { 0 };
let mut au = pk.begin_streamed(pts_ns, uf, Some(frame_index));
let (mut pkts, mut src) = (Vec::new(), Vec::new());
let sink = |pkts: &mut Vec<Vec<u8>>, h: &PacketHeader, b: &[u8]| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
};
for (c, &n) in chunks.iter().enumerate() {
let data: Vec<u8> = (0..n).map(|i| (c * 57 + i * 131 + 7) as u8).collect();
src.extend_from_slice(&data);
pk.push_streamed(&mut au, &data, true, coder.as_ref(), |h, b| {
sink(&mut pkts, h, b);
Ok(())
})
.unwrap();
}
pk.finish_streamed(au, coder.as_ref(), |h, b| {
sink(&mut pkts, h, b);
Ok(())
})
.unwrap();
(pkts, src)
}
/// An AU whose length is an exact multiple of the shard payload must still reassemble.
///
/// Regression: the slice flush drained `pending` to empty, so `finish_streamed` sealed a final
/// block of one zero-padded FILLER shard. Its derived base (`total_data 1`) overlapped the
/// sentinel block flushed a moment earlier, the receiver's retro-validation read that as a lying
/// header, and the whole AU was destroyed — one frame in every `shard_payload` (~12 s at 120 fps),
/// each costing a re-anchor freeze and a recovery keyframe.
#[test]
fn slice_streamed_exact_shard_multiple_completes() {
let cfg = prod_slice_config();
let coder = coder_for(FecScheme::Gf16);
let payload = cfg.shard_payload;
for shards in [16usize, 29, 30, 64] {
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[shards * payload]);
// Whatever the block split, the final block must carry real bytes — never a lone
// zero-pad shard sitting on top of the previous block's range.
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("{shards}-shard AU (exact multiple) must complete"));
assert_eq!(f.data, src, "{shards}-shard AU must be byte-identical");
}
// ...and the sweep around one of them, so an off-by-one in the keep-back can't hide.
for extra in 0..3usize {
let n = 30 * payload + extra;
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &[n]);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("{n}-byte AU must complete"));
assert_eq!(f.data, src);
}
}
/// A slice-streamed frame must cost the reassembler its OWN size, not the negotiated ceiling.
///
/// Regression: sentinel-opened frames allocated `max_frame_bytes` (8-64 MiB) each. Since the
/// slice wire makes every ordinary AU sentinel-opened, the in-flight budget
/// (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`) was spent after ~3 concurrent frames and every
/// packet of every further frame was dropped outright — a permanent loss storm on any link with
/// normal reorder, plus a multi-megabyte zeroing per access unit.
#[test]
fn slice_streamed_in_flight_budget_matches_legacy() {
let cfg = prod_slice_config();
let coder = coder_for(FecScheme::Gf16);
// A normal 40 KB access unit, opened but not completed — the shape a link with reorder
// holds several of at once.
for slice in [false, true] {
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
for i in 0..12u32 {
let (pkts, _) = streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
}
assert_eq!(
stats
.packets_dropped
.load(std::sync::atomic::Ordering::Relaxed),
0,
"slice={slice}: 12 ordinary AUs in flight must fit the in-flight budget"
);
}
}

Some files were not shown because too many files have changed in this diff Show More