Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
8ee224e5db | ||
|
|
e8499e6131 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
ed3d236ab8 | ||
|
|
e726542f96 | ||
|
|
e0427a3bb6 | ||
|
|
02a5bdb965 | ||
|
|
09b9ee8f53 | ||
|
|
43e3c7b69f |
@@ -0,0 +1,95 @@
|
||||
# Move a versionCode that is ALREADY on Google Play between tracks — no rebuild.
|
||||
#
|
||||
# Why this is separate from android.yml: promotion must not rebuild. A rebuild produces a fresh
|
||||
# versionCode (github.run_number) from possibly-newer sources, so it ships something nobody tested;
|
||||
# promoting assigns the byte-identical artifact the testers already ran. Bolting this onto
|
||||
# android.yml would mean an `if:` on all ten of its build steps.
|
||||
#
|
||||
# What it is for:
|
||||
# * promote a tested build up a track (alpha -> production)
|
||||
# * roll production back by re-pointing it at an older versionCode (to_track=production,
|
||||
# version_code=<the good one>, from_track blank)
|
||||
# * halt a rollout (status=halted)
|
||||
#
|
||||
# Defaults are deliberately the safe ones: dry_run starts TRUE, so a mis-typed versionCode
|
||||
# validates and deletes the edit instead of publishing. Flip it to false only when the dry run
|
||||
# printed what you meant.
|
||||
name: android-promote
|
||||
|
||||
# Two concurrent promotions would race on the same Play edit; the loser fails with a stale-edit
|
||||
# error. One at a time, and never cancel one mid-flight — a half-applied track change is worse
|
||||
# than a queued one.
|
||||
concurrency:
|
||||
group: android-promote
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_code:
|
||||
description: 'versionCode already on Play (e.g. 10816)'
|
||||
required: true
|
||||
to_track:
|
||||
description: 'destination track'
|
||||
required: true
|
||||
default: 'production'
|
||||
from_track:
|
||||
description: 'track to verify it is on, then clear (blank = touch nothing else)'
|
||||
required: false
|
||||
default: 'alpha'
|
||||
notes_tag:
|
||||
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
|
||||
required: false
|
||||
default: ''
|
||||
status:
|
||||
description: 'completed (100%) | inProgress (needs user_fraction) | halted | draft'
|
||||
required: true
|
||||
default: 'completed'
|
||||
user_fraction:
|
||||
description: 'staged rollout fraction for inProgress, e.g. 0.2 (blank otherwise)'
|
||||
required: false
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'validate only, publish nothing'
|
||||
required: true
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-24.04
|
||||
# Same image as android.yml purely for python3 + openssl (play-upload.py's only deps); it is
|
||||
# already warm on the runner. Nothing here builds.
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Promote
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
TO_TRACK: ${{ inputs.to_track }}
|
||||
FROM_TRACK: ${{ inputs.from_track }}
|
||||
NOTES_TAG: ${{ inputs.notes_tag }}
|
||||
STATUS: ${{ inputs.status }}
|
||||
USER_FRACTION: ${{ inputs.user_fraction }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -- --package io.unom.punktfunk \
|
||||
--promote "$VERSION_CODE" \
|
||||
--track "$TO_TRACK" --status "$STATUS"
|
||||
# Explicit `if`, not `[ ] && …`: under `sh -e` a false AND-OR list that ends up LAST in
|
||||
# the script aborts the step, and these get reordered.
|
||||
if [ -n "$FROM_TRACK" ]; then set -- "$@" --promote-from "$FROM_TRACK"; fi
|
||||
if [ -n "$USER_FRACTION" ]; then set -- "$@" --user-fraction "$USER_FRACTION"; fi
|
||||
if [ -n "$NOTES_TAG" ]; then
|
||||
NOTES="docs/releases/whatsnew/${NOTES_TAG}.txt"
|
||||
# Fail loudly rather than silently publishing with the PREVIOUS release's text still
|
||||
# showing on the store listing.
|
||||
[ -f "$NOTES" ] || { echo "ERROR: no such notes file: $NOTES"; exit 1; }
|
||||
set -- "$@" --release-notes-file "$NOTES"
|
||||
fi
|
||||
if [ "$DRY_RUN" = "true" ]; then set -- "$@" --no-commit; fi
|
||||
echo "promoting versionCode=$VERSION_CODE -> $TO_TRACK (dry_run=$DRY_RUN)"
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
@@ -34,9 +34,10 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||
# push is canary (Play `internal`).
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
|
||||
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
|
||||
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
|
||||
# reach `alpha` and someone had to promote it by hand in the Console.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -75,6 +76,56 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# FIRST, because it costs a second and everything after it costs ten minutes.
|
||||
#
|
||||
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
|
||||
# show nothing — it carries the PREVIOUS release's text onto this version, so production
|
||||
# users read notes for a build they are not getting. That is the same defect the v0.22.3
|
||||
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
|
||||
# store listing. Failing here also means a missing file cannot leave a half-published
|
||||
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
|
||||
#
|
||||
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
|
||||
# testers costs nothing.
|
||||
- name: Play release notes gate (tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ ! -f "$NOTES" ]; then
|
||||
echo "ERROR: $NOTES does not exist."
|
||||
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
|
||||
echo "phone/TV users). Without it Play reuses the previous release's text."
|
||||
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
|
||||
exit 1
|
||||
fi
|
||||
# A verbatim copy of another release's file is the same bug wearing a hat: the store
|
||||
# listing still describes the wrong build. Cheap to check, and only ever trips on an
|
||||
# actual copy-paste that was never edited.
|
||||
for other in docs/releases/whatsnew/*.txt; do
|
||||
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
|
||||
if cmp -s "$NOTES" "$other"; then
|
||||
echo "ERROR: $NOTES is byte-identical to $other."
|
||||
echo "Write notes describing THIS release, not the one before it."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
|
||||
# the last line of defence (and the only one android-promote.yml gets), but it runs at
|
||||
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
|
||||
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
|
||||
# so `wc -c` would reject a file that is comfortably legal.
|
||||
python3 - "$NOTES" <<'PY'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: {path} is empty.")
|
||||
if len(text) > 500:
|
||||
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
|
||||
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
|
||||
PY
|
||||
|
||||
# Everything below the checkout used to be four download steps (JDK, SDK,
|
||||
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
|
||||
# into the image now. This guard only re-asserts the Android targets so a
|
||||
@@ -122,11 +173,20 @@ jobs:
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
|
||||
# On a tag the gate step above already proved this exists, so the else branch is only
|
||||
# ever the canary path. See docs/releases/README.md.
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ -f "$NOTES" ]; then
|
||||
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
|
||||
fi
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
@@ -199,15 +259,21 @@ jobs:
|
||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||
# promotion to production in the Play console.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
|
||||
#
|
||||
# A tag therefore ships to real users with no further click. Two things keep that honest:
|
||||
# the tag is only pushed once every platform is green, and Play reviews each production
|
||||
# release before it reaches anyone. To ramp instead of going straight to 100%, this is
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
echo "uploading to Play track '$PLAY_TRACK'"
|
||||
python3 clients/android/ci/play-upload.py \
|
||||
--package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
set -- --package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
|
||||
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=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,26 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. When
|
||||
* this is on and the pad is captured, wire rumble for that pad is SUPPRESSED rather than mixed:
|
||||
* the DualSense's firmware treats audio haptics and classic rumble as mutually exclusive, so
|
||||
* the arbitration is a selection. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -243,6 +263,8 @@ class SettingsStore(context: Context) {
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -277,6 +299,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -321,6 +345,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
|
||||
@@ -496,6 +496,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 {
|
||||
|
||||
@@ -6,13 +6,23 @@ Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints G
|
||||
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
|
||||
tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
|
||||
Usage:
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
|
||||
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
|
||||
nothing). Use it to dry-run the credentials/AAB without touching the live track.
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--promote 10816 --promote-from alpha --track production
|
||||
|
||||
Promotion assigns an EXISTING versionCode to another track, so what ships to production is the
|
||||
byte-identical artifact the testers ran — rebuilding would burn a fresh versionCode and ship
|
||||
something nobody has tested. --promote-from additionally asserts the code really is on that track
|
||||
(catches a typo'd versionCode before it reaches production) and empties it in the SAME edit, so
|
||||
the move is atomic: testers are never left pinned to a code that production also serves.
|
||||
|
||||
--no-commit: do insert -> upload/assign -> track-update -> validate, then delete the edit
|
||||
(publishes nothing). Use it to dry-run the credentials/AAB/notes without touching the live track.
|
||||
"""
|
||||
import argparse, base64, json, os, subprocess, sys, tempfile, time
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
@@ -104,18 +114,79 @@ def access_token(sa) -> str:
|
||||
return tok["access_token"]
|
||||
|
||||
|
||||
# Play's "What's new" is capped at 500 characters per language. The cap lives in the Console
|
||||
# (the REST reference does not state it) and the API rejects longer text at commit — i.e. AFTER
|
||||
# the AAB has uploaded — so check it up front and print the actual count.
|
||||
NOTES_MAX = 500
|
||||
|
||||
|
||||
def load_release_notes(path, language):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: release-notes file is empty: {path}")
|
||||
if len(text) > NOTES_MAX:
|
||||
sys.exit(f"ERROR: release notes are {len(text)} chars, Play allows {NOTES_MAX}: {path}")
|
||||
print(f"release notes: {len(text)}/{NOTES_MAX} chars ({language})")
|
||||
return [{"language": language, "text": text}]
|
||||
|
||||
|
||||
def put_track(app, edit, tok, track, version_codes, status, user_fraction=None, notes=None):
|
||||
"""PUT one track. An empty version_codes list clears the track (what promotion does to the
|
||||
track it promoted OUT of)."""
|
||||
release = {"status": status, "versionCodes": [str(v) for v in version_codes]}
|
||||
if user_fraction is not None:
|
||||
release["userFraction"] = user_fraction
|
||||
if notes:
|
||||
release["releaseNotes"] = notes
|
||||
# Clearing a track means "no active releases", not "an empty release".
|
||||
body = {"track": track, "releases": [release] if version_codes else []}
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{track}", token=tok,
|
||||
data=json.dumps(body).encode(), content_type="application/json")
|
||||
|
||||
|
||||
def assert_on_track(app, edit, tok, track, vc):
|
||||
"""Fail before anything is written if --promote names a versionCode that is not actually on
|
||||
the track we claim to be promoting out of."""
|
||||
got = call("GET", f"{app}/edits/{edit}/tracks/{track}", token=tok)
|
||||
live = [c for r in got.get("releases", []) for c in r.get("versionCodes", [])]
|
||||
if str(vc) not in live:
|
||||
sys.exit(f"ERROR: versionCode {vc} is not on track '{track}' (it has: {live or 'nothing'})")
|
||||
print(f"verified versionCode={vc} is live on '{track}'")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--package", required=True)
|
||||
ap.add_argument("--aab", required=True)
|
||||
ap.add_argument("--aab", help="upload this bundle (mutually exclusive with --promote)")
|
||||
ap.add_argument("--promote", type=int, metavar="VERSIONCODE",
|
||||
help="assign an already-uploaded versionCode instead of uploading")
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
ap.add_argument("--release-notes-file", help="Play 'What's new' text (<=500 chars)")
|
||||
ap.add_argument("--release-notes-language", default="en-US")
|
||||
ap.add_argument("--no-commit", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(a.aab):
|
||||
if bool(a.aab) == bool(a.promote):
|
||||
sys.exit("ERROR: pass exactly one of --aab (upload) or --promote (assign an existing code)")
|
||||
if a.promote_from and not a.promote:
|
||||
sys.exit("ERROR: --promote-from only applies to --promote")
|
||||
# inProgress without a fraction is an API error; halted/completed with one is also rejected.
|
||||
if a.status == "inProgress" and a.user_fraction is None:
|
||||
sys.exit("ERROR: --status inProgress requires --user-fraction")
|
||||
if a.user_fraction is not None and not (0 < a.user_fraction < 1):
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
|
||||
sa = load_sa()
|
||||
tok = access_token(sa)
|
||||
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
|
||||
@@ -123,17 +194,25 @@ def main():
|
||||
|
||||
try:
|
||||
edit = call("POST", f"{app}/edits", token=tok)["id"]
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
|
||||
data=json.dumps({"track": a.track,
|
||||
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
|
||||
content_type="application/json")
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
|
||||
if a.promote:
|
||||
vc = a.promote
|
||||
if a.promote_from:
|
||||
assert_on_track(app, edit, tok, a.promote_from, vc)
|
||||
else:
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
print(f"cleared track '{a.promote_from}'")
|
||||
|
||||
if a.no_commit:
|
||||
call("POST", f"{app}/edits/{edit}:validate", token=tok)
|
||||
|
||||
@@ -78,6 +78,34 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index — not at claim time,
|
||||
* because the index does not exist until the first report arrives and the host addresses the
|
||||
* `0xD1` stream by that index. [stop] is called **before** the USB link closes, and must not
|
||||
* return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -111,6 +139,17 @@ class DsCapture(
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||
// joined, so ordering this first is what makes the borrow sound.
|
||||
if (padAudioStarted) {
|
||||
padAudioStarted = false
|
||||
// stop() joins the render thread, so nothing is using the descriptor after it returns
|
||||
// — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
@@ -133,6 +172,42 @@ class DsCapture(
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
// The wire index exists from here on, and the host addresses pad audio by it. Fired on
|
||||
// the link thread, once per capture.
|
||||
if (!padAudioStarted && padAudio != null) {
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd >= 0) {
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before
|
||||
// the renderer takes over — the one check that proves the descriptor, the
|
||||
// interface claim and the write path all work on THIS device, without needing
|
||||
// a host to be streaming. Same convention as debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and
|
||||
// the renderer must not also drive it — two engines on one usbfs
|
||||
// descriptor reap each other's completions, which is precisely the fault
|
||||
// this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
padAudio?.start(it.index, fd)
|
||||
}
|
||||
} else {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
|
||||
@@ -92,6 +92,40 @@ class HidUsbLink(
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||
*
|
||||
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||
* renderer — must open its own.
|
||||
*
|
||||
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||
*
|
||||
* The caller owns the returned connection and must close it.
|
||||
*/
|
||||
fun openAuxConnection(): UsbDeviceConnection? {
|
||||
val dev = device ?: return null
|
||||
return usb.openDevice(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||
*
|
||||
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||
* HID claim untouched.
|
||||
*
|
||||
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||
*/
|
||||
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
|
||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -332,6 +336,46 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -54,6 +54,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
// A pad rendering tier-A audio must never see wire rumble. `DsDevice` sets
|
||||
// `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble write, and that bit
|
||||
// *disables* audio haptics — so one replayed command would silently mute the voice
|
||||
// coils the 0xD1 stream is driving, for the rest of the session. Dropping it here
|
||||
// (rather than in Kotlin) keeps the rule next to the reason, and covers every caller.
|
||||
Ok(cmd) if crate::pad_audio::is_tier_a((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
@@ -156,6 +162,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
//! Pad audio on Android (the 0xD1 plane) — tier A, WP9.
|
||||
//!
|
||||
//! The Android twin of [`pf_client_core::pad_audio`]: drain the host's per-pad DualSense streams,
|
||||
//! Opus-decode haptics (kind 0) and speaker (kind 1), interleave them into the pad's own
|
||||
//! 4-channel layout, and render them on the physical pad.
|
||||
//!
|
||||
//! # Why this needs a USB driver instead of an audio API
|
||||
//!
|
||||
//! Every other client hands the 4-channel stream to the platform's audio graph — WASAPI on
|
||||
//! Windows, PipeWire on Linux, CoreAudio on Apple. **Android has no such option for this device.**
|
||||
//! AOSP's `UsbAlsaManager` carries a hardcoded VID/PID denylist that includes the DualSense
|
||||
//! (`054c:0ce6`), so the kernel enumerates the pad's playback node and the framework then discards
|
||||
//! it: `hasOutput: false`. There is no `AudioDeviceInfo` for `setPreferredDevice` to target, and
|
||||
//! `/dev/snd` is closed to apps by SELinux. Android's own `UsbRequest` API cannot help either — it
|
||||
//! rejects any endpoint that is not bulk or interrupt.
|
||||
//!
|
||||
//! So this path drives the pad's isochronous endpoint directly, through `uac-host` on the file
|
||||
//! descriptor Java already owns. That is measured, not hoped: on a Nothing Phone (3) the claim
|
||||
//! succeeds unprivileged, the gamepad and the pad's microphone both keep working, and the
|
||||
//! underrun-free floor is **4 ms in flight** — including under eight-core load with the SoC in
|
||||
//! severe thermal throttling.
|
||||
//!
|
||||
//! # The firmware exclusivity that shapes everything here
|
||||
//!
|
||||
//! `valid_flag0` bit 1 (`HAPTICS_SELECT`) *disables* audio haptics and selects classic rumble, and
|
||||
//! Linux's `hid-playstation` sets it on every force-feedback update — as does SDL, and as does our
|
||||
//! own [`crate::feedback`] path. **Tier A and tier C are mutually exclusive in the pad's firmware**,
|
||||
//! so a pad rendering this stream must have its wire rumble suppressed rather than mixed. The
|
||||
//! arbitration is a selection, never a blend.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use punktfunk_core::audio::AudioGapTracker;
|
||||
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use punktfunk_core::client::NativeClient;
|
||||
#[cfg(target_os = "android")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(target_os = "android")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_os = "android")]
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(target_os = "android")]
|
||||
use std::time::Duration;
|
||||
|
||||
/// The pad's render layout: 4 interleaved channels — speaker FL/FR on 0/1, the voice coils on
|
||||
/// 2/3. Feeding a 2-channel stream would leave the coils silent rather than fail, which is the
|
||||
/// failure mode most worth not having.
|
||||
const PAD_CHANNELS: usize = 4;
|
||||
|
||||
/// Both plane kinds decode as 48 kHz stereo.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
|
||||
/// Ring ceiling, in sample frames. 60 ms — far above the in-flight depth, because this bounds
|
||||
/// *decoder* backlog when the USB side stalls, not stream latency. Overflow drops the oldest.
|
||||
const MAX_BUFFER_FRAMES: usize = (SAMPLE_RATE as usize / 1000) * 60;
|
||||
|
||||
/// Largest Opus frame this decodes in one call: 120 ms at 48 kHz, the codec's maximum.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const MAX_FRAME_SAMPLES: usize = 5760;
|
||||
|
||||
/// How much audio to keep in flight on the USB endpoint.
|
||||
///
|
||||
/// WP7 measured the underrun-free floor on real hardware at **4 ms** (clean across three sweeps,
|
||||
/// including one under eight-core load with the CPU thermally throttled); 3 ms was marginal and
|
||||
/// 2 ms never survived. 6 ms takes one step of headroom above that floor, because the same
|
||||
/// measurement found isolated transient events roughly once per three seconds that are *not*
|
||||
/// depth-dependent — so the floor is a floor, not a target.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const IN_FLIGHT_MS: u32 = 6;
|
||||
|
||||
// ---- tier-A registry ---------------------------------------------------------------------------
|
||||
|
||||
/// Which wire pad indices are currently rendering tier-A audio, as a bitmask over the 16 wire
|
||||
/// slots.
|
||||
///
|
||||
/// Read on the rumble poll thread and written on the JNI thread, so it is an atomic rather than a
|
||||
/// lock: the reader is on a latency path and must never block behind a start/stop.
|
||||
static TIER_A_PADS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Mark (or clear) a pad as rendering tier-A audio.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn set_tier_a(pad: u8, on: bool) {
|
||||
use std::sync::atomic::Ordering;
|
||||
let bit = 1u32 << (pad & 0x0f);
|
||||
if on {
|
||||
TIER_A_PADS.fetch_or(bit, Ordering::Relaxed);
|
||||
} else {
|
||||
TIER_A_PADS.fetch_and(!bit, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this pad rendering tier-A audio, and therefore forbidden from receiving wire rumble?
|
||||
///
|
||||
/// **This is a firmware constraint, not a preference.** `valid_flag0` bit 1 (`HAPTICS_SELECT`)
|
||||
/// *disables* audio haptics and selects classic rumble, and `DsDevice` sets it on every rumble
|
||||
/// write — as Linux's `hid-playstation` and SDL both do. So a single rumble command reaching a
|
||||
/// tier-A pad silently mutes the voice coils this stream drives, for the rest of the session.
|
||||
/// Tier A and tier C are mutually exclusive **in the pad**: the arbitration selects, never blends.
|
||||
pub(crate) fn is_tier_a(pad: u8) -> bool {
|
||||
TIER_A_PADS.load(std::sync::atomic::Ordering::Relaxed) & (1u32 << (pad & 0x0f)) != 0
|
||||
}
|
||||
|
||||
// ---- the 4-channel mixer ---------------------------------------------------------------------
|
||||
|
||||
/// Interleave the two independent stereo streams into one 4-channel frame stream.
|
||||
///
|
||||
/// The kinds arrive on different cadences (haptics 5 ms, speaker 10 ms), so each has its own
|
||||
/// write cursor and [`pop`](Self::pop) emits everything the further-ahead kind has filled, with
|
||||
/// the lagging or absent kind's pair reading silence. A haptics-only session therefore renders
|
||||
/// the coils with a silent speaker pair, and vice versa, instead of stalling on the missing kind.
|
||||
///
|
||||
/// Samples are `i16` — the DualSense's own wire format — so nothing converts on the hot path.
|
||||
/// Pure logic, unit-tested below; pacing lives in the USB ring downstream.
|
||||
pub(crate) struct QuadMixer {
|
||||
/// Interleaved 4-channel samples; the front is the next frame out. Always
|
||||
/// `ready_frames() * PAD_CHANNELS` long.
|
||||
ring: VecDeque<i16>,
|
||||
/// Per-kind write cursor in FRAMES relative to the ring front, indexed by the wire `kind`.
|
||||
written: [usize; 2],
|
||||
/// Frames dropped to the ceiling — a stalled USB side, visible in the logs.
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
impl QuadMixer {
|
||||
pub(crate) fn new() -> QuadMixer {
|
||||
QuadMixer {
|
||||
ring: VecDeque::new(),
|
||||
written: [0; 2],
|
||||
dropped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one decoded stereo chunk (interleaved L/R) for `kind` at that kind's cursor,
|
||||
/// zero-extending as needed. Both cursors shift together on overflow, so the two kinds can
|
||||
/// never skew relative to one another.
|
||||
pub(crate) fn push(&mut self, kind: u8, stereo: &[i16]) {
|
||||
// Name both kinds rather than defaulting: a kind this build does not know belongs
|
||||
// nowhere in a 4-channel frame, and quietly folding it into the coil pair would render
|
||||
// an unknown stream straight into the actuators.
|
||||
let (k, off) = match kind {
|
||||
PAD_AUDIO_KIND_HAPTICS => (0usize, 2usize),
|
||||
PAD_AUDIO_KIND_SPEAKER => (1usize, 0usize),
|
||||
_ => return,
|
||||
};
|
||||
let frames = stereo.len() / 2;
|
||||
let base = self.written[k];
|
||||
let need = (base + frames) * PAD_CHANNELS;
|
||||
if self.ring.len() < need {
|
||||
self.ring.resize(need, 0);
|
||||
}
|
||||
for (i, fr) in stereo.chunks_exact(2).enumerate() {
|
||||
let at = (base + i) * PAD_CHANNELS + off;
|
||||
self.ring[at] = fr[0];
|
||||
self.ring[at + 1] = fr[1];
|
||||
}
|
||||
self.written[k] = base + frames;
|
||||
let over = self.ready_frames().saturating_sub(MAX_BUFFER_FRAMES);
|
||||
if over > 0 {
|
||||
self.dropped += over as u64;
|
||||
self.drop_front(over);
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames ready to output: the further-ahead kind's cursor.
|
||||
pub(crate) fn ready_frames(&self) -> usize {
|
||||
self.written[0].max(self.written[1])
|
||||
}
|
||||
|
||||
/// Frames discarded to the ceiling since construction.
|
||||
pub(crate) fn dropped_frames(&self) -> u64 {
|
||||
self.dropped
|
||||
}
|
||||
|
||||
/// Append every ready frame (interleaved 4-channel) to `out`; returns the frame count.
|
||||
pub(crate) fn pop(&mut self, out: &mut Vec<i16>) -> usize {
|
||||
let frames = self.ready_frames();
|
||||
let n = frames * PAD_CHANNELS;
|
||||
out.extend(self.ring.drain(..n.min(self.ring.len())));
|
||||
for w in &mut self.written {
|
||||
*w = w.saturating_sub(frames);
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// Throw the ready frames away — no sink to render them on right now.
|
||||
pub(crate) fn discard(&mut self) {
|
||||
let f = self.ready_frames();
|
||||
self.drop_front(f);
|
||||
}
|
||||
|
||||
fn drop_front(&mut self, frames: usize) {
|
||||
let n = (frames * PAD_CHANNELS).min(self.ring.len());
|
||||
self.ring.drain(..n);
|
||||
let f = n / PAD_CHANNELS;
|
||||
for w in &mut self.written {
|
||||
*w = w.saturating_sub(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- decode + packet loss concealment ---------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
/// Per-kind decode state: a stereo 48 kHz Opus decoder, the seq-gap tracker, and the last decoded
|
||||
/// frame size, which is the unit PLC synthesises in.
|
||||
struct KindStream {
|
||||
dec: opus::Decoder,
|
||||
gaps: AudioGapTracker,
|
||||
frame_samples: usize,
|
||||
}
|
||||
|
||||
/// Concealment frames to synthesise before decoding `seq`.
|
||||
///
|
||||
/// Zero until something has decoded, because there is nothing to size the PLC from yet. The
|
||||
/// tracker is fed regardless, so a gap seen before the first real frame cannot resurface later as
|
||||
/// a phantom. Pure, and unit-tested.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
fn plc_frames(gaps: &mut AudioGapTracker, seq: u32, frame_samples: usize) -> u32 {
|
||||
let missing = gaps.missing_before(seq);
|
||||
if frame_samples == 0 {
|
||||
0
|
||||
} else {
|
||||
missing
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the USB sink ------------------------------------------------------------------------------
|
||||
|
||||
/// Everything that talks to the pad. Linux and Android only: `usbfs` is a Linux kernel ABI, and
|
||||
/// this crate also builds as a host cdylib on macOS dev boxes, where the mixer and PLC above still
|
||||
/// compile and still run their tests.
|
||||
#[cfg(target_os = "android")]
|
||||
mod sink {
|
||||
use super::{IN_FLIGHT_MS, PAD_CHANNELS, SAMPLE_RATE};
|
||||
|
||||
/// Open the pad's 4-channel playback stream on a descriptor Java owns.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `fd` must be a live usbfs descriptor from an open `UsbDeviceConnection` that outlives the
|
||||
/// returned device — this **borrows** it and never closes it, because closing is
|
||||
/// `UsbDeviceConnection.close()`'s job and a double close would strand an unrelated
|
||||
/// descriptor much later.
|
||||
pub(super) unsafe fn device(fd: i32) -> usbfs_iso::UsbFsDevice {
|
||||
// SAFETY: forwarded from this function's own contract, which the JNI entry point upholds
|
||||
// by keeping the Java connection open for the lifetime of the renderer thread.
|
||||
unsafe { usbfs_iso::UsbFsDevice::from_borrowed_fd(fd) }
|
||||
}
|
||||
|
||||
/// Find the pad's 4-channel playback stream and open it.
|
||||
///
|
||||
/// Four channels is a hard requirement, not a preference: the voice coils *are* channels 3
|
||||
/// and 4, so a 2-channel alternate setting would open successfully and then render haptics
|
||||
/// into nothing.
|
||||
pub(super) fn open<'d>(
|
||||
dev: &'d usbfs_iso::UsbFsDevice,
|
||||
) -> Result<uac_host::Playback<'d>, uac_host::Error> {
|
||||
let blob = dev.raw_descriptors()?;
|
||||
let function = uac_host::parse(&blob)?;
|
||||
let stream = function
|
||||
.output_streams()
|
||||
.find(|s| usize::from(s.channels()) == PAD_CHANNELS)
|
||||
.ok_or(uac_host::Error::NoAudioFunction)?;
|
||||
|
||||
let opts = uac_host::OpenOptions {
|
||||
depth: usbfs_iso::Depth::Millis(IN_FLIGHT_MS),
|
||||
// One packet per URB: the finest granularity the bus offers, and what WP7 measured
|
||||
// the 4 ms floor with. Packing more multiplies one completion's latency.
|
||||
packets_per_urb: Some(1),
|
||||
// Keep the endpoint fed rather than gapping when the decoder is momentarily late.
|
||||
// A hole in an isochronous stream is silence forever; silence we chose is better.
|
||||
underrun: usbfs_iso::Underrun::FillSilence,
|
||||
..Default::default()
|
||||
};
|
||||
stream.open_with(dev, uac_host::Format::S16Le, SAMPLE_RATE, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the self test ------------------------------------------------------------------------------
|
||||
|
||||
/// Drive the pad directly with a synthetic tone, through **the real client path**.
|
||||
///
|
||||
/// This exists because the two things most likely to be wrong here cannot be unit-tested and are
|
||||
/// invisible without a host: whether the descriptor Kotlin handed over is one this renderer may
|
||||
/// drive exclusively, and whether the interface claim succeeds on this kernel. A standalone
|
||||
/// harness proves neither — it owns its descriptor by construction, which is exactly the condition
|
||||
/// that was violated when this renderer was handed the HID link's fd and the two engines began
|
||||
/// stealing each other's URB completions.
|
||||
///
|
||||
/// Opens the sink the same way [`render`] does and writes a sine into the voice-coil pair, which
|
||||
/// is felt rather than heard. Returns sample frames written, or a negative [`SelfTest`] code.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `fd` must be a live usbfs descriptor whose connection outlives the call, and which **nothing
|
||||
/// else is driving transfers on**.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) unsafe fn self_test(fd: i32, seconds: i32, hz: i32) -> i32 {
|
||||
// SAFETY: the caller's contract.
|
||||
let dev = unsafe { sink::device(fd) };
|
||||
let mut playback = match sink::open(&dev) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::warn!("pad audio self-test: could not open the stream: {e}");
|
||||
return SelfTest::OPEN_FAILED;
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"pad audio self-test: {} ch {} at {} Hz, {} us in flight",
|
||||
playback.channels(),
|
||||
playback.format(),
|
||||
playback.rate(),
|
||||
playback.schedule().in_flight_us()
|
||||
);
|
||||
|
||||
let rate = playback.rate();
|
||||
let channels = playback.channels() as usize;
|
||||
let frames_per_chunk = (rate as usize / 1000).max(1);
|
||||
let mut chunk = vec![0i16; frames_per_chunk * channels];
|
||||
let mut phase = 0.0f32;
|
||||
let step = std::f32::consts::TAU * hz.clamp(20, 500) as f32 / rate as f32;
|
||||
let total = u64::from(rate) * seconds.clamp(1, 30) as u64;
|
||||
let mut written = 0u64;
|
||||
|
||||
while written < total {
|
||||
for frame in chunk.chunks_mut(channels) {
|
||||
let sample = (phase.sin() * 16_384.0) as i16;
|
||||
phase += step;
|
||||
if phase >= std::f32::consts::TAU {
|
||||
phase -= std::f32::consts::TAU;
|
||||
}
|
||||
frame.fill(0);
|
||||
// Channels 2 and 3 are the voice coils; the speaker pair stays silent so a pass is
|
||||
// unambiguously FELT rather than merely audible.
|
||||
for c in 2..channels {
|
||||
frame[c] = sample;
|
||||
}
|
||||
}
|
||||
if let Err(e) = playback.write_interleaved(&chunk) {
|
||||
log::warn!("pad audio self-test: write failed after {written} frames: {e}");
|
||||
return SelfTest::WRITE_FAILED;
|
||||
}
|
||||
written += frames_per_chunk as u64;
|
||||
}
|
||||
let _ = playback.drain(Duration::from_millis(500));
|
||||
|
||||
let stats = playback.stats();
|
||||
log::info!(
|
||||
"pad audio self-test: {} frames, {} urbs, {} underruns, {} short bytes, {} urb errors",
|
||||
playback.frames_written(),
|
||||
stats.urbs_completed,
|
||||
stats.underruns,
|
||||
stats.short_bytes,
|
||||
stats.urb_errors
|
||||
);
|
||||
// Underruns are a producer-pacing property and deliberately NOT a failure here: the question
|
||||
// this answers is whether the client can drive the pad at all. Data reaching the bus is the
|
||||
// pass condition.
|
||||
if stats.urb_errors > 0 || playback.frames_written() == 0 {
|
||||
return SelfTest::NO_DATA;
|
||||
}
|
||||
playback.frames_written().min(i32::MAX as u64) as i32
|
||||
}
|
||||
|
||||
/// Negative results from [`self_test`]. Positive values are sample frames written.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) struct SelfTest;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl SelfTest {
|
||||
/// The claim or stream open failed — the OEM-kernel case, or a descriptor another engine owns.
|
||||
pub(crate) const OPEN_FAILED: i32 = -1;
|
||||
/// The stream opened but a write failed part-way.
|
||||
pub(crate) const WRITE_FAILED: i32 = -2;
|
||||
/// It ran, but nothing reached the bus.
|
||||
pub(crate) const NO_DATA: i32 = -3;
|
||||
}
|
||||
|
||||
// ---- the renderer worker -----------------------------------------------------------------------
|
||||
|
||||
/// A running renderer: the stop flag and the thread, joined on drop.
|
||||
///
|
||||
/// Mirrors [`crate::mic::MicCapture`]'s discipline — dropping the handle is what stops the stream,
|
||||
/// so a session teardown that forgets a step cannot leave a thread writing to a descriptor Java is
|
||||
/// about to close.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) struct PadAudio {
|
||||
pad: u8,
|
||||
stop: Arc<AtomicBool>,
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl Drop for PadAudio {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
// Belt and braces: the thread clears these itself on the way out, but if it died in a way
|
||||
// that skipped that, leaving the pad off wire rumble would cost the user all feedback.
|
||||
set_tier_a(self.pad, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the renderer for a pad whose descriptor Java has handed over.
|
||||
///
|
||||
/// Returns `None` when neither kind is enabled (nothing to render) or the thread will not start.
|
||||
/// **The caller must keep the `UsbDeviceConnection` open until the returned handle is dropped** —
|
||||
/// the renderer borrows the descriptor and never closes it.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn start(
|
||||
client: Arc<NativeClient>,
|
||||
pad: u8,
|
||||
fd: i32,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
) -> Option<PadAudio> {
|
||||
if !haptics && !speaker {
|
||||
return None;
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let join = spawn(client, Arc::clone(&stop), pad, fd, haptics, speaker)?;
|
||||
Some(PadAudio {
|
||||
pad,
|
||||
stop,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the pad-audio renderer — the 0xD1 plane's single consumer on Android.
|
||||
///
|
||||
/// `fd` is the pad's usbfs descriptor from `UsbDeviceConnection.getFileDescriptor()`; the caller
|
||||
/// **must** keep that connection open until [`stop`](AtomicBool) has been observed and the handle
|
||||
/// joined. Returns `None` if the thread could not be started.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn spawn(
|
||||
client: Arc<NativeClient>,
|
||||
stop: Arc<AtomicBool>,
|
||||
pad: u8,
|
||||
fd: i32,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
) -> Option<JoinHandle<()>> {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-pad-audio".into())
|
||||
.spawn(move || run(&client, &stop, pad, fd, haptics, speaker))
|
||||
.map_err(|e| log::warn!("pad-audio thread failed to start: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn run(client: &NativeClient, stop: &AtomicBool, pad: u8, fd: i32, haptics: bool, speaker: bool) {
|
||||
// Ask the scheduler for audio priority. Android does not hand SCHED_FIFO to ordinary app
|
||||
// threads, so -16 (ANDROID_PRIORITY_AUDIO) is the realistic knob — and WP7 measured that it
|
||||
// both applies and is enough to hold the 4 ms floor against eight busy cores.
|
||||
// SAFETY: `setpriority` on the calling thread; no pointers, no shared state.
|
||||
unsafe {
|
||||
libc::setpriority(libc::PRIO_PROCESS, 0, -16);
|
||||
}
|
||||
|
||||
// SAFETY: the caller's contract — the Java connection outlives this thread.
|
||||
let dev = unsafe { sink::device(fd) };
|
||||
// Through a reference, deliberately: `UsbFsDevice` has a `Drop`, and opening the stream in
|
||||
// this same scope would make the borrow outlive the value it borrows.
|
||||
render(&dev, client, stop, pad, haptics, speaker);
|
||||
}
|
||||
|
||||
/// Open the pad's stream and render on it until the session stops or the device goes away.
|
||||
#[cfg(target_os = "android")]
|
||||
fn render(
|
||||
dev: &usbfs_iso::UsbFsDevice,
|
||||
client: &NativeClient,
|
||||
stop: &AtomicBool,
|
||||
pad: u8,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
) {
|
||||
match sink::open(dev) {
|
||||
Ok(mut playback) => {
|
||||
log::info!(
|
||||
"pad audio: pad={pad} {} ch {} at {} Hz, {} us in flight",
|
||||
playback.channels(),
|
||||
playback.format(),
|
||||
playback.rate(),
|
||||
playback.schedule().in_flight_us()
|
||||
);
|
||||
// ONLY NOW commit the trade. Declaring the pad's render capability makes the host
|
||||
// emit 0xD1, and taking the pad off wire rumble is what makes tier A and tier C
|
||||
// mutually exclusive — doing either before the stream is known to open would, on a
|
||||
// kernel that refuses the claim, leave the user with no haptics of any kind.
|
||||
let caps = (if haptics { 0x01 } else { 0 }) | (if speaker { 0x02 } else { 0 });
|
||||
client.set_pad_audio_caps(pad, caps);
|
||||
set_tier_a(pad, true);
|
||||
|
||||
pump(client, stop, haptics, speaker, &mut playback);
|
||||
|
||||
// Give the pad back to wire rumble before this thread goes away.
|
||||
client.set_pad_audio_caps(pad, 0);
|
||||
set_tier_a(pad, false);
|
||||
}
|
||||
Err(e) => {
|
||||
// A kernel that refuses the claim: some OEM kernels do, and there is no app-side fix.
|
||||
// Nothing was declared and nothing was suppressed, so the session simply carries on
|
||||
// at tier C with ordinary rumble — a clean degrade rather than silent total loss.
|
||||
log::warn!("pad audio unavailable on pad {pad}, staying on rumble: {e}");
|
||||
drain_until_stop(client, stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
/// Keep the plane drained without rendering, so a host that is sending 0xD1 does not back up
|
||||
/// against a consumer that never reads.
|
||||
fn drain_until_stop(client: &NativeClient, stop: &AtomicBool) {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if client.next_pad_audio(Duration::from_millis(20)).is_none()
|
||||
&& stop.load(Ordering::Relaxed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The steady state: decode arriving frames, interleave, and hand whole frames to the pad.
|
||||
#[cfg(target_os = "android")]
|
||||
fn pump(
|
||||
client: &NativeClient,
|
||||
stop: &AtomicBool,
|
||||
haptics: bool,
|
||||
speaker: bool,
|
||||
playback: &mut uac_host::Playback<'_>,
|
||||
) {
|
||||
let mut mixer = QuadMixer::new();
|
||||
let mut streams: [Option<KindStream>; 2] = [None, None];
|
||||
let mut pcm: Vec<i16> = Vec::with_capacity(MAX_FRAME_SAMPLES * 2);
|
||||
let mut out: Vec<i16> = Vec::with_capacity(MAX_BUFFER_FRAMES * PAD_CHANNELS);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let Some(frame) = client.next_pad_audio(Duration::from_millis(10)) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// The settings gate each kind independently: haptics off but speaker on is a legitimate
|
||||
// configuration, and the host may still be sending both.
|
||||
let wanted = match frame.kind {
|
||||
PAD_AUDIO_KIND_HAPTICS => haptics,
|
||||
PAD_AUDIO_KIND_SPEAKER => speaker,
|
||||
_ => false,
|
||||
};
|
||||
if !wanted {
|
||||
continue;
|
||||
}
|
||||
|
||||
let k = usize::from(frame.kind).min(1);
|
||||
let st = match &mut streams[k] {
|
||||
Some(s) => s,
|
||||
slot @ None => match opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo) {
|
||||
Ok(dec) => slot.insert(KindStream {
|
||||
dec,
|
||||
gaps: AudioGapTracker::default(),
|
||||
frame_samples: 0,
|
||||
}),
|
||||
Err(e) => {
|
||||
log::warn!("pad audio: no Opus decoder for kind {}: {e}", frame.kind);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Conceal whatever the sequence numbers say is missing, before decoding what arrived.
|
||||
let missing = plc_frames(&mut st.gaps, frame.seq, st.frame_samples);
|
||||
for _ in 0..missing {
|
||||
pcm.resize(st.frame_samples * 2, 0);
|
||||
match st.dec.decode(&[], &mut pcm, false) {
|
||||
Ok(n) => mixer.push(frame.kind, &pcm[..n * 2]),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// An empty payload is DTX silence: the tracker has already accounted for the sequence,
|
||||
// and there is nothing to decode.
|
||||
if !frame.opus.is_empty() {
|
||||
pcm.resize(MAX_FRAME_SAMPLES * 2, 0);
|
||||
match st.dec.decode(&frame.opus, &mut pcm, false) {
|
||||
Ok(n) => {
|
||||
st.frame_samples = n;
|
||||
mixer.push(frame.kind, &pcm[..n * 2]);
|
||||
}
|
||||
Err(e) => log::debug!("pad audio: opus decode failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Hand over whole frames only. `write` stages any remainder internally, so a partial
|
||||
// chunk is never padded with silence mid-stream.
|
||||
out.clear();
|
||||
if mixer.pop(&mut out) > 0 {
|
||||
if let Err(e) = playback.write_interleaved(&out) {
|
||||
if is_fatal(&e) {
|
||||
log::warn!("pad audio: stream lost: {e}");
|
||||
return;
|
||||
}
|
||||
log::debug!("pad audio: write hiccup: {e}");
|
||||
mixer.discard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = playback.drain(Duration::from_millis(100));
|
||||
let stats = playback.stats();
|
||||
log::info!(
|
||||
"pad audio stopped: {} frames, {} underruns, {} short bytes, {} dropped by backlog",
|
||||
playback.frames_written(),
|
||||
stats.underruns,
|
||||
stats.short_bytes,
|
||||
mixer.dropped_frames(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Is this the end of the stream, or just a bad moment?
|
||||
///
|
||||
/// A vanished device is unrecoverable here — the descriptor belongs to a `UsbDeviceConnection`
|
||||
/// that Java must re-open — so the thread exits and the session continues without tier A. Anything
|
||||
/// else is treated as transient.
|
||||
#[cfg(target_os = "android")]
|
||||
fn is_fatal(e: &uac_host::Error) -> bool {
|
||||
matches!(e, uac_host::Error::Transport(t) if t.is_disconnected())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn speaker_lands_on_the_front_pair_and_haptics_on_the_coils() {
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[100, 200]);
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[300, 400]);
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(m.pop(&mut out), 1);
|
||||
// Channels 0/1 are the speaker, 2/3 are the voice coils — the pad's own layout.
|
||||
assert_eq!(out, vec![100, 200, 300, 400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_haptics_only_session_still_renders_with_a_silent_speaker_pair() {
|
||||
// The case that matters most: `pad_speaker = "off"` must not stall the coils waiting for
|
||||
// a kind that will never arrive.
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[7, 8, 9, 10]);
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(m.pop(&mut out), 2);
|
||||
assert_eq!(out, vec![0, 0, 7, 8, 0, 0, 9, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_kinds_never_skew_when_the_ceiling_drops_frames() {
|
||||
let mut m = QuadMixer::new();
|
||||
// Push well past the ceiling on one kind, then a marker on the other. Both cursors must
|
||||
// have moved together, so the marker still lands on the same output frame boundary.
|
||||
let flood = vec![1i16; (MAX_BUFFER_FRAMES + 500) * 2];
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &flood);
|
||||
assert!(m.dropped_frames() > 0);
|
||||
assert_eq!(m.ready_frames(), MAX_BUFFER_FRAMES);
|
||||
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[42, 43]);
|
||||
let mut out = Vec::new();
|
||||
let frames = m.pop(&mut out);
|
||||
assert_eq!(frames, MAX_BUFFER_FRAMES);
|
||||
assert_eq!(out.len(), frames * PAD_CHANNELS);
|
||||
// The speaker sample went to the FRONT of the ring (its cursor was reset with the drop),
|
||||
// not to wherever the flooded kind happened to be.
|
||||
assert_eq!(&out[..4], &[42, 43, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaving_survives_uneven_cadences() {
|
||||
// Haptics arrive at 5 ms and the speaker at 10 ms; popping mid-flight must not lose the
|
||||
// lagging kind's alignment.
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 1, 2, 2]);
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[9, 9]);
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(m.pop(&mut out), 2);
|
||||
assert_eq!(out, vec![9, 9, 1, 1, 0, 0, 2, 2]);
|
||||
|
||||
// Next round: both cursors are back at zero, so a fresh speaker frame aligns with a fresh
|
||||
// haptics frame rather than inheriting the previous round's offset.
|
||||
out.clear();
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[5, 5]);
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[6, 6]);
|
||||
assert_eq!(m.pop(&mut out), 1);
|
||||
assert_eq!(out, vec![5, 5, 6, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_kind_is_dropped_rather_than_rendered_into_the_coils() {
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(9, &[999, 999]);
|
||||
assert_eq!(
|
||||
m.ready_frames(),
|
||||
0,
|
||||
"an unknown kind must not occupy a channel pair"
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2]);
|
||||
assert_eq!(m.pop(&mut out), 1);
|
||||
assert_eq!(out, vec![0, 0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_a_registry_tracks_pads_independently() {
|
||||
// A rumble command reaching a tier-A pad mutes its coils for the session, so this gate
|
||||
// has to be exact rather than approximately right.
|
||||
set_tier_a(3, true);
|
||||
assert!(is_tier_a(3));
|
||||
assert!(!is_tier_a(4));
|
||||
set_tier_a(4, true);
|
||||
assert!(is_tier_a(3) && is_tier_a(4));
|
||||
set_tier_a(3, false);
|
||||
assert!(!is_tier_a(3), "clearing one pad must not clear another");
|
||||
assert!(is_tier_a(4));
|
||||
set_tier_a(4, false);
|
||||
assert!(!is_tier_a(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_a_registry_wraps_the_pad_index_into_the_wire_slot_space() {
|
||||
// The wire pad space is 4 bits; an out-of-range index must not shift the mask into
|
||||
// undefined territory (a shift >= 32 is a panic in debug and garbage in release).
|
||||
set_tier_a(0x1f, true);
|
||||
assert!(is_tier_a(0x0f), "0x1f and 0x0f are the same wire slot");
|
||||
set_tier_a(0x0f, false);
|
||||
assert!(!is_tier_a(0x1f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_empties_without_disturbing_alignment() {
|
||||
let mut m = QuadMixer::new();
|
||||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2, 3, 4]);
|
||||
m.discard();
|
||||
assert_eq!(m.ready_frames(), 0);
|
||||
let mut out = Vec::new();
|
||||
m.push(PAD_AUDIO_KIND_SPEAKER, &[8, 9]);
|
||||
assert_eq!(m.pop(&mut out), 1);
|
||||
assert_eq!(out, vec![8, 9, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_stays_silent_until_something_has_decoded() {
|
||||
let mut g = AudioGapTracker::default();
|
||||
// A gap before the first decode has nothing to size concealment from, and must not be
|
||||
// replayed later as a phantom.
|
||||
assert_eq!(plc_frames(&mut g, 5, 0), 0);
|
||||
assert_eq!(plc_frames(&mut g, 6, 480), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_conceals_a_real_gap_once_a_frame_size_is_known() {
|
||||
let mut g = AudioGapTracker::default();
|
||||
assert_eq!(plc_frames(&mut g, 0, 0), 0);
|
||||
assert_eq!(plc_frames(&mut g, 1, 480), 0);
|
||||
// Sequence 2 and 3 never arrived.
|
||||
assert_eq!(plc_frames(&mut g, 4, 480), 2);
|
||||
}
|
||||
}
|
||||
@@ -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,110 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -188,6 +188,12 @@ mod session_main {
|
||||
if !settings.forward_pad.is_empty() {
|
||||
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||
}
|
||||
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
|
||||
// slots declare their render caps at open time, which happens on attach — after this.
|
||||
gamepad.set_pad_audio_prefs(
|
||||
settings.pad_haptics,
|
||||
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -291,6 +297,11 @@ mod session_main {
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
echo_cancel: settings.echo_cancel,
|
||||
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||
// service learns the same prefs below so tier-A slots declare their render caps
|
||||
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -336,6 +336,9 @@ enum Ctl {
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
|
||||
PadAudioPrefs(u8),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -482,6 +485,18 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::KindOverride(pref));
|
||||
}
|
||||
|
||||
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
|
||||
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
|
||||
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
|
||||
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
|
||||
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
|
||||
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
|
||||
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
|
||||
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
|
||||
let bits = (haptics as u8) | ((speaker as u8) << 1);
|
||||
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -611,6 +626,11 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`, struct offsets 4..=9).
|
||||
/// The 47-byte effect struct is the USB report 0x02 minus its report-id byte, so struct
|
||||
/// offset 4 = report byte 5 (the same −1 shift that maps report offset 11 to
|
||||
/// [`Self::RIGHT_TRIGGER`] = 10 in [`trigger_packet`](Self::trigger_packet)).
|
||||
const AUDIO: usize = 4;
|
||||
const RIGHT_TRIGGER: usize = 10;
|
||||
const LEFT_TRIGGER: usize = 21;
|
||||
const PAD_LIGHTS: usize = 43;
|
||||
@@ -644,6 +664,29 @@ impl Ds5Feedback {
|
||||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||
p
|
||||
}
|
||||
|
||||
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
|
||||
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
|
||||
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
|
||||
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
|
||||
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
|
||||
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
|
||||
fn audio_haptics_packet() -> [u8; 47] {
|
||||
[0u8; 47]
|
||||
}
|
||||
|
||||
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
|
||||
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
|
||||
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
|
||||
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
|
||||
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
|
||||
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
|
||||
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
p[0] = (flags & 0x1E) << 3;
|
||||
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||
@@ -677,6 +720,14 @@ struct Slot {
|
||||
/// close lift a click held across detach/unplug.
|
||||
held_clicks: [bool; 2],
|
||||
last_accel: [i16; 3],
|
||||
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
|
||||
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
|
||||
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
|
||||
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
|
||||
/// disable-bit trap — see [`Worker::render_feedback`]).
|
||||
audio_caps: u8,
|
||||
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
|
||||
rumble_suppressed_logged: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -692,6 +743,8 @@ impl Slot {
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
audio_caps: 0,
|
||||
rumble_suppressed_logged: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,6 +778,10 @@ struct Worker {
|
||||
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||
kind_override: GamepadPref,
|
||||
/// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder
|
||||
/// declares some: tier-A detection then never runs and every arrival stays caps-less.
|
||||
pad_audio_prefs: u8,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -925,11 +982,18 @@ impl Worker {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
Self::set_slot_sensors(&mut slot, true);
|
||||
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
|
||||
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
|
||||
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
// Pad-audio render caps go in FIRST — the core ORs them into this (and
|
||||
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
|
||||
// set (0 for non-tier-A): wire indices are reused within a connection, so
|
||||
// a tier-A slot that closes must not leave its bits behind for the next
|
||||
// pad on the same index (the set_rumble_quirks rule).
|
||||
c.set_pad_audio_caps(index, slot.audio_caps);
|
||||
send(
|
||||
c,
|
||||
InputKind::GamepadArrival,
|
||||
@@ -952,6 +1016,27 @@ impl Worker {
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
if slot.audio_caps != 0 {
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
|
||||
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
|
||||
// "disable audio haptics") whenever its rumble path runs — which
|
||||
// would MUTE the voice coils the 0xD1 stream drives. One effects
|
||||
// packet with those bits CLEARED puts the pad back on audio haptics
|
||||
// ("Leaving emulated rumble bits off will restore audio haptics" —
|
||||
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
|
||||
// render_feedback so SDL never re-arms them.
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
|
||||
}
|
||||
// Hand the pad to the session's renderer worker. Windows correlation
|
||||
// needs the HID interface path; Linux matches the sink by signature.
|
||||
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||
tracing::info!(
|
||||
index,
|
||||
caps = slot.audio_caps,
|
||||
"tier-A DualSense: pad-audio render caps declared"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
id,
|
||||
index,
|
||||
@@ -965,6 +1050,35 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
|
||||
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
|
||||
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
|
||||
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
|
||||
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
|
||||
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
|
||||
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
|
||||
if self.pad_audio_prefs == 0 {
|
||||
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
|
||||
}
|
||||
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
|
||||
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
|
||||
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
|
||||
return 0; // not a DualSense/Edge — no wired check needed
|
||||
}
|
||||
use sdl3::joystick::ConnectionState;
|
||||
let wired = match pad.connection_state() {
|
||||
Ok(ConnectionState::Wired) => true,
|
||||
Ok(ConnectionState::Wireless) => false,
|
||||
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
|
||||
};
|
||||
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
|
||||
self.pad_audio_prefs
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
|
||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||
/// already gone (unplug).
|
||||
@@ -981,6 +1095,11 @@ impl Worker {
|
||||
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
|
||||
}
|
||||
let slot = self.slots.remove(i);
|
||||
if slot.audio_caps != 0 {
|
||||
// Take the pad back from the pad-audio renderer (its device-gone path then
|
||||
// re-correlates — and finds nothing until a tier-A pad registers again).
|
||||
crate::pad_audio::unregister_tier_a(slot.index);
|
||||
}
|
||||
tracing::info!(
|
||||
id = slot.id,
|
||||
index = slot.index,
|
||||
@@ -1269,6 +1388,7 @@ impl Worker {
|
||||
self.refresh_active();
|
||||
}
|
||||
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1540,6 +1660,20 @@ impl Worker {
|
||||
// first; the physical silence backstop is in `close_slot_at`).
|
||||
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
|
||||
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
|
||||
// so a slot with tier-A haptics active never issues wire rumble (the stream
|
||||
// carries the feedback; the game's rumble is in its haptics mix).
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
if !slot.rumble_suppressed_logged {
|
||||
slot.rumble_suppressed_logged = true;
|
||||
tracing::info!(
|
||||
pad = slot.index,
|
||||
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||
}
|
||||
}
|
||||
@@ -1572,6 +1706,17 @@ impl Worker {
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
// The audio-control region of a DS5 output report a game wrote host-side
|
||||
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
|
||||
// pad's effects packet, but only where a tier-A renderer is actually live
|
||||
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
|
||||
// nothing streams to would just mute/blast a future session's start state.
|
||||
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
|
||||
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1586,6 +1731,8 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
| HidOutput::Trigger { pad, .. }
|
||||
| HidOutput::TrackpadHaptic { pad, .. }
|
||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||
// AudioCtl's pad is u16 on the wire; the index space is 0..MAX_PADS end to end.
|
||||
HidOutput::AudioCtl { pad, .. } => *pad as u8,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1609,6 +1756,7 @@ impl Worker {
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
kind_override: GamepadPref::Auto,
|
||||
pad_audio_prefs: 0,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -1944,5 +2092,43 @@ mod slot_tests {
|
||||
}),
|
||||
6
|
||||
);
|
||||
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
|
||||
assert_eq!(
|
||||
hidout_pad(&HidOutput::AudioCtl {
|
||||
pad: 7,
|
||||
flags: 0,
|
||||
raw: [0; 6]
|
||||
}),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
|
||||
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
|
||||
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
|
||||
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
|
||||
#[test]
|
||||
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
|
||||
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
|
||||
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
|
||||
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
|
||||
// bits1..4 (0b1011) → flag0 bits 4..7.
|
||||
assert_eq!(p[0], 0b1011_0000);
|
||||
assert_eq!(
|
||||
p[0] & 0x03,
|
||||
0,
|
||||
"haptics-select must NOT replay into p[0] bits 0/1"
|
||||
);
|
||||
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
|
||||
assert!(p[1..4].iter().all(|&b| b == 0));
|
||||
assert!(p[10..].iter().all(|&b| b == 0));
|
||||
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
|
||||
assert_eq!(p[0], 0);
|
||||
assert_eq!(&p[4..10], &raw);
|
||||
// The tier-A activation packet is the all-clear: every enable bit off — per
|
||||
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
|
||||
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
@@ -481,6 +499,20 @@ fn pump(
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
|
||||
// the settings could render. The output device is opened LAZILY once frames actually
|
||||
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
|
||||
// a session without a wired DualSense costs one idle 10 ms poll loop.
|
||||
let pad_audio_thread = pad_audio_on
|
||||
.then(|| {
|
||||
crate::pad_audio::spawn(
|
||||
connector.clone(),
|
||||
stop.clone(),
|
||||
params.pad_haptics,
|
||||
pad_speaker_on,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||
@@ -1046,6 +1078,9 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = pad_audio_thread {
|
||||
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = clipboard_thread {
|
||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||
}
|
||||
|
||||
@@ -912,6 +912,21 @@ pub struct Settings {
|
||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
||||
#[serde(default)]
|
||||
pub mic_device: String,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
|
||||
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
|
||||
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
|
||||
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
|
||||
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
|
||||
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
|
||||
/// a wired DS5. `default` so pre-existing stores load with it on.
|
||||
#[serde(default = "default_true")]
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
|
||||
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
|
||||
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
|
||||
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
|
||||
#[serde(default = "default_pad_speaker")]
|
||||
pub pad_speaker: String,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||
@@ -943,6 +958,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).
|
||||
@@ -1015,6 +1034,8 @@ impl Default for Settings {
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
mic_device: String::new(),
|
||||
pad_haptics: true,
|
||||
pad_speaker: "pad".into(),
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
|
||||
@@ -10,14 +10,20 @@ use punktfunk_core::quic::HidOutput;
|
||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
||||
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
||||
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
|
||||
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
|
||||
/// fire).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HidoutDedup {
|
||||
led: Option<(u8, u8, u8)>,
|
||||
player_leds: Option<u8>,
|
||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||
trigger: [Option<Vec<u8>>; 2],
|
||||
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
|
||||
audio_ctl: Option<(u8, [u8; 6])>,
|
||||
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
|
||||
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
|
||||
haptics_select_logged: bool,
|
||||
}
|
||||
|
||||
impl HidoutDedup {
|
||||
@@ -60,6 +66,25 @@ impl HidoutDedup {
|
||||
}
|
||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||
HidOutput::TrackpadHaptic { .. } => true,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
let v = Some((*flags, *raw));
|
||||
if self.audio_ctl == v {
|
||||
false
|
||||
} else {
|
||||
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
|
||||
// audio haptics (not plain rumble emulation, whose all-zero audio region
|
||||
// never reaches here) — the trace that tells "the game does audio haptics"
|
||||
// apart from "the client just doesn't render them".
|
||||
if flags & 0x01 != 0 && !self.haptics_select_logged {
|
||||
self.haptics_select_logged = true;
|
||||
tracing::info!(
|
||||
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
|
||||
);
|
||||
}
|
||||
self.audio_ctl = v;
|
||||
true
|
||||
}
|
||||
}
|
||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||
@@ -123,4 +148,28 @@ mod tests {
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(d.should_forward(&trig(0, 2)));
|
||||
}
|
||||
|
||||
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
|
||||
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
|
||||
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
|
||||
#[test]
|
||||
fn audio_ctl_dedups_by_value() {
|
||||
let mut d = HidoutDedup::default();
|
||||
let audio = |flags, vol| HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags,
|
||||
raw: [vol, 0, 0, 0, 0, 0],
|
||||
};
|
||||
// Identical twice → exactly one emission.
|
||||
assert!(d.should_forward(&audio(0x17, 0x50)));
|
||||
assert!(!d.should_forward(&audio(0x17, 0x50)));
|
||||
// Either half changing (flags, or the raw region) forwards again.
|
||||
assert!(d.should_forward(&audio(0x16, 0x50)));
|
||||
assert!(d.should_forward(&audio(0x16, 0x60)));
|
||||
// The other kinds' state is untouched by audio traffic.
|
||||
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }));
|
||||
// `clear` (pad re-plug) re-arms the value dedup.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&audio(0x16, 0x60)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,14 @@ pub const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at by
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date)
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date); bytes 44..46
|
||||
// = update version, kept ABOVE Sony's real releases (0x0630 as of 2026-08) — an older value
|
||||
// makes PlayStation Accessories and libScePad titles demand a firmware update the virtual pad
|
||||
// cannot take ("can't complete the update"), and ≥ 0x0224 is what puts writers on the
|
||||
// COMPATIBLE_VIBRATION2 convention parse_ds_output accepts alongside flag0.
|
||||
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, 0x09, 0x00, 0x00,
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
@@ -477,7 +481,8 @@ pub struct DsFeedback {
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks and the audio-control region are
|
||||
/// forwarded raw for the client.
|
||||
///
|
||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
||||
@@ -494,10 +499,13 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
// 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 0x0154 so the
|
||||
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
|
||||
// otherwise have its rumble — including stops — silently ignored, and a missed stop
|
||||
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
|
||||
// (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;
|
||||
@@ -533,6 +541,21 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
|
||||
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
|
||||
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
|
||||
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
|
||||
// whenever an audio-valid flag is present or the region carries data; downstream dedup
|
||||
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
|
||||
let raw: [u8; 6] = data[5..11].try_into().unwrap();
|
||||
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
|
||||
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
|
||||
fb.hidout.push(HidOutput::AudioCtl {
|
||||
pad: pad.into(),
|
||||
flags,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -835,6 +858,48 @@ mod tests {
|
||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||
}
|
||||
|
||||
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
|
||||
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
|
||||
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
|
||||
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
|
||||
#[test]
|
||||
fn parse_output_surfaces_audio_ctl() {
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
|
||||
data[5] = 0x50; // headphone volume
|
||||
data[6] = 0x60; // speaker volume
|
||||
data[7] = 0x70; // mic volume
|
||||
data[8] = 0x05; // audio routing / enable bits
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(3, &data, &mut fb);
|
||||
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0b1_0111,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
}]
|
||||
);
|
||||
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
|
||||
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
|
||||
// the honest bytes either way.
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[9] = 0x01;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags: 0,
|
||||
raw: [0, 0, 0, 0, 0x01, 0],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
#[test]
|
||||
fn parse_output_rejects_garbage() {
|
||||
|
||||
@@ -475,6 +475,7 @@ mod tests {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
|
||||
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
@@ -759,6 +765,16 @@ impl PunktfunkHidOutput {
|
||||
out.effect_len = 6;
|
||||
}
|
||||
HidOutput::HidRaw { .. } => return None,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
|
||||
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly —
|
||||
// pads are 0..16 (`input::MAX_PADS`) end to end.
|
||||
out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
|
||||
out.pad = *pad as u8;
|
||||
out.which = *flags;
|
||||
out.effect[0..6].copy_from_slice(raw);
|
||||
out.effect_len = 6;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1172,6 +1188,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
|
||||
/// design/pen-tablet-input.md.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x20;
|
||||
|
||||
/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||
/// stream (a real DualSense's voice coils).
|
||||
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||
/// stream.
|
||||
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
@@ -1186,6 +1221,20 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
|
||||
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
|
||||
// `input::encode_gamepad_arrival` applies).
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
|
||||
);
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
|
||||
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
|
||||
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
|
||||
@@ -1768,6 +1817,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// forward-compatible.
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x04;
|
||||
|
||||
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
||||
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
|
||||
@@ -2312,6 +2368,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||
/// thread (one puller, may run alongside the other planes' pullers).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
/// `buf` is writable for `buf_len` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||
c: *mut PunktfunkConnection,
|
||||
out_pad: *mut u8,
|
||||
out_kind: *mut u8,
|
||||
out_seq: *mut u32,
|
||||
out_pts_ns: *mut u64,
|
||||
buf: *mut u8,
|
||||
buf_len: usize,
|
||||
timeout_ms: u32,
|
||||
) -> i32 {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return -1,
|
||||
};
|
||||
if buf.is_null() && buf_len != 0 {
|
||||
return -1;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Some(f) => {
|
||||
if f.opus.is_empty() || f.opus.len() > buf_len {
|
||||
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
|
||||
// as loss would synthesize concealment) or doesn't fit — report "nothing
|
||||
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
|
||||
// be undecodable anyway).
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
|
||||
// checked before it is written; `buf` is a caller-owned writable region of
|
||||
// `buf_len` bytes and the copy length was just bounds-checked against it.
|
||||
unsafe {
|
||||
if !out_pad.is_null() {
|
||||
*out_pad = f.pad;
|
||||
}
|
||||
if !out_kind.is_null() {
|
||||
*out_kind = f.kind;
|
||||
}
|
||||
if !out_seq.is_null() {
|
||||
*out_seq = f.seq;
|
||||
}
|
||||
if !out_pts_ns.is_null() {
|
||||
*out_pts_ns = f.pts_ns;
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
|
||||
}
|
||||
f.opus.len() as i32
|
||||
}
|
||||
// `None` folds timeout and closed; the shutdown flag tells them apart so the
|
||||
// embedder's plane loop can exit instead of polling a dead session forever.
|
||||
None if c.inner.is_session_ended() => -1,
|
||||
None => 0,
|
||||
}
|
||||
}));
|
||||
r.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||
/// before. Latest-wins per pad; unknown bits are masked off.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u8,
|
||||
audio_caps: u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
c.inner.set_pad_audio_caps(pad, audio_caps);
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||
@@ -4408,3 +4575,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quic"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||
#[test]
|
||||
fn hidout_abi_maps_audio_ctl() {
|
||||
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0x17,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
|
||||
assert_eq!(out.pad, 3);
|
||||
assert_eq!(out.which, 0x17);
|
||||
assert_eq!(out.effect_len, 6);
|
||||
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
|
||||
assert_eq!(out.effect[6..], [0; 5]);
|
||||
// A raw passthrough report still has no C representation (skipped at the pull site).
|
||||
assert!(
|
||||
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: 0,
|
||||
data: vec![0x80],
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
|
||||
RfiRequest, RichInput,
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
|
||||
ProbeRequest, RfiRequest, RichInput,
|
||||
};
|
||||
use crate::session::Frame;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{
|
||||
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
|
||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||
use self::planes::{
|
||||
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
|
||||
};
|
||||
use self::probe::ProbeState;
|
||||
use self::pump::run_pump;
|
||||
@@ -122,6 +124,14 @@ pub struct NativeClient {
|
||||
rumble_sched: Arc<rumble::RumbleShared>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Mutex<Receiver<HidOutput>>,
|
||||
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
|
||||
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
|
||||
pad_audio: Mutex<Receiver<PadAudioFrame>>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
|
||||
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
||||
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
||||
@@ -418,6 +428,10 @@ impl NativeClient {
|
||||
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
||||
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (pad_audio_tx, pad_audio_rx) =
|
||||
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
|
||||
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
|
||||
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
|
||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||
let (host_timing_tx, host_timing_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
||||
@@ -459,6 +473,7 @@ impl NativeClient {
|
||||
let clock_offset_w = clock_offset.clone();
|
||||
let decode_lat_w = decode_lat.clone();
|
||||
let live_bitrate_w = live_bitrate.clone();
|
||||
let pad_audio_caps_w = pad_audio_caps.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
@@ -502,6 +517,8 @@ impl NativeClient {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps: pad_audio_caps_w,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -550,6 +567,8 @@ impl NativeClient {
|
||||
rumble: Mutex::new(rumble_rx),
|
||||
rumble_sched,
|
||||
hidout: Mutex::new(hidout_rx),
|
||||
pad_audio: Mutex::new(pad_audio_rx),
|
||||
pad_audio_caps,
|
||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||
host_timing: Mutex::new(host_timing_rx),
|
||||
cursor_shape: Mutex::new(cursor_shape_rx),
|
||||
@@ -1051,6 +1070,33 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
|
||||
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
|
||||
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
|
||||
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
|
||||
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
|
||||
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
|
||||
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
|
||||
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
|
||||
/// puller per the plane contract.
|
||||
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
|
||||
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
|
||||
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
|
||||
/// Call at controller attach, BEFORE the pad's arrival is sent (like
|
||||
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
|
||||
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
|
||||
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
|
||||
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
|
||||
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
|
||||
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
|
||||
slot.store(audio_caps & 0x03, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
||||
/// the host sent for an HDR session; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
||||
|
||||
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
pub(crate) const HIDOUT_QUEUE: usize = 32;
|
||||
|
||||
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
|
||||
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
|
||||
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
|
||||
/// newest frame (the renderer conceals the gap).
|
||||
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
|
||||
|
||||
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
||||
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
||||
pub(crate) const HDR_META_QUEUE: usize = 8;
|
||||
|
||||
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
|
||||
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
|
||||
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
|
||||
// (see [`input_task`]).
|
||||
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
|
||||
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
|
||||
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
|
||||
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||
tokio::spawn(input_task::run(
|
||||
conn.clone(),
|
||||
input_rx,
|
||||
gamepad_snapshots,
|
||||
pad_audio_arrivals,
|
||||
pad_audio_caps,
|
||||
));
|
||||
|
||||
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
||||
// Self-healing latency bound: every frame still queued once this task catches up is standing
|
||||
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
encode_lat.clone(),
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) async fn run(
|
||||
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
||||
rumble_feed: super::super::rumble::RumbleFeed,
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||
@@ -70,6 +71,11 @@ pub(super) async fn run(
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
|
||||
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
|
||||
let _ = pad_audio_tx.try_send(f);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||
let _ = hdr_meta_tx.try_send(m);
|
||||
|
||||
@@ -15,8 +15,16 @@ pub(super) async fn run(
|
||||
conn: quinn::Connection,
|
||||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
gamepad_snapshots: bool,
|
||||
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
|
||||
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
|
||||
// so unexpected high bits would make it drop the kind declaration entirely.
|
||||
pad_audio: bool,
|
||||
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
|
||||
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
|
||||
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
|
||||
) {
|
||||
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||
use std::sync::atomic::Ordering;
|
||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||
@@ -37,6 +45,17 @@ pub(super) async fn run(
|
||||
const ARRIVAL_RESENDS: u8 = 2;
|
||||
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
||||
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
|
||||
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
|
||||
// byte-identical to the plain index — the pre-pad-audio wire.
|
||||
let arrival_flags = |idx: usize| -> u32 {
|
||||
let caps = if pad_audio {
|
||||
pad_audio_caps[idx].load(Ordering::Relaxed)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
crate::input::encode_gamepad_arrival(idx as u8, caps)
|
||||
};
|
||||
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
@@ -81,13 +100,28 @@ pub(super) async fn run(
|
||||
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
||||
// so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
continue;
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
|
||||
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
|
||||
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
|
||||
// the usual source). Fold event-carried bits into the registry so the re-send
|
||||
// burst keeps them, then send with the negotiation-gated flags word.
|
||||
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_PADS {
|
||||
if ev_caps != 0 {
|
||||
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
|
||||
}
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send
|
||||
// burst so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let arr = crate::input::InputEvent {
|
||||
flags: arrival_flags(idx),
|
||||
..ev
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
}
|
||||
@@ -104,7 +138,7 @@ pub(super) async fn run(
|
||||
code: kind as u32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: idx as u32,
|
||||
flags: arrival_flags(idx),
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
} else {
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::Result;
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{HdrMeta, HidOutput};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
|
||||
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
|
||||
/// closed, so the command API always observes connection teardown.
|
||||
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
|
||||
/// [`NativeClient::next_pad_audio`].
|
||||
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
|
||||
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
|
||||
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
|
||||
|
||||
@@ -64,7 +64,11 @@ pub enum InputKind {
|
||||
GamepadRemove = 13,
|
||||
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||
/// Sent when the client opens a pad slot — before that pad's
|
||||
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, (flags >> 24) as u8)
|
||||
}
|
||||
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||
/// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||
/// it drop the declaration).
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
|
||||
|
||||
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
|
||||
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
|
||||
/// pre-pad-audio wire bytes exactly.
|
||||
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
|
||||
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
|
||||
}
|
||||
|
||||
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
|
||||
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
|
||||
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
|
||||
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
|
||||
/// An old-format word (index only) yields `audio_caps = 0`.
|
||||
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, ((flags >> 8) & 0x03) as u8)
|
||||
}
|
||||
|
||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||
///
|
||||
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
|
||||
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
|
||||
kind: u8,
|
||||
/// LI_CCAP_* bits (0x02 = rumble).
|
||||
capabilities: u16,
|
||||
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
|
||||
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
|
||||
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
|
||||
/// express pad audio and always sets `0`, as does an old client.
|
||||
audio_caps: u8,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -443,6 +480,31 @@ mod tests {
|
||||
assert_eq!((pad, seq), (9, 123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_arrival_flags_roundtrip() {
|
||||
// The capability bits ride bits 8/9; the index stays the low byte.
|
||||
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
|
||||
let flags = encode_gamepad_arrival(pad, caps);
|
||||
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
|
||||
assert_eq!(flags & 0xFF, pad as u32);
|
||||
}
|
||||
assert_eq!(
|
||||
encode_gamepad_arrival(2, 0b11),
|
||||
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
|
||||
// old host) is byte-identical to the plain index, and decodes with caps 0.
|
||||
assert_eq!(encode_gamepad_arrival(5, 0), 5);
|
||||
assert_eq!(decode_gamepad_arrival(5), (5, 0));
|
||||
// Undefined high bits (a future extension) never leak into the index OR the caps.
|
||||
assert_eq!(
|
||||
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
|
||||
(9, 1)
|
||||
);
|
||||
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
|
||||
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_snapshot_roundtrip() {
|
||||
let s = GamepadSnapshot {
|
||||
|
||||
@@ -120,7 +120,13 @@ pub use stats::Stats;
|
||||
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 14;
|
||||
/// v15: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||
/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||
/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 15;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -111,6 +111,16 @@ pub const CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// simply ignored — no behavior change in either direction.
|
||||
pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||
/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||
/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||
/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||
/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||
/// precedent, per pad; toward an older or incapable host nothing changes. `0x04` — `0x01` is
|
||||
/// [`CLIENT_CAP_CURSOR`], `0x02` is [`CLIENT_CAP_PHASE_LOCK`].
|
||||
pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x04;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
|
||||
@@ -132,6 +142,17 @@ pub const HOST_CAP_CURSOR: u8 = 0x08;
|
||||
/// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
pub const HOST_CAP_PEN: u8 = 0x10;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||
/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||
/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||
/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||
/// capable client marks its pads' render capabilities on their arrivals
|
||||
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||
/// toward exactly those pads. `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is
|
||||
/// [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state /
|
||||
/// clipboard.
|
||||
pub const HOST_CAP_PAD_AUDIO: u8 = 0x20;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
@@ -314,6 +335,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_cap_bits_are_distinct() {
|
||||
// The new pad-audio bits pack into the existing caps bytes without colliding with any
|
||||
// taken bit (a collision would silently negotiate an unrelated feature).
|
||||
assert_eq!(
|
||||
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
HOST_CAP_PAD_AUDIO
|
||||
& (HOST_CAP_GAMEPAD_STATE
|
||||
| HOST_CAP_CLIPBOARD
|
||||
| HOST_CAP_TEXT_INPUT
|
||||
| HOST_CAP_CURSOR
|
||||
| HOST_CAP_PEN),
|
||||
0
|
||||
);
|
||||
// Single-bit values (a multi-bit cap would OR neighbours in).
|
||||
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
|
||||
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xD1):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
|
||||
//! cursor state, pad audio.
|
||||
|
||||
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
/// (0xCE, host→client).
|
||||
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
/// host→client).
|
||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
@@ -332,6 +335,7 @@ const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
const HIDOUT_HID_RAW: u8 = 0x05;
|
||||
const HIDOUT_AUDIO_CTL: u8 = 0x06;
|
||||
|
||||
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||
@@ -372,6 +376,16 @@ pub enum HidOutput {
|
||||
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
||||
/// firmware watchdog — a lost datagram heals on the next refresh.
|
||||
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
||||
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
|
||||
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
|
||||
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
|
||||
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
|
||||
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
|
||||
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
|
||||
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
|
||||
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
|
||||
/// state on every output report.
|
||||
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -404,6 +418,12 @@ impl HidOutput {
|
||||
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
||||
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
||||
}
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
out.push(HIDOUT_AUDIO_CTL);
|
||||
out.extend_from_slice(&pad.to_le_bytes());
|
||||
out.push(*flags);
|
||||
out.extend_from_slice(raw);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -441,6 +461,11 @@ impl HidOutput {
|
||||
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
|
||||
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
|
||||
}),
|
||||
HIDOUT_AUDIO_CTL if b.len() >= 11 => Some(HidOutput::AudioCtl {
|
||||
pad: u16::from_le_bytes([b[2], b[3]]),
|
||||
flags: b[4],
|
||||
raw: b[5..11].try_into().unwrap(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -699,6 +724,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
|
||||
|
||||
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
|
||||
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
|
||||
|
||||
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
|
||||
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
|
||||
/// against the main audio plane.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PadAudioFrame {
|
||||
/// Gamepad index (the wire pad space, same as rumble/HID-output).
|
||||
pub pad: u8,
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
|
||||
pub kind: u8,
|
||||
pub seq: u32,
|
||||
pub pts_ns: u64,
|
||||
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
|
||||
pub opus: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Pad-audio datagram, host → client:
|
||||
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
|
||||
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
|
||||
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
|
||||
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
|
||||
b.push(PAD_AUDIO_MAGIC);
|
||||
b.push(pad);
|
||||
b.push(kind);
|
||||
b.extend_from_slice(&seq.to_le_bytes());
|
||||
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
b.extend_from_slice(opus);
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
|
||||
/// length bounds every read before it happens).
|
||||
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
|
||||
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
|
||||
return None;
|
||||
}
|
||||
Some(PadAudioFrame {
|
||||
pad: buf[1],
|
||||
kind: buf[2],
|
||||
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
|
||||
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
|
||||
opus: buf[15..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::quic::*;
|
||||
@@ -1027,6 +1118,12 @@ mod tests {
|
||||
f
|
||||
},
|
||||
},
|
||||
// The DS5 audio-control region (haptics-select + speaker volume asserted).
|
||||
HidOutput::AudioCtl {
|
||||
pad: 1,
|
||||
flags: 0b0_0101,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
@@ -1045,6 +1142,47 @@ mod tests {
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_ctl_wire_layout_and_truncation() {
|
||||
// The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes].
|
||||
let a = HidOutput::AudioCtl {
|
||||
pad: 0x0201,
|
||||
flags: 0x17,
|
||||
raw: [1, 2, 3, 4, 5, 6],
|
||||
};
|
||||
let d = a.encode();
|
||||
assert_eq!(d, [0xCD, 0x06, 0x01, 0x02, 0x17, 1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(HidOutput::decode(&d), Some(a));
|
||||
// Truncated buffers are rejected outright (fixed length — never a partial read).
|
||||
for n in 2..d.len() {
|
||||
assert_eq!(HidOutput::decode(&d[..n]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_datagram_roundtrip_and_truncation() {
|
||||
let opus = [0x5Au8; 61];
|
||||
let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus);
|
||||
assert_eq!(d[0], PAD_AUDIO_MAGIC);
|
||||
assert_eq!(d.len(), 15 + opus.len());
|
||||
let f = decode_pad_audio_datagram(&d).unwrap();
|
||||
assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999));
|
||||
assert_eq!(f.opus, opus);
|
||||
// Truncated headers are rejected outright (never partially read).
|
||||
for n in 0..15 {
|
||||
assert_eq!(decode_pad_audio_datagram(&d[..n]), None);
|
||||
}
|
||||
// Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa.
|
||||
assert!(decode_audio_datagram(&d).is_none());
|
||||
assert!(decode_mic_datagram(&d).is_none());
|
||||
assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
|
||||
// Empty payload (DTX) is legal — header-only datagram.
|
||||
let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]);
|
||||
assert_eq!(hdr.len(), 15);
|
||||
assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_state_roundtrip() {
|
||||
for (flags, x, y) in [
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
||||
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
||||
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF plane codecs,
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xD1 plane codecs,
|
||||
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
|
||||
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
||||
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
|
||||
|
||||
@@ -259,6 +259,17 @@ windows = { version = "0.62", features = [
|
||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||
"Win32_System_Com",
|
||||
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
|
||||
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
|
||||
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
|
||||
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
|
||||
# ACL repair + the devnode's pad-index marker value.
|
||||
"Win32_Media_Audio",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Variant",
|
||||
"Win32_Devices_Properties",
|
||||
"Win32_System_Registry",
|
||||
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
|
||||
@@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||
// `pad-endpoint` devtest.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/pad_endpoint.rs"]
|
||||
pub(crate) mod pad_endpoint;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
|
||||
@@ -75,6 +75,17 @@ pub(crate) fn host_audio_requested() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
|
||||
}
|
||||
|
||||
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
|
||||
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
|
||||
/// devnode marker, registry-only reads); this is just the per-pass collection.
|
||||
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
renders
|
||||
.iter()
|
||||
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
|
||||
.map(|(_, id)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -90,7 +101,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
|
||||
// The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters
|
||||
// them out of every role. Identity is platform data (stamped container / devnode marker),
|
||||
// so it is collected HERE and passed in, like the candidate lists themselves.
|
||||
let pad_ids = pad_render_ids(&renders);
|
||||
let wiring = plan(
|
||||
&renders,
|
||||
&captures,
|
||||
want.as_deref(),
|
||||
host_audio_requested(),
|
||||
&pad_ids,
|
||||
);
|
||||
|
||||
// Log assignment changes exactly once (first plan included).
|
||||
static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
|
||||
@@ -135,7 +156,7 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
if let Some((mic_name, mic_id)) = &wiring.mic_render {
|
||||
if default_render_id().as_deref() == Some(mic_id.as_str()) {
|
||||
// Audible preference = the host_audio plan's loopback pick (real hardware first).
|
||||
match plan(&renders, &captures, want.as_deref(), true).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 \
|
||||
@@ -184,8 +205,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)
|
||||
@@ -332,8 +355,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
@@ -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.
|
||||
|
||||
@@ -102,13 +102,28 @@ fn virtualish(lname: &str) -> bool {
|
||||
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
|
||||
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
|
||||
/// flips the loopback preference to real hardware (audio audible on the host too); the default
|
||||
/// (`false`) prefers the silent sink so audio plays on the client only.
|
||||
/// (`false`) prefers the silent sink so audio plays on the client only. `pad_renders` are the
|
||||
/// endpoint IDs of the host's own pad-audio ("DualSense speaker") endpoints — platform data
|
||||
/// collected by `audio_control`, since a pad endpoint is identified by its stamped container /
|
||||
/// devnode, not by any name rule this module could express.
|
||||
pub(crate) fn plan(
|
||||
renders: &[Endpoint],
|
||||
captures: &[Endpoint],
|
||||
mic_want: Option<&str>,
|
||||
host_audio: bool,
|
||||
pad_renders: &[String],
|
||||
) -> Wiring {
|
||||
// 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice
|
||||
// would play out of a pad "speaker"), never a loopback source (a game's controller
|
||||
// audio cues would stream as desktop audio). Their names carry no virtual marker —
|
||||
// they are stamped "DualSense Wireless Controller" on purpose — so without this
|
||||
// exclusion the loopback rules would read one as real hardware.
|
||||
let renders: Vec<Endpoint> = renders
|
||||
.iter()
|
||||
.filter(|(_, id)| !pad_renders.iter().any(|p| p == id))
|
||||
.cloned()
|
||||
.collect();
|
||||
let renders = renders.as_slice();
|
||||
let find_render = |needle: &str| {
|
||||
renders
|
||||
.iter()
|
||||
@@ -191,7 +206,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)"
|
||||
@@ -220,7 +235,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)"
|
||||
@@ -240,7 +255,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)"
|
||||
@@ -257,7 +272,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}");
|
||||
}
|
||||
}
|
||||
@@ -269,7 +284,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");
|
||||
}
|
||||
@@ -287,7 +302,7 @@ mod tests {
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
@@ -311,7 +326,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)"
|
||||
@@ -325,7 +340,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());
|
||||
}
|
||||
@@ -338,7 +353,7 @@ mod tests {
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
let w = plan(&renders, &[], None, false);
|
||||
let w = plan(&renders, &[], None, false, &[]);
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
@@ -350,7 +365,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)"
|
||||
@@ -366,7 +381,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)");
|
||||
}
|
||||
@@ -384,7 +399,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)",
|
||||
@@ -407,7 +422,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}");
|
||||
}
|
||||
@@ -422,7 +437,34 @@ 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());
|
||||
}
|
||||
|
||||
/// A provisioned pad-audio endpoint (stamped "DualSense Wireless Controller") is invisible
|
||||
/// to the plan. Its name carries NO virtual marker — on purpose, games must read it as the
|
||||
/// pad's speaker — so the name rules alone would classify it as real hardware and hand it
|
||||
/// the loopback; only the id exclusion prevents that. Measured fact: the wiring plan on the
|
||||
/// target box already enumerated a stamped endpoint among `renders`.
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,50 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
|
||||
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
|
||||
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
|
||||
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
|
||||
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||
use crate::audio::pad_endpoint as pe;
|
||||
let idx: u8 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--index")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("ensure") => {
|
||||
let p = pe::ensure(idx)?;
|
||||
println!(
|
||||
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
|
||||
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("remove") => match pe::find(idx)? {
|
||||
Some(p) => {
|
||||
pe::remove(&p);
|
||||
println!(
|
||||
"pad-endpoint remove: requested removal of {}",
|
||||
p.device_instance
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Some("status") => pe::print_status(idx),
|
||||
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
|
||||
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
|
||||
///
|
||||
|
||||
@@ -65,6 +65,8 @@ pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
|
||||
index: *b.first()?,
|
||||
kind: *b.get(1)?,
|
||||
capabilities: le16(2)? as u16,
|
||||
// GameStream's LI_CCAP vocabulary can't express pad audio — native-plane only.
|
||||
audio_caps: 0,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
@@ -138,6 +140,7 @@ mod tests {
|
||||
index,
|
||||
kind,
|
||||
capabilities,
|
||||
..
|
||||
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
|
||||
else {
|
||||
panic!("expected Arrival");
|
||||
|
||||
@@ -602,6 +602,10 @@ fn real_main() -> Result<()> {
|
||||
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
|
||||
// Windows: pad-audio endpoint provisioning (`ensure`/`status`) + the pnputil removal
|
||||
// escape hatch (`remove`). `--index N` selects the pad slot (default 0).
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("pad-endpoint") => devtest::pad_endpoint(&args),
|
||||
// Capture→encode→file pipeline spike (dev tool).
|
||||
Some("spike") => spike::run(parse_spike(&args[1..])?),
|
||||
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
|
||||
|
||||
@@ -64,6 +64,12 @@ use pairing::pair_ceremony;
|
||||
mod audio;
|
||||
use audio::audio_thread;
|
||||
|
||||
/// Per-pad DualSense audio (the 0xD1 plane): loopback capture of the pre-provisioned pad
|
||||
/// endpoints → per-kind silence gate → stereo Opus → `PAD_AUDIO_MAGIC` datagrams. The input
|
||||
/// thread spawns/reaps one streamer per arriving pad (`input`); the Welcome advertises the cap
|
||||
/// via `pad_audio::host_cap` (`handshake`).
|
||||
mod pad_audio;
|
||||
|
||||
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
|
||||
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
|
||||
mod input;
|
||||
@@ -344,6 +350,14 @@ pub(crate) async fn serve(
|
||||
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
|
||||
// endpoint churn).
|
||||
let mic_service = crate::audio::MicPump::start();
|
||||
// Windows, env-gated (PUNKTFUNK_PAD_AUDIO / _SLOTS): pre-provision the per-pad "DualSense
|
||||
// speaker" render endpoints once per host lifetime — idempotent devnode + stamp work on a
|
||||
// dedicated COM thread, results published for sessions to query by pad index
|
||||
// (crate::audio::pad_endpoint::endpoint_for). If any stamp is stored-but-not-served, the
|
||||
// worker performs ONE AudioEndpointBuilder+Audiosrv restart now, before any session exists.
|
||||
// Failures log once and leave the feature off: pads still work, just without pad audio.
|
||||
#[cfg(target_os = "windows")]
|
||||
crate::audio::pad_endpoint::provision_at_startup();
|
||||
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
|
||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||
@@ -1164,9 +1178,14 @@ async fn serve_session(
|
||||
let input_handle = {
|
||||
let conn = conn.clone();
|
||||
let gamepad = welcome.gamepad;
|
||||
// Pad audio (0xD1) negotiated: the Welcome advertised the cap (Windows + provisioned
|
||||
// endpoints + the client asked — handshake reads `pad_audio::host_cap`). Read back off
|
||||
// the Welcome rather than recomputed, so the input thread's spawns cannot disagree
|
||||
// with what the client was told.
|
||||
let pad_audio_on = welcome.host_caps & punktfunk_core::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-input".into())
|
||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
|
||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad, pad_audio_on))
|
||||
.context("spawn input thread")?
|
||||
};
|
||||
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
||||
|
||||
@@ -544,6 +544,16 @@ pub(super) async fn negotiate(
|
||||
punktfunk_core::quic::HOST_CAP_PEN
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Per-pad DualSense audio (0xD1 + HidOutput::AudioCtl): granted only when the
|
||||
// client asked AND this host can capture it — Windows with the feature enabled
|
||||
// and at least one pad endpoint provisioned at startup. A capable client then
|
||||
// marks its pads' renderers on their arrivals; the input thread streams toward
|
||||
// exactly those pads (`super::pad_audio`).
|
||||
| if super::pad_audio::host_cap(hello.client_caps) {
|
||||
punktfunk_core::quic::HOST_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||
|
||||
@@ -515,6 +515,75 @@ impl Pads {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-pad 0xD1 streamers (`super::pad_audio`), keyed by pad index like every per-pad table
|
||||
/// here (bounded by [`MAX_WIRE_PADS`]; only slots 0..4 can ever have a provisioned endpoint —
|
||||
/// `spawn` refuses the rest). Spawned when a negotiated session's DualSense-family arrival
|
||||
/// declares renderer bits, reaped on remove / re-declare / session teardown.
|
||||
struct PadAudioSlots {
|
||||
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
|
||||
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
|
||||
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
|
||||
}
|
||||
|
||||
impl PadAudioSlots {
|
||||
fn new() -> PadAudioSlots {
|
||||
PadAudioSlots {
|
||||
slots: std::array::from_fn(|_| None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
|
||||
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
|
||||
/// retries, since arrivals are only re-sent a few times per slot open).
|
||||
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
|
||||
let idx = pad as usize;
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
if let Some((have, _)) = &self.slots[idx] {
|
||||
if *have == kinds {
|
||||
return; // identical re-arrival — keep the running streamer
|
||||
}
|
||||
tracing::info!(
|
||||
pad = idx,
|
||||
"pad-audio kinds changed — restarting the streamer"
|
||||
);
|
||||
self.stop(idx);
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
|
||||
self.slots[idx] = Some((kinds, h));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop + reap one pad's streamer. The join rides a detached reaper thread: a quiet pad's
|
||||
/// capturer can sit out its ~5 s recv timeout, and this thread must keep its ≤4 ms
|
||||
/// feedback cadence (games block on GET_REPORT handshakes) — the reaper still joins, just
|
||||
/// not here. A failed reaper spawn falls back to the handle's own drop (signal + join).
|
||||
fn stop(&mut self, idx: usize) {
|
||||
if let Some((_, h)) = self.slots.get_mut(idx).and_then(|s| s.take()) {
|
||||
h.signal();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk1-padreap".into())
|
||||
.spawn(move || h.stop());
|
||||
}
|
||||
}
|
||||
|
||||
/// Session teardown: flag every streamer FIRST so they wind down concurrently, then join —
|
||||
/// the worst case is ONE quiet-endpoint recv timeout (~5 s), well inside the session's
|
||||
/// 10 s side-thread join grace, not one per pad.
|
||||
fn stop_all(&mut self) {
|
||||
for s in self.slots.iter().flatten() {
|
||||
s.1.signal();
|
||||
}
|
||||
for s in &mut self.slots {
|
||||
if let Some((_, h)) = s.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
||||
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
|
||||
/// every pure-gyro motion sample up to 4 ms of quantization).
|
||||
@@ -669,8 +738,13 @@ pub(super) fn input_thread(
|
||||
conn: quinn::Connection,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
gamepad: GamepadPref,
|
||||
pad_audio_on: bool,
|
||||
) {
|
||||
let mut pads = Pads::new(gamepad);
|
||||
// Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on`
|
||||
// — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that
|
||||
// declare renderer bits, reaped on remove/teardown below.
|
||||
let mut pad_streams = PadAudioSlots::new();
|
||||
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
||||
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
||||
// is 5000 u32s.
|
||||
@@ -829,16 +903,53 @@ pub(super) fn input_thread(
|
||||
rumble_seen[idx] = false;
|
||||
rumble_seq[idx] = 0;
|
||||
rumble_stop_burst[idx] = 0;
|
||||
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
|
||||
// rest of this arm, so a reordered stale removal can't kill the
|
||||
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
|
||||
pad_streams.stop(idx);
|
||||
}
|
||||
}
|
||||
InputKind::GamepadArrival => {
|
||||
// Per-pad controller kind declaration (mixed types): route this pad's future
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
|
||||
// `flags` = pad index. Applied before the pad's first frame (the client sends it
|
||||
// on slot open), so the device is built as the right type from the start.
|
||||
let idx = ev.flags as usize;
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire
|
||||
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
|
||||
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
|
||||
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
|
||||
// Applied before the pad's first frame (the client sends it on slot open),
|
||||
// so the device is built as the right type from the start. The audio caps
|
||||
// are surfaced here for the 0xD1 capture path (which emits pad audio only
|
||||
// toward pads that declared a renderer).
|
||||
let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags);
|
||||
let idx = pad as usize;
|
||||
let kind = GamepadPref::from_u8(ev.code as u8);
|
||||
if audio_caps != 0 {
|
||||
tracing::debug!(
|
||||
pad = idx,
|
||||
haptics = audio_caps & 0x01 != 0,
|
||||
speaker = audio_caps & 0x02 != 0,
|
||||
"pad-audio render caps declared (arrival flags bits 8/9)"
|
||||
);
|
||||
}
|
||||
pads.set_kind(idx, kind);
|
||||
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
|
||||
// renderer, only on a session that negotiated the cap. Idempotent across
|
||||
// the arrival re-sends (same kinds keeps the running streamer); a
|
||||
// re-declare without bits — or as a kind with no pad audio — stops it.
|
||||
if pad_audio_on {
|
||||
let want = if matches!(
|
||||
kind,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
) {
|
||||
audio_caps
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if want != 0 {
|
||||
pad_streams.ensure(&conn, pad, want);
|
||||
} else {
|
||||
pad_streams.stop(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
@@ -994,6 +1105,9 @@ pub(super) fn input_thread(
|
||||
flags: 0,
|
||||
});
|
||||
}
|
||||
// Reap the per-pad 0xD1 streamers with the session (after the instant release sends above
|
||||
// — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all).
|
||||
pad_streams.stop_all();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
|
||||
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
|
||||
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
|
||||
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
|
||||
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
|
||||
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
|
||||
//! reopen-with-backoff on capture death, the same monotonic-seq-kept-across-reopens discipline,
|
||||
//! the same power-of-two encode-warn throttle.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
|
||||
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
|
||||
/// `kinds` bit for the speaker stream.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
|
||||
|
||||
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
|
||||
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
|
||||
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const HAPTICS_FRAME_MS: u32 = 5;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const SPEAKER_FRAME_MS: u32 = 10;
|
||||
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const HAPTICS_FRAME_SAMPLES: usize =
|
||||
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const SPEAKER_FRAME_SAMPLES: usize =
|
||||
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
|
||||
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
|
||||
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
|
||||
/// the pure splitter logic keeps its own copy).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const CAP_CHANNELS: usize = 4;
|
||||
|
||||
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
|
||||
/// very frame (haptics are felt latency; the first active frame must ship). ≈ −60 dBFS.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const GATE_OPEN_PEAK: f32 = 1e-3;
|
||||
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
|
||||
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
|
||||
/// clipped, short enough that an idle pad costs nothing in steady state.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const GATE_HANGOVER_MS: u32 = 250;
|
||||
|
||||
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
|
||||
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
|
||||
#[cfg(target_os = "windows")]
|
||||
const PAD_AUDIO_BITRATE: i32 = 64_000;
|
||||
|
||||
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
|
||||
/// rarely render pad audio) must cost ZERO encodes and ZERO datagrams, not a permanent 200 Hz
|
||||
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
|
||||
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
|
||||
/// unit-tested below.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct SilenceGate {
|
||||
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
|
||||
hangover_frames: u32,
|
||||
/// Consecutive sub-threshold frames seen so far while open.
|
||||
quiet: u32,
|
||||
/// Starts closed: a pad no game ever renders into never opens (and never sends).
|
||||
open: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl SilenceGate {
|
||||
fn new(frame_ms: u32) -> SilenceGate {
|
||||
SilenceGate {
|
||||
hangover_frames: (GATE_HANGOVER_MS / frame_ms).max(1),
|
||||
quiet: 0,
|
||||
open: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one frame; `true` = encode + send it. Signal opens the gate on THIS frame; the
|
||||
/// frame that completes the hangover closes it and is itself suppressed (the client
|
||||
/// already has ~250 ms of ramped-out silence by then).
|
||||
fn feed(&mut self, frame: &[f32]) -> bool {
|
||||
if frame.iter().any(|s| s.abs() >= GATE_OPEN_PEAK) {
|
||||
self.open = true;
|
||||
self.quiet = 0;
|
||||
} else if self.open {
|
||||
self.quiet += 1;
|
||||
if self.quiet >= self.hangover_frames {
|
||||
self.open = false;
|
||||
self.quiet = 0;
|
||||
}
|
||||
}
|
||||
self.open
|
||||
}
|
||||
}
|
||||
|
||||
/// One kind's send-admission + seq bookkeeping (pure logic — the capture thread wraps it with
|
||||
/// the encoder and the datagram send). `seq` is monotonic per (pad, kind) and NEVER advances
|
||||
/// while the gate is closed: frozen-seq = deliberate silence — the client tells silence from
|
||||
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
|
||||
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
|
||||
/// sees a gap, not a restart.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct LaneCtl {
|
||||
gate: SilenceGate,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl LaneCtl {
|
||||
fn new(frame_ms: u32) -> LaneCtl {
|
||||
LaneCtl {
|
||||
gate: SilenceGate::new(frame_ms),
|
||||
seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit one frame: `Some(seq)` = encode + send it with this seq (advanced for the next);
|
||||
/// `None` = gated — do not send, do not advance. An encode failure AFTER admission leaves a
|
||||
/// one-frame seq gap, which the client conceals exactly like datagram loss.
|
||||
fn admit(&mut self, frame: &[f32]) -> Option<u32> {
|
||||
if !self.gate.feed(frame) {
|
||||
return None;
|
||||
}
|
||||
let seq = self.seq;
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
Some(seq)
|
||||
}
|
||||
}
|
||||
|
||||
/// De-interleave one 4-ch block (FL FR BL BR) into its stereo pairs: `(front, back)` — front =
|
||||
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
|
||||
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
|
||||
/// across channels.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
|
||||
let mut front = Vec::with_capacity(block.len() / 2);
|
||||
let mut back = Vec::with_capacity(block.len() / 2);
|
||||
for s in block.chunks_exact(CAP_CHANNELS) {
|
||||
front.extend_from_slice(&s[..2]);
|
||||
back.extend_from_slice(&s[2..4]);
|
||||
}
|
||||
(front, back)
|
||||
}
|
||||
|
||||
/// Accumulates interleaved 4-ch capture and cuts it into the wire contract's per-kind stereo
|
||||
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
|
||||
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
|
||||
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct PadFramer {
|
||||
kinds: u8,
|
||||
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
|
||||
acc: Vec<f32>,
|
||||
/// Front-pair stereo accumulation toward the next 10 ms speaker frame.
|
||||
front: Vec<f32>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl PadFramer {
|
||||
fn new(kinds: u8) -> PadFramer {
|
||||
PadFramer {
|
||||
kinds,
|
||||
acc: Vec::with_capacity(HAPTICS_FRAME_SAMPLES * CAP_CHANNELS * 4),
|
||||
front: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one capture chunk; `emit(kind, stereo_frame)` fires for each completed frame
|
||||
/// (haptics first — it is the latency-critical pair).
|
||||
fn feed(&mut self, chunk: &[f32], mut emit: impl FnMut(u8, &[f32])) {
|
||||
self.acc.extend_from_slice(chunk);
|
||||
let block_len = HAPTICS_FRAME_SAMPLES * CAP_CHANNELS;
|
||||
while self.acc.len() >= block_len {
|
||||
let block: Vec<f32> = self.acc.drain(..block_len).collect();
|
||||
let (front, back) = split_quad(&block);
|
||||
if self.kinds & KIND_BIT_HAPTICS != 0 {
|
||||
emit(punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS, &back);
|
||||
}
|
||||
if self.kinds & KIND_BIT_SPEAKER != 0 {
|
||||
self.front.extend_from_slice(&front);
|
||||
let frame_len = SPEAKER_FRAME_SAMPLES * 2;
|
||||
while self.front.len() >= frame_len {
|
||||
let frame: Vec<f32> = self.front.drain(..frame_len).collect();
|
||||
emit(punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER, &frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the partial frames straddling a capture gap (reopen). The seq/gate state is NOT
|
||||
/// here — [`LaneCtl`] deliberately survives reopens, so the client sees a gap, not a
|
||||
/// restart.
|
||||
fn clear(&mut self) {
|
||||
self.acc.clear();
|
||||
self.front.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A running per-pad streamer. [`stop`](PadAudioHandle::stop) (or drop) flags the thread and
|
||||
/// joins it; [`signal`](PadAudioHandle::signal) only flags — the input thread's teardown flags
|
||||
/// every pad first so the joins overlap instead of serializing the capturer's worst-case ~5 s
|
||||
/// quiet-endpoint recv timeout.
|
||||
pub(super) struct PadAudioHandle {
|
||||
stop: Arc<AtomicBool>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PadAudioHandle {
|
||||
/// Flag the streamer to wind down without waiting for it.
|
||||
pub(super) fn signal(&self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Stop + reap. Bounded by the capturer's ~5 s quiet-endpoint recv timeout in the worst
|
||||
/// case — the mid-session reap paths run this on a detached reaper thread for that reason
|
||||
/// (`input.rs::PadAudioSlots::stop`); session teardown affords it inline (the 10 s
|
||||
/// side-thread join grace covers it).
|
||||
pub(super) fn stop(mut self) {
|
||||
self.reap();
|
||||
}
|
||||
|
||||
fn reap(&mut self) {
|
||||
self.signal();
|
||||
if let Some(join) = self.join.take() {
|
||||
let _ = join.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle dropped without `stop()` (reaper-spawn failure) still winds its thread down.
|
||||
impl Drop for PadAudioHandle {
|
||||
fn drop(&mut self) {
|
||||
self.reap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this session's Welcome should advertise
|
||||
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
|
||||
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
|
||||
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
|
||||
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
|
||||
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
|
||||
/// picks it up on its next connect.
|
||||
pub(super) fn host_cap(client_caps: u8) -> bool {
|
||||
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
asked
|
||||
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
|
||||
&& crate::audio::pad_endpoint::provisioned_endpoints()
|
||||
.is_some_and(|eps| !eps.is_empty())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Only the Windows virtual DualSense exposes pad audio endpoints today.
|
||||
let _ = asked;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the per-pad streamer toward `conn` for `pad`, streaming the kinds in `kinds` (bit 0 =
|
||||
/// haptics, bit 1 = speaker — the arrival's audio-caps packing). `stop` is this handle's own
|
||||
/// flag (fresh per spawn — pad streamers stop individually, not with the session). `None` when
|
||||
/// the slot has no provisioned endpoint (provisioning failed or still running, or the slot is
|
||||
/// past `PUNKTFUNK_PAD_AUDIO_SLOTS` — only 0..4 can ever have one) or the thread cannot spawn;
|
||||
/// the pad itself keeps working either way, just without audio.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn spawn(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
|
||||
return None;
|
||||
}
|
||||
let Some(ep) = crate::audio::pad_endpoint::endpoint_for(pad) else {
|
||||
tracing::debug!(
|
||||
pad,
|
||||
"pad-audio arrival for a slot without a provisioned endpoint — not streaming"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
if ep.endpoint_id.is_empty() {
|
||||
// The devnode-without-endpoint shape (`find`) — never in the provisioned set, but
|
||||
// cheap to refuse rather than spin the open/backoff loop on an empty id.
|
||||
return None;
|
||||
}
|
||||
let stop_t = stop.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-pad{pad}"))
|
||||
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
|
||||
{
|
||||
Ok(join) => Some(PadAudioHandle {
|
||||
stop,
|
||||
join: Some(join),
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
|
||||
/// without the audio side (and never advertise the cap, see [`host_cap`]).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(super) fn spawn(
|
||||
_conn: quinn::Connection,
|
||||
_pad: u8,
|
||||
_kinds: u8,
|
||||
_stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
None
|
||||
}
|
||||
|
||||
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
|
||||
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
|
||||
#[cfg(target_os = "windows")]
|
||||
struct Lane {
|
||||
kind: u8,
|
||||
ctl: LaneCtl,
|
||||
enc: opus::Encoder,
|
||||
encode_errs: u64,
|
||||
}
|
||||
|
||||
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
|
||||
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
|
||||
let mut lanes = Vec::new();
|
||||
for (bit, kind, frame_ms) in [
|
||||
(
|
||||
KIND_BIT_HAPTICS,
|
||||
punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS,
|
||||
HAPTICS_FRAME_MS,
|
||||
),
|
||||
(
|
||||
KIND_BIT_SPEAKER,
|
||||
punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER,
|
||||
SPEAKER_FRAME_MS,
|
||||
),
|
||||
] {
|
||||
if kinds & bit == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut enc = opus::Encoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
enc.set_bitrate(opus::Bitrate::Bits(PAD_AUDIO_BITRATE)).ok();
|
||||
enc.set_vbr(false).ok();
|
||||
lanes.push(Lane {
|
||||
kind,
|
||||
ctl: LaneCtl::new(frame_ms),
|
||||
enc,
|
||||
encode_errs: 0,
|
||||
});
|
||||
}
|
||||
Ok(lanes)
|
||||
}
|
||||
|
||||
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
|
||||
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
|
||||
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn pad_audio_thread(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
endpoint_id: String,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
use crate::audio::AudioCapturer as _;
|
||||
let mut lanes = match build_lanes(kinds) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %e, "pad-audio opus encoder init failed — pad continues without audio");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if lanes.is_empty() {
|
||||
return; // spawn() refuses kinds == 0 — belt and braces
|
||||
}
|
||||
let mut framer = PadFramer::new(kinds);
|
||||
// One Opus frame per datagram; 64 kbps CBR at ≤10 ms is ~80 bytes — sized with the session
|
||||
// plane's slack.
|
||||
let mut opus_buf = vec![0u8; 1500];
|
||||
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
|
||||
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
|
||||
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
|
||||
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
tracing::info!(
|
||||
pad,
|
||||
haptics = kinds & KIND_BIT_HAPTICS != 0,
|
||||
speaker = kinds & KIND_BIT_SPEAKER != 0,
|
||||
"pad audio streaming (0xD1, Opus 48 kHz, silence-gated)"
|
||||
);
|
||||
'session: while !stop.load(Ordering::SeqCst) {
|
||||
if capturer.is_none() {
|
||||
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
|
||||
Ok(c) => {
|
||||
if last_failed.take().is_some() {
|
||||
tracing::info!(pad, "pad-audio capture reopened");
|
||||
}
|
||||
capturer = Some(c);
|
||||
framer.clear(); // drop the partial frames straddling the gap
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(pad, error = %format!("{e:#}"), "pad-audio open failed — will retry");
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// An empty chunk is a QUIET endpoint (the capturer's idle timeout), not a death — keep
|
||||
// it; only a genuine Err (capture thread ended) drops the capturer for reopen.
|
||||
let chunk = match capturer.as_mut().unwrap().next_chunk() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %format!("{e:#}"), "pad-audio capture lost — reopening");
|
||||
capturer = None;
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut session_gone = false;
|
||||
framer.feed(&chunk, |kind, frame| {
|
||||
if session_gone {
|
||||
return;
|
||||
}
|
||||
let Some(lane) = lanes.iter_mut().find(|l| l.kind == kind) else {
|
||||
return; // framer emits only enabled kinds — unreachable, but never panic here
|
||||
};
|
||||
// Gated = deliberate silence: no datagram AND a frozen seq (the client tells
|
||||
// silence from loss by seq continuity).
|
||||
let Some(seq) = lane.ctl.admit(frame) else {
|
||||
return;
|
||||
};
|
||||
let pts_ns = now_ns();
|
||||
match lane.enc.encode_float(frame, &mut opus_buf) {
|
||||
Ok(n) => {
|
||||
let d = punktfunk_core::quic::encode_pad_audio_datagram(
|
||||
pad,
|
||||
kind,
|
||||
seq,
|
||||
pts_ns,
|
||||
&opus_buf[..n],
|
||||
);
|
||||
if conn.send_datagram(d.into()).is_err() {
|
||||
session_gone = true; // connection gone — the session is over
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
lane.encode_errs += 1;
|
||||
if lane.encode_errs.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
pad,
|
||||
kind,
|
||||
error = %e,
|
||||
count = lane.encode_errs,
|
||||
"pad-audio opus encode failed — dropping frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if session_gone {
|
||||
break 'session;
|
||||
}
|
||||
}
|
||||
// Dropping the capturer stops its WASAPI thread. Nothing to park: pad capture is per-pad,
|
||||
// per-session by design (unlike the session audio slot there is no cross-session reuse).
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
|
||||
|
||||
/// A stereo frame of `n` samples at a constant level.
|
||||
fn frame(level: f32, n: usize) -> Vec<f32> {
|
||||
vec![level; n * 2]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_opens_immediately_and_closes_after_hangover() {
|
||||
let mut g = SilenceGate::new(HAPTICS_FRAME_MS);
|
||||
// 250 ms of 5 ms frames.
|
||||
assert_eq!(g.hangover_frames, 50);
|
||||
// Closed from birth: an idle pad never sends.
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// A peak at exactly the threshold opens on THIS frame (haptics are felt latency).
|
||||
assert!(g.feed(&frame(GATE_OPEN_PEAK, HAPTICS_FRAME_SAMPLES)));
|
||||
// 49 quiet frames ride the hangover; the 50th completes 250 ms and is suppressed.
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// ... and stays closed.
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// Sub-threshold wiggle does not reopen; real signal does (negative peaks count).
|
||||
assert!(!g.feed(&frame(9e-4, HAPTICS_FRAME_SAMPLES)));
|
||||
assert!(g.feed(&frame(-0.5, HAPTICS_FRAME_SAMPLES)));
|
||||
// A loud frame mid-hangover rearms the full 250 ms.
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(g.feed(&frame(0.02, HAPTICS_FRAME_SAMPLES)));
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_hangover_scales_with_frame_ms() {
|
||||
let mut g = SilenceGate::new(SPEAKER_FRAME_MS);
|
||||
assert_eq!(g.hangover_frames, 25); // 250 ms of 10 ms frames
|
||||
assert!(g.feed(&frame(0.1, SPEAKER_FRAME_SAMPLES)));
|
||||
for _ in 0..24 {
|
||||
assert!(g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_freezes_while_gated_and_survives_reopen() {
|
||||
let mut lane = LaneCtl::new(HAPTICS_FRAME_MS);
|
||||
// Two audible frames: seq 0, 1.
|
||||
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(0));
|
||||
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(1));
|
||||
// The hangover is still sent (seq advances), then the gate closes and seq FREEZES —
|
||||
// deliberate silence the client tells from loss by continuity.
|
||||
for i in 0..49u32 {
|
||||
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), Some(2 + i));
|
||||
}
|
||||
for _ in 0..500 {
|
||||
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), None);
|
||||
}
|
||||
// A capture reopen resets ONLY the framer (PadFramer::clear) — LaneCtl is deliberately
|
||||
// untouched, so the next audible frame CONTINUES the sequence (gap, not restart).
|
||||
assert_eq!(lane.admit(&frame(0.9, HAPTICS_FRAME_SAMPLES)), Some(51));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitter_exact_pairs() {
|
||||
// Interleave [FL FR BL BR] × 2 frames with distinct values everywhere.
|
||||
let quad = [0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0];
|
||||
let (front, back) = split_quad(&quad);
|
||||
assert_eq!(front, [0.0, 1.0, 10.0, 11.0]);
|
||||
assert_eq!(back, [2.0, 3.0, 12.0, 13.0]);
|
||||
// A ragged tail (never produced by the capturer) is dropped, not smeared.
|
||||
let (front, back) = split_quad(&quad[..7]);
|
||||
assert_eq!((front.len(), back.len()), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_cuts_the_wire_cadence() {
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
|
||||
let mut got: Vec<(u8, usize, f32)> = Vec::new();
|
||||
// 10 ms of capture (480 samples), fed in ragged chunks: exactly two 5 ms haptics
|
||||
// frames from the back pair, then one 10 ms speaker frame from the front pair.
|
||||
let mut quad = Vec::new();
|
||||
for _ in 0..2 * HAPTICS_FRAME_SAMPLES {
|
||||
quad.extend_from_slice(&[0.25, 0.25, -0.5, -0.5]);
|
||||
}
|
||||
for chunk in quad.chunks(101) {
|
||||
f.feed(chunk, |kind, frame| got.push((kind, frame.len(), frame[0])));
|
||||
}
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
|
||||
(PAD_AUDIO_KIND_SPEAKER, 2 * SPEAKER_FRAME_SAMPLES, 0.25),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_masks_disabled_kinds() {
|
||||
// 20 ms of all-ones capture: 4 potential haptics frames, 2 potential speaker frames.
|
||||
let quad = vec![1.0f32; 4 * HAPTICS_FRAME_SAMPLES * CAP_CHANNELS];
|
||||
let mut kinds_seen = Vec::new();
|
||||
// Haptics-only: the front pair is never split out, let alone encoded.
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS);
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_HAPTICS; 4]);
|
||||
// Speaker-only: no haptics frames.
|
||||
let mut f = PadFramer::new(KIND_BIT_SPEAKER);
|
||||
kinds_seen.clear();
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_SPEAKER; 2]);
|
||||
// kinds = 0 is never spawned, but the framer must still be total: nothing comes out.
|
||||
let mut f = PadFramer::new(0);
|
||||
kinds_seen.clear();
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert!(kinds_seen.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_clear_drops_partials_only() {
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
|
||||
let mut emitted = 0;
|
||||
// 100 samples: no frame boundary reached yet.
|
||||
f.feed(&vec![0.1; 100 * CAP_CHANNELS], |_, _| emitted += 1);
|
||||
assert_eq!(emitted, 0);
|
||||
f.clear();
|
||||
// After the gap: exactly one haptics frame from 240 fresh samples — the 100 stale
|
||||
// samples are gone (they would skew every later frame boundary).
|
||||
f.feed(
|
||||
&vec![0.2; HAPTICS_FRAME_SAMPLES * CAP_CHANNELS],
|
||||
|kind, frame| {
|
||||
emitted += 1;
|
||||
assert_eq!(
|
||||
(kind, frame.len()),
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES)
|
||||
);
|
||||
},
|
||||
);
|
||||
assert_eq!(emitted, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cap_requires_the_client_bit() {
|
||||
// Without CLIENT_CAP_PAD_AUDIO the answer is no on EVERY platform (on Windows the
|
||||
// env + provisioning legs are environment-dependent — not unit-tested here).
|
||||
assert!(!host_cap(0));
|
||||
assert!(!host_cap(punktfunk_core::quic::CLIENT_CAP_CURSOR));
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,35 @@ even across a tag re-point.
|
||||
Canary / `-rc` builds have **no** file here on purpose: they get no curated body and are not
|
||||
announced.
|
||||
|
||||
## Google Play "What's new": `whatsnew/vX.Y.Z.txt`
|
||||
|
||||
Play shows its own release notes on the Play Store listing and in the Play Store app, and caps
|
||||
them at **500 characters per language** — the `vX.Y.Z.md` body is two orders of magnitude too
|
||||
long, so it gets its own short file: `docs/releases/whatsnew/vX.Y.Z.txt`.
|
||||
|
||||
Write it for a **phone/TV user**, not a host operator: only what changed in the Android app is
|
||||
worth their 500 characters. Plain text (Play renders no markdown), one `•` bullet per line, same
|
||||
voice rules as below. Copy `whatsnew/TEMPLATE.txt`.
|
||||
|
||||
**A `vX.Y.Z` tag without this file fails the android job before it builds.** This is a hard gate,
|
||||
not a warning, because the failure it prevents is silent: when the file is missing Play does not
|
||||
show an empty "What's new" — it **carries the previous release's text onto the new version**, so
|
||||
the store listing describes a build nobody is getting, and nothing surfaces that but reading the
|
||||
listing. It is the same shape as the v0.22.3 notes announcing a feature that release never
|
||||
contained. The gate also rejects a file byte-identical to another release's, which is that bug
|
||||
reached by copy-paste instead of by omission.
|
||||
|
||||
The gate runs first in the job, so a miss costs a second and leaves nothing half-published —
|
||||
no build, no assets on the Gitea release, nothing on Play. Two more checks sit downstream:
|
||||
`play-upload.py` refuses text over the 500-char cap (printing the real count) before it uploads,
|
||||
because the API only rejects oversized notes at commit, by which point the AAB is already on Play.
|
||||
|
||||
Canary is exempt: it has no curated notes, and Play reusing text for internal testers costs
|
||||
nothing.
|
||||
|
||||
Same freeze rule as the notes: once the tag exists, this file is the record of what that
|
||||
versionCode shipped.
|
||||
|
||||
## Voice & format
|
||||
|
||||
**Write for the people who USE Punktfunk to stream their games and desktops — not for the people who
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
• <The headline change, in what the user gets — not what was built.>
|
||||
• <Second thing. Name devices they recognize: a phone, an Android TV, a DualSense.>
|
||||
• <Third. Stop at what fits; 500 characters is the hard cap, and it is small.>
|
||||
|
||||
HOW TO USE THIS FILE
|
||||
Copy to vX.Y.Z.txt, replace every line, delete this block. It is skipped by the CI gate by
|
||||
name, so leaving it in place never blocks a release — but a copy that still says "<The
|
||||
headline change>" will ship to the Play Store exactly as written. Nothing checks your prose.
|
||||
|
||||
WHAT BELONGS HERE
|
||||
Only what changed in the ANDROID APP. Host-side work — Windows installers, gamescope, the web
|
||||
console — means nothing to someone reading a phone app's store listing, and it is spending
|
||||
characters you do not have. The full release notes in ../vX.Y.Z.md are where that lives.
|
||||
|
||||
RULES THE CI GATE ENFORCES (.gitea/workflows/android.yml)
|
||||
* The file must exist for a vX.Y.Z tag, or the release fails before it builds. Play does not
|
||||
show "no notes" when the file is missing — it carries the PREVIOUS release's text onto the
|
||||
new version, and nobody notices until they read the store listing.
|
||||
* It must not be byte-identical to another release's file.
|
||||
* play-upload.py refuses anything over 500 characters and prints the real count.
|
||||
|
||||
RULES NOTHING ENFORCES
|
||||
* Plain text. No markdown — Play renders none of it, so `**bold**` ships as asterisks.
|
||||
* One `•` bullet per line.
|
||||
* Same voice as ../README.md: lead with what the user can now do, no internal vocabulary.
|
||||
* Frozen once the tag exists. Editing it later does not change what already shipped; it only
|
||||
misleads the next person who reads it as the record of that versionCode.
|
||||
@@ -0,0 +1,4 @@
|
||||
• Much lower latency: a new frame scheduler cut glass-to-glass delay by about a third on the phones we measured, and the host can now line its capture up with your screen.
|
||||
• Microphone: mute it mid-stream, and echo cancellation is on by default.
|
||||
• DualSense controllers work over USB.
|
||||
• AV1 is picked automatically when your phone and host both support it.
|
||||
+158
-3
@@ -58,7 +58,13 @@
|
||||
// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 14
|
||||
// v15: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||
// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||
// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||
// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||
// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||
// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 15
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -82,6 +88,13 @@
|
||||
// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
|
||||
|
||||
// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||
// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||
// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||
// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
#define PUNKTFUNK_HIDOUT_AUDIO_CTL 5
|
||||
|
||||
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
||||
|
||||
@@ -266,6 +279,28 @@
|
||||
// design/pen-tablet-input.md.)
|
||||
#define PUNKTFUNK_HOST_CAP_PEN 16
|
||||
|
||||
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||
// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||
// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||
// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||
#define PUNKTFUNK_HOST_CAP_PAD_AUDIO 32
|
||||
|
||||
// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||
// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||
#define PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS 0
|
||||
|
||||
// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||
// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||
#define PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER 1
|
||||
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||
// stream (a real DualSense's voice coils).
|
||||
#define PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS 1
|
||||
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||
// stream.
|
||||
#define PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER 2
|
||||
|
||||
// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor
|
||||
// channel, `design/remote-desktop-sweep.md` M2).
|
||||
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
|
||||
@@ -276,6 +311,13 @@
|
||||
// forward-compatible.
|
||||
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
|
||||
|
||||
// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||
// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||
// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||
// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||
// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
#define PUNKTFUNK_CLIENT_CAP_PAD_AUDIO 4
|
||||
|
||||
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
||||
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||
@@ -342,6 +384,19 @@
|
||||
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
||||
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||
|
||||
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||
// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||
// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||
// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||
// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||
// it drop the declaration).
|
||||
#define ARRIVAL_FLAG_PAD_AUDIO_HAPTICS (1 << 8)
|
||||
|
||||
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||
// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||
// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||
#define ARRIVAL_FLAG_PAD_AUDIO_SPEAKER (1 << 9)
|
||||
|
||||
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||
// client's snapshot fold and the host's per-pad accumulators.
|
||||
#define MAX_PADS 16
|
||||
@@ -627,6 +682,18 @@
|
||||
#define CLIENT_CAP_PHASE_LOCK 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||
// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||
// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||
// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||
// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||
// precedent, per pad; toward an older or incapable host nothing changes. `0x04` — `0x01` is
|
||||
// [`CLIENT_CAP_CURSOR`], `0x02` is [`CLIENT_CAP_PHASE_LOCK`].
|
||||
#define CLIENT_CAP_PAD_AUDIO 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
@@ -652,6 +719,19 @@
|
||||
#define HOST_CAP_PEN 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||
// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||
// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||
// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||
// capable client marks its pads' render capabilities on their arrivals
|
||||
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||
// toward exactly those pads. `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is
|
||||
// [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state /
|
||||
// clipboard.
|
||||
#define HOST_CAP_PAD_AUDIO 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
@@ -939,7 +1019,9 @@
|
||||
// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
// (0xCE, host→client).
|
||||
// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
// host→client).
|
||||
#define PUNKTFUNK_AUDIO_MAGIC 201
|
||||
#endif
|
||||
|
||||
@@ -1043,6 +1125,31 @@
|
||||
#define CURSOR_RELATIVE_HINT 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||
// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||
// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||
// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||
// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||
// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||
// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||
// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||
// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||
#define PAD_AUDIO_MAGIC 209
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||
// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||
#define PAD_AUDIO_KIND_HAPTICS 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||
// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||
#define PAD_AUDIO_KIND_SPEAKER 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||
@@ -1357,7 +1464,11 @@ enum PunktfunkInputKind
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13,
|
||||
// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||
// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||
// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||
// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||
// Sent when the client opens a pad slot — before that pad's
|
||||
// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
@@ -2262,6 +2373,50 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||
// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||
// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||
// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||
// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||
// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||
// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||
// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||
// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||
// thread (one puller, may run alongside the other planes' pullers).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
// `buf` is writable for `buf_len` bytes.
|
||||
int32_t punktfunk_connection_next_pad_audio(PunktfunkConnection *c,
|
||||
uint8_t *out_pad,
|
||||
uint8_t *out_kind,
|
||||
uint32_t *out_seq,
|
||||
uint64_t *out_pts_ns,
|
||||
uint8_t *buf,
|
||||
uintptr_t buf_len,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||
// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||
// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||
// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||
// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||
// before. Latest-wins per pad; unknown bits are masked off.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle. Callable from any thread.
|
||||
PunktfunkStatus punktfunk_connection_set_pad_audio_caps(PunktfunkConnection *c,
|
||||
uint8_t pad,
|
||||
uint8_t audio_caps);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
|
||||
@@ -109,10 +109,13 @@ static DS_FEATURE_PAIRING: [u8; 20] = [ // 0x09 pairing info (MAC at 1..7)
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info
|
||||
static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info; bytes 44..46 = update version,
|
||||
// kept ABOVE Sony's real releases (0x0630 as of 2026-08) — an older value makes PlayStation
|
||||
// Accessories and libScePad titles demand a firmware update the virtual pad cannot take.
|
||||
// Mirrors inject/proto/dualsense_proto.rs DS_FEATURE_FIRMWARE; keep the two in sync.
|
||||
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, 0x09, 0x00, 0x00,
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
|
||||
@@ -108,16 +108,34 @@ trusted certificate, which is what the edge vhost provides in production.
|
||||
|
||||
1. **DNS** — `winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone
|
||||
(DNS-only, same as `docs`). ✅ *done*
|
||||
2. **Caddy vhost** on unom-1, next to the existing `docs.punktfunk.unom.io` block:
|
||||
2. **Caddy vhost** — in **`unom/infra`, `caddy/Caddyfile`**, next to the existing
|
||||
`docs.punktfunk.unom.io` block. ✅ *done*
|
||||
|
||||
```caddyfile
|
||||
winget.punktfunk.unom.io {
|
||||
reverse_proxy 127.0.0.1:3240
|
||||
import security_headers
|
||||
reverse_proxy localhost:3240
|
||||
}
|
||||
```
|
||||
|
||||
Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate
|
||||
for a name it does not serve. That is the expected state, not a broken deploy.
|
||||
⚠ **Not by hand on the box.** `~/caddy/Caddyfile` on unom-1 looks like the config but is a copy
|
||||
that `deploy-all.sh` rsyncs over from `unom/infra`, and there is no `.git` there to warn you. A
|
||||
vhost added only on the box survives until the next deploy and no longer: this one was
|
||||
hand-added on 2026-07-26, and the 2026-07-31 hardening commit rewrote the file from the repo's
|
||||
own copy and deleted it — its message says "all 7 vhosts" when there were 8 live.
|
||||
|
||||
Until the vhost exists the hostname resolves but the TLS handshake fails — Caddy has no
|
||||
certificate for a name it does not serve. That is the expected state on first setup, not a
|
||||
broken deploy, but it is also exactly how the clobber presents later: an `internal_error`
|
||||
(alert 80) with no peer certificate, which winget reports as
|
||||
`WINHTTP_CALLBACK_STATUS_FLAG_SECURITY_CHANNEL_ERROR` / `0x8a15003b` and which looks like a
|
||||
client bug. Diagnose by SNI, not by port 80 — Caddy 308s *every* Host to https, including names
|
||||
it has never heard of, so a redirect there proves nothing:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect winget.punktfunk.unom.io:443 \
|
||||
-servername winget.punktfunk.unom.io </dev/null 2>&1 | grep -E '^subject=|alert'
|
||||
```
|
||||
3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until
|
||||
a catalogue exists — expected, and visible on `/healthz`.
|
||||
4. Publish a stable tag, or build and ship the catalogue by hand:
|
||||
|
||||
Reference in New Issue
Block a user