Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c25ff1a80 | ||
|
|
1099c94ca3 | ||
|
|
e5ba78ea66 | ||
|
|
d0d2399476 | ||
|
|
53278c6f5f | ||
|
|
fc5b6296e3 | ||
|
|
4a0d0ce587 | ||
|
|
0d94ef0dbe | ||
|
|
defdfbdb58 | ||
|
|
8103958169 | ||
|
|
110ac9b663 | ||
|
|
1d6f4760f3 | ||
|
|
9dfbc2f895 | ||
|
|
56adb47026 | ||
|
|
52a9d02355 | ||
|
|
e5ca213339 | ||
|
|
e5416646f9 | ||
|
|
b79d90b463 | ||
|
|
8983ec04b9 | ||
|
|
d27e62f7c9 | ||
|
|
0a72959ef7 | ||
|
|
2d223274fc | ||
|
|
92f617a989 | ||
|
|
2f071a9a93 | ||
|
|
62d35bc4b6 | ||
|
|
5d06ef26ac | ||
|
|
fcf4076eb7 | ||
|
|
53eb592c43 | ||
|
|
956d8dd8ef | ||
|
|
b2e716ad5f | ||
|
|
ec288d64d3 | ||
|
|
68353a5d57 | ||
|
|
ffd5a33598 | ||
|
|
4af8b02be1 | ||
|
|
42a0dd52be | ||
|
|
b31495bea5 | ||
|
|
2d43275fcb | ||
|
|
77ddd05b13 | ||
|
|
a9a514dea0 | ||
|
|
173be61213 | ||
|
|
6e001e54b4 | ||
|
|
31b5f90b12 | ||
|
|
8abdd74a62 | ||
|
|
66a28d5abb | ||
|
|
e2faecfd42 | ||
|
|
76832a5b86 | ||
|
|
ec4bf75a6e | ||
|
|
2032c48ffa | ||
|
|
9a52c279f1 | ||
|
|
5be494f490 | ||
|
|
0d5e5b436b | ||
|
|
3a48cc2470 | ||
|
|
64a392634e | ||
|
|
35285afafc | ||
|
|
0d0e7e6861 | ||
|
|
143454590f | ||
|
|
9409d0a04c | ||
|
|
212bdc3b08 | ||
|
|
45cb525035 | ||
|
|
6fed1510ba | ||
|
|
4fd240deab | ||
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
8ee224e5db | ||
|
|
e8499e6131 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
ed3d236ab8 |
@@ -160,6 +160,14 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
|
||||
# built module) and it is the only automated cover those behaviours have.
|
||||
- name: kit unit tests
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:testDebugUnitTest --stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
|
||||
@@ -41,9 +41,23 @@ 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="${{ inputs.tag }}"
|
||||
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
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
@@ -67,4 +81,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
|
||||
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
|
||||
# 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"
|
||||
|
||||
@@ -29,4 +29,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tier-3 GPU stream benchmark
|
||||
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
|
||||
# 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
|
||||
|
||||
@@ -20,6 +20,24 @@
|
||||
# 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.
|
||||
|
||||
@@ -38,10 +38,18 @@ 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: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
|
||||
| sh -s -- -b /usr/local/bin v1.49.0
|
||||
set -euo pipefail
|
||||
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
|
||||
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
Generated
+19
@@ -2893,6 +2893,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3346,6 +3347,8 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4985,6 +4988,14 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ suspend fun connectToHost(
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,26 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -271,6 +291,8 @@ class SettingsStore(context: Context) {
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -308,6 +330,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -355,6 +379,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
|
||||
@@ -896,6 +896,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||
ToggleRow(
|
||||
title = "Controller haptics",
|
||||
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||
"the pad keeps ordinary rumble for games that don't send them",
|
||||
checked = s.padHaptics,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
|
||||
@@ -23,8 +23,9 @@ import android.view.InputDevice
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
@@ -78,6 +79,33 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -105,18 +133,28 @@ 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
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
usb.writeControl(stopReport(m))
|
||||
// Nothing can retry after this point, so a failure is worth saying out loud: it is
|
||||
// the difference between a quiet pad and one that buzzes until it is unplugged.
|
||||
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
|
||||
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
|
||||
// triggers the game was holding, which outlive the link just as stubbornly.
|
||||
resetRichFeedback(m)
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
@@ -131,20 +169,119 @@ class DsCapture(
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||
* indices are taken.
|
||||
*
|
||||
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||
*/
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
startPadAudio(p.index)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||
private fun startPadAudio(index: Int) {
|
||||
val hook = padAudio ?: return
|
||||
if (padAudioStarted) return
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd < 0) {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
return
|
||||
}
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||
// debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||
// completions, which is precisely the fault this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
// 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
|
||||
releaseSlot()
|
||||
// Release the transport too: the link only *signals* the drop, so without this an unplug
|
||||
// left its connection open, its interfaces claimed and its detach receiver registered.
|
||||
usb.stop()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
@@ -216,17 +353,24 @@ class DsCapture(
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
val stop = low == 0 && high == 0
|
||||
if (!stop) armBackstop(backstopMs)
|
||||
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
|
||||
}
|
||||
if (stop) {
|
||||
// Disarm only once the stop is actually on its way. Dropping the net *before* the
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +396,9 @@ class DsCapture(
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
|
||||
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
|
||||
// collapsing a backlog of them down to the last.
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
@@ -261,8 +408,38 @@ class DsCapture(
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
OutReportQueue.KEY_RUMBLE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
|
||||
*
|
||||
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
|
||||
* firmware — they outlive the stream, the app, and being unplugged. Ending a session while a
|
||||
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
|
||||
* nothing to release it but another game that happens to set one.
|
||||
*
|
||||
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
|
||||
* queue would never drain. Writes are best-effort — the pad may already be gone.
|
||||
*/
|
||||
private fun resetRichFeedback(m: DsDevice.Model) {
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
|
||||
// blacking the lightbar is a single composed report.
|
||||
ds4Rgb = 0
|
||||
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
|
||||
return
|
||||
}
|
||||
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
|
||||
for (which in 0..1) {
|
||||
usb.writeControl(
|
||||
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
|
||||
)
|
||||
}
|
||||
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
|
||||
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
|
||||
}
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
@@ -284,7 +461,12 @@ class DsCapture(
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
val m = model ?: return@Runnable
|
||||
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
|
||||
// than give up: this is the last thing between a stalled poll thread and a pad that
|
||||
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
|
||||
// clears `model` and disarms.
|
||||
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
@@ -297,5 +479,9 @@ class DsCapture(
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
|
||||
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
|
||||
* and the host has already moved on, so nothing else is coming to silence them. */
|
||||
const val STOP_RETRY_MS = 100L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,11 +276,26 @@ 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()
|
||||
it[3] = amp8(high).toByte()
|
||||
it[4] = amp8(low).toByte()
|
||||
it[3] = wireAmplitudeToByte(high).toByte()
|
||||
it[4] = wireAmplitudeToByte(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,17 +339,11 @@ object DsDevice {
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = amp8(high).toByte()
|
||||
it[5] = amp8(low).toByte()
|
||||
it[4] = wireAmplitudeToByte(high).toByte()
|
||||
it[5] = wireAmplitudeToByte(low).toByte()
|
||||
it[6] = r.toByte()
|
||||
it[7] = g.toByte()
|
||||
it[8] = b.toByte()
|
||||
}
|
||||
|
||||
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
|
||||
// vibrator path's toAmplitude).
|
||||
private fun amp8(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class GamepadFeedback(
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
|
||||
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
|
||||
const val LOG_EVERY = 128L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
@@ -125,37 +128,51 @@ class GamepadFeedback(
|
||||
fun start() {
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
||||
// the backstop (the hardware net under a stalled poll thread).
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
|
||||
// against the Rust packer.
|
||||
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
|
||||
// Rendering is binder calls into the vibrator service, and every one of them can
|
||||
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
|
||||
// the ordinary RuntimeException a dying service wraps its RemoteException in.
|
||||
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
|
||||
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
|
||||
// the session. Losing a single command is recoverable; losing the loop is not.
|
||||
runCatching {
|
||||
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
|
||||
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
dispatchHidout(buf, n)
|
||||
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
|
||||
// calls, and an unchecked throw here would silently end the rich-feedback plane.
|
||||
runCatching { dispatchHidout(buf, n) }
|
||||
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
|
||||
}
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
|
||||
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
|
||||
* command, which at a rumble plane's rate would bury the log.
|
||||
*/
|
||||
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
|
||||
if (seen == 0L || seen % LOG_EVERY == 0L) {
|
||||
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
|
||||
}
|
||||
return seen + 1
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
@@ -264,12 +281,12 @@ class GamepadFeedback(
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
runCatching { m.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
@@ -294,7 +311,7 @@ class GamepadFeedback(
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = bind.legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
runCatching { lv.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
@@ -314,8 +331,8 @@ class GamepadFeedback(
|
||||
*/
|
||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||
val v = deviceVibrator ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { v.cancel() } // (0,0) = stop
|
||||
return
|
||||
@@ -329,12 +346,6 @@ class GamepadFeedback(
|
||||
}
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
private fun toAmplitude(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||
|
||||
@@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
@@ -81,17 +81,57 @@ class HidUsbLink(
|
||||
|
||||
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
|
||||
* what gets discarded when it fills, and why that is not simply "the oldest". */
|
||||
private val outQueue = OutReportQueue()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
|
||||
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
|
||||
* it. Reset by [start]. */
|
||||
private val down = AtomicBoolean(false)
|
||||
|
||||
/** 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.
|
||||
@@ -114,6 +154,7 @@ class HidUsbLink(
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
down.set(false)
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
@@ -134,10 +175,7 @@ class HidUsbLink(
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +259,9 @@ class HidUsbLink(
|
||||
if (live.isEmpty()) {
|
||||
Log.e(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
// `start` already returned true, so without this the owner would sit waiting on a
|
||||
// capture that never streams and never reports itself dead.
|
||||
linkDown()
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
@@ -295,10 +336,23 @@ class HidUsbLink(
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the link down, exactly once, from whichever detector noticed first — the detach
|
||||
* broadcast (main thread) or the reader thread on its way out.
|
||||
*
|
||||
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
|
||||
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
|
||||
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
|
||||
* the interfaces claimed (the pad could not return to Android's own input stack) and the
|
||||
* receiver still registered — and a re-plug overwrote the field holding it, leaking a receiver
|
||||
* that stayed live for the process's lifetime.
|
||||
*/
|
||||
private fun linkDown() {
|
||||
running = false
|
||||
if (down.compareAndSet(false, true)) onClosed()
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
@@ -314,28 +368,35 @@ class HidUsbLink(
|
||||
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||
*
|
||||
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
|
||||
* this one — [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
|
||||
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
|
||||
*
|
||||
* Returns whether the report reached the device or is queued for it. A caller that is writing
|
||||
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
|
||||
* for one that landed.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
|
||||
if (data.isEmpty()) return false
|
||||
return when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
|
||||
outQueue.offer(data, coalesce)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
private fun setReport(type: Int, data: ByteArray): Boolean {
|
||||
val conn = connection ?: return false
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
|
||||
return sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,9 +405,8 @@ class HidUsbLink(
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
fun writeControl(data: ByteArray): Boolean =
|
||||
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
@@ -358,27 +418,48 @@ class HidUsbLink(
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
private fun sendReport(
|
||||
conn: UsbDeviceConnection,
|
||||
ifaceId: Int,
|
||||
type: Int,
|
||||
data: ByteArray,
|
||||
): Boolean {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
// controlTransfer returns the byte count, or a negative value on failure — a failed write
|
||||
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return n >= 0
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
/**
|
||||
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
|
||||
*
|
||||
* Safe to call from the `onClosed` handler itself — that is how an unplug now gets cleaned up,
|
||||
* and it arrives on the reader thread, which must not try to join itself.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
|
||||
down.set(true)
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
if (reader !== Thread.currentThread()) {
|
||||
runCatching { reader?.join(1000) }
|
||||
// Only forget the thread once it is actually gone: clearing it while it still runs
|
||||
// would let a later stop() skip the join and free the connection under it.
|
||||
reader = null
|
||||
}
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
|
||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -332,6 +336,46 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
|
||||
* policy knows which reports may be thrown away and which may not.
|
||||
*
|
||||
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
|
||||
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
|
||||
* be bounded — a stalled or unplugged device would otherwise grow it without limit — and the
|
||||
* question is what to discard when it fills.
|
||||
*
|
||||
* The old policy was "newest wins": drop from the head until there is room. That is right for
|
||||
* rumble, which is *level-styled* — the host re-sends it continuously, so a dropped frame is
|
||||
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
|
||||
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
|
||||
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
|
||||
* time that value happens to change, which may be never.
|
||||
*
|
||||
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
|
||||
* That distinction cannot be recovered from the report itself: every DualSense output report
|
||||
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
|
||||
* would happily let a rumble supersede a lightbar — the very bug this replaces, relocated.
|
||||
*
|
||||
* Two rules:
|
||||
* - A report offered with a coalescing key **replaces** the pending report with that key, in
|
||||
* place. A burst of rumble collapses to its latest value and never displaces anything else.
|
||||
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
|
||||
* report goes first. A one-shot is discarded only if the queue is full of nothing but
|
||||
* one-shots — which needs [cap] distinct one-shots outstanding, far beyond what a real pad
|
||||
* produces.
|
||||
*
|
||||
* Thread-safe: offered by the feedback threads, drained by the reader thread.
|
||||
*/
|
||||
internal class OutReportQueue(private val cap: Int = CAP) {
|
||||
private class Entry(val key: Int, val data: ByteArray)
|
||||
|
||||
private val items = ArrayDeque<Entry>()
|
||||
|
||||
/**
|
||||
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
|
||||
* constant identifying a level-styled stream whose newer values supersede older ones.
|
||||
*
|
||||
* Returns false only if the report had to be dropped outright — the caller can then treat the
|
||||
* write as failed rather than assuming it is on its way.
|
||||
*/
|
||||
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
|
||||
if (key != NO_COALESCE) {
|
||||
val at = items.indexOfFirst { it.key == key }
|
||||
if (at >= 0) {
|
||||
// Supersede in place: keeping the queue position stops a fast rumble stream from
|
||||
// repeatedly jumping the one-shots queued ahead of it.
|
||||
items[at] = Entry(key, data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (items.size >= cap) {
|
||||
val victim = items.indexOfFirst { it.key != NO_COALESCE }
|
||||
if (victim >= 0) {
|
||||
items.removeAt(victim)
|
||||
} else if (key != NO_COALESCE) {
|
||||
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
|
||||
// rather than a one-shot that will never come again.
|
||||
return false
|
||||
} else {
|
||||
items.removeFirst()
|
||||
}
|
||||
}
|
||||
items.addLast(Entry(key, data))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next report to submit, or null when nothing is pending. */
|
||||
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
|
||||
|
||||
fun clear() = synchronized(items) { items.clear() }
|
||||
|
||||
val size: Int get() = synchronized(items) { items.size }
|
||||
|
||||
companion object {
|
||||
/** This report is a one-shot: never superseded, evicted only as a last resort. */
|
||||
const val NO_COALESCE = 0
|
||||
|
||||
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
|
||||
const val KEY_RUMBLE = 1
|
||||
|
||||
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
|
||||
const val CAP = 32
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The two conversions every rumble path in this module needs, in one place.
|
||||
*
|
||||
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
|
||||
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
|
||||
* with no test on either side of the JNI boundary. Neither is complicated — which is exactly why a
|
||||
* silent divergence between copies would have been hard to notice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wire amplitude (`0..0xFFFF`) → an 8-bit motor/vibrator level.
|
||||
*
|
||||
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
|
||||
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
|
||||
* imperceptibly light, but it moves.
|
||||
*/
|
||||
internal fun wireAmplitudeToByte(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
|
||||
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
|
||||
|
||||
/**
|
||||
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
|
||||
*
|
||||
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
|
||||
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
|
||||
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 — the Rust side has a
|
||||
* compile-time assertion tying the two together, so this can't silently start truncating.
|
||||
*
|
||||
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
|
||||
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim —
|
||||
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
|
||||
*/
|
||||
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
|
||||
if (ev < 0L) return null // timeout / closed
|
||||
return RumbleCmd(
|
||||
pad = ((ev ushr 49) and 0xFL).toInt(),
|
||||
low = ((ev ushr 16) and 0xFFFF).toInt(),
|
||||
high = (ev and 0xFFFF).toInt(),
|
||||
backstopMs = (ev ushr 32) and 0xFFFF,
|
||||
)
|
||||
}
|
||||
@@ -273,10 +273,20 @@ class Sc2Capture(
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
// Both transports share this callback, so read which one was live BEFORE clearing it —
|
||||
// releasing the other would tear down a link that never dropped.
|
||||
val dropped = activeLink
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
|
||||
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
|
||||
// round-trip a link many times in one session.
|
||||
when (dropped) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -127,8 +127,14 @@ 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 hostname verifier
|
||||
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||
* 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.
|
||||
*/
|
||||
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||
val clientCert = CertificateFactory.getInstance("X.509")
|
||||
@@ -162,7 +168,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
|
||||
|
||||
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
val verifier = HostnameVerifier { hostname, session ->
|
||||
hostname == host || defaultVerifier.verify(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)
|
||||
}
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
|
||||
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
|
||||
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
|
||||
*/
|
||||
class OutReportQueueTest {
|
||||
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
|
||||
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
|
||||
|
||||
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
|
||||
private fun drain(q: OutReportQueue): List<Int> =
|
||||
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
|
||||
|
||||
@Test
|
||||
fun `rumble supersedes the pending rumble instead of queueing another`() {
|
||||
val q = OutReportQueue()
|
||||
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
|
||||
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
|
||||
assertArrayEquals(report(3), q.poll())
|
||||
assertNull(q.poll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10)) // a one-shot queued behind it
|
||||
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
|
||||
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
|
||||
// behind an endlessly-renewed entry.
|
||||
assertEquals(listOf(2, 10), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full queue sacrifices rumble, never a one-shot`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
q.offer(report(12))
|
||||
assertEquals(4, q.size)
|
||||
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
|
||||
// ordering; what matters is that the one-shots all survive.
|
||||
assertTrue(q.offer(report(13)))
|
||||
assertEquals(listOf(10, 11, 12, 13), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the one-shot the host never repeats survives a rumble storm`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
|
||||
// old newest-wins eviction the colour was dropped from the head and never came back,
|
||||
// leaving the pad lit wrong until the value next happened to change.
|
||||
q.offer(report(200)) // lightbar
|
||||
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
|
||||
val out = drain(q)
|
||||
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
|
||||
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertFalse(
|
||||
"with nothing coalescable to sacrifice, the replaceable report yields",
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
|
||||
)
|
||||
assertEquals(listOf(10, 11), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertTrue(q.offer(report(12)))
|
||||
assertEquals(listOf(11, 12), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the queue`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.clear()
|
||||
assertEquals(0, q.size)
|
||||
assertNull(q.poll())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
|
||||
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
|
||||
* sides, which is the only thing that catches one of them drifting.
|
||||
*/
|
||||
class RumbleWireTest {
|
||||
|
||||
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
|
||||
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
|
||||
((pad and 0xF).toLong() shl 49) or
|
||||
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
|
||||
(low.toLong() shl 16) or
|
||||
high.toLong()
|
||||
|
||||
@Test
|
||||
fun `every field round-trips at its extremes`() {
|
||||
val cases = listOf(
|
||||
listOf(0, 0, 0, 0),
|
||||
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
listOf(1, 0x1234, 0x5678, 500),
|
||||
listOf(7, 0, 0xFFFF, 2000),
|
||||
)
|
||||
for ((pad, low, high, backstop) in cases) {
|
||||
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
|
||||
assertEquals("pad", pad, cmd.pad)
|
||||
assertEquals("low", low, cmd.low)
|
||||
assertEquals("high", high, cmd.high)
|
||||
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
|
||||
@Test
|
||||
fun `all sixteen pad indices are distinct`() {
|
||||
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
|
||||
assertEquals((0 until 16).toList(), seen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the negative sentinel is not a command`() {
|
||||
assertNull(unpackRumbleEvent(-1L))
|
||||
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop is distinguishable from a hold`() {
|
||||
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
|
||||
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
|
||||
assertEquals(0, stop.low)
|
||||
assertEquals(0, stop.high)
|
||||
assertNotEquals(stop, hold)
|
||||
}
|
||||
|
||||
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
|
||||
|
||||
@Test
|
||||
fun `amplitude takes the high byte`() {
|
||||
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
|
||||
assertEquals(0x80, wireAmplitudeToByte(0x8000))
|
||||
assertEquals(0x12, wireAmplitudeToByte(0x1234))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero stays silent but a weak nonzero never does`() {
|
||||
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
|
||||
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
|
||||
for (v in listOf(1, 0x0042, 0x00FF)) {
|
||||
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
|
||||
}
|
||||
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -28,6 +28,76 @@ 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
|
||||
@@ -584,6 +654,14 @@ 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) => {
|
||||
@@ -599,6 +677,44 @@ 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
|
||||
@@ -632,28 +748,47 @@ fn feeder_loop(
|
||||
pending_split.pop_front();
|
||||
}
|
||||
}
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
t.meter.note_latch(latch_us, system_nano);
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the skew math + the stats lock
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -152,6 +152,10 @@ 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 {
|
||||
@@ -166,6 +170,9 @@ 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 {
|
||||
@@ -177,19 +184,33 @@ 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.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
///
|
||||
/// `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) {
|
||||
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| {
|
||||
@@ -200,6 +221,7 @@ 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);
|
||||
@@ -258,7 +280,17 @@ 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>) {
|
||||
fn drain(
|
||||
&self,
|
||||
) -> (
|
||||
Vec<u64>,
|
||||
u64,
|
||||
Vec<u64>,
|
||||
Vec<u64>,
|
||||
Vec<u64>,
|
||||
(u32, u32, u32),
|
||||
Option<punktfunk_core::phase::PresentCadence>,
|
||||
) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -271,6 +303,8 @@ 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -410,6 +444,11 @@ 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 {
|
||||
@@ -547,7 +586,11 @@ 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).
|
||||
/// 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`]).
|
||||
///
|
||||
/// 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
|
||||
@@ -561,7 +604,7 @@ impl Presenter {
|
||||
return None;
|
||||
}
|
||||
self.last_flush = Instant::now();
|
||||
let (latch, displays, feed, codec, e2e) = meter.drain();
|
||||
let (latch, displays, feed, codec, e2e, cad_raw, cadence) = meter.drain();
|
||||
if self.released == 0 && displays == 0 {
|
||||
return None; // idle stream — nothing worth a line
|
||||
}
|
||||
@@ -584,7 +627,8 @@ 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}",
|
||||
vsyncMs={:.2} panelMs={:.2} \
|
||||
judder={}permille mode={}vsync cadN={} stalls={} disorder={} cadPeriodMs={:.2}",
|
||||
self.released,
|
||||
displays,
|
||||
self.paced_drops,
|
||||
@@ -607,6 +651,12 @@ 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
|
||||
|
||||
@@ -18,6 +18,29 @@ use std::time::Duration;
|
||||
/// observes its `running=false` flag promptly on teardown.
|
||||
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
|
||||
const PAD_BITS: u32 = 4;
|
||||
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
|
||||
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
|
||||
/// rather than failing the build.
|
||||
const _: () = assert!(
|
||||
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
|
||||
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
|
||||
);
|
||||
|
||||
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
|
||||
///
|
||||
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
|
||||
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
|
||||
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
|
||||
/// without a live session handle — the shift arithmetic is the part worth pinning.
|
||||
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
|
||||
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
|
||||
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(low) << 16)
|
||||
| jlong::from(high)
|
||||
}
|
||||
|
||||
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
@@ -54,12 +77,15 @@ 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) {
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(cmd.low) << 16)
|
||||
| jlong::from(cmd.high)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
})
|
||||
@@ -156,7 +182,74 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_rumble_tests {
|
||||
use super::*;
|
||||
use punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
|
||||
/// broken, and nothing else in the build would say so.
|
||||
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
|
||||
let pad = ((ev >> 49) & 0xF) as u16;
|
||||
let backstop = ((ev >> 32) & 0xFFFF) as u32;
|
||||
let low = ((ev >> 16) & 0xFFFF) as u16;
|
||||
let high = (ev & 0xFFFF) as u16;
|
||||
(pad, low, high, backstop)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_every_field_at_its_extremes() {
|
||||
for &(pad, low, high, backstop) in &[
|
||||
(0u16, 0u16, 0u16, 0u32),
|
||||
(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
(1, 0x1234, 0x5678, 500),
|
||||
(7, 0, 0xFFFF, 2000),
|
||||
] {
|
||||
let ev = pack_rumble(pad, low, high, backstop);
|
||||
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_representable_pad_survives_the_four_bit_field() {
|
||||
for pad in 0..MAX_PADS as u16 {
|
||||
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
|
||||
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packed_command_is_never_negative() {
|
||||
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
|
||||
// "no command" and the rumble would simply vanish.
|
||||
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
|
||||
assert!(pack_rumble(0, 0, 0, 0) >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
|
||||
let ev = pack_rumble(3, 0, 0, u32::MAX);
|
||||
let (pad, _, _, backstop) = unpack(ev);
|
||||
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
|
||||
assert_eq!(backstop, 0xFFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_is_distinguishable_from_a_hold() {
|
||||
let stop = pack_rumble(2, 0, 0, 0);
|
||||
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
|
||||
assert_ne!(stop, hold);
|
||||
assert_eq!(unpack(stop).1, 0);
|
||||
assert_eq!(unpack(stop).2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
@@ -99,6 +104,14 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -12,7 +12,7 @@ import GameController
|
||||
public final class ControllerTester: ObservableObject {
|
||||
// `.manual`: the panel's toggles hold a level until changed — no session wire refreshes
|
||||
// exist here to keep the renderer's staleness watchdog fed.
|
||||
private let renderer = RumbleRenderer(policy: .manual)
|
||||
private let renderer = RumbleRenderer()
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
|
||||
@@ -21,8 +21,12 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
/// A caller that owns a particular pad passes the location id it wants (see
|
||||
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
|
||||
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
|
||||
/// lowest location id wins — an arbitrary but *stable* choice, where `Set.first` was neither.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
@@ -43,9 +47,57 @@ final class DualSenseHID {
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
/// The IOKit location id of the device this instance opened — the handle a caller correlates
|
||||
/// with its `GCController`. `nil` until a successful `open`.
|
||||
private(set) var locationID: UInt32?
|
||||
|
||||
/// A device's location id, or `nil` if IOKit does not report one.
|
||||
static func locationID(of dev: IOHIDDevice) -> UInt32? {
|
||||
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
|
||||
}
|
||||
|
||||
/// Every connected DualSense/Edge, by location id — what a caller pairs against its controllers.
|
||||
static func attachedLocationIDs() -> [UInt32] {
|
||||
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
let matches = productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
|
||||
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
return []
|
||||
}
|
||||
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
|
||||
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
|
||||
return devices.compactMap(locationID(of:)).sorted()
|
||||
}
|
||||
|
||||
/// Which attached device to drive, as an index into `ids` — the whole selection rule, pure so
|
||||
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
|
||||
///
|
||||
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
|
||||
/// merely arbitrary — it can differ between two calls in one process. With two DualSenses that
|
||||
/// made each renderer's pad→device binding a coin flip: both could land on the same device
|
||||
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
|
||||
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
|
||||
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
|
||||
/// place never displaces one it can.
|
||||
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
|
||||
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
|
||||
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
|
||||
}
|
||||
|
||||
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
|
||||
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
|
||||
let ordered = Array(devices)
|
||||
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
|
||||
return nil
|
||||
}
|
||||
return ordered[i]
|
||||
}
|
||||
|
||||
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
|
||||
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
|
||||
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
@@ -55,13 +107,21 @@ final class DualSenseHID {
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
let dev = Self.pick(devices, preferring: preferred)
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
locationID = Self.locationID(of: dev)
|
||||
if let preferred, locationID != preferred {
|
||||
// Not fatal — one pad still gets rumble — but with two pads attached it means this
|
||||
// renderer is driving the wrong one, and it is invisible without the log line.
|
||||
log.error(
|
||||
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
|
||||
)
|
||||
}
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
@@ -70,8 +130,16 @@ final class DualSenseHID {
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
///
|
||||
/// Returns whether the write reached the device. The caller needs this: it used to be logged
|
||||
/// and swallowed, so a failed write still counted as a successful render. That matters most
|
||||
/// for a **stop**, which has nothing behind it — the renderer stamps its write clock even on
|
||||
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
|
||||
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
|
||||
/// stop left the motors running with nothing scheduled to try again.
|
||||
@discardableResult
|
||||
func rumble(low: UInt8, high: UInt8) -> Bool {
|
||||
guard let dev = device else { return false }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
@@ -81,7 +149,9 @@ final class DualSenseHID {
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
|
||||
@@ -65,7 +65,7 @@ public final class GamepadFeedback {
|
||||
#if os(iOS)
|
||||
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||
deviceRumble = RumbleRenderer(actuator: .device)
|
||||
} else {
|
||||
deviceRumble = nil
|
||||
}
|
||||
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
renderer?.stop()
|
||||
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
|
||||
// per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd, which the
|
||||
// renderer's own notes record as able to hang — plus `DualSenseHID.close()`, whose
|
||||
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
|
||||
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
|
||||
// change, and the main thread is what drives the presenter's CADisplayLink, so
|
||||
// blocking here hitches the picture mid-stream. The renderer is already detached from
|
||||
// routing above, so nothing observes it after this point.
|
||||
if let renderer { Task.detached { renderer.stop() } }
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
@@ -128,7 +136,7 @@ public final class GamepadFeedback {
|
||||
replay(slot)
|
||||
} else {
|
||||
slots[pad] = Slot(controller: controller)
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(controller)
|
||||
withRouting { rumbleByPad[pad] = renderer }
|
||||
}
|
||||
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
|
||||
private func reset(_ controller: GCController?) {
|
||||
guard let c = controller else { return }
|
||||
c.playerIndex = .indexUnset
|
||||
// Put the lightbar out too. This class is what turned it on (see the `Led` and
|
||||
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
|
||||
// set stays lit in firmware after the stream ends — back at the launcher, or for a pad
|
||||
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
|
||||
// indicator IS the lightbar; a DualSense is not.
|
||||
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
|
||||
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
|
||||
@@ -43,8 +43,14 @@ enum RumbleTuning {
|
||||
|
||||
/// Wire amplitude (0...0xFFFF) → CoreHaptics intensity (0...1).
|
||||
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
|
||||
/// Wire amplitude → DualSense HID motor byte.
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
|
||||
/// Wire amplitude → DualSense HID motor byte. A nonzero command never collapses to silence:
|
||||
/// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as
|
||||
/// nothing at all on this path. Floored at 1 — imperceptibly light, but moving. (Android's
|
||||
/// `toAmplitude` has always done this; this was the odd one out.)
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 {
|
||||
let b = UInt8(wire >> 8)
|
||||
return wire != 0 && b == 0 ? 1 : b
|
||||
}
|
||||
/// Single-actuator pads render whichever motor is stronger.
|
||||
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
|
||||
/// Are two baked levels the same (skip the rebuild)?
|
||||
@@ -81,10 +87,11 @@ enum RumbleTuning {
|
||||
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown — the
|
||||
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
|
||||
/// exponential backoff.
|
||||
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
|
||||
/// `sessionStaleSeconds` → force silence. A lost stop can outlive the host's 500 ms heal
|
||||
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
|
||||
/// (the settings test panel) instead holds a level until it is changed.
|
||||
/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a
|
||||
/// `sessionStaleSeconds`; both are gone. Every liveness decision — lease expiry, legacy-host
|
||||
/// staleness, session close — now belongs to punktfunk-core's shared policy engine
|
||||
/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it
|
||||
/// is told and never decides on its own when a level should end.
|
||||
///
|
||||
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
|
||||
/// failures (pads without haptics, engine resets) downgrade to silence — rumble is best-effort
|
||||
@@ -93,17 +100,6 @@ enum RumbleTuning {
|
||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||
/// `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||
/// the call sites' intent.
|
||||
struct Policy {
|
||||
static let session = Policy()
|
||||
static let manual = Policy()
|
||||
}
|
||||
|
||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) — the opt-in
|
||||
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
|
||||
@@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
private let policy: Policy
|
||||
private let actuator: Actuator
|
||||
|
||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||
@@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||
self.policy = policy
|
||||
init(actuator: Actuator = .controller) {
|
||||
self.actuator = actuator
|
||||
}
|
||||
|
||||
@@ -459,6 +453,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
|
||||
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
|
||||
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
|
||||
// which handle died — lose `high` and `render` falls to the combined branch (selected
|
||||
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
|
||||
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
|
||||
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
|
||||
// which at least renders both motors somewhere.
|
||||
if low == nil || high == nil {
|
||||
log.warning("rumble: only one split-handle engine came up — falling back to combined")
|
||||
teardown() // disarms handlers, stops the survivor's players + engine, nils both
|
||||
}
|
||||
} else {
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
@@ -587,7 +593,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
// Ask for the device this renderer's controller actually is, so two attached DualSenses
|
||||
// do not both get driven through whichever one an unordered Set happened to yield first.
|
||||
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
@@ -595,6 +603,24 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Correlate a `GCController` with an IOKit location id.
|
||||
///
|
||||
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
|
||||
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
|
||||
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
|
||||
/// missing. With one pad attached this is the same device it always was.
|
||||
static func hidLocationID(for c: GCController) -> UInt32? {
|
||||
let ids = DualSenseHID.attachedLocationIDs()
|
||||
guard ids.count > 1 else { return ids.first }
|
||||
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
|
||||
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
|
||||
return ids.first
|
||||
}
|
||||
return ids[rank]
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||
@@ -605,8 +631,20 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
let keepalive = levels != (0, 0)
|
||||
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
|
||||
if levels != lastHidWrite.levels || keepalive {
|
||||
hid.rumble(low: levels.0, high: levels.1)
|
||||
lastHidWrite = (levels, .now())
|
||||
if hid.rumble(low: levels.0, high: levels.1) {
|
||||
lastHidWrite = (levels, .now())
|
||||
} else {
|
||||
// The write did not reach the device. Do NOT stamp the clock — that would claim a
|
||||
// render that never happened, and for a stop there is nothing behind it: the
|
||||
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
|
||||
// target is (0, 0), so the motors would keep running with nothing scheduled.
|
||||
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
|
||||
// rebuilds it. Health is reported so the state is visible rather than silent.
|
||||
log.error("rumble: HID write failed — dropping the handle, falling back")
|
||||
closeHID()
|
||||
reportHealth("Lost the direct connection to this DualSense; using the system path.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
#else
|
||||
|
||||
@@ -474,6 +474,7 @@ 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 {
|
||||
@@ -562,6 +563,112 @@ 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
|
||||
@@ -588,6 +695,50 @@ 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() }
|
||||
|
||||
@@ -624,8 +775,13 @@ 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()
|
||||
}
|
||||
@@ -656,6 +812,9 @@ 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
|
||||
@@ -663,6 +822,21 @@ 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)")
|
||||
@@ -746,6 +920,10 @@ 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`).
|
||||
@@ -967,7 +1145,14 @@ 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).
|
||||
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
|
||||
//
|
||||
// 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
|
||||
if pacing == .deadline {
|
||||
startDeadlinePresenter(debugStats: debugStats)
|
||||
return
|
||||
@@ -1241,6 +1426,9 @@ 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -43,5 +43,33 @@ final class DualSenseHIDTests: XCTestCase {
|
||||
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||
}
|
||||
|
||||
// MARK: - Device selection (B14)
|
||||
|
||||
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
|
||||
/// `Set.first` from an unordered set, so the pad→device binding was a coin flip that could
|
||||
/// point both renderers at the same pad.
|
||||
func testPreferredIndexHonoursAnExplicitLocation() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
|
||||
}
|
||||
|
||||
/// No preference (or one the pad no longer has): fall back to the LOWEST id — arbitrary, but
|
||||
/// stable across calls, which `Set.first` was not.
|
||||
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
|
||||
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
|
||||
// open — it degrades to the same stable fallback.
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
|
||||
}
|
||||
|
||||
/// A device IOKit reports no location for must never displace one it can place.
|
||||
func testPreferredIndexSortsUnplaceableDevicesLast() {
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
|
||||
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
|
||||
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
|
||||
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
|
||||
}
|
||||
|
||||
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ final class RumbleTuningTests: XCTestCase {
|
||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||
func testRendererSurvivesCallStormAndTeardownWithoutController() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(nil)
|
||||
for i in 0..<500 {
|
||||
renderer.apply(
|
||||
@@ -72,7 +72,7 @@ final class RumbleTuningTests: XCTestCase {
|
||||
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
|
||||
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
|
||||
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(nil)
|
||||
renderer.apply(low: 0x8000, high: 0x8000)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
|
||||
@@ -286,6 +286,12 @@ 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
|
||||
@@ -389,6 +395,11 @@ mod session_main {
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
echo_cancel: settings.echo_cancel,
|
||||
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||
// service learns the same prefs below so tier-A slots declare their render caps
|
||||
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
|
||||
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
|
||||
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
|
||||
# property stores entirely (the same version the host pins).
|
||||
winreg = "0.56"
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
|
||||
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
|
||||
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
|
||||
|
||||
@@ -98,13 +98,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
/// Settings device pickers via session main), or the OS default. A picked device that's
|
||||
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
|
||||
///
|
||||
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
|
||||
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
|
||||
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
|
||||
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
|
||||
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
|
||||
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
|
||||
pub(crate) fn device_by_id(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
id: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
let devices = enumerator
|
||||
.get_device_collection(direction)
|
||||
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
|
||||
let count = devices
|
||||
.get_nbr_devices()
|
||||
.map_err(|e| anyhow!("endpoint count: {e}"))?;
|
||||
for i in 0..count {
|
||||
let dev = devices
|
||||
.get_device_at_index(i)
|
||||
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
|
||||
if dev.get_id().is_ok_and(|got| got == id) {
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no active {direction:?} endpoint with id {id}")
|
||||
}
|
||||
|
||||
fn pick_device(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
var: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
||||
match enumerator.get_device(&id) {
|
||||
match device_by_id(enumerator, direction, &id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
var,
|
||||
|
||||
@@ -302,6 +302,21 @@ fn set_valve_hidapi(enabled: bool) {
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
|
||||
}
|
||||
|
||||
/// Disable the Valve HIDAPI drivers **before SDL exists** — call this alongside the other
|
||||
/// pre-`SDL_Init` hints, not after a subsystem is up.
|
||||
///
|
||||
/// The damage these drivers do happens at *enumeration*, which is part of initialising the
|
||||
/// joystick/gamepad subsystem. Setting the hint afterwards does detach the driver, but only after
|
||||
/// it has already sent the Deck its `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` — so the
|
||||
/// built-in trackpad-mouse dies system-wide and stays dead until the firmware watchdog restores
|
||||
/// lizard mode seconds later. The threaded worker ([`run`]) has always done this in the right
|
||||
/// order; the caller-pumped path could not, because by the time it receives a
|
||||
/// [`sdl3::GamepadSubsystem`] the enumeration has already happened. Hence a separate entry point
|
||||
/// its callers can put in the right place.
|
||||
pub fn preinit_disable_valve_hidapi() {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
@@ -354,8 +369,14 @@ 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),
|
||||
}
|
||||
@@ -412,9 +433,12 @@ impl GamepadService {
|
||||
/// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's
|
||||
/// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback).
|
||||
///
|
||||
/// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their
|
||||
/// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled
|
||||
/// for the duration of an attached session only.
|
||||
/// The Valve HIDAPI drivers are held off here too, but this is **too late to be the only
|
||||
/// place it happens**: the `subsystem` argument means enumeration is already done, and that
|
||||
/// is when the Deck driver kills the trackpad-mouse. The caller must also call
|
||||
/// [`preinit_disable_valve_hidapi`] with its other pre-`SDL_Init` hints. This call still
|
||||
/// earns its place — it re-asserts "off" for a process that ran a session earlier — but on
|
||||
/// its own it only detaches a driver that has already done the damage.
|
||||
pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) {
|
||||
set_valve_hidapi(false);
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -555,6 +579,18 @@ 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));
|
||||
}
|
||||
@@ -609,6 +645,38 @@ impl GamepadPump {
|
||||
self.worker.menu_poll();
|
||||
self.worker.render_feedback();
|
||||
}
|
||||
|
||||
/// Close every forwarded slot — flush its held wire state, tell the host to remove the pad,
|
||||
/// and physically silence it. Call once on the way out of the caller's event loop.
|
||||
///
|
||||
/// [`GamepadService::detach`] only *posts* `Ctl::Detach`; the close — the flush, the host-side
|
||||
/// `GamepadRemove`, and the explicit `set_rumble(0, 0)` backstop in `close_slot_at` — happens
|
||||
/// when the pump next drains it. An exit path that detached and then left the loop without
|
||||
/// another [`tick`](Self::tick) therefore skipped all of it, and nothing else would: the slots
|
||||
/// hold no `Drop` that silences them. A pad left mid-buzz stayed buzzing.
|
||||
///
|
||||
/// This closes the slots directly rather than draining the queued `Ctl::Detach` that would
|
||||
/// have done it. Same physical outcome by a shorter path, and deliberately so: this also runs
|
||||
/// from `Drop`, and `drain_ctl` reaches `Mutex::lock().unwrap()`, which on a poisoned lock
|
||||
/// would panic — during an unwind that aborts the process. Closing a slot touches no lock.
|
||||
///
|
||||
/// Idempotent, and safe with nothing attached.
|
||||
pub fn shutdown(&mut self) {
|
||||
self.worker.close_all_slots();
|
||||
}
|
||||
}
|
||||
|
||||
/// The silence backstop of last resort. A caller's loop can also leave by `?` on a fatal overlay
|
||||
/// or present error — several paths do — and those would skip an explicit
|
||||
/// [`shutdown`](GamepadPump::shutdown) entirely, leaving a forwarded pad buzzing on the way out.
|
||||
///
|
||||
/// Callers should still call `shutdown` at their normal exit rather than lean on this: the pad
|
||||
/// wants to go quiet *before* a long teardown (session join, `vkDeviceWaitIdle`), not after it.
|
||||
/// Doing both is free — `shutdown` is idempotent.
|
||||
impl Drop for GamepadPump {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest wire pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)) not already held
|
||||
@@ -682,13 +750,29 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
|
||||
/// Enable bits select only the fields each update touches, so rumble (driven separately
|
||||
/// through SDL) and untouched fields keep their state.
|
||||
///
|
||||
/// The offsets below are the USB output report's, **minus one**: SDL's payload carries no leading
|
||||
/// report id. `pf-inject`'s `dualsense_proto::out_report` is where that layout is written down and
|
||||
/// explained (including the Bluetooth `+2` base), but this crate cannot import it — `pf-inject` is
|
||||
/// host-side and neither crate depends on the other, and a DualSense report layout has no business
|
||||
/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and
|
||||
/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `−1` relationship rather than
|
||||
/// leaving it to a comment.
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
const RIGHT_TRIGGER: usize = 10;
|
||||
const LEFT_TRIGGER: usize = 21;
|
||||
const PAD_LIGHTS: usize = 43;
|
||||
const LED_RGB: usize = 44;
|
||||
/// 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;
|
||||
const LED_RGB: usize = 45 - Self::REPORT_ID_LEN;
|
||||
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters. Mirrors
|
||||
/// `PUNKTFUNK_HID_EFFECT_MAX`, which is the same number at the C-ABI boundary.
|
||||
const TRIGGER_LEN: usize = punktfunk_core::abi::PUNKTFUNK_HID_EFFECT_MAX as usize;
|
||||
|
||||
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
@@ -698,7 +782,7 @@ impl Ds5Feedback {
|
||||
(0x08, Self::LEFT_TRIGGER)
|
||||
};
|
||||
p[0] = flag;
|
||||
let n = effect.len().min(11);
|
||||
let n = effect.len().min(Self::TRIGGER_LEN);
|
||||
p[off..off + n].copy_from_slice(&effect[..n]);
|
||||
p
|
||||
}
|
||||
@@ -718,6 +802,29 @@ impl Ds5Feedback {
|
||||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||
p
|
||||
}
|
||||
|
||||
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
|
||||
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
|
||||
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
|
||||
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
|
||||
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
|
||||
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
|
||||
fn audio_haptics_packet() -> [u8; 47] {
|
||||
[0u8; 47]
|
||||
}
|
||||
|
||||
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
|
||||
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
|
||||
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
|
||||
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
|
||||
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
|
||||
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
|
||||
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
p[0] = (flags & 0x1E) << 3;
|
||||
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||
@@ -754,6 +861,14 @@ 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 {
|
||||
@@ -770,6 +885,8 @@ impl Slot {
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
gesture: SelectGesture::default(),
|
||||
audio_caps: 0,
|
||||
rumble_suppressed_logged: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,6 +1024,10 @@ 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<()>,
|
||||
@@ -1112,11 +1233,18 @@ impl Worker {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
Self::set_slot_sensors(&mut slot, true);
|
||||
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
|
||||
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
|
||||
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
// Pad-audio render caps go in FIRST — the core ORs them into this (and
|
||||
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
|
||||
// set (0 for non-tier-A): wire indices are reused within a connection, so
|
||||
// a tier-A slot that closes must not leave its bits behind for the next
|
||||
// pad on the same index (the set_rumble_quirks rule).
|
||||
c.set_pad_audio_caps(index, slot.audio_caps);
|
||||
send(
|
||||
c,
|
||||
InputKind::GamepadArrival,
|
||||
@@ -1139,6 +1267,27 @@ impl Worker {
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
if slot.audio_caps != 0 {
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
|
||||
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
|
||||
// "disable audio haptics") whenever its rumble path runs — which
|
||||
// would MUTE the voice coils the 0xD1 stream drives. One effects
|
||||
// packet with those bits CLEARED puts the pad back on audio haptics
|
||||
// ("Leaving emulated rumble bits off will restore audio haptics" —
|
||||
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
|
||||
// render_feedback so SDL never re-arms them.
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
|
||||
}
|
||||
// Hand the pad to the session's renderer worker. Windows correlation
|
||||
// needs the HID interface path; Linux matches the sink by signature.
|
||||
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||
tracing::info!(
|
||||
index,
|
||||
caps = slot.audio_caps,
|
||||
"tier-A DualSense: pad-audio render caps declared"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
id,
|
||||
index,
|
||||
@@ -1152,6 +1301,35 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
|
||||
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
|
||||
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
|
||||
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
|
||||
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
|
||||
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
|
||||
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
|
||||
if self.pad_audio_prefs == 0 {
|
||||
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
|
||||
}
|
||||
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
|
||||
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
|
||||
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
|
||||
return 0; // not a DualSense/Edge — no wired check needed
|
||||
}
|
||||
use sdl3::joystick::ConnectionState;
|
||||
let wired = match pad.connection_state() {
|
||||
Ok(ConnectionState::Wired) => true,
|
||||
Ok(ConnectionState::Wireless) => false,
|
||||
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
|
||||
};
|
||||
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
|
||||
self.pad_audio_prefs
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
|
||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||
/// already gone (unplug).
|
||||
@@ -1160,6 +1338,7 @@ impl Worker {
|
||||
// unplug) must not depend on what SDL does to a rumbling device at close. Errors are
|
||||
// expected for an already-unplugged pad.
|
||||
let _ = self.slots[i].pad.set_rumble(0, 0, 100);
|
||||
Self::reset_slot_feedback(&mut self.slots[i]);
|
||||
if let Some(c) = self.attached.clone() {
|
||||
Self::flush_slot(&c, &mut self.slots[i]);
|
||||
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
||||
@@ -1168,6 +1347,11 @@ impl Worker {
|
||||
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
|
||||
}
|
||||
let slot = self.slots.remove(i);
|
||||
if slot.audio_caps != 0 {
|
||||
// Take the pad back from the pad-audio renderer (its device-gone path then
|
||||
// re-correlates — and finds nothing until a tier-A pad registers again).
|
||||
crate::pad_audio::unregister_tier_a(slot.index);
|
||||
}
|
||||
tracing::info!(
|
||||
id = slot.id,
|
||||
index = slot.index,
|
||||
@@ -1175,6 +1359,35 @@ impl Worker {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hand the physical controller back in a neutral state before its handle closes.
|
||||
///
|
||||
/// Rumble stops on its own the moment nothing renews it, but the rich planes do not: an
|
||||
/// adaptive-trigger effect and a lightbar colour are LATCHED in the pad's firmware and survive
|
||||
/// the stream, the app, and being unplugged. Ending a session on a weapon's trigger resistance
|
||||
/// left the physical trigger stiff on the desktop afterwards, with nothing to clear it but
|
||||
/// another game. Apple's client already resets on teardown; this is the desktop half.
|
||||
///
|
||||
/// Best-effort throughout: the pad may already be gone (that is one of the ways we get here).
|
||||
fn reset_slot_feedback(slot: &mut Slot) {
|
||||
if matches!(
|
||||
slot.pref,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
) {
|
||||
// An all-zero trigger block is mode 0x00 — no effect — which is what releases the
|
||||
// trigger. Both sides, then the lightbar dark and the player indicator clear.
|
||||
for which in [0u8, 1] {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, &[0u8; 11]));
|
||||
}
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(0, 0, 0));
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(0));
|
||||
} else {
|
||||
// Anything else with an LED goes dark through SDL, which owns the per-device details.
|
||||
let _ = slot.pad.set_led(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_all_slots(&mut self) {
|
||||
while !self.slots.is_empty() {
|
||||
self.close_slot_at(0);
|
||||
@@ -1560,6 +1773,7 @@ impl Worker {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
}
|
||||
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1837,7 +2051,12 @@ impl Worker {
|
||||
let dur_ms: u32 = if (low, high) == (0, 0) {
|
||||
100 // a stop takes effect immediately; the duration is irrelevant
|
||||
} else {
|
||||
backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator
|
||||
// No local floor. There was a `.max(160)` here, and it could never do anything: the
|
||||
// engine's own `backstop()` returns `(2 * ttl).clamp(500, 5000)` or the 2000 ms legacy
|
||||
// value, so a non-zero command's backstop is never below 500. A floor that belongs to a
|
||||
// particular actuator belongs in its `ActuatorQuirks::min_pulse_ms`, which the engine
|
||||
// already applies — not re-invented per renderer where it can silently disagree.
|
||||
backstop_ms
|
||||
};
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
|
||||
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
|
||||
@@ -1867,6 +2086,20 @@ impl Worker {
|
||||
// first; the physical silence backstop is in `close_slot_at`).
|
||||
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
|
||||
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
|
||||
// so a slot with tier-A haptics active never issues wire rumble (the stream
|
||||
// carries the feedback; the game's rumble is in its haptics mix).
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
if !slot.rumble_suppressed_logged {
|
||||
slot.rumble_suppressed_logged = true;
|
||||
tracing::info!(
|
||||
pad = slot.index,
|
||||
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||
}
|
||||
}
|
||||
@@ -1892,6 +2125,11 @@ impl Worker {
|
||||
HidOutput::PlayerLeds { bits, .. } if is_ds => {
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||||
}
|
||||
// Every other pad with player LEDs gets them through SDL, which owns the
|
||||
// per-device pattern. This used to fall through and do nothing at all.
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
let _ = set_player_leds(&slot.pad, bits);
|
||||
}
|
||||
HidOutput::Trigger {
|
||||
which, ref effect, ..
|
||||
} if is_ds => {
|
||||
@@ -1899,12 +2137,57 @@ 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.
|
||||
HidOutput::Trigger { .. }
|
||||
| HidOutput::TrackpadHaptic { .. }
|
||||
| HidOutput::HidRaw { .. }
|
||||
| HidOutput::AudioCtl { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The SDL player index for the wire's positional player-LED `bits`, or `None` for "no player".
|
||||
///
|
||||
/// The wire carries a bitmask — one bit per LED, low 5 — while SDL wants a player *index* and owns
|
||||
/// the per-device pattern. The count bridges them: every convention that reaches this wire spells
|
||||
/// "player N" as N lit LEDs, both the DualSense patterns (`0x04`, `0x0A`, `0x15`, `0x1B`, `0x1F`)
|
||||
/// and the Switch/XInput run of low bits (`0x01`, `0x03`, `0x07`, `0x0F`). SDL's index is 0-based,
|
||||
/// so player 1 is index 0; no lit LED means *no* player rather than player 0.
|
||||
///
|
||||
/// Split out from [`set_player_leds`] so the mapping is testable — an `sdl3::Gamepad` needs a real
|
||||
/// device, so nothing that takes one can be.
|
||||
fn player_index_from_bits(bits: u8) -> Option<u16> {
|
||||
match (bits & 0x1F).count_ones() {
|
||||
0 => None,
|
||||
n => Some((n - 1) as u16),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive a non-DualSense pad's player LEDs from the wire's positional `bits`.
|
||||
fn set_player_leds(pad: &sdl3::gamepad::Gamepad, bits: u8) -> Result<(), sdl3::Error> {
|
||||
match player_index_from_bits(bits) {
|
||||
None => pad.unset_player_index(),
|
||||
Some(i) => pad.set_player_index(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire pad index a [`HidOutput`] is addressed to (every variant carries `pad`).
|
||||
fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
match h {
|
||||
@@ -1913,6 +2196,9 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1940,6 +2226,7 @@ impl Worker {
|
||||
system_forward: true,
|
||||
guide_gesture: false,
|
||||
synthetic_ups: Vec::new(),
|
||||
pad_audio_prefs: 0,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -2385,5 +2672,254 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Ds5Feedback`]'s three packet builders. The host-side parser, the Android writer and the Apple
|
||||
/// writer are all pinned by their own suites; this writer had nothing, despite being the one that
|
||||
/// hand-shifts every offset by the report-id length.
|
||||
#[cfg(test)]
|
||||
mod ds5_feedback_tests {
|
||||
use super::*;
|
||||
|
||||
/// The USB output report offsets, written out independently of the implementation. A DS5
|
||||
/// effects payload is the same block with the leading report id removed, so every offset is
|
||||
/// exactly one lower — this is the relationship the derived constants encode.
|
||||
#[test]
|
||||
fn ds5_offsets_track_the_usb_report() {
|
||||
for (usb, payload) in [
|
||||
(11usize, Ds5Feedback::RIGHT_TRIGGER),
|
||||
(22, Ds5Feedback::LEFT_TRIGGER),
|
||||
(44, Ds5Feedback::PAD_LIGHTS),
|
||||
(45, Ds5Feedback::LED_RGB),
|
||||
] {
|
||||
assert_eq!(payload, usb - 1, "payload offset for USB byte {usb}");
|
||||
}
|
||||
assert_eq!(Ds5Feedback::TRIGGER_LEN, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightbar_sets_only_its_enable_bit_and_its_three_bytes() {
|
||||
let p = Ds5Feedback::lightbar_packet(0x11, 0x22, 0x33);
|
||||
assert_eq!(p.len(), 47);
|
||||
assert_eq!(p[1], 0x04, "valid_flag1 lightbar bit");
|
||||
assert_eq!(p[0], 0, "must not claim any valid_flag0 field");
|
||||
assert_eq!(
|
||||
(
|
||||
p[Ds5Feedback::LED_RGB],
|
||||
p[Ds5Feedback::LED_RGB + 1],
|
||||
p[Ds5Feedback::LED_RGB + 2]
|
||||
),
|
||||
(0x11, 0x22, 0x33)
|
||||
);
|
||||
// Everything else stays zero — an over-broad packet would blank the triggers/player LEDs
|
||||
// it never meant to touch.
|
||||
let touched = [
|
||||
1,
|
||||
Ds5Feedback::LED_RGB,
|
||||
Ds5Feedback::LED_RGB + 1,
|
||||
Ds5Feedback::LED_RGB + 2,
|
||||
];
|
||||
assert!(p
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &b)| touched.contains(&i) || b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_leds_are_masked_to_five_bits() {
|
||||
let p = Ds5Feedback::player_packet(0xFF);
|
||||
assert_eq!(p[1], 0x10, "valid_flag1 player-indicator bit");
|
||||
assert_eq!(
|
||||
p[Ds5Feedback::PAD_LIGHTS],
|
||||
0x1F,
|
||||
"high bits are not ours to set"
|
||||
);
|
||||
let p = Ds5Feedback::player_packet(0b0000_0101);
|
||||
assert_eq!(p[Ds5Feedback::PAD_LIGHTS], 0b0000_0101);
|
||||
}
|
||||
|
||||
/// which 1 = R2 and which 0 = L2 — and the RIGHT block sits FIRST in the report, which is the
|
||||
/// pairing most likely to be transcribed backwards.
|
||||
#[test]
|
||||
fn trigger_which_selects_the_right_flag_and_offset() {
|
||||
let eff: Vec<u8> = (1..=11).collect();
|
||||
|
||||
let r = Ds5Feedback::trigger_packet(1, &eff);
|
||||
assert_eq!(r[0], 0x04, "valid_flag0 R2 bit");
|
||||
assert_eq!(
|
||||
&r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11],
|
||||
&eff[..]
|
||||
);
|
||||
assert_eq!(
|
||||
r[Ds5Feedback::LEFT_TRIGGER],
|
||||
0,
|
||||
"the other trigger is untouched"
|
||||
);
|
||||
|
||||
let l = Ds5Feedback::trigger_packet(0, &eff);
|
||||
assert_eq!(l[0], 0x08, "valid_flag0 L2 bit");
|
||||
assert_eq!(
|
||||
&l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11],
|
||||
&eff[..]
|
||||
);
|
||||
assert_eq!(l[Ds5Feedback::RIGHT_TRIGGER], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_effect_is_clamped_rather_than_overflowing_into_the_next_field() {
|
||||
let long = vec![0xAAu8; 40];
|
||||
let p = Ds5Feedback::trigger_packet(1, &long);
|
||||
assert_eq!(p.len(), 47);
|
||||
// Exactly TRIGGER_LEN bytes written; the left block must not be scribbled on.
|
||||
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 10], 0xAA);
|
||||
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 11], 0);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_effect_leaves_the_rest_of_the_block_zeroed() {
|
||||
let p = Ds5Feedback::trigger_packet(0, &[0x02, 0x99]);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0x02);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER + 1], 0x99);
|
||||
assert!(
|
||||
p[Ds5Feedback::LEFT_TRIGGER + 2..Ds5Feedback::LEFT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty effect is a well-formed all-zero block: mode 0x00 = release. It must still assert
|
||||
/// its enable bit, or the pad keeps whatever effect it was holding.
|
||||
#[test]
|
||||
fn an_empty_effect_is_a_release_not_a_no_op() {
|
||||
let p = Ds5Feedback::trigger_packet(1, &[]);
|
||||
assert_eq!(p[0], 0x04);
|
||||
assert!(
|
||||
p[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reset_packet_tests {
|
||||
use super::*;
|
||||
|
||||
/// The exact bytes a teardown sends to hand a DualSense back neutral. The *timing* of this
|
||||
/// (slot close) needs a live SDL handle and stays untestable, so pin the payloads: a wrong
|
||||
/// enable flag or a non-zero mode byte would silently leave the effect latched, which is the
|
||||
/// bug this reset exists to prevent.
|
||||
#[test]
|
||||
fn reset_packets_release_the_triggers_and_darken_the_lights() {
|
||||
// Trigger release: mode 0x00 with no parameters, on the side's own enable bit.
|
||||
let l = Ds5Feedback::trigger_packet(0, &[0u8; 11]);
|
||||
assert_eq!(l[0], 0x08, "left-trigger enable bit");
|
||||
assert!(
|
||||
l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0),
|
||||
"an all-zero block is mode 0x00 = no effect"
|
||||
);
|
||||
let r = Ds5Feedback::trigger_packet(1, &[0u8; 11]);
|
||||
assert_eq!(r[0], 0x04, "right-trigger enable bit");
|
||||
assert!(
|
||||
r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
|
||||
// Lightbar off: enable bit set, RGB all zero. The enable bit matters — without it the pad
|
||||
// ignores the payload and keeps the game's last colour.
|
||||
let bar = Ds5Feedback::lightbar_packet(0, 0, 0);
|
||||
assert_eq!(bar[1], 0x04, "lightbar enable bit");
|
||||
assert_eq!(
|
||||
&bar[Ds5Feedback::LED_RGB..Ds5Feedback::LED_RGB + 3],
|
||||
&[0, 0, 0]
|
||||
);
|
||||
|
||||
// Player indicator cleared.
|
||||
let pl = Ds5Feedback::player_packet(0);
|
||||
assert_eq!(pl[1], 0x10, "player-LED enable bit");
|
||||
assert_eq!(pl[Ds5Feedback::PAD_LIGHTS], 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod player_led_tests {
|
||||
use super::*;
|
||||
|
||||
/// Both conventions that reach this wire spell "player N" as N lit LEDs, so the count is the
|
||||
/// player number regardless of WHICH bits a given pad lights. Pinned because the mapping is
|
||||
/// otherwise only obvious once you have seen both patterns side by side.
|
||||
#[test]
|
||||
fn player_index_counts_lit_leds_for_both_conventions() {
|
||||
// DualSense / hid-playstation patterns — non-contiguous, symmetric about the centre LED.
|
||||
assert_eq!(player_index_from_bits(0x04), Some(0)); // player 1
|
||||
assert_eq!(player_index_from_bits(0x0A), Some(1)); // player 2
|
||||
assert_eq!(player_index_from_bits(0x15), Some(2)); // player 3
|
||||
assert_eq!(player_index_from_bits(0x1B), Some(3)); // player 4
|
||||
assert_eq!(player_index_from_bits(0x1F), Some(4)); // player 5
|
||||
|
||||
// Switch/XInput style — a contiguous run of low bits, the same count each time.
|
||||
assert_eq!(player_index_from_bits(0x01), Some(0));
|
||||
assert_eq!(player_index_from_bits(0x03), Some(1));
|
||||
assert_eq!(player_index_from_bits(0x07), Some(2));
|
||||
assert_eq!(player_index_from_bits(0x0F), Some(3));
|
||||
}
|
||||
|
||||
/// No lit LED is "no player", NOT player 0 — the difference between LEDs off and player 1 lit.
|
||||
#[test]
|
||||
fn no_lit_led_is_no_player() {
|
||||
assert_eq!(player_index_from_bits(0x00), None);
|
||||
// Only the low 5 bits are player LEDs; junk above them must not invent a player.
|
||||
assert_eq!(player_index_from_bits(0xE0), None);
|
||||
}
|
||||
|
||||
/// The mask is applied before counting, so out-of-range bits cannot inflate the index past
|
||||
/// the 5 real LEDs.
|
||||
#[test]
|
||||
fn high_bits_are_masked_off_before_counting() {
|
||||
assert_eq!(player_index_from_bits(0xFF), Some(4)); // 0x1F worth of LEDs, not 8
|
||||
assert_eq!(player_index_from_bits(0xE4), Some(0)); // 0x04 with junk on top
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ pub mod os;
|
||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||
// the bindings live on.
|
||||
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
|
||||
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
|
||||
// the tier-A pad registry the gamepad worker feeds it through.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod pad_audio;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod profiles;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,14 @@ pub struct SessionParams {
|
||||
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
|
||||
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
|
||||
pub echo_cancel: bool,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
|
||||
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
|
||||
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
|
||||
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
|
||||
/// off — see [`crate::pad_audio::speaker_active`]).
|
||||
pub pad_speaker: String,
|
||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||
pub clipboard: bool,
|
||||
@@ -356,6 +364,11 @@ fn pump(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
|
||||
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
|
||||
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
|
||||
let pad_speaker_on = crate::pad_audio::speaker_active(¶ms.pad_speaker);
|
||||
let pad_audio_on = params.pad_haptics || pad_speaker_on;
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
@@ -379,6 +392,11 @@ fn pump(
|
||||
0
|
||||
}) | (if params.phase_lock {
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
} else {
|
||||
0
|
||||
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
|
||||
}) | (if pad_audio_on {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
@@ -501,6 +519,20 @@ fn pump(
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
|
||||
// the settings could render. The output device is opened LAZILY once frames actually
|
||||
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
|
||||
// a session without a wired DualSense costs one idle 10 ms poll loop.
|
||||
let pad_audio_thread = pad_audio_on
|
||||
.then(|| {
|
||||
crate::pad_audio::spawn(
|
||||
connector.clone(),
|
||||
stop.clone(),
|
||||
params.pad_haptics,
|
||||
pad_speaker_on,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||
@@ -1066,6 +1098,9 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = pad_audio_thread {
|
||||
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = clipboard_thread {
|
||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||
}
|
||||
|
||||
@@ -1024,6 +1024,21 @@ pub struct Settings {
|
||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
||||
#[serde(default)]
|
||||
pub mic_device: String,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
|
||||
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
|
||||
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
|
||||
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
|
||||
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
|
||||
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
|
||||
/// a wired DS5. `default` so pre-existing stores load with it on.
|
||||
#[serde(default = "default_true")]
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
|
||||
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
|
||||
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
|
||||
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
|
||||
#[serde(default = "default_pad_speaker")]
|
||||
pub pad_speaker: String,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||
@@ -1071,6 +1086,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_pad_speaker() -> String {
|
||||
"pad".into()
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||
@@ -1179,6 +1198,8 @@ impl Default for Settings {
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
mic_device: String::new(),
|
||||
pad_haptics: true,
|
||||
pad_speaker: "pad".into(),
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
|
||||
@@ -309,6 +309,24 @@ 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).
|
||||
@@ -342,7 +360,17 @@ 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"),
|
||||
other => push(other),
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
|
||||
@@ -389,6 +417,38 @@ 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()];
|
||||
|
||||
@@ -169,7 +169,27 @@ 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>> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
// 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 rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||
if w == 0 || h == 0 || w > 32767 || h > 32767 {
|
||||
|
||||
@@ -5,19 +5,42 @@
|
||||
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
|
||||
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the latched rich state is re-emitted even though nothing changed.
|
||||
///
|
||||
/// The 0xCD plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is
|
||||
/// forwarded exactly once, so if that datagram is dropped the game will never produce it again —
|
||||
/// it keeps re-sending the same value and the dedup swallows every copy. The pad is then left
|
||||
/// holding the PREVIOUS value: the last weapon's trigger effect, the last lightbar colour, for as
|
||||
/// long as the game keeps that setting. For a trigger effect that can be the rest of a level.
|
||||
///
|
||||
/// Slow on purpose. This is a repair mechanism, not a transport — at one second a lost update
|
||||
/// costs a noticeable but bounded wrong-feel window, while the steady-state cost is at most four
|
||||
/// small datagrams per second per pad, against a rumble plane that already resends at ~120 ms.
|
||||
const RENEW_EVERY: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
|
||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
||||
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
||||
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
|
||||
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
|
||||
/// fire).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HidoutDedup {
|
||||
led: Option<(u8, u8, u8)>,
|
||||
player_leds: Option<u8>,
|
||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||
trigger: [Option<Vec<u8>>; 2],
|
||||
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
|
||||
audio_ctl: Option<(u8, [u8; 6])>,
|
||||
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
|
||||
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
|
||||
haptics_select_logged: bool,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl HidoutDedup {
|
||||
@@ -29,7 +52,53 @@ impl HidoutDedup {
|
||||
|
||||
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
|
||||
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
|
||||
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
|
||||
///
|
||||
/// `now` only stamps the renewal clock ([`Self::renewals`]) — forwarding a change resets it, so
|
||||
/// a plane the game is actively changing never pays for a renewal it does not need.
|
||||
pub fn should_forward(&mut self, h: &HidOutput, now: Instant) -> bool {
|
||||
let fwd = self.decide(h);
|
||||
if fwd {
|
||||
self.last_sent = Some(now);
|
||||
}
|
||||
fwd
|
||||
}
|
||||
|
||||
/// Re-emit the latched rich state, so one lost datagram cannot strand the pad on the previous
|
||||
/// value. Returns the reports to send (empty until [`RENEW_EVERY`] has passed since anything
|
||||
/// last went out); every one is idempotent, so a client that DID receive the original simply
|
||||
/// re-applies it.
|
||||
///
|
||||
/// One-shots are deliberately absent: replaying a `TrackpadHaptic` pulse would be a *new*
|
||||
/// pulse, not a repair, and `HidRaw` is already re-sent verbatim by the device's own refresh
|
||||
/// cadence (see the note in [`Self::decide`]).
|
||||
pub fn renewals(&mut self, pad: u8, now: Instant) -> Vec<HidOutput> {
|
||||
if self
|
||||
.last_sent
|
||||
.is_none_or(|t| now.duration_since(t) < RENEW_EVERY)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
self.last_sent = Some(now);
|
||||
let mut out = Vec::new();
|
||||
if let Some((r, g, b)) = self.led {
|
||||
out.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if let Some(bits) = self.player_leds {
|
||||
out.push(HidOutput::PlayerLeds { pad, bits });
|
||||
}
|
||||
for (which, effect) in self.trigger.iter().enumerate() {
|
||||
if let Some(effect) = effect {
|
||||
out.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: which as u8,
|
||||
effect: effect.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decide(&mut self, h: &HidOutput) -> bool {
|
||||
match h {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
let v = Some((*r, *g, *b));
|
||||
@@ -60,6 +129,25 @@ impl HidoutDedup {
|
||||
}
|
||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||
HidOutput::TrackpadHaptic { .. } => true,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
let v = Some((*flags, *raw));
|
||||
if self.audio_ctl == v {
|
||||
false
|
||||
} else {
|
||||
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
|
||||
// audio haptics (not plain rumble emulation, whose all-zero audio region
|
||||
// never reaches here) — the trace that tells "the game does audio haptics"
|
||||
// apart from "the client just doesn't render them".
|
||||
if flags & 0x01 != 0 && !self.haptics_select_logged {
|
||||
self.haptics_select_logged = true;
|
||||
tracing::info!(
|
||||
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
|
||||
);
|
||||
}
|
||||
self.audio_ctl = v;
|
||||
true
|
||||
}
|
||||
}
|
||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||
@@ -77,6 +165,7 @@ mod tests {
|
||||
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
|
||||
#[test]
|
||||
fn hidout_dedup_forwards_only_changes() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
let led = |r| HidOutput::Led {
|
||||
pad: 0,
|
||||
@@ -85,15 +174,15 @@ mod tests {
|
||||
b: 0,
|
||||
};
|
||||
// First value forwards; an exact repeat is dropped; a change forwards again.
|
||||
assert!(d.should_forward(&led(10)));
|
||||
assert!(!d.should_forward(&led(10)));
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&led(10), t));
|
||||
assert!(!d.should_forward(&led(10), t));
|
||||
assert!(d.should_forward(&led(20), t));
|
||||
|
||||
// Player LEDs dedup on their own field, independent of the lightbar.
|
||||
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
|
||||
assert!(d.should_forward(&pl(0b101), t));
|
||||
assert!(!d.should_forward(&pl(0b101), t));
|
||||
assert!(!d.should_forward(&led(20), t)); // lightbar still unchanged
|
||||
|
||||
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
|
||||
let trig = |which, byte| HidOutput::Trigger {
|
||||
@@ -101,10 +190,10 @@ mod tests {
|
||||
which,
|
||||
effect: vec![byte, 0, 0],
|
||||
};
|
||||
assert!(d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
|
||||
assert!(d.should_forward(&trig(0, 1), t));
|
||||
assert!(d.should_forward(&trig(1, 1), t)); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1), t));
|
||||
assert!(d.should_forward(&trig(0, 2), t)); // L2 effect changed
|
||||
|
||||
// One-shot haptic pulses are never deduped.
|
||||
let haptic = HidOutput::TrackpadHaptic {
|
||||
@@ -114,13 +203,153 @@ mod tests {
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic, t));
|
||||
assert!(d.should_forward(&haptic, t));
|
||||
|
||||
// `clear` re-arms every kind.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(d.should_forward(&trig(0, 2)));
|
||||
assert!(d.should_forward(&led(20), t));
|
||||
assert!(d.should_forward(&pl(0b101), t));
|
||||
assert!(d.should_forward(&trig(0, 2), t));
|
||||
}
|
||||
|
||||
/// A change is forwarded once and then deduped — so if that one datagram is lost, nothing else
|
||||
/// would ever carry it. The renewal is what repairs that.
|
||||
#[test]
|
||||
fn latched_state_is_renewed_so_a_lost_datagram_is_not_permanent() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
let trig = HidOutput::Trigger {
|
||||
pad: 3,
|
||||
which: 1,
|
||||
effect: vec![0x02, 0x90, 0xA0],
|
||||
};
|
||||
assert!(d.should_forward(&trig, t));
|
||||
assert!(
|
||||
!d.should_forward(&trig, t),
|
||||
"the game re-sends it; the dedup swallows it"
|
||||
);
|
||||
|
||||
// Nothing due yet.
|
||||
assert!(d.renewals(3, t + Duration::from_millis(999)).is_empty());
|
||||
|
||||
// Past the window: the latched state goes out again, addressed to the right pad.
|
||||
let out = d.renewals(3, t + Duration::from_millis(1000));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(matches!(
|
||||
&out[0],
|
||||
HidOutput::Trigger { pad: 3, which: 1, effect } if effect == &vec![0x02, 0x90, 0xA0]
|
||||
));
|
||||
|
||||
// And it keeps repairing on the same cadence, not just once.
|
||||
assert!(d.renewals(3, t + Duration::from_millis(1500)).is_empty());
|
||||
assert_eq!(d.renewals(3, t + Duration::from_millis(2000)).len(), 1);
|
||||
}
|
||||
|
||||
/// Every latched plane is renewed together, and a plane the game is actively driving does not
|
||||
/// pay for renewals it does not need (a forward resets the clock).
|
||||
#[test]
|
||||
fn renewal_covers_every_latched_plane_and_an_active_plane_defers_it() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 9,
|
||||
g: 8,
|
||||
b: 7
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::PlayerLeds {
|
||||
pad: 0,
|
||||
bits: 0b100
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 0,
|
||||
effect: vec![1]
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 1,
|
||||
effect: vec![2]
|
||||
},
|
||||
t
|
||||
));
|
||||
|
||||
let out = d.renewals(0, t + Duration::from_millis(1000));
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
4,
|
||||
"lightbar + player LEDs + both triggers, got {out:?}"
|
||||
);
|
||||
|
||||
// A genuine change re-stamps the clock, so the next renewal is a full window away.
|
||||
let later = t + Duration::from_millis(1500);
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3
|
||||
},
|
||||
later
|
||||
));
|
||||
assert!(d.renewals(0, later + Duration::from_millis(999)).is_empty());
|
||||
assert!(!d
|
||||
.renewals(0, later + Duration::from_millis(1000))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// Nothing latched = nothing to renew; a one-shot pulse must never be replayed as a "repair".
|
||||
#[test]
|
||||
fn renewal_is_silent_with_nothing_latched_and_never_replays_a_pulse() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
assert!(d.renewals(0, t + Duration::from_secs(60)).is_empty());
|
||||
|
||||
let pulse = HidOutput::TrackpadHaptic {
|
||||
pad: 0,
|
||||
side: 0,
|
||||
amplitude: 1,
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&pulse, t));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ use super::dualsense_proto::{
|
||||
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
||||
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
@@ -24,27 +29,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
|
||||
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
|
||||
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
|
||||
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
|
||||
|
||||
@@ -18,6 +18,11 @@ use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
||||
DS4_TOUCH_W, DS4_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -25,20 +30,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
|
||||
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
|
||||
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
|
||||
@@ -144,12 +135,6 @@ const DS4_RDESC: &[u8] = &[
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
|
||||
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
|
||||
pub struct DualShock4Pad {
|
||||
|
||||
@@ -254,13 +254,45 @@ fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window a played effect occupies: `replay.delay` of silence, then `replay.length` of rumble.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Playback {
|
||||
/// When the effect starts contributing — `play + replay.delay`. Until then it is armed but
|
||||
/// silent, which is the whole point of the delay.
|
||||
starts: Instant,
|
||||
/// When it stops, or `None` for replay length 0 (until explicitly stopped).
|
||||
ends: Option<Instant>,
|
||||
}
|
||||
|
||||
/// One FF effect a game uploaded: rumble magnitudes + playback state.
|
||||
struct Effect {
|
||||
strong: u16,
|
||||
weak: u16,
|
||||
/// `Some(deadline)` while playing (replay length 0 = until stopped).
|
||||
playing: Option<Option<Instant>>,
|
||||
/// `Some(window)` while playing.
|
||||
playing: Option<Playback>,
|
||||
replay_ms: u16,
|
||||
/// `replay.delay` — how long after the play command the effect stays silent. Decoded from the
|
||||
/// upload since forever and, until now, never acted on: the effect started immediately and
|
||||
/// ended `replay.length` later, so anything scheduling a delayed effect (DirectInput under
|
||||
/// Wine does this routinely) fired early AND finished early by the same amount.
|
||||
delay_ms: u16,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// The window a play command at `at` opens: silent for `replay.delay`, then `replay.length` of
|
||||
/// rumble (or until stopped, when the length is 0).
|
||||
///
|
||||
/// `replay.length` is measured from the END of the delay, not from the play command, so the
|
||||
/// delay shifts the whole window instead of eating into it. Split out from the `EV_FF` handler
|
||||
/// purely so this is testable — the handler itself needs a live uinput fd.
|
||||
fn window(&self, at: Instant) -> Playback {
|
||||
let starts = at + Duration::from_millis(self.delay_ms as u64);
|
||||
Playback {
|
||||
starts,
|
||||
ends: (self.replay_ms > 0)
|
||||
.then(|| starts + Duration::from_millis(self.replay_ms as u64)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
|
||||
@@ -268,7 +300,6 @@ struct Effect {
|
||||
/// the policy is pure and unit-testable without a live uinput fd.
|
||||
struct FfState {
|
||||
effects: HashMap<i16, Effect>,
|
||||
next_effect_id: i16,
|
||||
gain: u32,
|
||||
/// Last `(low, high)` reported, to dedup.
|
||||
last_mix: (u16, u16),
|
||||
@@ -284,7 +315,6 @@ impl FfState {
|
||||
fn new() -> FfState {
|
||||
FfState {
|
||||
effects: HashMap::new(),
|
||||
next_effect_id: 0,
|
||||
gain: 0xFFFF,
|
||||
last_mix: (0, 0),
|
||||
last_activity: Instant::now(),
|
||||
@@ -299,17 +329,29 @@ impl FfState {
|
||||
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
|
||||
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
|
||||
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
|
||||
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
|
||||
let quiet_since = |t: Instant| idle.is_some_and(|d| now.duration_since(t) >= d);
|
||||
let plane_stale = quiet_since(self.last_activity);
|
||||
let (mut strong, mut weak) = (0u32, 0u32);
|
||||
for e in self.effects.values_mut() {
|
||||
let Some(deadline) = e.playing else { continue };
|
||||
match deadline {
|
||||
let Some(p) = e.playing else { continue };
|
||||
// Still inside `replay.delay`: armed, silent, and NOT a candidate for expiry or the
|
||||
// abandoned-effect force-off — it has not had its turn yet.
|
||||
if now < p.starts {
|
||||
continue;
|
||||
}
|
||||
match p.ends {
|
||||
Some(d) if now >= d => e.playing = None,
|
||||
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
|
||||
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
|
||||
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
|
||||
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
|
||||
None if stale => {
|
||||
//
|
||||
// "Abandoned" needs the effect to have been AUDIBLE for the window too, not just
|
||||
// the plane quiet: the play command is itself the last activity, so an effect with
|
||||
// a `replay.delay` longer than the window would otherwise be force-stopped the
|
||||
// instant it finally started — silent the whole time it waited, then killed on its
|
||||
// first contributing tick.
|
||||
None if plane_stale && quiet_since(p.starts) => {
|
||||
tracing::info!(
|
||||
strong = e.strong,
|
||||
weak = e.weak,
|
||||
@@ -531,11 +573,13 @@ impl VirtualPad {
|
||||
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
||||
up.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||
let mut e = up.effect;
|
||||
if e.id == -1 {
|
||||
e.id = self.ff.next_effect_id;
|
||||
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
|
||||
}
|
||||
let e = up.effect;
|
||||
// No `id == -1` fallback: ff-core's `input_ff_upload` picks a free slot and
|
||||
// writes it into the effect BEFORE handing the request to uinput, so what
|
||||
// arrives here is always an assigned id. The fallback that used to allocate
|
||||
// one from a local counter could therefore never run, and a local counter is
|
||||
// the wrong answer anyway — the kernel owns that id space.
|
||||
debug_assert!(e.id >= 0, "uinput handed us an unassigned FF effect id");
|
||||
if e.type_ == FF_RUMBLE {
|
||||
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
||||
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
||||
@@ -544,10 +588,12 @@ impl VirtualPad {
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
slot.strong = strong;
|
||||
slot.weak = weak;
|
||||
slot.replay_ms = e.replay_length;
|
||||
slot.delay_ms = e.replay_delay;
|
||||
}
|
||||
up.effect.id = e.id; // hand the assigned slot back to the kernel
|
||||
up.retval = 0;
|
||||
@@ -574,14 +620,7 @@ impl VirtualPad {
|
||||
(EV_FF, code) => {
|
||||
self.ff.note_activity();
|
||||
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
|
||||
e.playing = if ev.value != 0 {
|
||||
Some((e.replay_ms > 0).then(|| {
|
||||
Instant::now()
|
||||
+ std::time::Duration::from_millis(e.replay_ms as u64)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
e.playing = (ev.value != 0).then(|| e.window(Instant::now()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -802,15 +841,34 @@ mod ff_state_tests {
|
||||
ff
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, until explicitly stopped.
|
||||
fn playing(at: Instant) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, for `len`.
|
||||
fn playing_for(at: Instant, len: Duration) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: Some(at + len),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
// Playing since before the window: "abandoned" means audible AND unattended, so an
|
||||
// effect that only just started is not a candidate however stale the plane is.
|
||||
playing: playing(now - Duration::from_millis(2600)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let now = Instant::now();
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
|
||||
// The game goes silent on the FF plane past the idle window: cut, exactly once.
|
||||
@@ -825,8 +883,9 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x4000,
|
||||
weak: 0,
|
||||
playing: Some(Some(now + Duration::from_secs(10))),
|
||||
playing: playing_for(now, Duration::from_secs(10)),
|
||||
replay_ms: 10_000,
|
||||
delay_ms: 0,
|
||||
});
|
||||
// FF plane long stale, but the effect declared a finite replay — the declared duration is
|
||||
// the contract (a real pad honors it too), so it keeps playing…
|
||||
@@ -842,26 +901,135 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now - Duration::from_millis(3000)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
ff.last_activity = now - Duration::from_millis(3000);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
||||
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
|
||||
ff.last_activity = now;
|
||||
ff.effects.get_mut(&0).unwrap().playing = Some(None);
|
||||
ff.effects.get_mut(&0).unwrap().playing = playing(now);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
}
|
||||
|
||||
/// `replay.delay` shifts the whole window: silent until it elapses, then the FULL
|
||||
/// `replay.length`. Before this the delay was decoded and dropped, so a delayed effect both
|
||||
/// started early and finished early — DirectInput under Wine schedules these routinely.
|
||||
#[test]
|
||||
fn replay_delay_holds_the_effect_off_then_gives_it_its_full_length() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_millis(500);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback {
|
||||
starts,
|
||||
ends: Some(starts + Duration::from_secs(1)),
|
||||
}),
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
});
|
||||
// Inside the delay: armed but silent.
|
||||
assert_eq!(ff.mix(now, IDLE), None);
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(499), IDLE), None);
|
||||
// Delay elapsed: it plays.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(501), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
// Still playing at 1400 ms — it gets its full second FROM the delay, not from the play.
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(1400), IDLE), None);
|
||||
// And ends at delay + length, not at length.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(1600), IDLE),
|
||||
Some((0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// The window a play opens, straight from the uploaded fields — this is the half that reads
|
||||
/// `replay.delay` at all. Pinned separately because the `EV_FF` handler that calls it needs a
|
||||
/// live uinput fd, so a test driving `mix` alone would pass with the delay ignored entirely.
|
||||
#[test]
|
||||
fn window_offsets_the_whole_playback_by_replay_delay() {
|
||||
let at = Instant::now();
|
||||
|
||||
let delayed = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
};
|
||||
let w = delayed.window(at);
|
||||
assert_eq!(
|
||||
w.starts,
|
||||
at + Duration::from_millis(500),
|
||||
"delay defers the start"
|
||||
);
|
||||
assert_eq!(
|
||||
w.ends,
|
||||
Some(at + Duration::from_millis(1500)),
|
||||
"length runs from the END of the delay, so the effect keeps its full second"
|
||||
);
|
||||
|
||||
// No delay: starts immediately, unchanged from before.
|
||||
let plain = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 0,
|
||||
};
|
||||
let w = plain.window(at);
|
||||
assert_eq!(w.starts, at);
|
||||
assert_eq!(w.ends, Some(at + Duration::from_millis(1000)));
|
||||
|
||||
// Length 0 = until stopped, but the delay still applies.
|
||||
let infinite = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 250,
|
||||
};
|
||||
let w = infinite.window(at);
|
||||
assert_eq!(w.starts, at + Duration::from_millis(250));
|
||||
assert_eq!(w.ends, None);
|
||||
}
|
||||
|
||||
/// A delayed effect must not be force-stopped as "abandoned" while it is still waiting: it has
|
||||
/// not had its turn, and the idle window is shorter than a delay can legitimately be.
|
||||
#[test]
|
||||
fn a_waiting_effect_is_not_cut_by_the_idle_watchdog() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_secs(5);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback { starts, ends: None }),
|
||||
replay_ms: 0,
|
||||
delay_ms: 5000,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(60); // long stale
|
||||
assert_eq!(ff.mix(now, IDLE), None); // silent, but NOT cut
|
||||
// It still plays when its delay elapses.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(5001), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_watchdog_never_cuts() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(600);
|
||||
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
|
||||
|
||||
@@ -1022,10 +1022,21 @@ 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 {
|
||||
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
|
||||
self.held_keys.push(ev.code);
|
||||
// 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::KeyUp => self.held_keys.retain(|&c| c != ev.code),
|
||||
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code & 0xff),
|
||||
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
|
||||
self.held_buttons.push(ev.code);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::steam_proto::{
|
||||
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
|
||||
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, request_id, set_report_data, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2,
|
||||
UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2,
|
||||
UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
@@ -32,20 +37,6 @@ use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI — same layout as the DualSense backend.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
|
||||
/// ~450 ms continuous; give margin).
|
||||
const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
@@ -53,11 +44,6 @@ const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
|
||||
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
|
||||
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
|
||||
/// per-pad `b9.6` pulse + guard handle it instead.
|
||||
@@ -214,10 +200,13 @@ impl SteamDeckPad {
|
||||
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
|
||||
let end = (12 + 16).min(UHID_EVENT_SIZE);
|
||||
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
|
||||
let id = request_id(&ev);
|
||||
// SET_REPORT data: [report-id 0, cmd, …]. Take exactly the bytes the kernel
|
||||
// declared — this used to read a fixed 16-byte window, which truncated any
|
||||
// longer report and, for a shorter one, fed the parser whatever the reused
|
||||
// event buffer still held past the payload. Every sibling backend that parses
|
||||
// SET_REPORT already read the size field; this one didn't.
|
||||
if let Some(r) = parse_steam_output(set_report_data(&ev)).rumble {
|
||||
rumble = Some(r);
|
||||
}
|
||||
let _ = self.reply_set_report(id);
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::triton_proto::{
|
||||
triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR,
|
||||
TRITON_WIRED_PRODUCT,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
@@ -30,25 +35,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
|
||||
pub struct TritonPad {
|
||||
fd: File,
|
||||
|
||||
@@ -22,6 +22,10 @@ use super::switch_proto::{
|
||||
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
|
||||
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -29,24 +33,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
|
||||
/// tears down the bound `hid-nintendo` interface).
|
||||
pub struct SwitchProPad {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The `/dev/uhid` event ABI (`linux/uhid.h`), in one place.
|
||||
//!
|
||||
//! Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller and Steam
|
||||
//! Controller 2 — speaks the same kernel protocol, and each carried its own verbatim copy of these
|
||||
//! constants plus its own `put_cstr`. Five copies of one kernel ABI is five chances to drift from
|
||||
//! it, and they already had: `switch_pro` was missing the SET_REPORT pair entirely, and one backend
|
||||
//! read a fixed-size SET_REPORT payload instead of the length the kernel gave it (see
|
||||
//! [`set_report_data`]).
|
||||
//!
|
||||
//! `struct uhid_event` is `__packed__`: a `u32` `type` followed by a union whose largest member is
|
||||
//! `uhid_create2_req` (name 128 + phys 64 + uniq 64 + rd_size 2 + bus 2 + 4×u32 + rd_data 4096 =
|
||||
//! 4372 bytes). Nothing here allocates or parses a whole event — the backends still drive their own
|
||||
//! read/write loops; this module owns the numbers and the two field accessors that are easy to get
|
||||
//! subtly wrong.
|
||||
|
||||
/// The character device every backend opens.
|
||||
pub const UHID_PATH: &str = "/dev/uhid";
|
||||
|
||||
// Event types (`enum uhid_event_type`). Only the ones the backends actually use.
|
||||
pub const UHID_DESTROY: u32 = 1;
|
||||
pub const UHID_OUTPUT: u32 = 6;
|
||||
pub const UHID_GET_REPORT: u32 = 9;
|
||||
pub const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
pub const UHID_CREATE2: u32 = 11;
|
||||
pub const UHID_INPUT2: u32 = 12;
|
||||
pub const UHID_SET_REPORT: u32 = 13;
|
||||
pub const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
|
||||
/// `HID_MAX_DESCRIPTOR_SIZE` — also the cap on a report payload we will copy out of an event.
|
||||
pub const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
/// `size_of::<uhid_event>()`: the `u32` type tag plus the create2 union.
|
||||
pub const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
/// `BUS_USB` from `linux/input.h`.
|
||||
pub const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Offset of the `id` field shared by the GET_REPORT / SET_REPORT request and reply structs.
|
||||
const OFF_ID: usize = 4;
|
||||
/// Offset of `uhid_set_report_req::size` (after `id: u32`, `rnum: u8`, `rtype: u8`).
|
||||
const OFF_SET_REPORT_SIZE: usize = 10;
|
||||
/// Offset of the payload in a SET_REPORT request — and of `data` in the reply structs.
|
||||
const OFF_DATA: usize = 12;
|
||||
/// Offset of `uhid_output_req::size` (the payload follows `data[4096]`).
|
||||
const OFF_OUTPUT_SIZE: usize = 4 + HID_MAX_DESCRIPTOR_SIZE;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer. The buffer is zeroed by the caller, so
|
||||
/// truncation still leaves a NUL terminator.
|
||||
pub fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The request id of a GET_REPORT / SET_REPORT event — what the matching reply must echo.
|
||||
pub fn request_id(ev: &[u8]) -> u32 {
|
||||
u32::from_ne_bytes([ev[OFF_ID], ev[OFF_ID + 1], ev[OFF_ID + 2], ev[OFF_ID + 3]])
|
||||
}
|
||||
|
||||
/// The payload of a `UHID_SET_REPORT` event: exactly the bytes the kernel says are there.
|
||||
///
|
||||
/// Read the length from the event's own `size` field. Assuming a fixed window instead is wrong in
|
||||
/// both directions — a longer report is silently truncated, and a shorter one is parsed together
|
||||
/// with whatever stale bytes the reused event buffer still holds past its end, which for a rumble
|
||||
/// report means acting on numbers the game never wrote.
|
||||
pub fn set_report_data(ev: &[u8]) -> &[u8] {
|
||||
let size = u16::from_ne_bytes([ev[OFF_SET_REPORT_SIZE], ev[OFF_SET_REPORT_SIZE + 1]]) as usize;
|
||||
let end = (OFF_DATA + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
|
||||
&ev[OFF_DATA.min(end)..end]
|
||||
}
|
||||
|
||||
/// The payload of a `UHID_OUTPUT` event (`uhid_output_req`: `data[4096]` then `size`).
|
||||
pub fn output_data(ev: &[u8]) -> &[u8] {
|
||||
let size = u16::from_ne_bytes([ev[OFF_OUTPUT_SIZE], ev[OFF_OUTPUT_SIZE + 1]]) as usize;
|
||||
let end = (4 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
|
||||
&ev[4.min(end)..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blank() -> Vec<u8> {
|
||||
vec![0u8; UHID_EVENT_SIZE]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_report_data_honours_the_events_own_size() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&5u16.to_ne_bytes());
|
||||
for (i, b) in [1u8, 2, 3, 4, 5].iter().enumerate() {
|
||||
ev[OFF_DATA + i] = *b;
|
||||
}
|
||||
// Stale bytes past the payload — a fixed-window read would hand these to the parser.
|
||||
ev[OFF_DATA + 5] = 0xAA;
|
||||
ev[OFF_DATA + 15] = 0xBB;
|
||||
assert_eq!(set_report_data(&ev), &[1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_report_data_is_not_truncated_at_sixteen() {
|
||||
let mut ev = blank();
|
||||
let n = 40usize;
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&(n as u16).to_ne_bytes());
|
||||
for i in 0..n {
|
||||
ev[OFF_DATA + i] = i as u8;
|
||||
}
|
||||
let d = set_report_data(&ev);
|
||||
assert_eq!(
|
||||
d.len(),
|
||||
n,
|
||||
"a report longer than 16 bytes must survive whole"
|
||||
);
|
||||
assert_eq!(d[39], 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_and_empty_sizes_stay_in_bounds() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&u16::MAX.to_ne_bytes());
|
||||
assert!(set_report_data(&ev).len() <= HID_MAX_DESCRIPTOR_SIZE);
|
||||
assert!(OFF_DATA + set_report_data(&ev).len() <= UHID_EVENT_SIZE);
|
||||
|
||||
let ev0 = blank(); // size = 0
|
||||
assert!(set_report_data(&ev0).is_empty());
|
||||
assert!(output_data(&ev0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_data_reads_its_trailing_size_field() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_OUTPUT_SIZE..OFF_OUTPUT_SIZE + 2].copy_from_slice(&3u16.to_ne_bytes());
|
||||
ev[4] = 0x02;
|
||||
ev[5] = 0x11;
|
||||
ev[6] = 0x22;
|
||||
ev[7] = 0x33; // past the declared size
|
||||
assert_eq!(output_data(&ev), &[0x02, 0x11, 0x22]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_id_round_trips() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_ID..OFF_ID + 4].copy_from_slice(&0xDEAD_BEEFu32.to_ne_bytes());
|
||||
assert_eq!(request_id(&ev), 0xDEAD_BEEF);
|
||||
}
|
||||
}
|
||||
@@ -250,11 +250,19 @@ impl DsState {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
// Invert in i16 space, BEFORE the quantisation, rather than as `255 - to_u8(v)`.
|
||||
// 0..=255 has no exact midpoint: `to_u8` puts centre at 0x80, which leaves 128 codes below
|
||||
// it and 127 above, so mirroring the *output* (`255 - 0x80` = 0x7F) lands a centred stick
|
||||
// one LSB off the 0x80 that `DsState::neutral` — and the pad's own resting report — use.
|
||||
// Games idle-poll a centred stick constantly, so that off-by-one showed up as a permanent
|
||||
// sub-deadzone tilt on the Y axes only. Negating first maps centre to centre by
|
||||
// construction and keeps both extremes exact (+32767 → 0, -32768 → 255); the only cost is
|
||||
// that i16::MIN and -32767 share the 255 code, one LSB at the very end of the travel.
|
||||
let mut s = DsState {
|
||||
lx: to_u8(lx),
|
||||
ly: 255 - to_u8(ly),
|
||||
ly: to_u8(ly.saturating_neg()),
|
||||
rx: to_u8(rx),
|
||||
ry: 255 - to_u8(ry),
|
||||
ry: to_u8(ry.saturating_neg()),
|
||||
l2: lt,
|
||||
r2: rt,
|
||||
..DsState::neutral()
|
||||
@@ -471,7 +479,14 @@ fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
#[derive(Default)]
|
||||
pub struct DsFeedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
|
||||
/// `(low, high)` motor levels, if a report carried them.
|
||||
///
|
||||
/// This parser widens the device's 8-bit motor bytes by `<< 8`, so the values it produces are
|
||||
/// `0..=0xFF00` in steps of 0x100 — NOT `0..=0xFFFF`, which is what this said before. The
|
||||
/// Windows backend widens the same bytes by `× 257` and does reach 0xFFFF. Both are correct:
|
||||
/// every consumer narrows with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. Do not
|
||||
/// "fix" one to match the other — see [`crate::uhid_manager::PadFeedback::rumble`], which is
|
||||
/// the type that sees both.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and
|
||||
/// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence +
|
||||
@@ -479,67 +494,119 @@ pub struct DsFeedback {
|
||||
pub resync: bool,
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
/// Field offsets in the DualSense **output** report, as indices into a whole USB report — i.e.
|
||||
/// including the leading report id at `[0]`. This is the one place in Rust the layout is written
|
||||
/// down; index off these rather than repeating the numbers.
|
||||
///
|
||||
/// **The same fields sit at different offsets per transport, and that is not drift.** Every writer
|
||||
/// lays out one common block; what changes is how much header precedes it:
|
||||
///
|
||||
/// | base | where | first payload byte |
|
||||
/// |---|---|---|
|
||||
/// | `0` | USB report, id included — what these constants describe, and what this parser reads | `[1]` |
|
||||
/// | `−1` | SDL `DS5EffectsState_t` — a 47-byte payload with NO report id (`pf-client-core`'s `Ds5Feedback`) | `[0]` |
|
||||
/// | `+2` | Bluetooth report `0x31` — id, sequence, magic, then the block; CRC32 in the last 4 bytes | `[3]` |
|
||||
///
|
||||
/// Subtract or add the base to translate. Mirrors that cannot import this module — Kotlin
|
||||
/// (`DsDevice.kt`, USB base 0) and Swift (`DualSenseHID.swift`, which handles both the USB and
|
||||
/// Bluetooth bases) — carry a pointer back here; keep them in step by hand.
|
||||
pub mod out_report {
|
||||
/// `valid_flag0`: BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2.
|
||||
pub const VALID_FLAG0: usize = 1;
|
||||
/// `valid_flag1`: BIT2 lightbar, BIT4 player indicators.
|
||||
pub const VALID_FLAG1: usize = 2;
|
||||
/// High-frequency (small / right) motor.
|
||||
pub const MOTOR_RIGHT: usize = 3;
|
||||
/// Low-frequency (big / left) motor.
|
||||
pub const MOTOR_LEFT: usize = 4;
|
||||
/// First byte of the RIGHT trigger's parameter block — it precedes the left one in the report.
|
||||
pub const RIGHT_TRIGGER: usize = 11;
|
||||
/// First byte of the LEFT trigger's parameter block.
|
||||
pub const LEFT_TRIGGER: usize = 22;
|
||||
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters.
|
||||
pub const TRIGGER_LEN: usize = 11;
|
||||
/// `valid_flag2`: BIT2 = `COMPATIBLE_VIBRATION2` (the firmware ≥ 2.24 rumble signal).
|
||||
pub const VALID_FLAG2: usize = 39;
|
||||
/// Lit player-indicator bits (low 5).
|
||||
pub const PLAYER_LEDS: usize = 44;
|
||||
/// Lightbar red; green and blue follow.
|
||||
pub const LED_RGB: usize = 45;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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),
|
||||
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
|
||||
/// broadcast.
|
||||
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
use out_report as o;
|
||||
// data[0] is the report id (0x02). Be defensive about short reports.
|
||||
if data.first() != Some(&0x02) || data.len() < 48 {
|
||||
return;
|
||||
}
|
||||
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
|
||||
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
|
||||
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
|
||||
// that never read the version, stay on flag0. Both conventions must land here: a
|
||||
// rumble dropped on either — including stops — is silently ignored, and a missed
|
||||
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
|
||||
// forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
let flag0 = data[o::VALID_FLAG0]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[o::VALID_FLAG1]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor first, low-frequency (big/left) second.
|
||||
// Widened 0..255 → 0..0xFF00 by `<< 8` (NOT 0xFFFF — see `DsFeedback::rumble`), same
|
||||
// (low, high) convention as the uinput pad's mixer, and routed to the 0xCA plane.
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// instead of flag0 BIT0. Our feature report advertises a version above 2.24
|
||||
// (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater quiet), so the
|
||||
// kernel and SDL write the v2 flag — while older writers, and any that never read the
|
||||
// version, stay on flag0. Both conventions must land here: a rumble dropped on either
|
||||
// — including stops — is silently ignored, and a missed stop buzzes for the rest of
|
||||
// the session (the 500 ms refresh re-sends stale state forever).
|
||||
if flag0 & 0x03 != 0 || data[o::VALID_FLAG2] & 0x04 != 0 {
|
||||
let high = (data[o::MOTOR_RIGHT] as u16) << 8;
|
||||
let low = (data[o::MOTOR_LEFT] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
|
||||
if flag1 & 0x04 != 0 {
|
||||
let (r, g, b) = (data[45], data[46], data[47]);
|
||||
let (r, g, b) = (data[o::LED_RGB], data[o::LED_RGB + 1], data[o::LED_RGB + 2]);
|
||||
fb.hidout.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if flag1 & 0x10 != 0 {
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: data[44] & 0x1F,
|
||||
bits: data[o::PLAYER_LEDS] & 0x1F,
|
||||
});
|
||||
}
|
||||
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
|
||||
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
|
||||
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= 33 {
|
||||
// The RIGHT trigger block comes FIRST in the report — per SDL's DS5EffectsState_t /
|
||||
// inputtino's ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= o::LEFT_TRIGGER + o::TRIGGER_LEN {
|
||||
if flag0 & 0x04 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 1,
|
||||
effect: data[11..22].to_vec(),
|
||||
effect: data[o::RIGHT_TRIGGER..o::RIGHT_TRIGGER + o::TRIGGER_LEN].to_vec(),
|
||||
});
|
||||
}
|
||||
if flag0 & 0x08 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 0,
|
||||
effect: data[22..33].to_vec(),
|
||||
effect: data[o::LEFT_TRIGGER..o::LEFT_TRIGGER + o::TRIGGER_LEN].to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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)]
|
||||
@@ -783,6 +850,29 @@ mod tests {
|
||||
assert_eq!(r[53], 0x0A);
|
||||
}
|
||||
|
||||
/// A centred stick must encode as the pad's own neutral on BOTH axes. Inverting the quantised
|
||||
/// byte (`255 - v`) put Y one LSB below it, which games idle-poll constantly — a permanent
|
||||
/// sub-deadzone tilt. Extremes must stay exact either way.
|
||||
#[test]
|
||||
fn centred_sticks_encode_as_neutral_on_every_axis() {
|
||||
let n = DsState::neutral();
|
||||
let s = DsState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!((s.lx, s.ly), (n.lx, n.ly), "left stick centre");
|
||||
assert_eq!((s.rx, s.ry), (n.rx, n.ry), "right stick centre");
|
||||
|
||||
// Y is still inverted (XInput +y = up, DualSense 0 = up) and both ends stay exact.
|
||||
let up = DsState::from_gamepad(0, 0, i16::MAX, 0, i16::MAX, 0, 0);
|
||||
assert_eq!((up.ly, up.ry), (0, 0), "full up = 0");
|
||||
let down = DsState::from_gamepad(0, 0, i16::MIN, 0, i16::MIN, 0, 0);
|
||||
assert_eq!((down.ly, down.ry), (255, 255), "full down = 255");
|
||||
|
||||
// X keeps its existing mapping.
|
||||
let right = DsState::from_gamepad(0, i16::MAX, 0, i16::MAX, 0, 0, 0);
|
||||
assert_eq!((right.lx, right.rx), (255, 255));
|
||||
let left = DsState::from_gamepad(0, i16::MIN, 0, i16::MIN, 0, 0, 0);
|
||||
assert_eq!((left.lx, left.rx), (0, 0));
|
||||
}
|
||||
|
||||
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
|
||||
/// `buttons[2]`.
|
||||
#[test]
|
||||
@@ -842,6 +932,48 @@ mod tests {
|
||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||
}
|
||||
|
||||
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
|
||||
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
|
||||
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
|
||||
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
|
||||
#[test]
|
||||
fn parse_output_surfaces_audio_ctl() {
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
|
||||
data[5] = 0x50; // headphone volume
|
||||
data[6] = 0x60; // speaker volume
|
||||
data[7] = 0x70; // mic volume
|
||||
data[8] = 0x05; // audio routing / enable bits
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(3, &data, &mut fb);
|
||||
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0b1_0111,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
}]
|
||||
);
|
||||
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
|
||||
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
|
||||
// the honest bytes either way.
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[9] = 0x01;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags: 0,
|
||||
raw: [0, 0, 0, 0, 0x01, 0],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
#[test]
|
||||
fn parse_output_rejects_garbage() {
|
||||
|
||||
@@ -183,8 +183,9 @@ impl SteamState {
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
|
||||
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
|
||||
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32767 ([`trigger_u16`]) and set the
|
||||
/// full-pull bit when pressed. Trackpad + motion + the back grips arrive separately
|
||||
/// ([`apply_rich`], the M3 wire).
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
@@ -200,8 +201,8 @@ impl SteamState {
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
..SteamState::neutral()
|
||||
};
|
||||
let mut b = 0u64;
|
||||
@@ -375,8 +376,8 @@ pub fn sc_from_gamepad(
|
||||
ly,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
// The wire right stick becomes a right-pad contact (see the doc above).
|
||||
rpad_x: rx,
|
||||
rpad_y: ry,
|
||||
@@ -466,6 +467,18 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
|
||||
}
|
||||
|
||||
/// Scale a wire trigger (u8 `0..=255`) onto the Deck's full axis (u16 `0..=32767`).
|
||||
///
|
||||
/// This was `v * 128`, which tops out at 32640 — a fully-pulled trigger reported 99.6% and the top
|
||||
/// 127 counts of the declared range were unreachable, so a game reading the axis could never see a
|
||||
/// true full pull. One multiply gets both ends exact (`0 → 0`, `255 → 32767`) and stays monotonic.
|
||||
///
|
||||
/// `serialize_report`'s inverse (`>> 7`, for the legacy u8 trigger bytes) still round-trips both
|
||||
/// ends against this: `32767 >> 7 == 255`.
|
||||
fn trigger_u16(v: u8) -> u16 {
|
||||
((v as u32 * 32767) / 255) as u16
|
||||
}
|
||||
|
||||
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
||||
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
|
||||
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
||||
@@ -473,7 +486,12 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut buf = [0u8; STEAM_REPORT_LEN];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
// `min`, not `clamp(1, 21)`. Clamping the LOW end to 1 and then slicing `bytes[..len]` asks a
|
||||
// zero-byte slice for one byte, which panics — on the service thread, for an input the kernel
|
||||
// already has a graceful answer to. Reporting the true length lets its own validation
|
||||
// (`1 <= reply[1] <= 21`) reject an empty serial and fall back to "XXXXXXXXXX", which is the
|
||||
// documented behaviour for a reply it does not like.
|
||||
let len = bytes.len().min(21);
|
||||
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
@@ -704,7 +722,7 @@ mod tests {
|
||||
assert_ne!(s.buttons & btn::STEAM, 0);
|
||||
assert_ne!(s.buttons & btn::LB, 0);
|
||||
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
|
||||
assert_eq!(s.lt, 255 * 128);
|
||||
assert_eq!(s.lt, 32767); // full pull reaches the TOP of the declared range
|
||||
assert_eq!(s.lx, 1000);
|
||||
assert_eq!(s.ly, -2000);
|
||||
|
||||
@@ -730,6 +748,30 @@ mod tests {
|
||||
assert_eq!(s.accel, [16384, -8192, 0]);
|
||||
}
|
||||
|
||||
/// An empty serial must not panic. `clamp(1, 21)` asked a zero-byte slice for one byte, which
|
||||
/// is an out-of-range slice index — on the service thread. The kernel rejects a zero length by
|
||||
/// its own rule (`1 <= reply[1] <= 21`) and falls back, which is the graceful answer.
|
||||
#[test]
|
||||
fn empty_serial_reply_does_not_panic() {
|
||||
let r = serial_reply("");
|
||||
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE);
|
||||
assert_eq!(
|
||||
r[2], 0,
|
||||
"length the kernel will reject, rather than a panic"
|
||||
);
|
||||
|
||||
// Normal and over-long serials still behave.
|
||||
let r = serial_reply("ABC123");
|
||||
assert_eq!(r[2], 6);
|
||||
assert_eq!(&r[4..10], b"ABC123");
|
||||
let long = "X".repeat(40);
|
||||
assert_eq!(
|
||||
serial_reply(&long)[2],
|
||||
21,
|
||||
"clamped to the protocol maximum"
|
||||
);
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
|
||||
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
|
||||
|
||||
@@ -18,7 +18,12 @@ use std::time::{Duration, Instant};
|
||||
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
|
||||
#[derive(Default)]
|
||||
pub struct PadFeedback {
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
|
||||
/// `(low, high)` motor levels, if the pass saw a rumble report.
|
||||
///
|
||||
/// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that
|
||||
/// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows
|
||||
/// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a
|
||||
/// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
|
||||
@@ -159,6 +164,22 @@ impl OverflowWarn {
|
||||
/// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive
|
||||
/// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a
|
||||
/// rumble write, so its any-activity keying is already rumble-keyed by construction).
|
||||
///
|
||||
/// KNOWN COST, deliberately accepted. That invariant only covers writers that re-assert. A game
|
||||
/// driving the pad through the kernel's *evdev* FF interface does not: `ff-memless` sends one
|
||||
/// output report when an effect starts and one when it stops, with nothing in between, so a finite
|
||||
/// effect longer than this window is cut in half here. The uinput path
|
||||
/// (`linux/gamepad.rs`) exempts exactly that case — but it can, because evdev FF hands it an
|
||||
/// explicit `replay.length`. Nothing equivalent reaches this layer: [`PadFeedback`] carries motor
|
||||
/// levels, and the protocols it speaks (DualSense / DS4 / Deck / Switch Pro) are all
|
||||
/// level-triggered with no duration field anywhere in a report. So the choice is between cutting a
|
||||
/// long finite effect and letting an abandoned residual drone forever, and the residual is the one
|
||||
/// with field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is
|
||||
/// not affected either way — `hid-nintendo` re-sends rumble continuously, and a physical Pro's
|
||||
/// HD-rumble decays faster than this window regardless.
|
||||
///
|
||||
/// Do not "fix" this by widening or disabling the window without evidence about which failure real
|
||||
/// titles actually hit; the hatch below exists for exactly that experiment.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides
|
||||
@@ -338,10 +359,17 @@ impl<B: PadProto> UhidManager<B> {
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (a game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
if self.hidout_dedup[i].should_forward(&h, now) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
// Re-assert the latched rich state on a slow cadence. Deduping a plane that rides
|
||||
// unreliable datagrams means a dropped update is never re-derived from the game — it
|
||||
// keeps sending the same value and the dedup eats every copy — so without this one
|
||||
// lost datagram leaves the pad on the previous weapon's trigger effect indefinitely.
|
||||
for h in self.hidout_dedup[i].renewals(i as u8, now) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +518,7 @@ mod tests {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
|
||||
@@ -819,46 +819,77 @@ impl DriverAttach {
|
||||
|
||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||
///
|
||||
/// Runs on its own thread and returns immediately. The caller is the session's pad service
|
||||
/// thread — the one feeding input and rumble — and everything below is slow: the driver-store
|
||||
/// check waits up to [`INVENTORY_WAIT`] for a `pnputil` enumeration that can take tens of
|
||||
/// seconds, and the devnode lookup is a synchronous PnP call. Blocking there stalled input for
|
||||
/// up to two seconds *per unattached pad* (the wait is a deadline, not a one-off: while the
|
||||
/// enumeration is still outstanding every pad pays it again), at exactly the moment a session
|
||||
/// is already going wrong. Diagnostics must never be able to hurt the thing they diagnose.
|
||||
///
|
||||
/// Off the hot path the wait also stops being a compromise — it can afford to be patient and
|
||||
/// report what it actually found rather than "still enumerating".
|
||||
fn diagnose(&self) {
|
||||
let store = match driver_store_has(self.inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => {
|
||||
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log);
|
||||
let shm_name = self.shm_name.clone();
|
||||
let instance_id = self.instance_id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-driver-diagnose".into())
|
||||
.spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
|
||||
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
|
||||
/// draining pad slots even when the driver store is wedged.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
|
||||
/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into
|
||||
/// the closure so the blocking calls stay visible as blocking.
|
||||
fn diagnose_blocking(
|
||||
driver: &'static str,
|
||||
inf: &'static str,
|
||||
driver_log: &'static str,
|
||||
shm_name: &str,
|
||||
instance_id: Option<String>,
|
||||
) {
|
||||
let store = match driver_store_has(inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string(),
|
||||
};
|
||||
tracing::warn!(
|
||||
driver,
|
||||
shm = %shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] waits for the background pnputil query before reporting
|
||||
/// without it. Only [`diagnose_blocking`] waits, and that has a thread to itself, so this is
|
||||
/// generous: pnputil routinely takes longer than a couple of seconds on a busy driver store, and
|
||||
/// the old two-second budget — chosen to limit the damage while this ran on the pad service thread
|
||||
/// — meant the diagnosis usually gave up and printed "still enumerating", which is the one answer
|
||||
/// that helps nobody. Nothing waits on this thread, so patience costs only a late log line.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
|
||||
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
|
||||
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
|
||||
/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query
|
||||
/// still running past [`INVENTORY_WAIT`]) or
|
||||
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
|
||||
fn driver_store_inventory() -> Option<&'static str> {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
|
||||
@@ -457,6 +457,11 @@ pub mod triton_proto;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/triton_usbip.rs"]
|
||||
pub mod triton_usbip;
|
||||
/// Linux: the `/dev/uhid` event ABI shared by every UHID gamepad backend — the constants each
|
||||
/// used to transcribe for itself, plus the field accessors that read a payload's real length.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/uhid_abi.rs"]
|
||||
pub mod uhid_abi;
|
||||
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
|
||||
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
||||
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
||||
|
||||
+92
-17
@@ -70,11 +70,64 @@ 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);
|
||||
restrict_dir_to_system_admins(dir, first_hardening_of(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
|
||||
@@ -86,17 +139,23 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
/// 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) {
|
||||
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
|
||||
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
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
@@ -108,8 +167,13 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
|
||||
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
|
||||
"/grant:r",
|
||||
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
|
||||
// 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-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
|
||||
])
|
||||
@@ -130,6 +194,19 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
|
||||
/// 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();
|
||||
@@ -160,9 +237,7 @@ 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 = std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string());
|
||||
let icacls = icacls_path();
|
||||
let status = std::process::Command::new(icacls)
|
||||
.arg(path.as_os_str())
|
||||
.args([
|
||||
|
||||
@@ -30,6 +30,12 @@ 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.
|
||||
@@ -183,6 +189,15 @@ 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
|
||||
@@ -198,14 +213,29 @@ 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.
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
use crate::input::{Capture, FingerPhase};
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::present_pace::{
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, JUDDER_LOG_PERMILLE, MARGIN_MAX_NS,
|
||||
MARGIN_STEP_NS,
|
||||
};
|
||||
use crate::touch::Abs;
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
@@ -466,6 +467,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
crate::win32::set_app_user_model_id();
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Hold SDL's Valve HIDAPI drivers off BEFORE SDL_Init: the Deck driver clears the pad's
|
||||
// digital mappings at *enumeration*, which is part of bringing the gamepad subsystem up, so a
|
||||
// hint set after `sdl.gamepad()` — where this used to live, inside GamepadService::pumped —
|
||||
// only detached a driver that had already killed the built-in trackpad-mouse system-wide. The
|
||||
// symptom was the Deck losing its trackpad cursor at the start of every session until the
|
||||
// firmware watchdog restored lizard mode. They are still enabled for an attached session.
|
||||
pf_client_core::gamepad::preinit_disable_valve_hidapi();
|
||||
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
|
||||
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
|
||||
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
|
||||
@@ -1814,6 +1822,7 @@ 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,
|
||||
@@ -1827,6 +1836,8 @@ 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();
|
||||
@@ -1848,7 +1859,14 @@ 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.
|
||||
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
|
||||
// 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)
|
||||
{
|
||||
tracing::info!(
|
||||
smoothing = st.presented.smoothing,
|
||||
mode = st.presented.mode,
|
||||
@@ -1864,6 +1882,8 @@ 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"
|
||||
);
|
||||
}
|
||||
@@ -1895,6 +1915,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
};
|
||||
|
||||
// Every exit from the loop above converges here, which is why the gamepad teardown belongs
|
||||
// here and not on the individual `break`s. `gamepad.detach()` only queues the detach; the
|
||||
// close — flush, host-side GamepadRemove, and the explicit rumble-stop backstop — runs when
|
||||
// the pump drains it. Single mode broke out of the loop immediately after detaching and
|
||||
// Event::Quit never detached at all, so both left forwarded pads unflushed and, if the game
|
||||
// was rumbling at the time, still buzzing.
|
||||
pump.shutdown();
|
||||
// Join the pump BEFORE the device-wide idle: its decode submissions on the shared
|
||||
// device would race vkDeviceWaitIdle otherwise.
|
||||
if let Some(st) = stream.take() {
|
||||
@@ -2354,6 +2381,14 @@ 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).
|
||||
|
||||
@@ -66,6 +66,11 @@ 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",
|
||||
|
||||
@@ -1764,6 +1764,11 @@ impl VirtualDisplayManager {
|
||||
if let Some(saved) = inner.group.ccd_saved.take() {
|
||||
restore_displays_ccd(&saved);
|
||||
}
|
||||
// Drop the isolate's crash-recovery marker even when there was no snapshot to restore
|
||||
// (a failed `isolate_displays_ccd` leaves `ccd_saved` None, and `restore_displays_ccd`
|
||||
// — which clears it itself — then never runs). The group is gone either way, so no
|
||||
// future host start owes this desk a force-EXTEND.
|
||||
pf_win_display::win_display::isolate_journal::clear();
|
||||
// EXPERIMENTAL `ddc_power_off` wake. OUTSIDE the `ccd_saved` gate, for the same reason
|
||||
// `pnp_disabled` is above it: the panels were commanded dark BEFORE the isolate, and
|
||||
// the isolate can return `None` (its `query_active_config` failed). Nested inside that
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! `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
|
||||
@@ -40,16 +41,40 @@ 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.";
|
||||
// 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.
|
||||
// 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.
|
||||
unsafe {
|
||||
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
|
||||
let h = match CreateMutexW(Some(&sa), 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). Same meaning: an instance is live.
|
||||
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
|
||||
// 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`."
|
||||
),
|
||||
Err(e) => {
|
||||
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
|
||||
}
|
||||
@@ -57,8 +82,114 @@ 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
|
||||
}
|
||||
|
||||
@@ -1215,6 +1215,186 @@ pub fn target_inventory() -> Vec<TargetInventory> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Crash-recovery journal for the EXCLUSIVE isolate — the marker that lets a *fresh* host undo what
|
||||
/// a *dead* one did.
|
||||
///
|
||||
/// [`isolate_displays_ccd`] deactivates the operator's physical displays and hands the pre-isolate
|
||||
/// topology back to its caller, which restores it at teardown ([`restore_displays_ccd`]). That
|
||||
/// snapshot lives in **process memory only**, so a host that crashes, is killed, or is stopped
|
||||
/// mid-session never restores it. Windows does not restore it either — the isolated topology is
|
||||
/// deliberately never saved to the CCD database, precisely so teardown can put the user's layout
|
||||
/// back. The result was a field-reported dead end: the physical screen stays dark, no timeout ever
|
||||
/// fires, and nothing in the product puts it back (the operator's only recourse was `DisplaySwitch`
|
||||
/// or a reboot).
|
||||
///
|
||||
/// Same shape as [`monitor_devnode`](crate::monitor_devnode)'s PnP journal: write a marker while the
|
||||
/// isolate is live, clear it on a clean restore, and re-light the desk at host startup if a marker
|
||||
/// survived.
|
||||
///
|
||||
/// **Why the EXTEND preset rather than replaying the saved CCD blob.** That blob pins target ids
|
||||
/// *including the virtual display's*, and the crashed host's monitors die with it (startup reaps the
|
||||
/// orphans), so a replay would mostly fail `ERROR_BAD_CONFIGURATION` and land in the very
|
||||
/// force-EXTEND backstop [`restore_displays_ccd`] already keeps for that case. EXTEND re-activates
|
||||
/// every connected display from the OS's own database, needs no struct serialization, and stays
|
||||
/// correct across a reboot — where saved target ids would be stale anyway.
|
||||
pub mod isolate_journal {
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// What we last wrote, so the exclusive re-assert watchdog's repeat isolates don't rewrite the
|
||||
/// file every couple of seconds. `None` = "no marker known to be on disk".
|
||||
static LAST: Mutex<Option<Vec<u32>>> = Mutex::new(None);
|
||||
|
||||
fn path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("display-isolate-active.json")
|
||||
}
|
||||
|
||||
/// Record that `deactivated` physical target(s) are switched off for a live exclusive isolate.
|
||||
/// Best-effort: a journal we cannot write costs crash recovery, not the session.
|
||||
pub fn mark(deactivated: &[u32]) {
|
||||
if deactivated.is_empty() {
|
||||
return; // nothing was deactivated ⇒ nothing for a later host to put back
|
||||
}
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_deref() == Some(deactivated) {
|
||||
return;
|
||||
}
|
||||
let p = path();
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = pf_paths::create_private_dir(dir);
|
||||
}
|
||||
match std::fs::write(
|
||||
&p,
|
||||
serde_json::to_vec_pretty(deactivated).unwrap_or_default(),
|
||||
) {
|
||||
Ok(()) => *last = Some(deactivated.to_vec()),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"display isolate: could not write the crash-recovery journal — if this host dies \
|
||||
mid-session the deactivated panels will stay dark"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The isolate is over (restored, or there was nothing to restore) — drop the marker.
|
||||
/// Idempotent; safe to call when no marker exists.
|
||||
pub fn clear() {
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = std::fs::remove_file(path());
|
||||
*last = None;
|
||||
}
|
||||
|
||||
/// Host-startup crash recovery: if a previous host exited with an exclusive isolate live, its
|
||||
/// physical displays are still deactivated. Re-light them with the EXTEND preset.
|
||||
///
|
||||
/// Call once, early in `serve`, **before** any session touches the topology. Gated on the marker
|
||||
/// rather than on "is anything active", so a legitimately headless host is never forced awake.
|
||||
pub fn startup_recover() {
|
||||
let Some(targets) = pending() else {
|
||||
return;
|
||||
};
|
||||
tracing::warn!(
|
||||
deactivated = ?targets,
|
||||
"display isolate: a previous host exited with the operator's display(s) deactivated for \
|
||||
an EXCLUSIVE session and never restored them — forcing the EXTEND preset so the desk is \
|
||||
not left dark"
|
||||
);
|
||||
super::force_extend_topology();
|
||||
clear();
|
||||
}
|
||||
|
||||
/// The marker a previous host left behind, if any (its deactivated target ids) — the *decision*
|
||||
/// half of [`startup_recover`], split out so the recovery rule is testable without driving a
|
||||
/// real `SetDisplayConfig` against the machine running the test.
|
||||
pub fn pending() -> Option<Vec<u32>> {
|
||||
let bytes = std::fs::read(path()).ok()?;
|
||||
Some(serde_json::from_slice(&bytes).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (which `path()` resolves through) and the `LAST` cache are both
|
||||
/// process-global, so these cases must not interleave.
|
||||
static ENV: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Point the journal at a scratch dir for the duration of one case.
|
||||
fn with_temp_dir(name: &str, f: impl FnOnce(&std::path::Path)) {
|
||||
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("scratch dir");
|
||||
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
|
||||
clear(); // reset the LAST cache + any leftover marker from a previous run
|
||||
f(&dir);
|
||||
clear();
|
||||
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The crash path: a host marks what it switched off and dies. The next start must see the
|
||||
/// marker (and which targets), which is what makes it force the desk back on.
|
||||
#[test]
|
||||
fn a_mark_survives_for_the_next_host_and_clear_retracts_it() {
|
||||
with_temp_dir("roundtrip", |_| {
|
||||
assert_eq!(pending(), None, "a clean box owes no recovery");
|
||||
mark(&[101, 202]);
|
||||
assert_eq!(
|
||||
pending(),
|
||||
Some(vec![101, 202]),
|
||||
"a crashed host's marker must be readable by the next start"
|
||||
);
|
||||
clear();
|
||||
assert_eq!(pending(), None, "a clean teardown retracts the marker");
|
||||
});
|
||||
}
|
||||
|
||||
/// An isolate that deactivated nothing (single-display box: the virtual output is already
|
||||
/// the only head) owes the next start no force-EXTEND — marking there would re-arrange a
|
||||
/// desk we never touched.
|
||||
#[test]
|
||||
fn deactivating_nothing_writes_no_marker() {
|
||||
with_temp_dir("empty", |_| {
|
||||
mark(&[]);
|
||||
assert_eq!(pending(), None);
|
||||
});
|
||||
}
|
||||
|
||||
/// The re-assert watchdog re-isolates every couple of seconds while something fights it;
|
||||
/// that must not mean a disk write per cycle.
|
||||
#[test]
|
||||
fn repeating_the_same_mark_does_not_rewrite_the_file() {
|
||||
with_temp_dir("cached", |dir| {
|
||||
let file = dir.join("display-isolate-active.json");
|
||||
mark(&[7]);
|
||||
// Overwrite behind the journal's back rather than comparing mtimes — a filesystem
|
||||
// whose timestamp resolution is coarser than two back-to-back writes would let an
|
||||
// mtime assertion pass without proving anything.
|
||||
std::fs::write(&file, b"SENTINEL").unwrap();
|
||||
mark(&[7]);
|
||||
assert_eq!(
|
||||
std::fs::read(&file).unwrap(),
|
||||
b"SENTINEL",
|
||||
"an unchanged mark must not rewrite the journal"
|
||||
);
|
||||
// A CHANGED set still lands — the group grew/shrank and recovery must follow it.
|
||||
mark(&[7, 8]);
|
||||
assert_eq!(pending(), Some(vec![7, 8]));
|
||||
});
|
||||
}
|
||||
|
||||
/// A corrupt/truncated journal must still trigger recovery: the FILE's existence is the
|
||||
/// signal ("a host left displays off"), its contents are only diagnostics.
|
||||
#[test]
|
||||
fn an_unparseable_marker_still_asks_for_recovery() {
|
||||
with_temp_dir("corrupt", |dir| {
|
||||
std::fs::write(dir.join("display-isolate-active.json"), b"{ not json").unwrap();
|
||||
assert_eq!(pending(), Some(Vec::new()));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
|
||||
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
|
||||
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
|
||||
@@ -1246,6 +1426,18 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
|
||||
return Some(saved);
|
||||
}
|
||||
|
||||
// Journal what we are about to switch off BEFORE the first apply, not after a verified one: the
|
||||
// window this exists to cover includes dying mid-apply. `saved.0` is the ACTIVE path set
|
||||
// (QDC_ONLY_ACTIVE_PATHS), so everything in it outside the keep set is exactly what teardown
|
||||
// owes the operator back. See `isolate_journal`.
|
||||
let doomed: Vec<u32> = saved
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| p.targetInfo.id)
|
||||
.filter(|id| !keep_target_ids.contains(id))
|
||||
.collect();
|
||||
isolate_journal::mark(&doomed);
|
||||
|
||||
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
|
||||
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
|
||||
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
||||
@@ -1769,6 +1961,15 @@ static DARK_SINKS_FUTILE: std::sync::Mutex<Vec<(u32, String)>> = std::sync::Mute
|
||||
/// removed), re-activating the displays we deactivated.
|
||||
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
|
||||
pub fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
restore_displays_ccd_inner(saved);
|
||||
// Clear the crash-recovery marker only AFTER the restore (and its dark-desk backstop) has run,
|
||||
// never before: a host that dies part-way through the restore must still leave the marker
|
||||
// behind so the next start re-lights the desk. `_inner` has several early returns, which is
|
||||
// why this wraps rather than trailing the body.
|
||||
isolate_journal::clear();
|
||||
}
|
||||
|
||||
fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -56,6 +56,167 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
|
||||
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
|
||||
|
||||
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
|
||||
# `pub const`, so without an entry here names as generic as MAX_PADS, TAG_LEN, ABI_VERSION and
|
||||
# INPUT_MAGIC land in the namespace of every C embedder that includes this header — and, as the
|
||||
# note above says, a clashing #define silently takes the last definition rather than failing to
|
||||
# compile. The table above had been doing this by hand for the handful someone noticed; this is
|
||||
# the rest of them, so the stated rule finally holds for the whole surface.
|
||||
#
|
||||
# NOT covered, deliberately: associated constants (`ColorInfo_CP_BT709`, `ClockResync_ROUNDS`,
|
||||
# `ResyncGuard_MAX_REJECTED_STREAK`). cbindgen already qualifies those with their type name,
|
||||
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
|
||||
# namespaced, just not by us.
|
||||
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
|
||||
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
|
||||
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
|
||||
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
|
||||
"BTN_PADDLE2" = "PUNKTFUNK_BTN_PADDLE2"
|
||||
"BTN_PADDLE3" = "PUNKTFUNK_BTN_PADDLE3"
|
||||
"BTN_PADDLE4" = "PUNKTFUNK_BTN_PADDLE4"
|
||||
"CHROMA_IDC_420" = "PUNKTFUNK_CHROMA_IDC_420"
|
||||
"CHROMA_IDC_444" = "PUNKTFUNK_CHROMA_IDC_444"
|
||||
"CIPHER_AES_128_GCM" = "PUNKTFUNK_CIPHER_AES_128_GCM"
|
||||
"CIPHER_CHACHA20_POLY1305" = "PUNKTFUNK_CIPHER_CHACHA20_POLY1305"
|
||||
"CLIENT_CAP_AUDIO_RED" = "PUNKTFUNK_CLIENT_CAP_AUDIO_RED"
|
||||
"CLIENT_CAP_CURSOR" = "PUNKTFUNK_CLIENT_CAP_CURSOR"
|
||||
"CLIENT_CAP_PHASE_LOCK" = "PUNKTFUNK_CLIENT_CAP_PHASE_LOCK"
|
||||
"CLIP_CANCELLED_CODE" = "PUNKTFUNK_CLIP_CANCELLED_CODE"
|
||||
"CLIP_CHUNK" = "PUNKTFUNK_CLIP_CHUNK"
|
||||
"CLIP_FETCH_CAP" = "PUNKTFUNK_CLIP_FETCH_CAP"
|
||||
"CLIP_FETCH_DENIED" = "PUNKTFUNK_CLIP_FETCH_DENIED"
|
||||
"CLIP_FETCH_OK" = "PUNKTFUNK_CLIP_FETCH_OK"
|
||||
"CLIP_FETCH_STALE" = "PUNKTFUNK_CLIP_FETCH_STALE"
|
||||
"CLIP_FETCH_UNAVAILABLE" = "PUNKTFUNK_CLIP_FETCH_UNAVAILABLE"
|
||||
"CLIP_FILE_INDEX_NONE" = "PUNKTFUNK_CLIP_FILE_INDEX_NONE"
|
||||
"CLIP_FLAG_FILES" = "PUNKTFUNK_CLIP_FLAG_FILES"
|
||||
"CLIP_MAX_KINDS" = "PUNKTFUNK_CLIP_MAX_KINDS"
|
||||
"CLIP_MAX_MIME" = "PUNKTFUNK_CLIP_MAX_MIME"
|
||||
"CLIP_POLICY_FILES" = "PUNKTFUNK_CLIP_POLICY_FILES"
|
||||
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
|
||||
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
|
||||
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
|
||||
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
|
||||
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
|
||||
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
|
||||
"CLIP_STREAM_KIND_FETCH" = "PUNKTFUNK_CLIP_STREAM_KIND_FETCH"
|
||||
"ClockResync_ROUNDS" = "PUNKTFUNK_ClockResync_ROUNDS"
|
||||
"CODEC_AV1" = "PUNKTFUNK_CODEC_AV1"
|
||||
"CODEC_H264" = "PUNKTFUNK_CODEC_H264"
|
||||
"CODEC_HEVC" = "PUNKTFUNK_CODEC_HEVC"
|
||||
"CODEC_PYROWAVE" = "PUNKTFUNK_CODEC_PYROWAVE"
|
||||
"ColorInfo_CP_BT2020" = "PUNKTFUNK_ColorInfo_CP_BT2020"
|
||||
"ColorInfo_CP_BT709" = "PUNKTFUNK_ColorInfo_CP_BT709"
|
||||
"ColorInfo_MC_BT2020_NCL" = "PUNKTFUNK_ColorInfo_MC_BT2020_NCL"
|
||||
"ColorInfo_MC_BT709" = "PUNKTFUNK_ColorInfo_MC_BT709"
|
||||
"ColorInfo_TRC_BT709" = "PUNKTFUNK_ColorInfo_TRC_BT709"
|
||||
"ColorInfo_TRC_HLG" = "PUNKTFUNK_ColorInfo_TRC_HLG"
|
||||
"ColorInfo_TRC_PQ" = "PUNKTFUNK_ColorInfo_TRC_PQ"
|
||||
"CURSOR_RELATIVE_HINT" = "PUNKTFUNK_CURSOR_RELATIVE_HINT"
|
||||
"CURSOR_SHAPE_MAX_SIDE" = "PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE"
|
||||
"CURSOR_STATE_MAGIC" = "PUNKTFUNK_CURSOR_STATE_MAGIC"
|
||||
"CURSOR_VISIBLE" = "PUNKTFUNK_CURSOR_VISIBLE"
|
||||
"FLAG_EOF" = "PUNKTFUNK_FLAG_EOF"
|
||||
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
|
||||
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
|
||||
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
|
||||
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
|
||||
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
|
||||
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
|
||||
"HELLO_NAME_MAX" = "PUNKTFUNK_HELLO_NAME_MAX"
|
||||
"HID_RAW_FEATURE" = "PUNKTFUNK_HID_RAW_FEATURE"
|
||||
"HID_RAW_OUTPUT" = "PUNKTFUNK_HID_RAW_OUTPUT"
|
||||
"HID_REPORT_MAX" = "PUNKTFUNK_HID_REPORT_MAX"
|
||||
"HIDOUT_MAGIC" = "PUNKTFUNK_HIDOUT_MAGIC"
|
||||
"HOST_CAP_AUDIO_RED" = "PUNKTFUNK_HOST_CAP_AUDIO_RED"
|
||||
"HOST_CAP_CLIPBOARD" = "PUNKTFUNK_HOST_CAP_CLIPBOARD"
|
||||
"HOST_CAP_CURSOR" = "PUNKTFUNK_HOST_CAP_CURSOR"
|
||||
"HOST_CAP_GAMEPAD_STATE" = "PUNKTFUNK_HOST_CAP_GAMEPAD_STATE"
|
||||
"HOST_CAP_PEN" = "PUNKTFUNK_HOST_CAP_PEN"
|
||||
"HOST_CAP_TEXT_INPUT" = "PUNKTFUNK_HOST_CAP_TEXT_INPUT"
|
||||
"HOST_TIMING_MAGIC" = "PUNKTFUNK_HOST_TIMING_MAGIC"
|
||||
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
|
||||
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
|
||||
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
|
||||
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
|
||||
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
|
||||
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
|
||||
"MAX_SCALE" = "PUNKTFUNK_MAX_SCALE"
|
||||
"MIC_MAGIC" = "PUNKTFUNK_MIC_MAGIC"
|
||||
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
|
||||
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
|
||||
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
|
||||
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
|
||||
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
|
||||
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
|
||||
"MSG_CLIP_FETCH_HDR" = "PUNKTFUNK_MSG_CLIP_FETCH_HDR"
|
||||
"MSG_CLIP_OFFER" = "PUNKTFUNK_MSG_CLIP_OFFER"
|
||||
"MSG_CLIP_STATE" = "PUNKTFUNK_MSG_CLIP_STATE"
|
||||
"MSG_CLOCK_ECHO" = "PUNKTFUNK_MSG_CLOCK_ECHO"
|
||||
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
|
||||
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
|
||||
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
|
||||
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
|
||||
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
|
||||
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
|
||||
"MSG_PAIR_REQUEST" = "PUNKTFUNK_MSG_PAIR_REQUEST"
|
||||
"MSG_PAIR_RESULT" = "PUNKTFUNK_MSG_PAIR_RESULT"
|
||||
"MSG_PHASE_REPORT" = "PUNKTFUNK_MSG_PHASE_REPORT"
|
||||
"MSG_PROBE_REQUEST" = "PUNKTFUNK_MSG_PROBE_REQUEST"
|
||||
"MSG_PROBE_RESULT" = "PUNKTFUNK_MSG_PROBE_RESULT"
|
||||
"MSG_RECONFIGURE" = "PUNKTFUNK_MSG_RECONFIGURE"
|
||||
"MSG_RECONFIGURED" = "PUNKTFUNK_MSG_RECONFIGURED"
|
||||
"MSG_REQUEST_KEYFRAME" = "PUNKTFUNK_MSG_REQUEST_KEYFRAME"
|
||||
"MSG_RFI_REQUEST" = "PUNKTFUNK_MSG_RFI_REQUEST"
|
||||
"MSG_SET_BITRATE" = "PUNKTFUNK_MSG_SET_BITRATE"
|
||||
"MSG_SHARD_PAYLOAD_ACK" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK"
|
||||
"MSG_SHARD_PAYLOAD_CHANGED" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED"
|
||||
"NO_OUTPUT_KEYFRAME_STREAK" = "PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK"
|
||||
"PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" = "PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE"
|
||||
"PAIR_BOUND_OTHER_CLOSE_CODE" = "PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE"
|
||||
"PAIR_DENIED_CLOSE_CODE" = "PUNKTFUNK_PAIR_DENIED_CLOSE_CODE"
|
||||
"PAIR_NO_IDENTITY_CLOSE_CODE" = "PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE"
|
||||
"PAIR_NOT_ARMED_CLOSE_CODE" = "PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE"
|
||||
"PAIR_RATE_LIMITED_CLOSE_CODE" = "PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE"
|
||||
"PAIR_SUPERSEDED_CLOSE_CODE" = "PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE"
|
||||
"PEN_ANGLE_UNKNOWN" = "PUNKTFUNK_PEN_ANGLE_UNKNOWN"
|
||||
"PEN_BARREL1" = "PUNKTFUNK_PEN_BARREL1"
|
||||
"PEN_BARREL2" = "PUNKTFUNK_PEN_BARREL2"
|
||||
"PEN_BATCH_MAX" = "PUNKTFUNK_PEN_BATCH_MAX"
|
||||
"PEN_DISTANCE_UNKNOWN" = "PUNKTFUNK_PEN_DISTANCE_UNKNOWN"
|
||||
"PEN_IN_RANGE" = "PUNKTFUNK_PEN_IN_RANGE"
|
||||
"PEN_PREDICTED" = "PUNKTFUNK_PEN_PREDICTED"
|
||||
"PEN_SAMPLE_WIRE_LEN" = "PUNKTFUNK_PEN_SAMPLE_WIRE_LEN"
|
||||
"PEN_TILT_UNKNOWN" = "PUNKTFUNK_PEN_TILT_UNKNOWN"
|
||||
"PEN_TOUCH_TIMEOUT_MS" = "PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS"
|
||||
"PEN_TOUCHING" = "PUNKTFUNK_PEN_TOUCHING"
|
||||
"PRESETS" = "PUNKTFUNK_PRESETS"
|
||||
"QUIT_CLOSE_CODE" = "PUNKTFUNK_QUIT_CLOSE_CODE"
|
||||
"REANCHOR_MARKS_TO_LIFT" = "PUNKTFUNK_REANCHOR_MARKS_TO_LIFT"
|
||||
"REJECT_BUSY_CLOSE_CODE" = "PUNKTFUNK_REJECT_BUSY_CLOSE_CODE"
|
||||
"ResyncGuard_MAX_REJECTED_STREAK" = "PUNKTFUNK_ResyncGuard_MAX_REJECTED_STREAK"
|
||||
"RFI_MAX_RANGE" = "PUNKTFUNK_RFI_MAX_RANGE"
|
||||
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
|
||||
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
|
||||
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
|
||||
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
|
||||
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
|
||||
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"
|
||||
"USER_FLAG_CHUNK_ALIGNED" = "PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED"
|
||||
"USER_FLAG_RECOVERY_ANCHOR" = "PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR"
|
||||
"USER_FLAG_RECOVERY_POINT" = "PUNKTFUNK_USER_FLAG_RECOVERY_POINT"
|
||||
"USER_FLAG_SLICE_STREAM" = "PUNKTFUNK_USER_FLAG_SLICE_STREAM"
|
||||
"VIDEO_CAP_10BIT" = "PUNKTFUNK_VIDEO_CAP_10BIT"
|
||||
"VIDEO_CAP_444" = "PUNKTFUNK_VIDEO_CAP_444"
|
||||
"VIDEO_CAP_CHACHA20" = "PUNKTFUNK_VIDEO_CAP_CHACHA20"
|
||||
"VIDEO_CAP_HDR" = "PUNKTFUNK_VIDEO_CAP_HDR"
|
||||
"VIDEO_CAP_HOST_TIMING" = "PUNKTFUNK_VIDEO_CAP_HOST_TIMING"
|
||||
"VIDEO_CAP_MULTI_SLICE" = "PUNKTFUNK_VIDEO_CAP_MULTI_SLICE"
|
||||
"VIDEO_CAP_PROBE_SEQ" = "PUNKTFUNK_VIDEO_CAP_PROBE_SEQ"
|
||||
"VIDEO_CAP_STREAMED_AU" = "PUNKTFUNK_VIDEO_CAP_STREAMED_AU"
|
||||
"WIRE_VERSION" = "PUNKTFUNK_WIRE_VERSION"
|
||||
"WIRE_VERSION_CLOSE_CODE" = "PUNKTFUNK_WIRE_VERSION_CLOSE_CODE"
|
||||
|
||||
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
|
||||
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
|
||||
[enum]
|
||||
|
||||
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
@@ -698,7 +704,10 @@ pub struct PunktfunkHidOutput {
|
||||
/// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
|
||||
pub effect_len: u8,
|
||||
/// Trigger: the raw DualSense trigger parameter block (mode + params).
|
||||
pub effect: [u8; 11],
|
||||
/// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is
|
||||
/// exported precisely so embedders can size their own buffers against it, and it declaring one
|
||||
/// number while the struct it describes hardcoded another was the whole hazard.
|
||||
pub effect: [u8; PUNKTFUNK_HID_EFFECT_MAX as usize],
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
@@ -759,6 +768,17 @@ 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)
|
||||
}
|
||||
@@ -1172,6 +1192,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
|
||||
/// design/pen-tablet-input.md.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 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")]
|
||||
@@ -1186,6 +1225,20 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
|
||||
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
|
||||
// `input::encode_gamepad_arrival` applies).
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
|
||||
);
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
|
||||
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
|
||||
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
|
||||
@@ -1768,6 +1821,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// forward-compatible.
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 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.
|
||||
@@ -2312,6 +2372,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||
/// thread (one puller, may run alongside the other planes' pullers).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
/// `buf` is writable for `buf_len` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||
c: *mut PunktfunkConnection,
|
||||
out_pad: *mut u8,
|
||||
out_kind: *mut u8,
|
||||
out_seq: *mut u32,
|
||||
out_pts_ns: *mut u64,
|
||||
buf: *mut u8,
|
||||
buf_len: usize,
|
||||
timeout_ms: u32,
|
||||
) -> i32 {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return -1,
|
||||
};
|
||||
if buf.is_null() && buf_len != 0 {
|
||||
return -1;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Some(f) => {
|
||||
if f.opus.is_empty() || f.opus.len() > buf_len {
|
||||
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
|
||||
// as loss would synthesize concealment) or doesn't fit — report "nothing
|
||||
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
|
||||
// be undecodable anyway).
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
|
||||
// checked before it is written; `buf` is a caller-owned writable region of
|
||||
// `buf_len` bytes and the copy length was just bounds-checked against it.
|
||||
unsafe {
|
||||
if !out_pad.is_null() {
|
||||
*out_pad = f.pad;
|
||||
}
|
||||
if !out_kind.is_null() {
|
||||
*out_kind = f.kind;
|
||||
}
|
||||
if !out_seq.is_null() {
|
||||
*out_seq = f.seq;
|
||||
}
|
||||
if !out_pts_ns.is_null() {
|
||||
*out_pts_ns = f.pts_ns;
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
|
||||
}
|
||||
f.opus.len() as i32
|
||||
}
|
||||
// `None` folds timeout and closed; the shutdown flag tells them apart so the
|
||||
// embedder's plane loop can exit instead of polling a dead session forever.
|
||||
None if c.inner.is_session_ended() => -1,
|
||||
None => 0,
|
||||
}
|
||||
}));
|
||||
r.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||
/// before. Latest-wins per pad; unknown bits are masked off.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u8,
|
||||
audio_caps: u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
c.inner.set_pad_audio_caps(pad, audio_caps);
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||
@@ -2497,10 +2668,12 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
|
||||
/// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
|
||||
/// shared rumble policy engine instead of forking it (typically called at controller attach).
|
||||
/// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
|
||||
/// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
|
||||
/// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
|
||||
/// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user);
|
||||
/// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller
|
||||
/// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`:
|
||||
/// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
|
||||
/// actuator.
|
||||
/// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that
|
||||
/// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
@@ -4412,3 +4585,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quic"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||
#[test]
|
||||
fn hidout_abi_maps_audio_ctl() {
|
||||
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0x17,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
|
||||
assert_eq!(out.pad, 3);
|
||||
assert_eq!(out.which, 0x17);
|
||||
assert_eq!(out.effect_len, 6);
|
||||
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
|
||||
assert_eq!(out.effect[6..], [0; 5]);
|
||||
// A raw passthrough report still has no C representation (skipped at the pull site).
|
||||
assert!(
|
||||
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: 0,
|
||||
data: vec![0x80],
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,17 @@ 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;
|
||||
@@ -697,6 +708,10 @@ 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 —
|
||||
@@ -708,6 +723,17 @@ 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
|
||||
@@ -2084,6 +2110,100 @@ 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)
|
||||
|
||||
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
|
||||
RfiRequest, RichInput,
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
|
||||
ProbeRequest, RfiRequest, RichInput,
|
||||
};
|
||||
use crate::session::Frame;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{
|
||||
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
|
||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||
use self::planes::{
|
||||
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
|
||||
};
|
||||
use self::probe::ProbeState;
|
||||
use self::pump::run_pump;
|
||||
@@ -122,6 +124,14 @@ pub struct NativeClient {
|
||||
rumble_sched: Arc<rumble::RumbleShared>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Mutex<Receiver<HidOutput>>,
|
||||
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
|
||||
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
|
||||
pad_audio: Mutex<Receiver<PadAudioFrame>>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
|
||||
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
||||
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
||||
@@ -418,6 +428,10 @@ impl NativeClient {
|
||||
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
||||
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (pad_audio_tx, pad_audio_rx) =
|
||||
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
|
||||
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
|
||||
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
|
||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||
let (host_timing_tx, host_timing_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
||||
@@ -459,6 +473,7 @@ impl NativeClient {
|
||||
let clock_offset_w = clock_offset.clone();
|
||||
let decode_lat_w = decode_lat.clone();
|
||||
let live_bitrate_w = live_bitrate.clone();
|
||||
let pad_audio_caps_w = pad_audio_caps.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
@@ -508,6 +523,8 @@ impl NativeClient {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps: pad_audio_caps_w,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -556,6 +573,8 @@ impl NativeClient {
|
||||
rumble: Mutex::new(rumble_rx),
|
||||
rumble_sched,
|
||||
hidout: Mutex::new(hidout_rx),
|
||||
pad_audio: Mutex::new(pad_audio_rx),
|
||||
pad_audio_caps,
|
||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||
host_timing: Mutex::new(host_timing_rx),
|
||||
cursor_shape: Mutex::new(cursor_shape_rx),
|
||||
@@ -1061,6 +1080,33 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
|
||||
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
|
||||
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
|
||||
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
|
||||
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
|
||||
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
|
||||
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
|
||||
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
|
||||
/// puller per the plane contract.
|
||||
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
|
||||
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
|
||||
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
|
||||
/// Call at controller attach, BEFORE the pad's arrival is sent (like
|
||||
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
|
||||
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
|
||||
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
|
||||
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
|
||||
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
|
||||
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
|
||||
slot.store(audio_caps & 0x03, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
||||
/// the host sent for an HDR session; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
||||
|
||||
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
pub(crate) const HIDOUT_QUEUE: usize = 32;
|
||||
|
||||
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
|
||||
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
|
||||
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
|
||||
/// newest frame (the renderer conceals the gap).
|
||||
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
|
||||
|
||||
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
||||
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
||||
pub(crate) const HDR_META_QUEUE: usize = 8;
|
||||
|
||||
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
|
||||
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
|
||||
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
|
||||
// (see [`input_task`]).
|
||||
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
|
||||
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
|
||||
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
|
||||
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||
tokio::spawn(input_task::run(
|
||||
conn.clone(),
|
||||
input_rx,
|
||||
gamepad_snapshots,
|
||||
pad_audio_arrivals,
|
||||
pad_audio_caps,
|
||||
));
|
||||
|
||||
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
||||
// Self-healing latency bound: every frame still queued once this task catches up is standing
|
||||
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
encode_lat.clone(),
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) async fn run(
|
||||
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
||||
rumble_feed: super::super::rumble::RumbleFeed,
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||
@@ -60,22 +61,28 @@ pub(super) async fn run(
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||
// A pad index the client cannot represent is dropped outright, before either
|
||||
// consumer sees it. It used to be waved through: the seq gate was skipped (its
|
||||
// per-pad cursor has no slot for it) and it was handed to the legacy queue,
|
||||
// while the policy engine silently discarded it on its own bounds check — so
|
||||
// "both consumers are fed" below was false for exactly these, and an embedder
|
||||
// draining the queue could be handed an index it would use to subscript its
|
||||
// own per-pad array. The host never emits one; this is malformed or hostile.
|
||||
let idx = u.pad as usize;
|
||||
if idx >= crate::input::MAX_PADS {
|
||||
continue;
|
||||
}
|
||||
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||
let fresh = match u.envelope {
|
||||
Some(env) => {
|
||||
let idx = u.pad as usize;
|
||||
if idx < crate::input::MAX_PADS {
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
true // out-of-range pad (host never sends these): no gate
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
@@ -94,6 +101,11 @@ pub(super) async fn run(
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
|
||||
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
|
||||
let _ = pad_audio_tx.try_send(f);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||
let _ = hdr_meta_tx.try_send(m);
|
||||
|
||||
@@ -15,8 +15,16 @@ pub(super) async fn run(
|
||||
conn: quinn::Connection,
|
||||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
gamepad_snapshots: bool,
|
||||
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
|
||||
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
|
||||
// so unexpected high bits would make it drop the kind declaration entirely.
|
||||
pad_audio: bool,
|
||||
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
|
||||
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
|
||||
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
|
||||
) {
|
||||
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||
use std::sync::atomic::Ordering;
|
||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||
@@ -37,6 +45,28 @@ 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 {
|
||||
@@ -81,30 +111,56 @@ pub(super) async fn run(
|
||||
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
||||
// so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
continue;
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
|
||||
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
|
||||
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
|
||||
// the usual source). Fold event-carried bits into the registry so the re-send
|
||||
// burst keeps them, then send with the negotiation-gated flags word.
|
||||
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_PADS {
|
||||
if ev_caps != 0 {
|
||||
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
|
||||
}
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send
|
||||
// burst so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
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;
|
||||
}
|
||||
}
|
||||
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: idx as u32,
|
||||
flags: arrival_flags(idx),
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
} else {
|
||||
|
||||
@@ -36,6 +36,22 @@ pub const LEGACY_STALE_MS: u64 = 1000;
|
||||
/// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall).
|
||||
const BACKSTOP_LEGACY_MS: u32 = 2000;
|
||||
|
||||
/// The longest lease the engine honours, whatever the envelope claims — the receiver-side mirror of
|
||||
/// the host's own `RUMBLE_TTL_CEIL_MS`.
|
||||
///
|
||||
/// No host built from this tree can exceed it (the `PUNKTFUNK_RUMBLE_TTL_MS` hatch is clamped to
|
||||
/// `[150, 5000]` before it reaches the wire), so this is defence in depth against a third-party or
|
||||
/// modified sender that stamps a long TTL and then wedges its renewal pump while the connection
|
||||
/// stays up. It matters on exactly the platforms that sustain a level for the whole lease: Apple,
|
||||
/// whose renderer deliberately keeps no staleness policy of its own, and a Deck slot, whose
|
||||
/// keepalive re-kicks the actuator until the lease ends. Duration-parameterized embedders (SDL,
|
||||
/// Android) already self-terminate at the clamped backstop.
|
||||
///
|
||||
/// Deliberately NOT `pub`: an embedder has no use for it, and every `pub` const in this crate is
|
||||
/// emitted into `include/punktfunk_core.h` as an UNPREFIXED `#define` — a collision hazard the
|
||||
/// header already has ~170 instances of, and one this has no reason to add to.
|
||||
const MAX_LEASE_MS: u16 = 5_000;
|
||||
|
||||
/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net
|
||||
/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits
|
||||
/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself
|
||||
@@ -53,10 +69,25 @@ pub struct RumbleCommand {
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ActuatorQuirks {
|
||||
/// Re-emit an unchanged non-zero level every this many ms — for actuators whose hardware
|
||||
/// output decays between wire renewals (Steam Deck ≈ 40, macOS DualSense-over-HID BT ≈ 900).
|
||||
/// `0` = no keepalive (the common case).
|
||||
/// output decays between wire renewals. `0` = no keepalive (the common case).
|
||||
///
|
||||
/// The one in-tree producer is the Steam Deck's ≈ 40 ms (`pf-client-core`'s slot open, paired
|
||||
/// with `dedup_jitter`). The macOS DualSense-over-HID Bluetooth decay is NOT served by this
|
||||
/// quirk, though it reads like the obvious second example: the Apple client keeps its own
|
||||
/// ≈ 900 ms keepalive down in `RumbleRenderer` (`RumbleTuning.hidKeepaliveSeconds`) because
|
||||
/// the re-emit has to happen BELOW the command layer. An engine keepalive arrives as a
|
||||
/// command carrying the same levels, and that renderer skips a HID write whose levels are
|
||||
/// unchanged — so the re-emit would be swallowed by the very dedupe it exists to defeat
|
||||
/// (`dedup_jitter` is the Deck's answer to the same problem one layer up).
|
||||
pub keepalive_ms: u16,
|
||||
/// Floor for `backstop_ms` on non-zero commands (Android's `createOneShot` throws on 0).
|
||||
/// Floor for `backstop_ms` on non-zero commands.
|
||||
///
|
||||
/// **No in-tree producer sets this non-zero** — it is reachable only through the C ABI
|
||||
/// (`punktfunk_connection_set_rumble_quirks`), for embedders whose duration-taking API
|
||||
/// rejects short values. The case it was written for is handled elsewhere: Android's
|
||||
/// `createOneShot` does throw on a non-positive duration, but the Kotlin renderer floors the
|
||||
/// duration itself at the call, and that path never declares quirks at all. Kept because it
|
||||
/// is exported ABI, and because a floor belongs here rather than re-invented per embedder.
|
||||
pub min_pulse_ms: u16,
|
||||
/// Alternate the low motor's LSB on keepalive re-emits (imperceptible) so an SDL-class layer
|
||||
/// that no-ops identical values still writes the device — the Deck's dedupe-defeat.
|
||||
@@ -75,8 +106,11 @@ struct PadState {
|
||||
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
|
||||
dirty: bool,
|
||||
next_keepalive: Option<Instant>,
|
||||
/// Current jitter phase (see [`ActuatorQuirks::dedup_jitter`]).
|
||||
jitter: bool,
|
||||
/// The exact value last handed to an embedder. `(0, 0)` ⇔ the engine believes this actuator is
|
||||
/// silent. It replaces a free-running jitter phase because one field answers all three live
|
||||
/// questions: would re-sending this be a no-op device write (the dedupe nudge), is a stop
|
||||
/// redundant, and would the nudge synthesize the reserved stop.
|
||||
last_emit: (u16, u16),
|
||||
quirks: ActuatorQuirks,
|
||||
}
|
||||
|
||||
@@ -88,7 +122,7 @@ impl PadState {
|
||||
legacy_wire: None,
|
||||
dirty: false,
|
||||
next_keepalive: None,
|
||||
jitter: false,
|
||||
last_emit: (0, 0),
|
||||
quirks: ActuatorQuirks {
|
||||
keepalive_ms: 0,
|
||||
min_pulse_ms: 0,
|
||||
@@ -112,6 +146,7 @@ impl PadState {
|
||||
self.legacy_wire = None;
|
||||
self.next_keepalive = None;
|
||||
self.dirty = false;
|
||||
self.last_emit = (0, 0);
|
||||
RumbleCommand {
|
||||
pad,
|
||||
low: 0,
|
||||
@@ -119,6 +154,40 @@ impl PadState {
|
||||
backstop_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the command for the pad's current level, and record what we handed out.
|
||||
///
|
||||
/// On a `dedup_jitter` actuator, re-emitting the value the device last took is a no-op write on
|
||||
/// an SDL-class layer, so the low motor's LSB is nudged. Keying that on `last_emit` rather than
|
||||
/// on a free-running phase is what makes it work on EVERY emit path. Previously the nudge lived
|
||||
/// only in the keepalive branch, so a host renewal — which arrives every `ttl*3/10` ms, 120 ms
|
||||
/// at the 400 ms default and 60 ms at the hatch floor — re-emitted the raw level, collided with
|
||||
/// the last jittered write, was swallowed, AND re-anchored the keepalive. That stretched the
|
||||
/// gap between *distinct* device writes to 80 ms at the default cadence and 100 ms at the
|
||||
/// floor, on an actuator whose quirk declares 40.
|
||||
///
|
||||
/// The nudge is refused when it would synthesize the reserved `(0, 0)` stop. That is level
|
||||
/// `(1, 0)` and only that: `high` must already be 0, and `low ^ 1 == 0` implies `low == 1`.
|
||||
/// There the LSB steps up instead, so the phase still alternates (1 ↔ 3, two parts in 65535)
|
||||
/// and the pad never receives a stop the policy did not order.
|
||||
fn emit(&mut self, pad: u16) -> RumbleCommand {
|
||||
let (mut low, high) = self.level;
|
||||
if self.quirks.dedup_jitter && (low, high) == self.last_emit {
|
||||
let alt = low ^ 1;
|
||||
low = if (alt, high) == (0, 0) {
|
||||
low | 0b10
|
||||
} else {
|
||||
alt
|
||||
};
|
||||
}
|
||||
self.last_emit = (low, high);
|
||||
RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: self.backstop(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy
|
||||
@@ -156,6 +225,8 @@ impl RumbleEngine {
|
||||
p.dirty = true;
|
||||
match ttl_ms {
|
||||
Some(t) => {
|
||||
// Never honour a lease longer than [`MAX_LEASE_MS`], whatever the sender claims.
|
||||
let t = t.min(MAX_LEASE_MS);
|
||||
p.ttl_ms = t;
|
||||
p.legacy_wire = None;
|
||||
p.deadline = if (low, high) != (0, 0) {
|
||||
@@ -214,22 +285,25 @@ impl RumbleEngine {
|
||||
if p.dirty {
|
||||
p.dirty = false;
|
||||
if p.level == (0, 0) {
|
||||
return (Some(p.silence(pad)), None);
|
||||
// Relay a stop only if the actuator is, as far as the engine knows, still
|
||||
// buzzing. A zero on an already-silent pad heals nothing and costs every
|
||||
// embedder a command — Android an unconditional log line plus a binder
|
||||
// `cancel()`. Two senders produce them: the host's deliberate
|
||||
// `RUMBLE_STOP_BURST` re-sends after the first stop already landed, and (behind
|
||||
// `PUNKTFUNK_RUMBLE_ENVELOPE=0`) the legacy flat 500 ms refresh, which re-sends
|
||||
// zeros for every latched pad for the rest of the session. The burst still
|
||||
// heals the case it exists for: a LOST first stop leaves the pad buzzing, so
|
||||
// `last_emit != (0, 0)` and the re-send does emit.
|
||||
if p.last_emit != (0, 0) {
|
||||
return (Some(p.silence(pad)), None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if p.quirks.keepalive_ms > 0 {
|
||||
p.next_keepalive =
|
||||
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
|
||||
}
|
||||
let (low, high) = p.level;
|
||||
return (
|
||||
Some(RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: p.backstop(),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
return (Some(p.emit(pad)), None);
|
||||
}
|
||||
// 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired
|
||||
// or stale pad was silenced before reaching here, so a keepalive can never sustain a
|
||||
@@ -239,20 +313,7 @@ impl RumbleEngine {
|
||||
let due = *p.next_keepalive.get_or_insert(now + ka);
|
||||
if now >= due {
|
||||
p.next_keepalive = Some(now + ka);
|
||||
let (mut low, high) = p.level;
|
||||
if p.quirks.dedup_jitter {
|
||||
p.jitter = !p.jitter;
|
||||
low ^= p.jitter as u16;
|
||||
}
|
||||
return (
|
||||
Some(RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: p.backstop(),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
return (Some(p.emit(pad)), None);
|
||||
}
|
||||
merge_wake(&mut wake, due);
|
||||
}
|
||||
@@ -357,6 +418,22 @@ pub(crate) struct Closed;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Steam Deck's declared quirks — the only shipping actuator with `dedup_jitter`.
|
||||
const DECK: ActuatorQuirks = ActuatorQuirks {
|
||||
keepalive_ms: 40,
|
||||
min_pulse_ms: 0,
|
||||
dedup_jitter: true,
|
||||
};
|
||||
|
||||
/// Drain the engine the way an embedder does: poll until nothing is due.
|
||||
fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> {
|
||||
let mut out = Vec::new();
|
||||
while let (Some(c), _) = e.poll(t) {
|
||||
out.push((c.low, c.high));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ms(v: u64) -> Duration {
|
||||
Duration::from_millis(v)
|
||||
}
|
||||
@@ -527,4 +604,133 @@ mod tests {
|
||||
);
|
||||
assert_eq!(shared.next_command(ms(10)), Err(Closed));
|
||||
}
|
||||
|
||||
/// A host renewal must not repeat the value the device last took, or an SDL-class layer
|
||||
/// swallows the write. Before the jitter moved onto every emit path it lived only in the
|
||||
/// keepalive branch, so each renewal collided with the last jittered write and was deduped.
|
||||
#[test]
|
||||
fn renewal_keeps_the_dedupe_jitter_alternating() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(101, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(80)), vec![(100, 200)]);
|
||||
// The renewal at the 120 ms default cadence: same level, must still be a distinct write.
|
||||
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]);
|
||||
}
|
||||
|
||||
/// Phase-robust version of the same property, at the TTL hatch's 60 ms renewal floor: no two
|
||||
/// consecutive DISTINCT device writes may be further apart than the declared 40 ms cadence.
|
||||
#[test]
|
||||
fn renewal_never_gaps_distinct_writes_at_the_60ms_floor() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
let (mut last, mut last_write, mut worst) = ((0u16, 0u16), 0u64, 0u64);
|
||||
for tick in 0..=360u64 {
|
||||
let t = t0 + ms(tick);
|
||||
if tick % 60 == 0 {
|
||||
e.wire_update(t, 0, 100, 200, Some(400));
|
||||
}
|
||||
for v in drain(&mut e, t) {
|
||||
assert_ne!(v, (0, 0), "a live lease must never emit the stop sentinel");
|
||||
if v != last {
|
||||
worst = worst.max(tick - last_write);
|
||||
last_write = tick;
|
||||
last = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
worst <= 41,
|
||||
"worst distinct-write gap {worst} ms exceeds the 40 ms declared cadence"
|
||||
);
|
||||
}
|
||||
|
||||
/// The nudge must stay behind `dedup_jitter`: an off-by-one amplitude on a default-quirks pad
|
||||
/// would land in Apple's identical-target comparison and Android's one-shot amplitudes.
|
||||
#[test]
|
||||
fn default_quirks_pads_get_the_level_verbatim_on_every_renewal() {
|
||||
let mut e = RumbleEngine::new(); // Apple / Android / plain SDL
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
|
||||
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
|
||||
assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800)));
|
||||
}
|
||||
|
||||
/// Level `(1, 0)` is the one value whose LSB flip is the reserved stop. The nudge steps up
|
||||
/// instead, so the phase still alternates and no stop is invented under a live lease.
|
||||
#[test]
|
||||
fn jitter_never_synthesizes_the_stop_sentinel() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 1, 0, Some(400));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 1, 0, 800)));
|
||||
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 3, 0, 800)));
|
||||
assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 1, 0, 800)));
|
||||
}
|
||||
|
||||
/// A zero for a pad the engine already believes is silent is dropped: it heals nothing and
|
||||
/// costs every embedder a command. The deliberate stop-burst heal is unaffected, because a
|
||||
/// LOST stop leaves the pad buzzing and the re-send therefore does emit.
|
||||
#[test]
|
||||
fn a_redundant_stop_is_dropped_but_the_burst_still_heals_a_lost_one() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
|
||||
// First stop reaches the embedder…
|
||||
e.wire_update(t0 + ms(10), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(10)), vec![(0, 0)]);
|
||||
// …and the burst re-sends behind it are now silent.
|
||||
e.wire_update(t0 + ms(20), 0, 0, 0, Some(0));
|
||||
e.wire_update(t0 + ms(30), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(30)), Vec::new());
|
||||
|
||||
// But if the pad is buzzing (the stop that mattered was lost), a re-send still emits.
|
||||
e.wire_update(t0 + ms(40), 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]);
|
||||
e.wire_update(t0 + ms(50), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]);
|
||||
}
|
||||
|
||||
/// The client bounds the host's lease. `RUMBLE_TTL_CEIL_MS` is sender-side only, so a modified
|
||||
/// or third-party host could otherwise stamp a huge TTL and wedge its pump, leaving Apple and
|
||||
/// the Deck buzzing for the whole of it.
|
||||
#[test]
|
||||
fn an_overlong_lease_is_clamped_to_the_ceiling() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(u16::MAX));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 5000)));
|
||||
// Silenced at the ceiling, not at the 65 s the sender asked for.
|
||||
assert!(e.poll(t0 + ms(MAX_LEASE_MS as u64 - 1)).0.is_none());
|
||||
assert_eq!(
|
||||
e.poll(t0 + ms(MAX_LEASE_MS as u64)).0,
|
||||
Some(cmd(0, 0, 0, 0)),
|
||||
"the lease must end at the ceiling"
|
||||
);
|
||||
}
|
||||
|
||||
/// A v2 envelope carrying `ttl_ms == 0` on a LIVE level. The audit suspected the zero would be
|
||||
/// mistaken for the legacy sentinel in `backstop()`; it cannot, because the expiry check
|
||||
/// preempts the relay branch — the pad silences on the same poll and never reaches a backstop.
|
||||
/// Pinned so that ordering stays load-bearing rather than incidental.
|
||||
#[test]
|
||||
fn a_zero_ttl_envelope_silences_rather_than_taking_the_legacy_backstop() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(0));
|
||||
assert_eq!(
|
||||
e.poll(t0).0,
|
||||
Some(cmd(0, 0, 0, 0)),
|
||||
"a zero-length lease must expire immediately, not emit with a legacy backstop"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::Result;
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{HdrMeta, HidOutput};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
|
||||
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
|
||||
/// closed, so the command API always observes connection teardown.
|
||||
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
|
||||
/// [`NativeClient::next_pad_audio`].
|
||||
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
|
||||
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
|
||||
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
|
||||
|
||||
@@ -64,7 +64,11 @@ pub enum InputKind {
|
||||
GamepadRemove = 13,
|
||||
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||
/// Sent when the client opens a pad slot — before that pad's
|
||||
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, (flags >> 24) as u8)
|
||||
}
|
||||
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||
/// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||
/// it drop the declaration).
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
|
||||
|
||||
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
|
||||
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
|
||||
/// pre-pad-audio wire bytes exactly.
|
||||
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
|
||||
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
|
||||
}
|
||||
|
||||
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
|
||||
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
|
||||
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
|
||||
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
|
||||
/// An old-format word (index only) yields `audio_caps = 0`.
|
||||
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, ((flags >> 8) & 0x03) as u8)
|
||||
}
|
||||
|
||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||
///
|
||||
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
|
||||
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
|
||||
kind: u8,
|
||||
/// LI_CCAP_* bits (0x02 = rumble).
|
||||
capabilities: u16,
|
||||
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
|
||||
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
|
||||
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
|
||||
/// express pad audio and always sets `0`, as does an old client.
|
||||
audio_caps: u8,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -443,6 +480,31 @@ mod tests {
|
||||
assert_eq!((pad, seq), (9, 123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_arrival_flags_roundtrip() {
|
||||
// The capability bits ride bits 8/9; the index stays the low byte.
|
||||
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
|
||||
let flags = encode_gamepad_arrival(pad, caps);
|
||||
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
|
||||
assert_eq!(flags & 0xFF, pad as u32);
|
||||
}
|
||||
assert_eq!(
|
||||
encode_gamepad_arrival(2, 0b11),
|
||||
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
|
||||
// old host) is byte-identical to the plain index, and decodes with caps 0.
|
||||
assert_eq!(encode_gamepad_arrival(5, 0), 5);
|
||||
assert_eq!(decode_gamepad_arrival(5), (5, 0));
|
||||
// Undefined high bits (a future extension) never leak into the index OR the caps.
|
||||
assert_eq!(
|
||||
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
|
||||
(9, 1)
|
||||
);
|
||||
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
|
||||
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_snapshot_roundtrip() {
|
||||
let s = GamepadSnapshot {
|
||||
|
||||
@@ -107,6 +107,10 @@ pub use stats::Stats;
|
||||
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
|
||||
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
|
||||
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
/// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
|
||||
/// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
|
||||
/// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
|
||||
/// unchanged. (Documented late — the bump shipped without its line here.)
|
||||
/// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
|
||||
/// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
|
||||
/// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
|
||||
@@ -120,7 +124,21 @@ pub use stats::Stats;
|
||||
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 14;
|
||||
/// v15: versions the shared rumble policy engine's C surface —
|
||||
/// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
|
||||
/// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
|
||||
/// still read 7 and no bump was made, so every core since has exported them while advertising a
|
||||
/// version that never promised them. That cannot be corrected retroactively — a shipped binary
|
||||
/// 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;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -129,6 +129,167 @@ 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::*;
|
||||
@@ -294,3 +455,178 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,15 @@ 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,
|
||||
@@ -154,6 +163,16 @@ 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
|
||||
@@ -337,6 +356,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_cap_bits_are_distinct() {
|
||||
// The new pad-audio bits pack into the existing caps bytes without colliding with any
|
||||
// taken bit (a collision would silently negotiate an unrelated feature).
|
||||
assert_eq!(
|
||||
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
HOST_CAP_PAD_AUDIO
|
||||
& (HOST_CAP_GAMEPAD_STATE
|
||||
| HOST_CAP_CLIPBOARD
|
||||
| HOST_CAP_TEXT_INPUT
|
||||
| HOST_CAP_CURSOR
|
||||
| HOST_CAP_PEN),
|
||||
0
|
||||
);
|
||||
// Single-bit values (a multi-bit cap would OR neighbours in).
|
||||
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
|
||||
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xD1):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
|
||||
//! cursor state, pad audio.
|
||||
|
||||
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
/// (0xCE, host→client).
|
||||
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
/// host→client).
|
||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
@@ -401,11 +404,22 @@ impl RichInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
|
||||
/// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
|
||||
/// into its report.
|
||||
///
|
||||
/// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
|
||||
/// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
|
||||
/// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
|
||||
/// been bounded on both ends all along.
|
||||
pub const TRIGGER_EFFECT_MAX: usize = 11;
|
||||
|
||||
const HIDOUT_LED: u8 = 0x01;
|
||||
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
|
||||
@@ -431,6 +445,14 @@ pub enum HidOutput {
|
||||
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
|
||||
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
|
||||
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
|
||||
///
|
||||
/// **STAGED SCAFFOLDING — deliberately unreachable today, do not delete.** Nothing on the host
|
||||
/// produces this variant and no client renders it; it codes/decodes and round-trips in tests
|
||||
/// and nothing else. It stays because `HIDOUT_TRACKPAD_HAPTIC` is an allocated tag on a
|
||||
/// SHIPPED wire: removing the variant would not reclaim the tag (a future peer could still
|
||||
/// send it), it would only lose the decoder that keeps such a datagram from being mistaken
|
||||
/// for something else. The producer is the Steam Controller coil path; the renderer is the
|
||||
/// client-side coil write. Wire up either half and this becomes live with no format change.
|
||||
TrackpadHaptic {
|
||||
pad: u8,
|
||||
side: u8,
|
||||
@@ -446,6 +468,16 @@ pub enum HidOutput {
|
||||
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
||||
/// firmware watchdog — a lost datagram heals on the next refresh.
|
||||
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
||||
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
|
||||
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
|
||||
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
|
||||
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
|
||||
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
|
||||
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
|
||||
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
|
||||
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
|
||||
/// state on every output report.
|
||||
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -460,7 +492,7 @@ impl HidOutput {
|
||||
}
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||
out.extend_from_slice(effect);
|
||||
out.extend_from_slice(&effect[..effect.len().min(TRIGGER_EFFECT_MAX)]);
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
@@ -478,6 +510,12 @@ impl HidOutput {
|
||||
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
||||
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
||||
}
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
out.push(HIDOUT_AUDIO_CTL);
|
||||
out.extend_from_slice(&pad.to_le_bytes());
|
||||
out.push(*flags);
|
||||
out.extend_from_slice(raw);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -497,10 +535,17 @@ impl HidOutput {
|
||||
pad: b[2],
|
||||
bits: b[3],
|
||||
}),
|
||||
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
|
||||
// `> 4`, not `>= 4`: a body with no effect bytes at all is malformed, and decoding it
|
||||
// as an EMPTY effect was actively harmful — downstream an empty block is written as an
|
||||
// all-zero trigger report, which is mode 0x00, which RELEASES a held effect. A
|
||||
// truncated datagram could therefore silently cancel the trigger a game was holding.
|
||||
// A genuine "no effect" is a full-length zero block and still decodes fine.
|
||||
HIDOUT_TRIGGER if b.len() > 4 => Some(HidOutput::Trigger {
|
||||
pad: b[2],
|
||||
which: b[3],
|
||||
effect: b[4..].to_vec(),
|
||||
// Bounded like `HidRaw` below: at most the parameter block is kept from the
|
||||
// (attacker-sized) tail.
|
||||
effect: b[4..b.len().min(4 + TRIGGER_EFFECT_MAX)].to_vec(),
|
||||
}),
|
||||
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
|
||||
pad: b[2],
|
||||
@@ -515,6 +560,22 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -773,6 +834,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
|
||||
|
||||
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
|
||||
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
|
||||
|
||||
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
|
||||
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
|
||||
/// against the main audio plane.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PadAudioFrame {
|
||||
/// Gamepad index (the wire pad space, same as rumble/HID-output).
|
||||
pub pad: u8,
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
|
||||
pub kind: u8,
|
||||
pub seq: u32,
|
||||
pub pts_ns: u64,
|
||||
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
|
||||
pub opus: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Pad-audio datagram, host → client:
|
||||
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
|
||||
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
|
||||
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
|
||||
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
|
||||
b.push(PAD_AUDIO_MAGIC);
|
||||
b.push(pad);
|
||||
b.push(kind);
|
||||
b.extend_from_slice(&seq.to_le_bytes());
|
||||
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
b.extend_from_slice(opus);
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
|
||||
/// length bounds every read before it happens).
|
||||
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
|
||||
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
|
||||
return None;
|
||||
}
|
||||
Some(PadAudioFrame {
|
||||
pad: buf[1],
|
||||
kind: buf[2],
|
||||
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
|
||||
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
|
||||
opus: buf[15..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::quic::*;
|
||||
@@ -981,6 +1108,82 @@ mod tests {
|
||||
assert!(decode_rumble_datagram(&d[..6]).is_none());
|
||||
}
|
||||
|
||||
/// `Trigger` is the only variable-length variant that used to be bounded on NEITHER side.
|
||||
/// Pinned here because both halves matter: an over-long effect must be clamped on the way out
|
||||
/// AND on the way in, and a body with no effect bytes must not decode at all.
|
||||
#[test]
|
||||
fn trigger_effect_is_clamped_on_both_encode_and_decode() {
|
||||
// Encode clamps: a caller handing over an over-long block cannot put it on the wire.
|
||||
let long = HidOutput::Trigger {
|
||||
pad: 1,
|
||||
which: 0,
|
||||
effect: vec![0xAB; 200],
|
||||
};
|
||||
let d = long.encode();
|
||||
assert_eq!(
|
||||
d.len(),
|
||||
4 + TRIGGER_EFFECT_MAX,
|
||||
"magic + kind + pad + which + at most the parameter block"
|
||||
);
|
||||
|
||||
// Decode clamps independently of encode — a hostile peer does not use our encoder.
|
||||
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 1, 0];
|
||||
hostile.extend_from_slice(&[0xCD; 500]);
|
||||
match HidOutput::decode(&hostile) {
|
||||
Some(HidOutput::Trigger { effect, .. }) => {
|
||||
assert_eq!(effect.len(), TRIGGER_EFFECT_MAX, "tail is bounded");
|
||||
}
|
||||
other => panic!("expected a clamped Trigger, got {other:?}"),
|
||||
}
|
||||
|
||||
// An exact-length effect survives untouched, and round-trips.
|
||||
let ok = HidOutput::Trigger {
|
||||
pad: 2,
|
||||
which: 1,
|
||||
effect: vec![0x02, 0x90, 0xA0, 0xFF, 0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
assert_eq!(HidOutput::decode(&ok.encode()), Some(ok));
|
||||
}
|
||||
|
||||
/// A body with no effect bytes is malformed and must be REJECTED, not read as an empty effect:
|
||||
/// downstream an empty block becomes an all-zero trigger report, which is mode 0x00 — it
|
||||
/// releases whatever effect the game was holding. A truncated datagram must not do that.
|
||||
#[test]
|
||||
fn a_trigger_with_no_effect_bytes_is_rejected_not_read_as_cancel() {
|
||||
let empty = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0];
|
||||
assert_eq!(HidOutput::decode(&empty), None);
|
||||
|
||||
// One byte of effect is a legitimate short block (consumers zero-pad it) and still decodes.
|
||||
let one = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0, 0x02];
|
||||
assert_eq!(
|
||||
HidOutput::decode(&one),
|
||||
Some(HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 0,
|
||||
effect: vec![0x02]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// `HidRaw`'s bound was already correct on both sides — pinned alongside `Trigger` so the pair
|
||||
/// cannot drift apart again.
|
||||
#[test]
|
||||
fn hid_raw_stays_bounded_on_both_sides() {
|
||||
let long = HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: HID_RAW_OUTPUT,
|
||||
data: vec![0x11; 500],
|
||||
};
|
||||
assert_eq!(long.encode().len(), 4 + HID_REPORT_MAX);
|
||||
|
||||
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_HID_RAW, 0, HID_RAW_FEATURE];
|
||||
hostile.extend_from_slice(&[0x22; 900]);
|
||||
match HidOutput::decode(&hostile) {
|
||||
Some(HidOutput::HidRaw { data, .. }) => assert_eq!(data.len(), HID_REPORT_MAX),
|
||||
other => panic!("expected a clamped HidRaw, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
|
||||
// v2 envelope round-trips seq + ttl.
|
||||
@@ -1180,6 +1383,12 @@ mod tests {
|
||||
f
|
||||
},
|
||||
},
|
||||
// The DS5 audio-control region (haptics-select + speaker volume asserted).
|
||||
HidOutput::AudioCtl {
|
||||
pad: 1,
|
||||
flags: 0b0_0101,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
@@ -1198,6 +1407,92 @@ 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 [
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
||||
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
||||
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF plane codecs,
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xD1 plane codecs,
|
||||
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
|
||||
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
||||
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
|
||||
|
||||
@@ -259,6 +259,17 @@ windows = { version = "0.62", features = [
|
||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||
"Win32_System_Com",
|
||||
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
|
||||
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
|
||||
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
|
||||
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
|
||||
# ACL repair + the devnode's pad-index marker value.
|
||||
"Win32_Media_Audio",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Variant",
|
||||
"Win32_Devices_Properties",
|
||||
"Win32_System_Registry",
|
||||
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
|
||||
@@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||
// `pad-endpoint` devtest.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/pad_endpoint.rs"]
|
||||
pub(crate) mod pad_endpoint;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
|
||||
@@ -143,6 +143,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
}
|
||||
|
||||
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
|
||||
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
|
||||
/// devnode marker, registry-only reads); this is just the per-pass collection.
|
||||
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
renders
|
||||
.iter()
|
||||
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
|
||||
.map(|(_, id)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||
@@ -159,6 +170,10 @@ 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
|
||||
@@ -179,6 +194,7 @@ 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,
|
||||
@@ -245,7 +261,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
if let Some((mic_name, mic_id)) = &wiring.mic_render {
|
||||
if default_render_id().as_deref() == Some(mic_id.as_str()) {
|
||||
// Audible preference = the host_audio plan's loopback pick (real hardware first).
|
||||
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
|
||||
match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render {
|
||||
Some((name, id)) => match set_default_endpoint(&id) {
|
||||
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
|
||||
"default playback was the virtual-mic target — moved it so desktop \
|
||||
@@ -302,8 +318,10 @@ fn park_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default.prev")
|
||||
}
|
||||
|
||||
/// The current default RENDER endpoint id, if any.
|
||||
fn default_render_id() -> Option<String> {
|
||||
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
|
||||
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
|
||||
/// default playback device).
|
||||
pub(crate) fn default_render_id() -> Option<String> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.ok()?
|
||||
.get_default_device(&Direction::Render)
|
||||
@@ -430,11 +448,13 @@ pub(crate) fn restore_default_playback() {
|
||||
}
|
||||
|
||||
/// Open a device by endpoint id, with a name for error context.
|
||||
///
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||
/// docs), so it fails at random on ids that are perfectly valid.
|
||||
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
|
||||
.get_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
|
||||
super::pad_endpoint::open_wasapi_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
@@ -481,8 +501,9 @@ const _: () = {
|
||||
|
||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
|
||||
/// fails.
|
||||
fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default
|
||||
/// through the same machinery.
|
||||
pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(&en, wiring, d) {
|
||||
match judge_default(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(&en, wiring, &nid) {
|
||||
match judge_default(wiring, &nid) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
audio_client.stop_stream().ok();
|
||||
tracing::info!(device = %name,
|
||||
@@ -726,8 +726,11 @@ enum DefaultKind {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
|
||||
let Ok(dev) = en.get_device(id) else {
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
|
||||
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
|
||||
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
|
||||
return DefaultKind::Unknown;
|
||||
};
|
||||
let name = dev.get_friendlyname().unwrap_or_default();
|
||||
@@ -736,7 +739,15 @@ fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str)
|
||||
.mic_render
|
||||
.as_ref()
|
||||
.is_some_and(|(_, mic_id)| mic_id == id);
|
||||
if is_mic || wiring_plan::excluded_from_loopback(&ln) {
|
||||
// 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) {
|
||||
DefaultKind::Dud(name)
|
||||
} else {
|
||||
DefaultKind::Capturable(name)
|
||||
|
||||
@@ -253,25 +253,16 @@ pub(crate) fn install_steam_audio_pair() -> bool {
|
||||
mic || spk
|
||||
}
|
||||
|
||||
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
||||
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
||||
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
||||
/// per-arch `drivers\Windows10\{arch}\` directory.
|
||||
///
|
||||
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
|
||||
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
|
||||
/// inside, which is this function's own business.
|
||||
fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
use windows::core::{s, w, PCWSTR};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
/// Full path of a Steam Remote Play driver INF under Steam's per-arch driver directory
|
||||
/// (`%CommonProgramFiles(x86)%\Steam\drivers\Windows10\{arch}\<inf_name>`), as a NUL-terminated
|
||||
/// UTF-16 buffer. Shared by [`try_install_steam_audio`] and the pad-endpoint provisioning
|
||||
/// ([`super::pad_endpoint`]), which feeds the same INF to `UpdateDriverForPlugAndPlayDevicesW`
|
||||
/// when no installed Steam Streaming Speakers devnode exposes its `oemNN.inf`. `None` when the
|
||||
/// environment expansion fails (existence is the caller's check).
|
||||
pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
|
||||
use windows::Win32::System::LibraryLoader::{
|
||||
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
};
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
|
||||
return false;
|
||||
}
|
||||
// Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
let subdir = "x64";
|
||||
@@ -290,8 +281,33 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
let n =
|
||||
unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) };
|
||||
if n == 0 || n as usize > path.len() {
|
||||
return None;
|
||||
}
|
||||
path.truncate(n as usize); // keeps the NUL
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
||||
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
||||
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
||||
/// per-arch `drivers\Windows10\{arch}\` directory.
|
||||
///
|
||||
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
|
||||
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
|
||||
/// inside, which is this function's own business.
|
||||
fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
use windows::core::{s, w, PCWSTR};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::System::LibraryLoader::{
|
||||
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
};
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(path) = steam_driver_inf_path(inf_name) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot
|
||||
// pick up a planted `newdev.dll` from the working directory. The handle is checked before use.
|
||||
|
||||
@@ -186,6 +186,17 @@ 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
|
||||
@@ -195,8 +206,17 @@ 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)
|
||||
plan_with_formats(
|
||||
renders,
|
||||
captures,
|
||||
mic_want,
|
||||
host_audio,
|
||||
&no_formats,
|
||||
2,
|
||||
pad_renders,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
|
||||
@@ -221,7 +241,20 @@ 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()
|
||||
@@ -422,7 +455,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)"
|
||||
@@ -451,7 +484,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)"
|
||||
@@ -471,7 +504,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)"
|
||||
@@ -488,7 +521,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}");
|
||||
}
|
||||
}
|
||||
@@ -500,7 +533,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");
|
||||
}
|
||||
@@ -518,7 +551,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)"
|
||||
@@ -546,7 +579,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)"
|
||||
@@ -560,7 +593,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());
|
||||
}
|
||||
@@ -576,7 +609,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)",
|
||||
@@ -597,7 +630,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)"
|
||||
@@ -620,7 +653,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)",
|
||||
@@ -642,7 +675,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}");
|
||||
@@ -691,7 +724,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)",
|
||||
@@ -721,7 +754,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)"
|
||||
@@ -737,7 +770,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)"
|
||||
@@ -753,7 +786,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)"
|
||||
@@ -773,8 +806,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());
|
||||
}
|
||||
@@ -792,7 +825,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)");
|
||||
}
|
||||
|
||||
@@ -820,7 +853,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)"
|
||||
@@ -836,7 +869,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)");
|
||||
}
|
||||
@@ -854,7 +887,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)",
|
||||
@@ -877,7 +910,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}");
|
||||
}
|
||||
@@ -892,7 +925,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());
|
||||
}
|
||||
|
||||
@@ -918,7 +951,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}");
|
||||
@@ -929,10 +962,70 @@ mod tests {
|
||||
// anyway), while the Steam pair is the remedy that adds a capturable sink.
|
||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
let msg = describe_no_loopback(&renders, &w);
|
||||
assert!(msg.contains("install Steam"), "{msg}");
|
||||
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
|
||||
}
|
||||
|
||||
/// A stamped pad endpoint is invisible to the plan. Its name carries NO virtual marker — on
|
||||
/// purpose, games must read it as the pad's speaker — so the name rules alone would classify
|
||||
/// it as real hardware and hand it the loopback; only the id exclusion prevents that.
|
||||
/// Measured fact: the wiring plan on the target box already enumerated a stamped endpoint.
|
||||
#[test]
|
||||
fn pad_endpoints_invisible() {
|
||||
let renders = [
|
||||
ep("DualSense Wireless Controller"),
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
];
|
||||
let pads = [renders[0].1.clone()];
|
||||
let w = plan(&renders, &[], None, false, &pads);
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
// Even an operator mic override matching the pad's name must not claim it; with the
|
||||
// pad as the only render endpoint there is honestly no mic target and no loopback.
|
||||
let w = plan(
|
||||
&renders[..1],
|
||||
&[],
|
||||
Some("wireless controller"),
|
||||
false,
|
||||
&pads,
|
||||
);
|
||||
assert!(w.mic_render.is_none());
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
/// The exclusion has to survive the LAST RESORT tier, which this merge introduced alongside
|
||||
/// pad audio. `last_resort` matches on the Steam-Speakers name, but it reads the same
|
||||
/// shadowed `renders`, so a pad can never be reached through it either — otherwise the whole
|
||||
/// desktop mix would be routed into the controller's voice coils.
|
||||
#[test]
|
||||
fn a_pad_is_never_the_last_resort() {
|
||||
// Only the pad and the Steam pair exist; the mic reserves the Streaming Microphone, so
|
||||
// the plan falls all the way through to the last resort.
|
||||
let renders = [
|
||||
ep("DualSense Wireless Controller"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let pads = [renders[0].1.clone()];
|
||||
let w = plan(&renders, &captures, None, false, &pads);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Speakers)",
|
||||
"the last resort must skip the pad"
|
||||
);
|
||||
assert!(w.loopback_last_resort);
|
||||
|
||||
// …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable
|
||||
// rather than falling back onto the coils.
|
||||
let w = plan(&renders[..1], &captures, None, false, &pads);
|
||||
assert!(
|
||||
w.loopback_render.is_none(),
|
||||
"a pad was taken as the last resort"
|
||||
);
|
||||
assert!(!w.loopback_last_resort);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
//! [`KNOWN`] as new forks appear) matched against running processes, registered OS services/units,
|
||||
//! and on-disk install markers. The platform back-ends (`detect/windows.rs`, `detect/linux.rs`)
|
||||
//! provide the raw facts; the matching + rendering here is portable and unit-tested.
|
||||
//!
|
||||
//! **Not every fingerprint is a conflict.** Only a host that is running, or that will start on its
|
||||
//! own, can take the ports or load a second virtual-display driver. A leftover `Program Files`
|
||||
//! folder from an uninstall, a binary on `PATH`, or a service registered but *disabled* clashes
|
||||
//! with nothing — Sunshine's and Apollo's uninstallers both leave their config/log directories
|
||||
//! behind, so treating mere presence as a conflict cries wolf on a machine whose other host is long
|
||||
//! gone. [`Evidence::is_active`] draws that line and [`Detection::is_active`] lifts it to the
|
||||
//! product; the warning surfaces (startup log, `/local/summary` → the web console's conflicts card,
|
||||
//! the `detect-conflicts` exit code) report **only** active detections, while the full report still
|
||||
//! lists the dormant ones as context for support. This matches the installer's own probe
|
||||
//! (`punktfunk-host.iss`'s `StreamHostEnabled`: service start type <= 2), which was narrowed to
|
||||
//! exactly this rule after a dormant Sunshine aborted a `winget install` in the field, and the tray,
|
||||
//! which dropped its always-on warning over a merely-installed Sunshine in `3e782852`.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -73,17 +86,38 @@ impl Product {
|
||||
pub enum Evidence {
|
||||
/// A matching process is running **right now** (process/executable basename).
|
||||
Running { process: String },
|
||||
/// An OS service / systemd unit for the product is registered (installed; may be stopped).
|
||||
Service { name: String },
|
||||
/// An OS service / systemd unit for the product is registered. `autostart` is the load-bearing
|
||||
/// bit: a service that comes up on its own (Windows start type boot/system/automatic; an enabled
|
||||
/// systemd unit) *will* clash, whereas a disabled/manual one is inert until someone starts it by
|
||||
/// hand — at which point the `Running` evidence catches it on the next scan.
|
||||
Service { name: String, autostart: bool },
|
||||
/// Installed on disk — a Program Files directory, a flatpak app id, or a binary on `PATH`.
|
||||
/// Always dormant: files that nothing launches bind no ports.
|
||||
Installed { at: String },
|
||||
}
|
||||
|
||||
impl Evidence {
|
||||
/// Does this observation mean a conflicting host will actually take the ports / load a second
|
||||
/// virtual-display driver? See the module docs — this is the whole false-alarm fix.
|
||||
pub fn is_active(&self) -> bool {
|
||||
match self {
|
||||
Evidence::Running { .. } => true,
|
||||
Evidence::Service { autostart, .. } => *autostart,
|
||||
Evidence::Installed { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> String {
|
||||
match self {
|
||||
Evidence::Running { process } => format!("running now ({process})"),
|
||||
Evidence::Service { name } => format!("service {name}"),
|
||||
Evidence::Service {
|
||||
name,
|
||||
autostart: true,
|
||||
} => format!("service {name} (starts automatically)"),
|
||||
Evidence::Service {
|
||||
name,
|
||||
autostart: false,
|
||||
} => format!("service {name} (disabled/manual — dormant)"),
|
||||
Evidence::Installed { at } => format!("installed at {at}"),
|
||||
}
|
||||
}
|
||||
@@ -105,12 +139,24 @@ impl Detection {
|
||||
.any(|e| matches!(e, Evidence::Running { .. }))
|
||||
}
|
||||
|
||||
/// A compact one-line label for the tray/console summary, e.g. `Sunshine (running)`.
|
||||
/// True when this host is running **or** will start on its own — i.e. the detection is worth
|
||||
/// warning a user about. A product seen only as files on disk or a disabled service is dormant
|
||||
/// and reports `false`; see the module docs.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.evidence.iter().any(Evidence::is_active)
|
||||
}
|
||||
|
||||
/// A compact one-line label for the console summary, e.g. `Sunshine (running)`. The qualifier
|
||||
/// names what was actually observed, so a card built from these labels can never claim a
|
||||
/// dormant install is running.
|
||||
pub fn label(&self) -> String {
|
||||
let name = self.product.label();
|
||||
if self.is_running() {
|
||||
format!("{} (running)", self.product.label())
|
||||
format!("{name} (running)")
|
||||
} else if self.is_active() {
|
||||
format!("{name} (starts automatically)")
|
||||
} else {
|
||||
self.product.label().to_string()
|
||||
format!("{name} (installed, not running)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,28 +271,66 @@ pub fn snapshot() -> &'static [Detection] {
|
||||
SNAPSHOT.get().map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Compact labels for the tray / web-console summary (e.g. `["Sunshine (running)", "Apollo"]`).
|
||||
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
|
||||
detections.iter().map(Detection::label).collect()
|
||||
/// True if any detection is active — the one gate the warning surfaces share (startup log, the
|
||||
/// `detect-conflicts` exit code, the console card).
|
||||
pub fn any_active(detections: &[Detection]) -> bool {
|
||||
detections.iter().any(Detection::is_active)
|
||||
}
|
||||
|
||||
/// A full human-readable report: the blurb + one bullet per detected host with its evidence.
|
||||
/// Empty string when nothing was detected (callers gate on `is_empty()`).
|
||||
/// Compact labels for the web-console summary (e.g. `["Sunshine (running)"]`).
|
||||
///
|
||||
/// **Active detections only.** A dormant leftover (an uninstalled Sunshine's `Program Files` folder,
|
||||
/// a disabled service) is deliberately absent: this feeds the console's conflicts card, which exists
|
||||
/// to explain why clients cannot reach a working-looking host, and files that nothing launches never
|
||||
/// cause that. The full [`render_report`] still lists them for support.
|
||||
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
|
||||
detections
|
||||
.iter()
|
||||
.filter(|d| d.is_active())
|
||||
.map(Detection::label)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A full human-readable report, split by whether the finding can actually clash. Empty string when
|
||||
/// nothing was detected at all (callers gate on `is_empty()`).
|
||||
///
|
||||
/// The dormant section is why this stays verbose where [`summary_labels`] is quiet: when a user asks
|
||||
/// "why does Punktfunk think I have Apollo?", the answer is the exact leftover path, and the report
|
||||
/// says in the same breath that it needs no action.
|
||||
pub fn render_report(detections: &[Detection]) -> String {
|
||||
if detections.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = String::from("Detected another game-streaming host on this machine.\n");
|
||||
s.push_str(UNSUPPORTED_BLURB);
|
||||
s.push_str("\n\nDetected:\n");
|
||||
for d in detections {
|
||||
let bullet = |d: &Detection| {
|
||||
let ev = d
|
||||
.evidence
|
||||
.iter()
|
||||
.map(Evidence::render)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
s.push_str(&format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label()));
|
||||
format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label())
|
||||
};
|
||||
let (active, dormant): (Vec<_>, Vec<_>) = detections.iter().partition(|d| d.is_active());
|
||||
let mut s = String::new();
|
||||
if !active.is_empty() {
|
||||
s.push_str("Detected another game-streaming host on this machine.\n");
|
||||
s.push_str(UNSUPPORTED_BLURB);
|
||||
s.push_str("\n\nDetected:\n");
|
||||
for d in &active {
|
||||
s.push_str(&bullet(d));
|
||||
}
|
||||
}
|
||||
if !dormant.is_empty() {
|
||||
if !active.is_empty() {
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str(
|
||||
"Also present but DORMANT — not running and not set to start on its own, so it clashes \
|
||||
with nothing and needs no action (typically leftovers from an uninstall):\n",
|
||||
);
|
||||
for d in &dormant {
|
||||
s.push_str(&bullet(d));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -275,15 +359,19 @@ mod tests {
|
||||
},
|
||||
Evidence::Service {
|
||||
name: "SunshineService".into(),
|
||||
autostart: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert!(d.is_running());
|
||||
assert!(d.is_active());
|
||||
assert_eq!(d.label(), "Sunshine (running)");
|
||||
}
|
||||
|
||||
/// The field case this split exists for: Apollo uninstalled, its `Program Files` folder left
|
||||
/// behind. Nothing launches it, so it is NOT a conflict and must never reach the console card.
|
||||
#[test]
|
||||
fn installed_only_is_not_running() {
|
||||
fn a_leftover_install_dir_is_dormant_and_never_surfaces() {
|
||||
let d = det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed {
|
||||
@@ -291,42 +379,77 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
assert!(!d.is_running());
|
||||
assert_eq!(d.label(), "Apollo");
|
||||
assert!(!d.is_active(), "files on disk cannot bind a port");
|
||||
assert_eq!(d.label(), "Apollo (installed, not running)");
|
||||
assert!(summary_labels(std::slice::from_ref(&d)).is_empty());
|
||||
assert!(!any_active(&[d]));
|
||||
}
|
||||
|
||||
/// A registered-but-DISABLED service is the other half of the same false alarm: `service_exists`
|
||||
/// used to count it, which disagreed with the installer's `Start <= 2` probe.
|
||||
#[test]
|
||||
fn a_disabled_service_is_dormant_but_an_autostart_one_is_not() {
|
||||
let disabled = det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Service {
|
||||
name: "SunshineService".into(),
|
||||
autostart: false,
|
||||
}],
|
||||
);
|
||||
assert!(!disabled.is_active());
|
||||
assert!(summary_labels(&[disabled]).is_empty());
|
||||
|
||||
let auto = det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Service {
|
||||
name: "SunshineService".into(),
|
||||
autostart: true,
|
||||
}],
|
||||
);
|
||||
assert!(auto.is_active());
|
||||
assert!(!auto.is_running(), "registered to start != started");
|
||||
assert_eq!(auto.label(), "Sunshine (starts automatically)");
|
||||
assert_eq!(
|
||||
summary_labels(&[auto]),
|
||||
vec!["Sunshine (starts automatically)".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_lists_every_product_and_the_blurb() {
|
||||
let report = render_report(&[
|
||||
det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Running {
|
||||
process: "sunshine".into(),
|
||||
}],
|
||||
),
|
||||
det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed {
|
||||
at: "/usr/bin/apollo".into(),
|
||||
}],
|
||||
),
|
||||
]);
|
||||
fn report_separates_active_from_dormant_and_keeps_the_blurb() {
|
||||
let active = det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Running {
|
||||
process: "sunshine".into(),
|
||||
}],
|
||||
);
|
||||
let dormant = det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed {
|
||||
at: "/usr/bin/apollo".into(),
|
||||
}],
|
||||
);
|
||||
let report = render_report(&[active.clone(), dormant.clone()]);
|
||||
assert!(report.contains("UNSUPPORTED"));
|
||||
// The bullets name the PRODUCT and let the evidence speak — `Detection::label`'s qualifier
|
||||
// would only restate what follows the dash ("Sunshine (running) — running now (sunshine)").
|
||||
// The qualifier is for `summary_labels`, which has no evidence text beside it.
|
||||
assert!(report.contains("Sunshine \u{2014} running now (sunshine)"));
|
||||
assert!(report.contains("DORMANT"));
|
||||
assert!(report.contains("Apollo \u{2014} installed at /usr/bin/apollo"));
|
||||
// Only the live one is offered to the console card.
|
||||
assert_eq!(
|
||||
summary_labels(&[
|
||||
det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Running {
|
||||
process: "sunshine".into()
|
||||
}]
|
||||
),
|
||||
det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed { at: "x".into() }]
|
||||
),
|
||||
]),
|
||||
vec!["Sunshine (running)".to_string(), "Apollo".to_string()]
|
||||
summary_labels(&[active, dormant.clone()]),
|
||||
vec!["Sunshine (running)".to_string()]
|
||||
);
|
||||
|
||||
// A dormant-only machine gets the explanatory listing WITHOUT the "unsupported" alarm — the
|
||||
// whole point is that this needs no action.
|
||||
let dormant_only = render_report(&[dormant]);
|
||||
assert!(dormant_only.contains("DORMANT"));
|
||||
assert!(
|
||||
!dormant_only.contains("UNSUPPORTED"),
|
||||
"a leftover folder must not read as an unsupported dual-host setup:\n{dormant_only}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,11 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
for unit in known.linux_units {
|
||||
let file = format!("{unit}.service");
|
||||
if unit_dirs.iter().any(|d| Path::new(d).join(&file).exists()) {
|
||||
ev.push(Evidence::Service { name: file });
|
||||
let autostart = unit_enabled(&file, home.as_deref());
|
||||
ev.push(Evidence::Service {
|
||||
name: file,
|
||||
autostart,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +82,49 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
ev
|
||||
}
|
||||
|
||||
/// Is `unit` (a `<name>.service` filename) **enabled** — i.e. will systemd start it on its own?
|
||||
///
|
||||
/// `systemctl enable` works by symlinking the unit into a target's `.wants`/`.requires` directory,
|
||||
/// so the presence of that link is the enablement fact — readable without spawning `systemctl`
|
||||
/// (this module is deliberately subprocess-free, and the host often runs where `systemctl` output
|
||||
/// would need a bus connection anyway). A unit file that exists but is linked from no target is
|
||||
/// installed-but-inert: nothing starts it at boot, so it clashes with nothing.
|
||||
///
|
||||
/// Scans the `.wants`/`.requires` subdirectories of the drop-in roots systemd actually reads, rather
|
||||
/// than hardcoding `multi-user.target` — a unit pulled in by `graphical.target`, a user
|
||||
/// `default.target`, or any other target is just as enabled.
|
||||
fn unit_enabled(unit: &str, home: Option<&std::ffi::OsStr>) -> bool {
|
||||
let mut roots: Vec<String> = vec![
|
||||
"/etc/systemd/system".into(),
|
||||
"/run/systemd/system".into(),
|
||||
"/usr/lib/systemd/system".into(),
|
||||
"/lib/systemd/system".into(),
|
||||
"/etc/systemd/user".into(),
|
||||
"/usr/lib/systemd/user".into(),
|
||||
];
|
||||
if let Some(h) = home {
|
||||
roots.push(format!("{}/.config/systemd/user", h.to_string_lossy()));
|
||||
}
|
||||
for root in roots {
|
||||
let Ok(entries) = std::fs::read_dir(&root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if !(name.ends_with(".wants") || name.ends_with(".requires")) {
|
||||
continue;
|
||||
}
|
||||
// `symlink_metadata` so a DANGLING link still counts: a link into a target's .wants is
|
||||
// what "enabled" means, and a broken one still says the operator enabled it.
|
||||
if std::fs::symlink_metadata(entry.path().join(unit)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn find_on_path(bin: &str, path: Option<&std::ffi::OsStr>) -> Option<String> {
|
||||
let dirs = path.map(std::env::split_paths).into_iter().flatten();
|
||||
// Always also probe the common bindirs, even if PATH is unset/narrow (e.g. a service context).
|
||||
|
||||
@@ -7,7 +7,7 @@ use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_service::service::ServiceAccess;
|
||||
use windows_service::service::{ServiceAccess, ServiceStartType};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
/// Lowercased executable basenames (without `.exe`) of every running process, via a Toolhelp
|
||||
@@ -49,9 +49,10 @@ pub fn running_processes() -> Vec<String> {
|
||||
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
let mut ev = Vec::new();
|
||||
for svc in known.win_services {
|
||||
if service_exists(svc) {
|
||||
if let Some(autostart) = service_start_type(svc) {
|
||||
ev.push(Evidence::Service {
|
||||
name: (*svc).to_string(),
|
||||
autostart,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,14 +64,35 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
ev
|
||||
}
|
||||
|
||||
/// True if a service by this name is registered with the SCM (running or stopped). Opening it with
|
||||
/// `QUERY_STATUS` fails cleanly when it doesn't exist.
|
||||
fn service_exists(name: &str) -> bool {
|
||||
let Ok(mgr) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
mgr.open_service(name, ServiceAccess::QUERY_STATUS).is_ok()
|
||||
/// `Some(autostart)` if a service by this name is registered with the SCM (running or stopped),
|
||||
/// `None` if it does not exist. Opening it fails cleanly when it doesn't exist.
|
||||
///
|
||||
/// `autostart` mirrors the installer's `StreamHostEnabled` (start type <= 2): only boot/system/auto
|
||||
/// come up on their own, and only a host that comes up can take the GameStream ports. A disabled or
|
||||
/// manual service is dormant — see the module docs on `super`. When the start type cannot be read
|
||||
/// (no `QUERY_CONFIG` right) we report the service as dormant rather than guessing it autostarts:
|
||||
/// the false-alarm this whole split exists to kill is worse than a missed warning, and a host that
|
||||
/// is genuinely up is caught by the process scan regardless of what its service config says.
|
||||
fn service_start_type(name: &str) -> Option<bool> {
|
||||
let mgr = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
|
||||
let svc = mgr
|
||||
.open_service(
|
||||
name,
|
||||
ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
|
||||
)
|
||||
// Fall back to a status-only handle so a service we may not configure still registers as
|
||||
// present (dormant) instead of vanishing from the report entirely.
|
||||
.or_else(|_| mgr.open_service(name, ServiceAccess::QUERY_STATUS))
|
||||
.ok()?;
|
||||
let autostart = svc.query_config().is_ok_and(|c| {
|
||||
matches!(
|
||||
c.start_type,
|
||||
ServiceStartType::AutoStart
|
||||
| ServiceStartType::BootStart
|
||||
| ServiceStartType::SystemStart
|
||||
)
|
||||
});
|
||||
Some(autostart)
|
||||
}
|
||||
|
||||
/// The install directory under any of the Program Files roots, if it exists.
|
||||
|
||||
@@ -384,6 +384,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
index: idx,
|
||||
kind: 2,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
println!(
|
||||
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
||||
@@ -430,6 +431,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
index: idx,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
println!(
|
||||
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
||||
@@ -486,6 +488,119 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
|
||||
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
|
||||
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
|
||||
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
|
||||
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||
use crate::audio::pad_endpoint as pe;
|
||||
let idx: u8 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--index")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
// `--endpoint <id>` drives ANY render endpoint, not just a provisioned pad one. It is the
|
||||
// discriminator between "this process cannot activate anything" and "our endpoint is broken":
|
||||
// aim the same binary at a known-good endpoint and see whether it succeeds there.
|
||||
let endpoint_override: Option<String> = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--endpoint")
|
||||
.nth(1)
|
||||
.cloned();
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("ensure") => {
|
||||
let p = pe::ensure(idx)?;
|
||||
println!(
|
||||
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
|
||||
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("remove") => match pe::find(idx)? {
|
||||
Some(p) => {
|
||||
pe::remove(&p);
|
||||
println!(
|
||||
"pad-endpoint remove: requested removal of {}",
|
||||
p.device_instance
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
// `punktfunk-host pad-endpoint <n> tone [seconds] [hz]` — drive the endpoint directly so
|
||||
// the whole pad-audio chain can be exercised without a game. Without this, every attempt
|
||||
// costs a game launch and a failure does not say which link broke.
|
||||
Some("tone") => {
|
||||
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0);
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
// `find` (a system lookup), NOT `endpoint_for` (the service's in-process
|
||||
// cache): this runs as a separate CLI process and has no cache of its own.
|
||||
let Some(ep) = pe::find(idx)? else {
|
||||
println!(
|
||||
"pad-endpoint tone: no pad-audio devnode for pad {idx} — run \
|
||||
`ensure` first"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
if ep.endpoint_id.is_empty() {
|
||||
println!("pad-endpoint tone: pad {idx} has no endpoint id yet");
|
||||
return Ok(());
|
||||
}
|
||||
ep.endpoint_id
|
||||
}
|
||||
};
|
||||
// `--pair front` drives the pad's SPEAKER instead of the voice coils — the only way to
|
||||
// exercise the speaker kind without a game that renders one.
|
||||
let pair = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--pair")
|
||||
.nth(1)
|
||||
.map_or(pe::TonePair::Back, |s| pe::TonePair::parse(s));
|
||||
println!(
|
||||
"pad-endpoint tone: {hz} Hz into the {} of {endpoint_id} for {secs}s",
|
||||
pair.label()
|
||||
);
|
||||
pe::render_test_tone(&endpoint_id, secs, hz, pair)?;
|
||||
println!(
|
||||
"pad-endpoint tone: done. A connected client with pad audio enabled should have \
|
||||
buzzed; the host log shows whether the gate opened."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// `punktfunk-host pad-endpoint capture [seconds]` — the receiving half of `tone`. Run
|
||||
// both at once to exercise render -> engine -> loopback -> pair routing with no game and
|
||||
// no client attached.
|
||||
Some("capture") => {
|
||||
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => match pe::find(idx)? {
|
||||
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
|
||||
_ => {
|
||||
println!("pad-endpoint capture: pad {idx} has no endpoint — run `ensure`");
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
println!("pad-endpoint capture: listening on {endpoint_id} for {secs}s");
|
||||
pe::capture_probe(&endpoint_id, secs)
|
||||
}
|
||||
Some("status") => pe::print_status(idx),
|
||||
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
|
||||
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
|
||||
///
|
||||
|
||||
@@ -26,6 +26,14 @@ 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),
|
||||
@@ -35,8 +43,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 0700.
|
||||
pf_paths::create_private_dir(&dir).ok();
|
||||
// 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.
|
||||
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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user