Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35ba64ca0f | ||
|
|
5f71aeb024 | ||
|
|
e1adc5d6d7 | ||
|
|
76a271b97a | ||
|
|
b53568c99f | ||
|
|
db0637928b | ||
|
|
22bc81238d | ||
|
|
de6b9e94ec | ||
|
|
5ebe840320 | ||
|
|
4b1ce6b905 | ||
|
|
a11c672bea | ||
|
|
cbd0e9664d | ||
|
|
66df1624b6 | ||
|
|
6f07bd94d3 | ||
|
|
d2085879da | ||
|
|
19f637ea6e | ||
|
|
4a0d0ce587 | ||
|
|
0d94ef0dbe | ||
|
|
a1b8627e70 | ||
|
|
91fa32fbb6 | ||
|
|
defdfbdb58 | ||
|
|
8103958169 | ||
|
|
ce8f3e9eaf | ||
|
|
bd383f1820 | ||
|
|
8728d90e01 | ||
|
|
3d4a659959 | ||
|
|
a418d2852a | ||
|
|
110ac9b663 | ||
|
|
1d6f4760f3 | ||
|
|
9dfbc2f895 | ||
|
|
56adb47026 | ||
|
|
52a9d02355 | ||
|
|
e5ca213339 | ||
|
|
e5416646f9 | ||
|
|
b79d90b463 | ||
|
|
8983ec04b9 | ||
|
|
d27e62f7c9 | ||
|
|
0a72959ef7 | ||
|
|
2d223274fc | ||
|
|
92f617a989 | ||
|
|
2f071a9a93 | ||
|
|
62d35bc4b6 | ||
|
|
5d06ef26ac | ||
|
|
fcf4076eb7 | ||
|
|
53eb592c43 | ||
|
|
956d8dd8ef | ||
|
|
b2e716ad5f | ||
|
|
ec288d64d3 | ||
|
|
68353a5d57 | ||
|
|
ffd5a33598 | ||
|
|
4af8b02be1 | ||
|
|
42a0dd52be | ||
|
|
b31495bea5 | ||
|
|
2d43275fcb | ||
|
|
77ddd05b13 | ||
|
|
a9a514dea0 | ||
|
|
173be61213 | ||
|
|
6e001e54b4 | ||
|
|
31b5f90b12 | ||
|
|
8abdd74a62 | ||
|
|
66a28d5abb | ||
|
|
e2faecfd42 | ||
|
|
76832a5b86 | ||
|
|
ec4bf75a6e | ||
|
|
2032c48ffa | ||
|
|
9a52c279f1 | ||
|
|
5be494f490 | ||
|
|
0d5e5b436b | ||
|
|
3a48cc2470 | ||
|
|
64a392634e | ||
|
|
35285afafc | ||
|
|
0d0e7e6861 | ||
|
|
143454590f | ||
|
|
9409d0a04c | ||
|
|
212bdc3b08 | ||
|
|
45cb525035 | ||
|
|
6fed1510ba | ||
|
|
4fd240deab | ||
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
8ee224e5db | ||
|
|
e8499e6131 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
ed3d236ab8 |
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assert that a builder image's :latest is the SAME manifest as its content key, and
|
||||
# re-point it when it isn't.
|
||||
#
|
||||
# This is what we do instead of pinning consumers by @sha256: digest
|
||||
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
|
||||
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
|
||||
# answer derivable from the commit alone. Checking it on every run turns :latest from a
|
||||
# tag someone remembered to move into a function of the tree.
|
||||
#
|
||||
# Two different things make them diverge and neither is distinguishable from here:
|
||||
#
|
||||
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
|
||||
# but it is exactly the H-6 attack and it must not pass silently.
|
||||
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
|
||||
# nothing re-points :latest — it stays on the newer build forever while every
|
||||
# consumer pulls a builder that does not match the tree it is building. That bug
|
||||
# predates this script.
|
||||
#
|
||||
# Both are repaired identically, so: repair, and shout. Failing the build instead would
|
||||
# turn a legitimate revert into a red main with no way forward.
|
||||
#
|
||||
# Reads go to the anonymous port, the single write to the authenticated one.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
|
||||
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
|
||||
: "${CI_REGISTRY:?CI_REGISTRY not set}"
|
||||
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
|
||||
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
|
||||
|
||||
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
|
||||
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
|
||||
# "missing" is a state this has to reason about, not an error to abort on.
|
||||
digest_of() {
|
||||
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
|
||||
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
|
||||
}
|
||||
|
||||
key_digest=$(digest_of "$KEY")
|
||||
latest_digest=$(digest_of latest)
|
||||
|
||||
if [ -z "$key_digest" ]; then
|
||||
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$key_digest" = "$latest_digest" ]; then
|
||||
echo "$IMAGE:latest == :$KEY ($key_digest)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
|
||||
echo " was: ${latest_digest:-<no :latest tag>}"
|
||||
echo " wanted: $key_digest (:$KEY)"
|
||||
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
|
||||
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
|
||||
|
||||
now=$(digest_of latest)
|
||||
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
|
||||
echo "$IMAGE:latest re-pointed to $key_digest"
|
||||
@@ -160,6 +160,14 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
|
||||
# built module) and it is the only automated cover those behaviours have.
|
||||
- name: kit unit tests
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:testDebugUnitTest --stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
|
||||
@@ -41,9 +41,23 @@ jobs:
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
|
||||
# is a raw textual substitution performed BEFORE the shell sees the line, so a
|
||||
# workflow_dispatch input containing shell syntax executes as this step — and this is the
|
||||
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
|
||||
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
TAG="$INPUT_TAG"
|
||||
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$TAG" in
|
||||
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
@@ -67,4 +81,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
|
||||
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
|
||||
# Same reasoning as the publish step above: the input is data in the environment, never
|
||||
# text spliced into the command line.
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
|
||||
|
||||
@@ -29,4 +29,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tier-3 GPU stream benchmark
|
||||
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
|
||||
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
|
||||
# substituted before the shell parses the line, so an input carrying shell syntax would run
|
||||
# as this step (2026-08-05 review H-6).
|
||||
env:
|
||||
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
|
||||
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
|
||||
|
||||
@@ -46,7 +46,10 @@ env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PACKAGE: punktfunk-decky # generic-registry package name
|
||||
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
|
||||
# The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name"
|
||||
# (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by
|
||||
# the folder) — see clients/decky/scripts/package.sh.
|
||||
PLUGIN: punktfunk
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
|
||||
+107
-21
@@ -3,13 +3,18 @@
|
||||
# Two very different image families now:
|
||||
#
|
||||
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
|
||||
# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
|
||||
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
|
||||
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
|
||||
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
|
||||
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
|
||||
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
|
||||
# is re-pushed alongside every new key and is what the consuming workflows pin.
|
||||
# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are
|
||||
# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, +
|
||||
# rust-toolchain.toml for the cross image), and a build only happens when that key
|
||||
# has no manifest yet. A push that doesn't touch ci/ costs one curl per image
|
||||
# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the
|
||||
# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed
|
||||
# alongside every new key and is what the consuming workflows pin.
|
||||
#
|
||||
# READS come from :5010 and need no credential. WRITES go to :5011 and need
|
||||
# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name,
|
||||
# not by the host:port the client used — so an image pushed to :5011 is the same
|
||||
# image every consumer pulls from :5010.
|
||||
#
|
||||
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
|
||||
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
|
||||
@@ -17,8 +22,38 @@
|
||||
#
|
||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
|
||||
# the LAN registry is unauthenticated inside the LAN).
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images).
|
||||
# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user
|
||||
# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places.
|
||||
#
|
||||
# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 -------------------------------
|
||||
# The registry used to accept anonymous pushes from any LAN peer, and every
|
||||
# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker
|
||||
# position #1 of the project's own threat model did not need to break any signing
|
||||
# logic: push one tag, and the next android.yml run executes their code in the same job
|
||||
# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for
|
||||
# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON).
|
||||
#
|
||||
# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and
|
||||
# refuses everything else with 405, :5011 demands basic auth on every request. The half
|
||||
# in this file is done below: pushes and release-tag manifest PUTs authenticate.
|
||||
#
|
||||
# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256:
|
||||
# digest". We deliberately do something else, because after authentication the digest
|
||||
# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag
|
||||
# is now exactly the set who can push to main and edit a pinned digest in this very
|
||||
# file: a pin defends against nobody it did not already trust, while costing a
|
||||
# two-commit dance on every ci/ change (~3x a month) during which consumers silently run
|
||||
# a builder image that predates the ci/ change they are testing.
|
||||
#
|
||||
# What actually closes the residual gap — a tag quietly overwritten out of band — is
|
||||
# making :latest a CHECKED function of the tree instead of a tag someone remembered to
|
||||
# move. The "Reconcile :latest" step below asserts on every run that :latest and
|
||||
# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That
|
||||
# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes
|
||||
# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at
|
||||
# the newer build forever. Revisit inline digest pins if the push credential ever leaves
|
||||
# the maintainer trust set.
|
||||
#
|
||||
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
|
||||
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
|
||||
@@ -42,7 +77,10 @@ on:
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
# Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto
|
||||
# one store; see the header.
|
||||
CI_REGISTRY: 192.168.1.58:5010
|
||||
CI_REGISTRY_PUSH: 192.168.1.58:5011
|
||||
|
||||
jobs:
|
||||
builders:
|
||||
@@ -98,21 +136,40 @@ jobs:
|
||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
|
||||
# can only fail. Consumers still pull the identical image from :5010.
|
||||
- name: Build
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
# --pull is cheap now: base images come through the ci-core pull-through mirror.
|
||||
run: |
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
|
||||
ci
|
||||
|
||||
- name: Log in to the LAN registry
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
|
||||
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
|
||||
|
||||
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
|
||||
# happened to build. Two things break that: an out-of-band overwrite (the H-6
|
||||
# attack, now only reachable by someone holding the push credential), and a plain
|
||||
# revert of ci/, which leaves :latest on the newer build because the older key is
|
||||
# already a cache hit and nothing re-points it. Both look identical from here and
|
||||
# both are repaired the same way, so repair and shout rather than fail the build.
|
||||
- name: Reconcile :latest with the content key
|
||||
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
# A release pins reproducible builder images without any rebuild: copy the key's
|
||||
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
|
||||
@@ -124,8 +181,19 @@ jobs:
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
|
||||
--data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
|
||||
# image), so the credential docker login wrote would die with it anyway. Don't
|
||||
# make that a load-bearing assumption about a runner label somebody may change to
|
||||
# a host runner later.
|
||||
- name: Log out of the LAN registry
|
||||
if: always()
|
||||
run: docker logout "$CI_REGISTRY_PUSH" || true
|
||||
|
||||
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
|
||||
@@ -164,15 +232,26 @@ jobs:
|
||||
run: |
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$CI_REGISTRY/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY/$IMAGE:latest" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
|
||||
.
|
||||
|
||||
- name: Log in to the LAN registry
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY/$IMAGE:latest"
|
||||
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
|
||||
|
||||
- name: Reconcile :latest with the content key
|
||||
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Tag for release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -182,8 +261,15 @@ jobs:
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
|
||||
--data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Log out of the LAN registry
|
||||
if: always()
|
||||
run: docker logout "$CI_REGISTRY_PUSH" || true
|
||||
|
||||
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
|
||||
apps:
|
||||
|
||||
@@ -38,10 +38,18 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
|
||||
#
|
||||
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
|
||||
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
|
||||
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
|
||||
# the whole step reproducible: bump the tag in both places together.
|
||||
- name: Install syft
|
||||
env:
|
||||
SYFT_VERSION: v1.49.0
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
|
||||
| sh -s -- -b /usr/local/bin v1.49.0
|
||||
set -euo pipefail
|
||||
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
|
||||
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
Generated
+19
@@ -2893,6 +2893,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3346,6 +3347,8 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4985,6 +4988,14 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
+157
-9
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.23.0"
|
||||
"version": "0.24.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1052,7 +1052,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1307,7 +1307,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Replace a provider's library entries (declarative reconcile)",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1318,6 +1318,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "store",
|
||||
"in": "query",
|
||||
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -1348,7 +1357,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"description": "Invalid provider id, store id, or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1367,6 +1376,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "That store is already claimed by another provider",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the catalog",
|
||||
"content": {
|
||||
@@ -4159,7 +4178,8 @@
|
||||
"tier",
|
||||
"platforms",
|
||||
"compatible",
|
||||
"update_available"
|
||||
"update_available",
|
||||
"categories"
|
||||
],
|
||||
"properties": {
|
||||
"author": {
|
||||
@@ -4172,6 +4192,13 @@
|
||||
],
|
||||
"description": "A revocation covering the catalogued version — do not offer this without shouting."
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
|
||||
},
|
||||
"compatible": {
|
||||
"type": "boolean",
|
||||
"description": "Can this host install it?"
|
||||
@@ -4179,6 +4206,13 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"detected": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
|
||||
},
|
||||
"homepage": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4365,6 +4399,17 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4409,6 +4454,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4467,6 +4516,17 @@
|
||||
"type": "object",
|
||||
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
|
||||
"properties": {
|
||||
"env_marker": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EnvMarker",
|
||||
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
|
||||
}
|
||||
]
|
||||
},
|
||||
"exe": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4487,6 +4547,15 @@
|
||||
"null"
|
||||
],
|
||||
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
|
||||
},
|
||||
"steam_appid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4715,6 +4784,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EnvMarker": {
|
||||
"type": "object",
|
||||
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
|
||||
"example": "HEROIC_APP_NAME"
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5165,6 +5255,10 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
@@ -5296,6 +5390,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameRole": {
|
||||
"type": "string",
|
||||
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
|
||||
"enum": [
|
||||
"game",
|
||||
"launcher"
|
||||
]
|
||||
},
|
||||
"GameSession": {
|
||||
"type": "string",
|
||||
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
|
||||
@@ -6334,6 +6436,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
|
||||
@@ -6366,6 +6475,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The plugin's kind — see [`PluginRegistration::category`]."
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6604,6 +6720,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6780,26 +6900,46 @@
|
||||
},
|
||||
"ScannerInfo": {
|
||||
"type": "object",
|
||||
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
||||
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"enabled"
|
||||
"enabled",
|
||||
"origin"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this host runs the scanner (default true)."
|
||||
"description": "Whether this host runs the source (default true)."
|
||||
},
|
||||
"entries": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
|
||||
"minimum": 0
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
|
||||
"example": "steam"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Human-facing name for the console toggle.",
|
||||
"example": "Steam"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/SourceOrigin",
|
||||
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6962,6 +7102,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SourceOrigin": {
|
||||
"type": "string",
|
||||
"description": "Where a [`ScannerInfo`] comes from.",
|
||||
"enum": [
|
||||
"builtin",
|
||||
"plugin"
|
||||
]
|
||||
},
|
||||
"SourceView": {
|
||||
"type": "object",
|
||||
"description": "A configured catalog source and how its last refresh went.",
|
||||
|
||||
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ suspend fun connectToHost(
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,26 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -271,6 +291,8 @@ class SettingsStore(context: Context) {
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -308,6 +330,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -355,6 +379,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
|
||||
@@ -896,6 +896,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||
ToggleRow(
|
||||
title = "Controller haptics",
|
||||
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||
"the pad keeps ordinary rumble for games that don't send them",
|
||||
checked = s.padHaptics,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,25 @@ import kotlin.math.roundToInt
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
|
||||
* is length-guarded, so an older native lib simply omits the lines it can't feed.
|
||||
*
|
||||
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
|
||||
* every tier, and the detailed tier names what was excluded on its own line. The principle is the
|
||||
* Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout
|
||||
* — which no client can pace under — is reported rather than charged. It also stops the HUD reading
|
||||
* worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a
|
||||
* headline that carried the compositor's wait was compared against numbers that never contained it.
|
||||
*
|
||||
* The RAW figures are not lost — the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs`
|
||||
* and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the
|
||||
* untouched numbers.
|
||||
*
|
||||
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||
* [StatsVerbosity]):
|
||||
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
|
||||
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
|
||||
* and the excluded-floor line when one was measured.
|
||||
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
|
||||
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
|
||||
*/
|
||||
@@ -95,9 +107,15 @@ internal fun StatsOverlay(
|
||||
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
|
||||
// honestly stays capture→decoded — the equation always tiles the headline interval.
|
||||
val dispValid = s.size >= 26 && s[22] != 0.0
|
||||
// The OS present floor this window (see [osFloorMs]) is excluded from every shown
|
||||
// display / end-to-end number, at every tier — it is pipeline depth no client can pace
|
||||
// under, so charging it to Punktfunk made our HUD read worse than clients that simply
|
||||
// never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as
|
||||
// they were.
|
||||
val floorMs = osFloorMs(s)
|
||||
val tag = if (skew) "" else " (same-host clock)"
|
||||
val (p50, p95, endpoint) = if (dispValid) {
|
||||
Triple(s[24], s[25], "capture→displayed")
|
||||
Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
|
||||
} else {
|
||||
Triple(s[2], s[3], "capture→decoded")
|
||||
}
|
||||
@@ -120,6 +138,11 @@ internal fun StatsOverlay(
|
||||
// dropping/serializing, an fps deficit is upstream.
|
||||
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
|
||||
val displayTerm = when {
|
||||
// Floor excluded: what remains of the `display` term is the half Punktfunk
|
||||
// owns (the presenter's pace wait), and the excluded line below carries the
|
||||
// latch — printing the split too would report the same milliseconds twice.
|
||||
dispValid && floorMs > 0 ->
|
||||
" + display ${"%.1f".format(shave(s[23], floorMs))}"
|
||||
dispValid && split ->
|
||||
" + display ${"%.1f".format(s[23])} " +
|
||||
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
|
||||
@@ -143,16 +166,14 @@ internal fun StatsOverlay(
|
||||
"= $hostTerms + $decodeTerm$displayTerm$presents",
|
||||
Color.White,
|
||||
)
|
||||
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
|
||||
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
|
||||
// applies the same shave so iPhone↔Android HUD numbers compare directly.
|
||||
if (dispValid && hz > 0) {
|
||||
val shave = 2000.0 / hz
|
||||
// What the numbers above leave out, named — the Apple client's
|
||||
// `os present +N excluded` line, same wording so the two HUDs read alike.
|
||||
// (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and
|
||||
// Android's shave is measured rather than assumed at 2 refresh periods.)
|
||||
if (floorMs > 0) {
|
||||
statLine(
|
||||
"≈ Apple-HUD equiv: end-to-end " +
|
||||
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
|
||||
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)",
|
||||
Color(0xFFA8D8B8),
|
||||
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
|
||||
Color(0xFF9AA6B8),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) {
|
||||
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
/**
|
||||
* The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms — the
|
||||
* measured `latch` p50 at index 27, i.e. release→`OnFrameRendered`: SurfaceFlinger's own latch and
|
||||
* scanout. That is compositor pipeline depth no client can pace under, so it is reported as
|
||||
* excluded rather than charged to Punktfunk — the Apple client's policy since its presentation
|
||||
* rebuild, where the same floor is measured from the display link's vend lead.
|
||||
*
|
||||
* Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch
|
||||
* varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed
|
||||
* where the ~2-interval model predicts less), and this term self-adapts to all three. It is also
|
||||
* available on every render path — the presenter's and both legacy release-immediately ones — since
|
||||
* the release stamp it starts from is parked on every render, so it does not depend on
|
||||
* `presenterActive` (29).
|
||||
*
|
||||
* `0.0` means unmeasured — no display stage this window (an older native lib, API < 33, or a
|
||||
* platform that refused the callback), or no latch sample paired — and every caller then leaves its
|
||||
* number raw, which is the honest fallback: we exclude only what we actually measured.
|
||||
*/
|
||||
private fun osFloorMs(s: DoubleArray): Double {
|
||||
val dispValid = s.size >= 26 && s[22] != 0.0
|
||||
if (!dispValid || s.size < 28) return 0.0
|
||||
return s[27].coerceAtLeast(0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero — the percentiles are
|
||||
* drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can
|
||||
* legitimately go slightly negative on a well-paced window without anything being wrong.
|
||||
*/
|
||||
private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0)
|
||||
|
||||
/**
|
||||
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
|
||||
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
|
||||
@@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) {
|
||||
* one reliability signal worth surfacing even at the tersest tier.
|
||||
*/
|
||||
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
|
||||
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
|
||||
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
|
||||
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window,
|
||||
// less the excluded OS present floor — the same number the richer tiers headline.
|
||||
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2]
|
||||
val parts = buildList {
|
||||
add("${s[0].roundToInt()} fps")
|
||||
if (latValid) add("${"%.1f".format(e2eP50)} ms")
|
||||
|
||||
@@ -507,6 +507,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
|
||||
@@ -355,9 +355,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
// dispValid, displayP50, e2eDispP50, e2eDispP95].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
|
||||
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
|
||||
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
|
||||
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
|
||||
// latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the
|
||||
// `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked
|
||||
// low-latency decoder. Light per-window loss
|
||||
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
|
||||
// compact loss flag both render.
|
||||
StatsOverlay(
|
||||
|
||||
@@ -23,8 +23,9 @@ import android.view.InputDevice
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
@@ -78,6 +79,33 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -105,18 +133,28 @@ class DsCapture(
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||
// joined, so ordering this first is what makes the borrow sound.
|
||||
stopPadAudio()
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
usb.writeControl(stopReport(m))
|
||||
// Nothing can retry after this point, so a failure is worth saying out loud: it is
|
||||
// the difference between a quiet pad and one that buzzes until it is unplugged.
|
||||
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
|
||||
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
|
||||
// triggers the game was holding, which outlive the link just as stubbornly.
|
||||
resetRichFeedback(m)
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
@@ -131,20 +169,119 @@ class DsCapture(
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||
* indices are taken.
|
||||
*
|
||||
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||
*/
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
startPadAudio(p.index)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||
private fun startPadAudio(index: Int) {
|
||||
val hook = padAudio ?: return
|
||||
if (padAudioStarted) return
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd < 0) {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
return
|
||||
}
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||
// debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||
// completions, which is precisely the fault this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
|
||||
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
|
||||
// clears it — so without this the stream renders into a muted actuator and looks for
|
||||
// all the world like the host is sending nothing.
|
||||
restoreAudioHaptics()
|
||||
hook.start(index, fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
|
||||
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
|
||||
* the interrupt-OUT queue is busy or draining, and it is idempotent.
|
||||
*/
|
||||
private fun restoreAudioHaptics() {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
|
||||
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
|
||||
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
|
||||
*
|
||||
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
|
||||
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
|
||||
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
|
||||
* audio and its wire rumble on the way back in.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun stopPadAudio() {
|
||||
if (!padAudioStarted) return
|
||||
padAudioStarted = false
|
||||
// The hook's stop joins the render thread, so nothing is using the descriptor once it
|
||||
// returns — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
|
||||
stopPadAudio()
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
// Release the transport too: the link only *signals* the drop, so without this an unplug
|
||||
// left its connection open, its interfaces claimed and its detach receiver registered.
|
||||
usb.stop()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
@@ -216,17 +353,24 @@ class DsCapture(
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
val stop = low == 0 && high == 0
|
||||
if (!stop) armBackstop(backstopMs)
|
||||
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
|
||||
}
|
||||
if (stop) {
|
||||
// Disarm only once the stop is actually on its way. Dropping the net *before* the
|
||||
// write — as this used to — meant a discarded stop left the motors running with
|
||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
|
||||
// haptics stream is live the coils it drives were muted by the very write that
|
||||
// silenced the motors. Give them back.
|
||||
if (sent && padAudioStarted) restoreAudioHaptics()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +396,9 @@ class DsCapture(
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
|
||||
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
|
||||
// collapsing a backlog of them down to the last.
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
@@ -261,8 +408,38 @@ class DsCapture(
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
OutReportQueue.KEY_RUMBLE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
|
||||
*
|
||||
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
|
||||
* firmware — they outlive the stream, the app, and being unplugged. Ending a session while a
|
||||
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
|
||||
* nothing to release it but another game that happens to set one.
|
||||
*
|
||||
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
|
||||
* queue would never drain. Writes are best-effort — the pad may already be gone.
|
||||
*/
|
||||
private fun resetRichFeedback(m: DsDevice.Model) {
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
|
||||
// blacking the lightbar is a single composed report.
|
||||
ds4Rgb = 0
|
||||
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
|
||||
return
|
||||
}
|
||||
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
|
||||
for (which in 0..1) {
|
||||
usb.writeControl(
|
||||
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
|
||||
)
|
||||
}
|
||||
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
|
||||
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
|
||||
}
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
@@ -284,7 +461,12 @@ class DsCapture(
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
val m = model ?: return@Runnable
|
||||
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
|
||||
// than give up: this is the last thing between a stalled poll thread and a pad that
|
||||
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
|
||||
// clears `model` and disarms.
|
||||
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
@@ -297,5 +479,9 @@ class DsCapture(
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
|
||||
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
|
||||
* and the host has already moved on, so nothing else is coming to silence them. */
|
||||
const val STOP_RETRY_MS = 100L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,11 +276,26 @@ object DsDevice {
|
||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||
*/
|
||||
/**
|
||||
* B6: hand the voice coils back to the audio-haptics path.
|
||||
*
|
||||
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
|
||||
* "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives.
|
||||
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
|
||||
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
|
||||
*
|
||||
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
|
||||
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
|
||||
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
|
||||
* client, which is the same packet one transport over.
|
||||
*/
|
||||
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
|
||||
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
it[3] = amp8(high).toByte()
|
||||
it[4] = amp8(low).toByte()
|
||||
it[3] = wireAmplitudeToByte(high).toByte()
|
||||
it[4] = wireAmplitudeToByte(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,17 +339,11 @@ object DsDevice {
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = amp8(high).toByte()
|
||||
it[5] = amp8(low).toByte()
|
||||
it[4] = wireAmplitudeToByte(high).toByte()
|
||||
it[5] = wireAmplitudeToByte(low).toByte()
|
||||
it[6] = r.toByte()
|
||||
it[7] = g.toByte()
|
||||
it[8] = b.toByte()
|
||||
}
|
||||
|
||||
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
|
||||
// vibrator path's toAmplitude).
|
||||
private fun amp8(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class GamepadFeedback(
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
|
||||
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
|
||||
const val LOG_EVERY = 128L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
@@ -125,37 +128,51 @@ class GamepadFeedback(
|
||||
fun start() {
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
||||
// the backstop (the hardware net under a stalled poll thread).
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
|
||||
// against the Rust packer.
|
||||
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
|
||||
// Rendering is binder calls into the vibrator service, and every one of them can
|
||||
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
|
||||
// the ordinary RuntimeException a dying service wraps its RemoteException in.
|
||||
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
|
||||
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
|
||||
// the session. Losing a single command is recoverable; losing the loop is not.
|
||||
runCatching {
|
||||
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
|
||||
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
dispatchHidout(buf, n)
|
||||
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
|
||||
// calls, and an unchecked throw here would silently end the rich-feedback plane.
|
||||
runCatching { dispatchHidout(buf, n) }
|
||||
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
|
||||
}
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
|
||||
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
|
||||
* command, which at a rumble plane's rate would bury the log.
|
||||
*/
|
||||
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
|
||||
if (seen == 0L || seen % LOG_EVERY == 0L) {
|
||||
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
|
||||
}
|
||||
return seen + 1
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
@@ -264,12 +281,12 @@ class GamepadFeedback(
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
runCatching { m.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
@@ -294,7 +311,7 @@ class GamepadFeedback(
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = bind.legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
runCatching { lv.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
@@ -314,8 +331,8 @@ class GamepadFeedback(
|
||||
*/
|
||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||
val v = deviceVibrator ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { v.cancel() } // (0,0) = stop
|
||||
return
|
||||
@@ -329,12 +346,6 @@ class GamepadFeedback(
|
||||
}
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
private fun toAmplitude(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||
|
||||
@@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
@@ -81,17 +81,57 @@ class HidUsbLink(
|
||||
|
||||
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
|
||||
* what gets discarded when it fills, and why that is not simply "the oldest". */
|
||||
private val outQueue = OutReportQueue()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
|
||||
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
|
||||
* it. Reset by [start]. */
|
||||
private val down = AtomicBoolean(false)
|
||||
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||
*
|
||||
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||
* renderer — must open its own.
|
||||
*
|
||||
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||
*
|
||||
* The caller owns the returned connection and must close it.
|
||||
*/
|
||||
fun openAuxConnection(): UsbDeviceConnection? {
|
||||
val dev = device ?: return null
|
||||
return usb.openDevice(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||
*
|
||||
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||
* HID claim untouched.
|
||||
*
|
||||
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||
*/
|
||||
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
@@ -114,6 +154,7 @@ class HidUsbLink(
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
down.set(false)
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
@@ -134,10 +175,7 @@ class HidUsbLink(
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +259,9 @@ class HidUsbLink(
|
||||
if (live.isEmpty()) {
|
||||
Log.e(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
// `start` already returned true, so without this the owner would sit waiting on a
|
||||
// capture that never streams and never reports itself dead.
|
||||
linkDown()
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
@@ -295,10 +336,23 @@ class HidUsbLink(
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the link down, exactly once, from whichever detector noticed first — the detach
|
||||
* broadcast (main thread) or the reader thread on its way out.
|
||||
*
|
||||
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
|
||||
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
|
||||
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
|
||||
* the interfaces claimed (the pad could not return to Android's own input stack) and the
|
||||
* receiver still registered — and a re-plug overwrote the field holding it, leaking a receiver
|
||||
* that stayed live for the process's lifetime.
|
||||
*/
|
||||
private fun linkDown() {
|
||||
running = false
|
||||
if (down.compareAndSet(false, true)) onClosed()
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
@@ -314,28 +368,35 @@ class HidUsbLink(
|
||||
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||
*
|
||||
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
|
||||
* this one — [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
|
||||
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
|
||||
*
|
||||
* Returns whether the report reached the device or is queued for it. A caller that is writing
|
||||
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
|
||||
* for one that landed.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
|
||||
if (data.isEmpty()) return false
|
||||
return when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
|
||||
outQueue.offer(data, coalesce)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
private fun setReport(type: Int, data: ByteArray): Boolean {
|
||||
val conn = connection ?: return false
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
|
||||
return sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,9 +405,8 @@ class HidUsbLink(
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
fun writeControl(data: ByteArray): Boolean =
|
||||
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
@@ -358,27 +418,48 @@ class HidUsbLink(
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
private fun sendReport(
|
||||
conn: UsbDeviceConnection,
|
||||
ifaceId: Int,
|
||||
type: Int,
|
||||
data: ByteArray,
|
||||
): Boolean {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
// controlTransfer returns the byte count, or a negative value on failure — a failed write
|
||||
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return n >= 0
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
/**
|
||||
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
|
||||
*
|
||||
* Safe to call from the `onClosed` handler itself — that is how an unplug now gets cleaned up,
|
||||
* and it arrives on the reader thread, which must not try to join itself.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
|
||||
down.set(true)
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
if (reader !== Thread.currentThread()) {
|
||||
runCatching { reader?.join(1000) }
|
||||
// Only forget the thread once it is actually gone: clearing it while it still runs
|
||||
// would let a later stop() skip the join and free the connection under it.
|
||||
reader = null
|
||||
}
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
|
||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -332,6 +336,46 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
|
||||
* policy knows which reports may be thrown away and which may not.
|
||||
*
|
||||
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
|
||||
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
|
||||
* be bounded — a stalled or unplugged device would otherwise grow it without limit — and the
|
||||
* question is what to discard when it fills.
|
||||
*
|
||||
* The old policy was "newest wins": drop from the head until there is room. That is right for
|
||||
* rumble, which is *level-styled* — the host re-sends it continuously, so a dropped frame is
|
||||
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
|
||||
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
|
||||
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
|
||||
* time that value happens to change, which may be never.
|
||||
*
|
||||
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
|
||||
* That distinction cannot be recovered from the report itself: every DualSense output report
|
||||
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
|
||||
* would happily let a rumble supersede a lightbar — the very bug this replaces, relocated.
|
||||
*
|
||||
* Two rules:
|
||||
* - A report offered with a coalescing key **replaces** the pending report with that key, in
|
||||
* place. A burst of rumble collapses to its latest value and never displaces anything else.
|
||||
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
|
||||
* report goes first. A one-shot is discarded only if the queue is full of nothing but
|
||||
* one-shots — which needs [cap] distinct one-shots outstanding, far beyond what a real pad
|
||||
* produces.
|
||||
*
|
||||
* Thread-safe: offered by the feedback threads, drained by the reader thread.
|
||||
*/
|
||||
internal class OutReportQueue(private val cap: Int = CAP) {
|
||||
private class Entry(val key: Int, val data: ByteArray)
|
||||
|
||||
private val items = ArrayDeque<Entry>()
|
||||
|
||||
/**
|
||||
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
|
||||
* constant identifying a level-styled stream whose newer values supersede older ones.
|
||||
*
|
||||
* Returns false only if the report had to be dropped outright — the caller can then treat the
|
||||
* write as failed rather than assuming it is on its way.
|
||||
*/
|
||||
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
|
||||
if (key != NO_COALESCE) {
|
||||
val at = items.indexOfFirst { it.key == key }
|
||||
if (at >= 0) {
|
||||
// Supersede in place: keeping the queue position stops a fast rumble stream from
|
||||
// repeatedly jumping the one-shots queued ahead of it.
|
||||
items[at] = Entry(key, data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (items.size >= cap) {
|
||||
val victim = items.indexOfFirst { it.key != NO_COALESCE }
|
||||
if (victim >= 0) {
|
||||
items.removeAt(victim)
|
||||
} else if (key != NO_COALESCE) {
|
||||
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
|
||||
// rather than a one-shot that will never come again.
|
||||
return false
|
||||
} else {
|
||||
items.removeFirst()
|
||||
}
|
||||
}
|
||||
items.addLast(Entry(key, data))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next report to submit, or null when nothing is pending. */
|
||||
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
|
||||
|
||||
fun clear() = synchronized(items) { items.clear() }
|
||||
|
||||
val size: Int get() = synchronized(items) { items.size }
|
||||
|
||||
companion object {
|
||||
/** This report is a one-shot: never superseded, evicted only as a last resort. */
|
||||
const val NO_COALESCE = 0
|
||||
|
||||
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
|
||||
const val KEY_RUMBLE = 1
|
||||
|
||||
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
|
||||
const val CAP = 32
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The two conversions every rumble path in this module needs, in one place.
|
||||
*
|
||||
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
|
||||
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
|
||||
* with no test on either side of the JNI boundary. Neither is complicated — which is exactly why a
|
||||
* silent divergence between copies would have been hard to notice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wire amplitude (`0..0xFFFF`) → an 8-bit motor/vibrator level.
|
||||
*
|
||||
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
|
||||
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
|
||||
* imperceptibly light, but it moves.
|
||||
*/
|
||||
internal fun wireAmplitudeToByte(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
|
||||
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
|
||||
|
||||
/**
|
||||
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
|
||||
*
|
||||
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
|
||||
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
|
||||
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 — the Rust side has a
|
||||
* compile-time assertion tying the two together, so this can't silently start truncating.
|
||||
*
|
||||
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
|
||||
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim —
|
||||
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
|
||||
*/
|
||||
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
|
||||
if (ev < 0L) return null // timeout / closed
|
||||
return RumbleCmd(
|
||||
pad = ((ev ushr 49) and 0xFL).toInt(),
|
||||
low = ((ev ushr 16) and 0xFFFF).toInt(),
|
||||
high = (ev and 0xFFFF).toInt(),
|
||||
backstopMs = (ev ushr 32) and 0xFFFF,
|
||||
)
|
||||
}
|
||||
@@ -273,10 +273,20 @@ class Sc2Capture(
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
// Both transports share this callback, so read which one was live BEFORE clearing it —
|
||||
// releasing the other would tear down a link that never dropped.
|
||||
val dropped = activeLink
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
|
||||
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
|
||||
// round-trip a link many times in one session.
|
||||
when (dropped) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -127,8 +127,14 @@ object LibraryClient {
|
||||
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||
* defers to normal public trust for any other origin (an external CDN URL).
|
||||
*
|
||||
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
|
||||
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
|
||||
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
|
||||
* certificate for any name is accepted for the host — which is exactly what 2026-08-05 review M-2
|
||||
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
|
||||
* default verifier; the pin is its only credential, on purpose.
|
||||
*/
|
||||
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||
val clientCert = CertificateFactory.getInstance("X.509")
|
||||
@@ -162,7 +168,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
|
||||
|
||||
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
val verifier = HostnameVerifier { hostname, session ->
|
||||
hostname == host || defaultVerifier.verify(hostname, session)
|
||||
if (hostname == host) {
|
||||
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
|
||||
//
|
||||
// This used to be a bare `hostname == host`, which composed with the trust manager's
|
||||
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
|
||||
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
|
||||
// MITM with any free CA-issued cert intercepted the connection, received the client's
|
||||
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
|
||||
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
|
||||
// only Android did not.
|
||||
try {
|
||||
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
// Any other origin (an external CDN art URL) is ordinary public trust: the system
|
||||
// trust manager validated the chain, and this checks the name against it.
|
||||
defaultVerifier.verify(hostname, session)
|
||||
}
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
|
||||
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
|
||||
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
|
||||
*/
|
||||
class OutReportQueueTest {
|
||||
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
|
||||
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
|
||||
|
||||
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
|
||||
private fun drain(q: OutReportQueue): List<Int> =
|
||||
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
|
||||
|
||||
@Test
|
||||
fun `rumble supersedes the pending rumble instead of queueing another`() {
|
||||
val q = OutReportQueue()
|
||||
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
|
||||
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
|
||||
assertArrayEquals(report(3), q.poll())
|
||||
assertNull(q.poll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10)) // a one-shot queued behind it
|
||||
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
|
||||
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
|
||||
// behind an endlessly-renewed entry.
|
||||
assertEquals(listOf(2, 10), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full queue sacrifices rumble, never a one-shot`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
q.offer(report(12))
|
||||
assertEquals(4, q.size)
|
||||
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
|
||||
// ordering; what matters is that the one-shots all survive.
|
||||
assertTrue(q.offer(report(13)))
|
||||
assertEquals(listOf(10, 11, 12, 13), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the one-shot the host never repeats survives a rumble storm`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
|
||||
// old newest-wins eviction the colour was dropped from the head and never came back,
|
||||
// leaving the pad lit wrong until the value next happened to change.
|
||||
q.offer(report(200)) // lightbar
|
||||
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
|
||||
val out = drain(q)
|
||||
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
|
||||
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertFalse(
|
||||
"with nothing coalescable to sacrifice, the replaceable report yields",
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
|
||||
)
|
||||
assertEquals(listOf(10, 11), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertTrue(q.offer(report(12)))
|
||||
assertEquals(listOf(11, 12), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the queue`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.clear()
|
||||
assertEquals(0, q.size)
|
||||
assertNull(q.poll())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
|
||||
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
|
||||
* sides, which is the only thing that catches one of them drifting.
|
||||
*/
|
||||
class RumbleWireTest {
|
||||
|
||||
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
|
||||
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
|
||||
((pad and 0xF).toLong() shl 49) or
|
||||
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
|
||||
(low.toLong() shl 16) or
|
||||
high.toLong()
|
||||
|
||||
@Test
|
||||
fun `every field round-trips at its extremes`() {
|
||||
val cases = listOf(
|
||||
listOf(0, 0, 0, 0),
|
||||
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
listOf(1, 0x1234, 0x5678, 500),
|
||||
listOf(7, 0, 0xFFFF, 2000),
|
||||
)
|
||||
for ((pad, low, high, backstop) in cases) {
|
||||
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
|
||||
assertEquals("pad", pad, cmd.pad)
|
||||
assertEquals("low", low, cmd.low)
|
||||
assertEquals("high", high, cmd.high)
|
||||
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
|
||||
@Test
|
||||
fun `all sixteen pad indices are distinct`() {
|
||||
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
|
||||
assertEquals((0 until 16).toList(), seen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the negative sentinel is not a command`() {
|
||||
assertNull(unpackRumbleEvent(-1L))
|
||||
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop is distinguishable from a hold`() {
|
||||
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
|
||||
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
|
||||
assertEquals(0, stop.low)
|
||||
assertEquals(0, stop.high)
|
||||
assertNotEquals(stop, hold)
|
||||
}
|
||||
|
||||
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
|
||||
|
||||
@Test
|
||||
fun `amplitude takes the high byte`() {
|
||||
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
|
||||
assertEquals(0x80, wireAmplitudeToByte(0x8000))
|
||||
assertEquals(0x12, wireAmplitudeToByte(0x1234))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero stays silent but a weak nonzero never does`() {
|
||||
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
|
||||
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
|
||||
for (v in listOf(1, 0x0042, 0x00FF)) {
|
||||
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
|
||||
}
|
||||
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -18,6 +18,29 @@ use std::time::Duration;
|
||||
/// observes its `running=false` flag promptly on teardown.
|
||||
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
|
||||
const PAD_BITS: u32 = 4;
|
||||
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
|
||||
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
|
||||
/// rather than failing the build.
|
||||
const _: () = assert!(
|
||||
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
|
||||
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
|
||||
);
|
||||
|
||||
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
|
||||
///
|
||||
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
|
||||
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
|
||||
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
|
||||
/// without a live session handle — the shift arithmetic is the part worth pinning.
|
||||
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
|
||||
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
|
||||
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(low) << 16)
|
||||
| jlong::from(high)
|
||||
}
|
||||
|
||||
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
@@ -54,12 +77,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(cmd.low) << 16)
|
||||
| jlong::from(cmd.high)
|
||||
}
|
||||
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
|
||||
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
|
||||
// write, and that bit disables the audio-haptics path — so one replayed command would
|
||||
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
|
||||
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
|
||||
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
|
||||
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
|
||||
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
@@ -156,7 +182,74 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_rumble_tests {
|
||||
use super::*;
|
||||
use punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
|
||||
/// broken, and nothing else in the build would say so.
|
||||
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
|
||||
let pad = ((ev >> 49) & 0xF) as u16;
|
||||
let backstop = ((ev >> 32) & 0xFFFF) as u32;
|
||||
let low = ((ev >> 16) & 0xFFFF) as u16;
|
||||
let high = (ev & 0xFFFF) as u16;
|
||||
(pad, low, high, backstop)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_every_field_at_its_extremes() {
|
||||
for &(pad, low, high, backstop) in &[
|
||||
(0u16, 0u16, 0u16, 0u32),
|
||||
(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
(1, 0x1234, 0x5678, 500),
|
||||
(7, 0, 0xFFFF, 2000),
|
||||
] {
|
||||
let ev = pack_rumble(pad, low, high, backstop);
|
||||
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_representable_pad_survives_the_four_bit_field() {
|
||||
for pad in 0..MAX_PADS as u16 {
|
||||
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
|
||||
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packed_command_is_never_negative() {
|
||||
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
|
||||
// "no command" and the rumble would simply vanish.
|
||||
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
|
||||
assert!(pack_rumble(0, 0, 0, 0) >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
|
||||
let ev = pack_rumble(3, 0, 0, u32::MAX);
|
||||
let (pad, _, _, backstop) = unpack(ev);
|
||||
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
|
||||
assert_eq!(backstop, 0xFFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_is_distinguishable_from_a_hold() {
|
||||
let stop = pack_rumble(2, 0, 0, 0);
|
||||
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
|
||||
assert_ne!(stop, hold);
|
||||
assert_eq!(unpack(stop).1, 0);
|
||||
assert_eq!(unpack(stop).2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
@@ -99,6 +104,14 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -12,7 +12,7 @@ import GameController
|
||||
public final class ControllerTester: ObservableObject {
|
||||
// `.manual`: the panel's toggles hold a level until changed — no session wire refreshes
|
||||
// exist here to keep the renderer's staleness watchdog fed.
|
||||
private let renderer = RumbleRenderer(policy: .manual)
|
||||
private let renderer = RumbleRenderer()
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
|
||||
@@ -21,8 +21,12 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
/// A caller that owns a particular pad passes the location id it wants (see
|
||||
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
|
||||
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
|
||||
/// lowest location id wins — an arbitrary but *stable* choice, where `Set.first` was neither.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
@@ -43,9 +47,57 @@ final class DualSenseHID {
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
/// The IOKit location id of the device this instance opened — the handle a caller correlates
|
||||
/// with its `GCController`. `nil` until a successful `open`.
|
||||
private(set) var locationID: UInt32?
|
||||
|
||||
/// A device's location id, or `nil` if IOKit does not report one.
|
||||
static func locationID(of dev: IOHIDDevice) -> UInt32? {
|
||||
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
|
||||
}
|
||||
|
||||
/// Every connected DualSense/Edge, by location id — what a caller pairs against its controllers.
|
||||
static func attachedLocationIDs() -> [UInt32] {
|
||||
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
let matches = productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
|
||||
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
return []
|
||||
}
|
||||
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
|
||||
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
|
||||
return devices.compactMap(locationID(of:)).sorted()
|
||||
}
|
||||
|
||||
/// Which attached device to drive, as an index into `ids` — the whole selection rule, pure so
|
||||
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
|
||||
///
|
||||
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
|
||||
/// merely arbitrary — it can differ between two calls in one process. With two DualSenses that
|
||||
/// made each renderer's pad→device binding a coin flip: both could land on the same device
|
||||
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
|
||||
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
|
||||
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
|
||||
/// place never displaces one it can.
|
||||
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
|
||||
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
|
||||
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
|
||||
}
|
||||
|
||||
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
|
||||
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
|
||||
let ordered = Array(devices)
|
||||
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
|
||||
return nil
|
||||
}
|
||||
return ordered[i]
|
||||
}
|
||||
|
||||
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
|
||||
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
|
||||
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
@@ -55,13 +107,21 @@ final class DualSenseHID {
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
let dev = Self.pick(devices, preferring: preferred)
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
locationID = Self.locationID(of: dev)
|
||||
if let preferred, locationID != preferred {
|
||||
// Not fatal — one pad still gets rumble — but with two pads attached it means this
|
||||
// renderer is driving the wrong one, and it is invisible without the log line.
|
||||
log.error(
|
||||
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
|
||||
)
|
||||
}
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
@@ -70,8 +130,16 @@ final class DualSenseHID {
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
///
|
||||
/// Returns whether the write reached the device. The caller needs this: it used to be logged
|
||||
/// and swallowed, so a failed write still counted as a successful render. That matters most
|
||||
/// for a **stop**, which has nothing behind it — the renderer stamps its write clock even on
|
||||
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
|
||||
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
|
||||
/// stop left the motors running with nothing scheduled to try again.
|
||||
@discardableResult
|
||||
func rumble(low: UInt8, high: UInt8) -> Bool {
|
||||
guard let dev = device else { return false }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
@@ -81,7 +149,9 @@ final class DualSenseHID {
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
|
||||
@@ -65,7 +65,7 @@ public final class GamepadFeedback {
|
||||
#if os(iOS)
|
||||
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||
deviceRumble = RumbleRenderer(actuator: .device)
|
||||
} else {
|
||||
deviceRumble = nil
|
||||
}
|
||||
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
renderer?.stop()
|
||||
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
|
||||
// per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd, which the
|
||||
// renderer's own notes record as able to hang — plus `DualSenseHID.close()`, whose
|
||||
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
|
||||
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
|
||||
// change, and the main thread is what drives the presenter's CADisplayLink, so
|
||||
// blocking here hitches the picture mid-stream. The renderer is already detached from
|
||||
// routing above, so nothing observes it after this point.
|
||||
if let renderer { Task.detached { renderer.stop() } }
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
@@ -128,7 +136,7 @@ public final class GamepadFeedback {
|
||||
replay(slot)
|
||||
} else {
|
||||
slots[pad] = Slot(controller: controller)
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(controller)
|
||||
withRouting { rumbleByPad[pad] = renderer }
|
||||
}
|
||||
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
|
||||
private func reset(_ controller: GCController?) {
|
||||
guard let c = controller else { return }
|
||||
c.playerIndex = .indexUnset
|
||||
// Put the lightbar out too. This class is what turned it on (see the `Led` and
|
||||
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
|
||||
// set stays lit in firmware after the stream ends — back at the launcher, or for a pad
|
||||
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
|
||||
// indicator IS the lightbar; a DualSense is not.
|
||||
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
|
||||
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
|
||||
@@ -43,8 +43,14 @@ enum RumbleTuning {
|
||||
|
||||
/// Wire amplitude (0...0xFFFF) → CoreHaptics intensity (0...1).
|
||||
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
|
||||
/// Wire amplitude → DualSense HID motor byte.
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
|
||||
/// Wire amplitude → DualSense HID motor byte. A nonzero command never collapses to silence:
|
||||
/// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as
|
||||
/// nothing at all on this path. Floored at 1 — imperceptibly light, but moving. (Android's
|
||||
/// `toAmplitude` has always done this; this was the odd one out.)
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 {
|
||||
let b = UInt8(wire >> 8)
|
||||
return wire != 0 && b == 0 ? 1 : b
|
||||
}
|
||||
/// Single-actuator pads render whichever motor is stronger.
|
||||
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
|
||||
/// Are two baked levels the same (skip the rebuild)?
|
||||
@@ -81,10 +87,11 @@ enum RumbleTuning {
|
||||
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown — the
|
||||
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
|
||||
/// exponential backoff.
|
||||
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
|
||||
/// `sessionStaleSeconds` → force silence. A lost stop can outlive the host's 500 ms heal
|
||||
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
|
||||
/// (the settings test panel) instead holds a level until it is changed.
|
||||
/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a
|
||||
/// `sessionStaleSeconds`; both are gone. Every liveness decision — lease expiry, legacy-host
|
||||
/// staleness, session close — now belongs to punktfunk-core's shared policy engine
|
||||
/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it
|
||||
/// is told and never decides on its own when a level should end.
|
||||
///
|
||||
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
|
||||
/// failures (pads without haptics, engine resets) downgrade to silence — rumble is best-effort
|
||||
@@ -93,17 +100,6 @@ enum RumbleTuning {
|
||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||
/// `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||
/// the call sites' intent.
|
||||
struct Policy {
|
||||
static let session = Policy()
|
||||
static let manual = Policy()
|
||||
}
|
||||
|
||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) — the opt-in
|
||||
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
|
||||
@@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
private let policy: Policy
|
||||
private let actuator: Actuator
|
||||
|
||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||
@@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||
self.policy = policy
|
||||
init(actuator: Actuator = .controller) {
|
||||
self.actuator = actuator
|
||||
}
|
||||
|
||||
@@ -459,6 +453,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
|
||||
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
|
||||
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
|
||||
// which handle died — lose `high` and `render` falls to the combined branch (selected
|
||||
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
|
||||
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
|
||||
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
|
||||
// which at least renders both motors somewhere.
|
||||
if low == nil || high == nil {
|
||||
log.warning("rumble: only one split-handle engine came up — falling back to combined")
|
||||
teardown() // disarms handlers, stops the survivor's players + engine, nils both
|
||||
}
|
||||
} else {
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
@@ -587,7 +593,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
// Ask for the device this renderer's controller actually is, so two attached DualSenses
|
||||
// do not both get driven through whichever one an unordered Set happened to yield first.
|
||||
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
@@ -595,6 +603,24 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Correlate a `GCController` with an IOKit location id.
|
||||
///
|
||||
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
|
||||
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
|
||||
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
|
||||
/// missing. With one pad attached this is the same device it always was.
|
||||
static func hidLocationID(for c: GCController) -> UInt32? {
|
||||
let ids = DualSenseHID.attachedLocationIDs()
|
||||
guard ids.count > 1 else { return ids.first }
|
||||
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
|
||||
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
|
||||
return ids.first
|
||||
}
|
||||
return ids[rank]
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||
@@ -605,8 +631,20 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
let keepalive = levels != (0, 0)
|
||||
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
|
||||
if levels != lastHidWrite.levels || keepalive {
|
||||
hid.rumble(low: levels.0, high: levels.1)
|
||||
lastHidWrite = (levels, .now())
|
||||
if hid.rumble(low: levels.0, high: levels.1) {
|
||||
lastHidWrite = (levels, .now())
|
||||
} else {
|
||||
// The write did not reach the device. Do NOT stamp the clock — that would claim a
|
||||
// render that never happened, and for a stop there is nothing behind it: the
|
||||
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
|
||||
// target is (0, 0), so the motors would keep running with nothing scheduled.
|
||||
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
|
||||
// rebuilds it. Health is reported so the state is visible rather than silent.
|
||||
log.error("rumble: HID write failed — dropping the handle, falling back")
|
||||
closeHID()
|
||||
reportHealth("Lost the direct connection to this DualSense; using the system path.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
#else
|
||||
|
||||
@@ -43,5 +43,33 @@ final class DualSenseHIDTests: XCTestCase {
|
||||
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||
}
|
||||
|
||||
// MARK: - Device selection (B14)
|
||||
|
||||
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
|
||||
/// `Set.first` from an unordered set, so the pad→device binding was a coin flip that could
|
||||
/// point both renderers at the same pad.
|
||||
func testPreferredIndexHonoursAnExplicitLocation() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
|
||||
}
|
||||
|
||||
/// No preference (or one the pad no longer has): fall back to the LOWEST id — arbitrary, but
|
||||
/// stable across calls, which `Set.first` was not.
|
||||
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
|
||||
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
|
||||
// open — it degrades to the same stable fallback.
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
|
||||
}
|
||||
|
||||
/// A device IOKit reports no location for must never displace one it can place.
|
||||
func testPreferredIndexSortsUnplaceableDevicesLast() {
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
|
||||
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
|
||||
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
|
||||
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
|
||||
}
|
||||
|
||||
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||
|
||||
@@ -56,7 +56,7 @@ final class RumbleTuningTests: XCTestCase {
|
||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||
func testRendererSurvivesCallStormAndTeardownWithoutController() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(nil)
|
||||
for i in 0..<500 {
|
||||
renderer.apply(
|
||||
@@ -72,7 +72,7 @@ final class RumbleTuningTests: XCTestCase {
|
||||
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
|
||||
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
|
||||
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(nil)
|
||||
renderer.apply(low: 0x8000, high: 0x8000)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "punktfunk",
|
||||
"name": "Punktfunk",
|
||||
"author": "enrico",
|
||||
"flags": ["debug"],
|
||||
"api_version": 1,
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DECK="${DECK:?set DECK=deck@<ip>}"
|
||||
NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')"
|
||||
# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name" —
|
||||
# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header.
|
||||
NAME=punktfunk
|
||||
STAGE_LOCAL="$HERE/out/$NAME"
|
||||
[ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; }
|
||||
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
# package.json,decky.pyi,LICENSE,README.md}
|
||||
# out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh)
|
||||
#
|
||||
# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal
|
||||
# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs
|
||||
# only bash, python3 and zip.
|
||||
# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is,
|
||||
# so the dir in the zip becomes ~/homebrew/plugins/<dir>). It is deliberately NOT read from
|
||||
# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in
|
||||
# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder
|
||||
# name. Keeping the folder lowercase means a rename of the label can't strand the old directory
|
||||
# next to a new one (which would show up as two plugins).
|
||||
# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$HERE"
|
||||
@@ -15,7 +19,7 @@ cd "$HERE"
|
||||
[ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; }
|
||||
[ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; }
|
||||
|
||||
NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')"
|
||||
NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name"
|
||||
VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
|
||||
@@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The label a saved row shows.
|
||||
*
|
||||
* A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add`
|
||||
* falls back to the address when the pairing path had nothing better, so the row ends up
|
||||
* captioned with the same string it already prints underneath. When the box is on the air it
|
||||
* is advertising its actual hostname — prefer that, and the row reads "home-worker-5" instead
|
||||
* of "192.168.1.21".
|
||||
*
|
||||
* A real saved name always wins over the advert, even a stale one: it may be a name the user
|
||||
* chose, and a live advert must never quietly overwrite that. Compared against the SAVED
|
||||
* address, so a host that moved DHCP lease still recognises its old address as a placeholder.
|
||||
*/
|
||||
function hostLabel(s: SavedHost, advert?: DiscoveredHost): string {
|
||||
const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`;
|
||||
if (!placeholder) return s.name;
|
||||
return advert?.name || s.name || s.addr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the saved store and the live browse into the rows the panel draws.
|
||||
*
|
||||
@@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho
|
||||
// Prefer a live advert's address: the host may have moved since it was last saved.
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
name: hostLabel(s, advert),
|
||||
addr: advert?.addr ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex,
|
||||
@@ -387,7 +406,10 @@ export async function applyUpdate(
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
// The name Decky uninstalls before extracting the new zip — it locates the folder by
|
||||
// matching plugin.json "name", so this must equal THIS build's plugin.json name (the
|
||||
// brand-cased one), not the lowercase on-disk dir.
|
||||
"Punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
|
||||
@@ -337,9 +337,11 @@ export default definePlugin(() => {
|
||||
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||
void ensureGamepadUiShortcut();
|
||||
return {
|
||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
||||
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||
name: "punktfunk",
|
||||
// `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is
|
||||
// USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand
|
||||
// case. Decky finds an installed plugin by matching plugin.json "name" (never the folder
|
||||
// name), so this is independent of the on-disk dir, which stays lowercase `punktfunk`.
|
||||
name: "Punktfunk",
|
||||
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
|
||||
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
|
||||
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
|
||||
|
||||
@@ -70,9 +70,18 @@ declare const appStore:
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
try {
|
||||
const get = appStore?.GetAppOverviewByAppID;
|
||||
if (!get) return true; // no way to verify — preserve the reuse path
|
||||
return get(appId) != null;
|
||||
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
|
||||
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
|
||||
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
|
||||
// "true". That is not a stale-data bug but a total one: the guard then answers "still
|
||||
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
|
||||
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
|
||||
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
|
||||
// one is a ReferenceError that optional chaining does NOT prevent.
|
||||
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
|
||||
return true; // no way to verify — preserve the reuse path
|
||||
}
|
||||
return appStore.GetAppOverviewByAppID(appId) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -773,6 +773,7 @@ fn mock_library() -> (
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
role: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -286,6 +286,12 @@ mod session_main {
|
||||
// Spawned at first params-build so it exists for --connect AND console launches.
|
||||
#[cfg(unix)]
|
||||
crate::ctl_socket::spawn(gamepad.clone());
|
||||
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
|
||||
// slots declare their render caps at open time, which happens on attach — after this.
|
||||
gamepad.set_pad_audio_prefs(
|
||||
settings.pad_haptics,
|
||||
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -389,6 +395,11 @@ mod session_main {
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
echo_cancel: settings.echo_cancel,
|
||||
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||
// service learns the same prefs below so tier-A slots declare their render caps
|
||||
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
|
||||
@@ -1911,10 +1911,35 @@ pub(crate) fn settings_page(
|
||||
} else {
|
||||
border(vstack(Vec::<Element>::new())).into()
|
||||
};
|
||||
// Every save on this page is fire-and-forget by design — a failed settings write must
|
||||
// never take a stream down — so a client whose config store rejects writes looks entirely
|
||||
// normal: toggles move, profiles appear, and NOTHING survives a restart. That is exactly
|
||||
// how it reached us from the field ("it's in read-only mode"), with no log file to send
|
||||
// either. When the store is refusing writes, say so, name the path, and stop pretending.
|
||||
//
|
||||
// Same always-mounted-slot discipline as `sheet_slot`: one child in both states, and the
|
||||
// SAME KIND in both (a Border wrapping the bar, versus an empty background-less Border —
|
||||
// which per style.rs is not hit-testable, so it swallows no clicks). Neither a grid child
|
||||
// nor a vstack child is ever added or removed, which is where this reconciler's phantom
|
||||
// bookkeeping breaks.
|
||||
let store_slot: Element = match pf_client_core::trust::store_health::last_error() {
|
||||
Some(err) => border(
|
||||
InfoBar::new("Your changes aren\u{2019}t being saved")
|
||||
.message(format!(
|
||||
"Punktfunk can\u{2019}t write to its settings folder, so nothing on this \
|
||||
page will survive a restart. {err}"
|
||||
))
|
||||
.error()
|
||||
.is_closable(false),
|
||||
)
|
||||
.margin(edges(24.0, 12.0, 28.0, 0.0))
|
||||
.into(),
|
||||
None => border(vstack(Vec::<Element>::new())).into(),
|
||||
};
|
||||
// The bar rides an Auto row above the nav's Star row, so the nav (and the sheet's scrim
|
||||
// over it) still fills the rest of the window.
|
||||
grid(vec![
|
||||
scope_bar.grid_row(0),
|
||||
Element::from(vstack(vec![store_slot, scope_bar])).grid_row(0),
|
||||
Element::from(grid(vec![nav.into(), sheet_slot, confirm])).grid_row(1),
|
||||
])
|
||||
.rows([GridLength::Auto, GridLength::STAR])
|
||||
|
||||
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
|
||||
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
|
||||
# property stores entirely (the same version the host pins).
|
||||
winreg = "0.56"
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
|
||||
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
|
||||
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
|
||||
|
||||
@@ -98,13 +98,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
/// Settings device pickers via session main), or the OS default. A picked device that's
|
||||
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
|
||||
///
|
||||
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
|
||||
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
|
||||
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
|
||||
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
|
||||
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
|
||||
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
|
||||
pub(crate) fn device_by_id(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
id: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
let devices = enumerator
|
||||
.get_device_collection(direction)
|
||||
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
|
||||
let count = devices
|
||||
.get_nbr_devices()
|
||||
.map_err(|e| anyhow!("endpoint count: {e}"))?;
|
||||
for i in 0..count {
|
||||
let dev = devices
|
||||
.get_device_at_index(i)
|
||||
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
|
||||
if dev.get_id().is_ok_and(|got| got == id) {
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no active {direction:?} endpoint with id {id}")
|
||||
}
|
||||
|
||||
fn pick_device(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
var: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
||||
match enumerator.get_device(&id) {
|
||||
match device_by_id(enumerator, direction, &id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
var,
|
||||
|
||||
@@ -302,6 +302,21 @@ fn set_valve_hidapi(enabled: bool) {
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
|
||||
}
|
||||
|
||||
/// Disable the Valve HIDAPI drivers **before SDL exists** — call this alongside the other
|
||||
/// pre-`SDL_Init` hints, not after a subsystem is up.
|
||||
///
|
||||
/// The damage these drivers do happens at *enumeration*, which is part of initialising the
|
||||
/// joystick/gamepad subsystem. Setting the hint afterwards does detach the driver, but only after
|
||||
/// it has already sent the Deck its `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` — so the
|
||||
/// built-in trackpad-mouse dies system-wide and stays dead until the firmware watchdog restores
|
||||
/// lizard mode seconds later. The threaded worker ([`run`]) has always done this in the right
|
||||
/// order; the caller-pumped path could not, because by the time it receives a
|
||||
/// [`sdl3::GamepadSubsystem`] the enumeration has already happened. Hence a separate entry point
|
||||
/// its callers can put in the right place.
|
||||
pub fn preinit_disable_valve_hidapi() {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
@@ -354,8 +369,14 @@ enum Ctl {
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
Forwarding(bool),
|
||||
SystemButtons { forward_raw: bool, gesture: bool },
|
||||
SystemButtons {
|
||||
forward_raw: bool,
|
||||
gesture: bool,
|
||||
},
|
||||
TapButton(u32),
|
||||
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
|
||||
PadAudioPrefs(u8),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -412,9 +433,12 @@ impl GamepadService {
|
||||
/// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's
|
||||
/// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback).
|
||||
///
|
||||
/// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their
|
||||
/// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled
|
||||
/// for the duration of an attached session only.
|
||||
/// The Valve HIDAPI drivers are held off here too, but this is **too late to be the only
|
||||
/// place it happens**: the `subsystem` argument means enumeration is already done, and that
|
||||
/// is when the Deck driver kills the trackpad-mouse. The caller must also call
|
||||
/// [`preinit_disable_valve_hidapi`] with its other pre-`SDL_Init` hints. This call still
|
||||
/// earns its place — it re-asserts "off" for a process that ran a session earlier — but on
|
||||
/// its own it only detaches a driver that has already done the damage.
|
||||
pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) {
|
||||
set_valve_hidapi(false);
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -555,6 +579,18 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
|
||||
}
|
||||
|
||||
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
|
||||
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
|
||||
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
|
||||
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
|
||||
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
|
||||
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
|
||||
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
|
||||
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
|
||||
let bits = (haptics as u8) | ((speaker as u8) << 1);
|
||||
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -609,6 +645,38 @@ impl GamepadPump {
|
||||
self.worker.menu_poll();
|
||||
self.worker.render_feedback();
|
||||
}
|
||||
|
||||
/// Close every forwarded slot — flush its held wire state, tell the host to remove the pad,
|
||||
/// and physically silence it. Call once on the way out of the caller's event loop.
|
||||
///
|
||||
/// [`GamepadService::detach`] only *posts* `Ctl::Detach`; the close — the flush, the host-side
|
||||
/// `GamepadRemove`, and the explicit `set_rumble(0, 0)` backstop in `close_slot_at` — happens
|
||||
/// when the pump next drains it. An exit path that detached and then left the loop without
|
||||
/// another [`tick`](Self::tick) therefore skipped all of it, and nothing else would: the slots
|
||||
/// hold no `Drop` that silences them. A pad left mid-buzz stayed buzzing.
|
||||
///
|
||||
/// This closes the slots directly rather than draining the queued `Ctl::Detach` that would
|
||||
/// have done it. Same physical outcome by a shorter path, and deliberately so: this also runs
|
||||
/// from `Drop`, and `drain_ctl` reaches `Mutex::lock().unwrap()`, which on a poisoned lock
|
||||
/// would panic — during an unwind that aborts the process. Closing a slot touches no lock.
|
||||
///
|
||||
/// Idempotent, and safe with nothing attached.
|
||||
pub fn shutdown(&mut self) {
|
||||
self.worker.close_all_slots();
|
||||
}
|
||||
}
|
||||
|
||||
/// The silence backstop of last resort. A caller's loop can also leave by `?` on a fatal overlay
|
||||
/// or present error — several paths do — and those would skip an explicit
|
||||
/// [`shutdown`](GamepadPump::shutdown) entirely, leaving a forwarded pad buzzing on the way out.
|
||||
///
|
||||
/// Callers should still call `shutdown` at their normal exit rather than lean on this: the pad
|
||||
/// wants to go quiet *before* a long teardown (session join, `vkDeviceWaitIdle`), not after it.
|
||||
/// Doing both is free — `shutdown` is idempotent.
|
||||
impl Drop for GamepadPump {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest wire pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)) not already held
|
||||
@@ -682,13 +750,29 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
|
||||
/// Enable bits select only the fields each update touches, so rumble (driven separately
|
||||
/// through SDL) and untouched fields keep their state.
|
||||
///
|
||||
/// The offsets below are the USB output report's, **minus one**: SDL's payload carries no leading
|
||||
/// report id. `pf-inject`'s `dualsense_proto::out_report` is where that layout is written down and
|
||||
/// explained (including the Bluetooth `+2` base), but this crate cannot import it — `pf-inject` is
|
||||
/// host-side and neither crate depends on the other, and a DualSense report layout has no business
|
||||
/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and
|
||||
/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `−1` relationship rather than
|
||||
/// leaving it to a comment.
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
const RIGHT_TRIGGER: usize = 10;
|
||||
const LEFT_TRIGGER: usize = 21;
|
||||
const PAD_LIGHTS: usize = 43;
|
||||
const LED_RGB: usize = 44;
|
||||
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
|
||||
/// values so the subtraction is visible at the point of definition.
|
||||
const REPORT_ID_LEN: usize = 1;
|
||||
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`): report byte 5.
|
||||
const AUDIO: usize = 5 - Self::REPORT_ID_LEN;
|
||||
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
|
||||
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
|
||||
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
|
||||
const LED_RGB: usize = 45 - Self::REPORT_ID_LEN;
|
||||
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters. Mirrors
|
||||
/// `PUNKTFUNK_HID_EFFECT_MAX`, which is the same number at the C-ABI boundary.
|
||||
const TRIGGER_LEN: usize = punktfunk_core::abi::PUNKTFUNK_HID_EFFECT_MAX as usize;
|
||||
|
||||
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
@@ -698,7 +782,7 @@ impl Ds5Feedback {
|
||||
(0x08, Self::LEFT_TRIGGER)
|
||||
};
|
||||
p[0] = flag;
|
||||
let n = effect.len().min(11);
|
||||
let n = effect.len().min(Self::TRIGGER_LEN);
|
||||
p[off..off + n].copy_from_slice(&effect[..n]);
|
||||
p
|
||||
}
|
||||
@@ -718,6 +802,29 @@ impl Ds5Feedback {
|
||||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||
p
|
||||
}
|
||||
|
||||
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
|
||||
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
|
||||
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
|
||||
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
|
||||
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
|
||||
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
|
||||
fn audio_haptics_packet() -> [u8; 47] {
|
||||
[0u8; 47]
|
||||
}
|
||||
|
||||
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
|
||||
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
|
||||
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
|
||||
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
|
||||
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
|
||||
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
|
||||
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
p[0] = (flags & 0x1E) << 3;
|
||||
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||
@@ -754,6 +861,14 @@ struct Slot {
|
||||
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
|
||||
/// `guide_gesture` policy is on.
|
||||
gesture: SelectGesture,
|
||||
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
|
||||
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
|
||||
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
|
||||
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
|
||||
/// disable-bit trap — see [`Worker::render_feedback`]).
|
||||
audio_caps: u8,
|
||||
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
|
||||
rumble_suppressed_logged: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -770,6 +885,8 @@ impl Slot {
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
gesture: SelectGesture::default(),
|
||||
audio_caps: 0,
|
||||
rumble_suppressed_logged: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,6 +1024,10 @@ struct Worker {
|
||||
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
|
||||
/// down went out on receipt, the up goes out from the poll once `due` passes.
|
||||
synthetic_ups: Vec<(u8, u32, Instant)>,
|
||||
/// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder
|
||||
/// declares some: tier-A detection then never runs and every arrival stays caps-less.
|
||||
pad_audio_prefs: u8,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -1112,11 +1233,18 @@ impl Worker {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
Self::set_slot_sensors(&mut slot, true);
|
||||
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
|
||||
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
|
||||
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
// Pad-audio render caps go in FIRST — the core ORs them into this (and
|
||||
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
|
||||
// set (0 for non-tier-A): wire indices are reused within a connection, so
|
||||
// a tier-A slot that closes must not leave its bits behind for the next
|
||||
// pad on the same index (the set_rumble_quirks rule).
|
||||
c.set_pad_audio_caps(index, slot.audio_caps);
|
||||
send(
|
||||
c,
|
||||
InputKind::GamepadArrival,
|
||||
@@ -1139,6 +1267,27 @@ impl Worker {
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
if slot.audio_caps != 0 {
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
|
||||
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
|
||||
// "disable audio haptics") whenever its rumble path runs — which
|
||||
// would MUTE the voice coils the 0xD1 stream drives. One effects
|
||||
// packet with those bits CLEARED puts the pad back on audio haptics
|
||||
// ("Leaving emulated rumble bits off will restore audio haptics" —
|
||||
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
|
||||
// render_feedback so SDL never re-arms them.
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
|
||||
}
|
||||
// Hand the pad to the session's renderer worker. Windows correlation
|
||||
// needs the HID interface path; Linux matches the sink by signature.
|
||||
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||
tracing::info!(
|
||||
index,
|
||||
caps = slot.audio_caps,
|
||||
"tier-A DualSense: pad-audio render caps declared"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
id,
|
||||
index,
|
||||
@@ -1152,6 +1301,35 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
|
||||
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
|
||||
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
|
||||
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
|
||||
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
|
||||
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
|
||||
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
|
||||
if self.pad_audio_prefs == 0 {
|
||||
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
|
||||
}
|
||||
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
|
||||
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
|
||||
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
|
||||
return 0; // not a DualSense/Edge — no wired check needed
|
||||
}
|
||||
use sdl3::joystick::ConnectionState;
|
||||
let wired = match pad.connection_state() {
|
||||
Ok(ConnectionState::Wired) => true,
|
||||
Ok(ConnectionState::Wireless) => false,
|
||||
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
|
||||
};
|
||||
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
|
||||
self.pad_audio_prefs
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
|
||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||
/// already gone (unplug).
|
||||
@@ -1160,6 +1338,7 @@ impl Worker {
|
||||
// unplug) must not depend on what SDL does to a rumbling device at close. Errors are
|
||||
// expected for an already-unplugged pad.
|
||||
let _ = self.slots[i].pad.set_rumble(0, 0, 100);
|
||||
Self::reset_slot_feedback(&mut self.slots[i]);
|
||||
if let Some(c) = self.attached.clone() {
|
||||
Self::flush_slot(&c, &mut self.slots[i]);
|
||||
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
||||
@@ -1168,6 +1347,11 @@ impl Worker {
|
||||
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
|
||||
}
|
||||
let slot = self.slots.remove(i);
|
||||
if slot.audio_caps != 0 {
|
||||
// Take the pad back from the pad-audio renderer (its device-gone path then
|
||||
// re-correlates — and finds nothing until a tier-A pad registers again).
|
||||
crate::pad_audio::unregister_tier_a(slot.index);
|
||||
}
|
||||
tracing::info!(
|
||||
id = slot.id,
|
||||
index = slot.index,
|
||||
@@ -1175,6 +1359,35 @@ impl Worker {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hand the physical controller back in a neutral state before its handle closes.
|
||||
///
|
||||
/// Rumble stops on its own the moment nothing renews it, but the rich planes do not: an
|
||||
/// adaptive-trigger effect and a lightbar colour are LATCHED in the pad's firmware and survive
|
||||
/// the stream, the app, and being unplugged. Ending a session on a weapon's trigger resistance
|
||||
/// left the physical trigger stiff on the desktop afterwards, with nothing to clear it but
|
||||
/// another game. Apple's client already resets on teardown; this is the desktop half.
|
||||
///
|
||||
/// Best-effort throughout: the pad may already be gone (that is one of the ways we get here).
|
||||
fn reset_slot_feedback(slot: &mut Slot) {
|
||||
if matches!(
|
||||
slot.pref,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
) {
|
||||
// An all-zero trigger block is mode 0x00 — no effect — which is what releases the
|
||||
// trigger. Both sides, then the lightbar dark and the player indicator clear.
|
||||
for which in [0u8, 1] {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, &[0u8; 11]));
|
||||
}
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(0, 0, 0));
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(0));
|
||||
} else {
|
||||
// Anything else with an LED goes dark through SDL, which owns the per-device details.
|
||||
let _ = slot.pad.set_led(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_all_slots(&mut self) {
|
||||
while !self.slots.is_empty() {
|
||||
self.close_slot_at(0);
|
||||
@@ -1560,6 +1773,7 @@ impl Worker {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
}
|
||||
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1837,7 +2051,12 @@ impl Worker {
|
||||
let dur_ms: u32 = if (low, high) == (0, 0) {
|
||||
100 // a stop takes effect immediately; the duration is irrelevant
|
||||
} else {
|
||||
backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator
|
||||
// No local floor. There was a `.max(160)` here, and it could never do anything: the
|
||||
// engine's own `backstop()` returns `(2 * ttl).clamp(500, 5000)` or the 2000 ms legacy
|
||||
// value, so a non-zero command's backstop is never below 500. A floor that belongs to a
|
||||
// particular actuator belongs in its `ActuatorQuirks::min_pulse_ms`, which the engine
|
||||
// already applies — not re-invented per renderer where it can silently disagree.
|
||||
backstop_ms
|
||||
};
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
|
||||
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
|
||||
@@ -1867,6 +2086,20 @@ impl Worker {
|
||||
// first; the physical silence backstop is in `close_slot_at`).
|
||||
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
|
||||
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
|
||||
// so a slot with tier-A haptics active never issues wire rumble (the stream
|
||||
// carries the feedback; the game's rumble is in its haptics mix).
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
if !slot.rumble_suppressed_logged {
|
||||
slot.rumble_suppressed_logged = true;
|
||||
tracing::info!(
|
||||
pad = slot.index,
|
||||
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||
}
|
||||
}
|
||||
@@ -1892,6 +2125,11 @@ impl Worker {
|
||||
HidOutput::PlayerLeds { bits, .. } if is_ds => {
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||||
}
|
||||
// Every other pad with player LEDs gets them through SDL, which owns the
|
||||
// per-device pattern. This used to fall through and do nothing at all.
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
let _ = set_player_leds(&slot.pad, bits);
|
||||
}
|
||||
HidOutput::Trigger {
|
||||
which, ref effect, ..
|
||||
} if is_ds => {
|
||||
@@ -1899,12 +2137,57 @@ impl Worker {
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
_ => {}
|
||||
// The audio-control region of a DS5 output report a game wrote host-side
|
||||
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
|
||||
// pad's effects packet, but only where a tier-A renderer is actually live
|
||||
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
|
||||
// nothing streams to would just mute/blast a future session's start state.
|
||||
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
|
||||
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
|
||||
}
|
||||
// Deliberately unhandled, listed rather than left to a bare `_` so a new
|
||||
// variant cannot join them silently: adaptive triggers exist only on a
|
||||
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
|
||||
// and carried by `send_effect` above when the pad is one. `AudioCtl` lands here
|
||||
// only when the guarded arm above declined it — a non-DualSense pad, or one with
|
||||
// no live tier-A renderer — which is the pre-pad-audio behaviour: drop it.
|
||||
HidOutput::Trigger { .. }
|
||||
| HidOutput::TrackpadHaptic { .. }
|
||||
| HidOutput::HidRaw { .. }
|
||||
| HidOutput::AudioCtl { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The SDL player index for the wire's positional player-LED `bits`, or `None` for "no player".
|
||||
///
|
||||
/// The wire carries a bitmask — one bit per LED, low 5 — while SDL wants a player *index* and owns
|
||||
/// the per-device pattern. The count bridges them: every convention that reaches this wire spells
|
||||
/// "player N" as N lit LEDs, both the DualSense patterns (`0x04`, `0x0A`, `0x15`, `0x1B`, `0x1F`)
|
||||
/// and the Switch/XInput run of low bits (`0x01`, `0x03`, `0x07`, `0x0F`). SDL's index is 0-based,
|
||||
/// so player 1 is index 0; no lit LED means *no* player rather than player 0.
|
||||
///
|
||||
/// Split out from [`set_player_leds`] so the mapping is testable — an `sdl3::Gamepad` needs a real
|
||||
/// device, so nothing that takes one can be.
|
||||
fn player_index_from_bits(bits: u8) -> Option<u16> {
|
||||
match (bits & 0x1F).count_ones() {
|
||||
0 => None,
|
||||
n => Some((n - 1) as u16),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive a non-DualSense pad's player LEDs from the wire's positional `bits`.
|
||||
fn set_player_leds(pad: &sdl3::gamepad::Gamepad, bits: u8) -> Result<(), sdl3::Error> {
|
||||
match player_index_from_bits(bits) {
|
||||
None => pad.unset_player_index(),
|
||||
Some(i) => pad.set_player_index(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire pad index a [`HidOutput`] is addressed to (every variant carries `pad`).
|
||||
fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
match h {
|
||||
@@ -1913,6 +2196,9 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
| HidOutput::Trigger { pad, .. }
|
||||
| HidOutput::TrackpadHaptic { pad, .. }
|
||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||
// AudioCtl's pad is the plane's only u16. `HidOutput::decode` rejects anything at or
|
||||
// above MAX_PADS (B27), so by the time one reaches here the narrowing is lossless.
|
||||
HidOutput::AudioCtl { pad, .. } => *pad as u8,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1940,6 +2226,7 @@ impl Worker {
|
||||
system_forward: true,
|
||||
guide_gesture: false,
|
||||
synthetic_ups: Vec::new(),
|
||||
pad_audio_prefs: 0,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -2385,5 +2672,254 @@ mod slot_tests {
|
||||
}),
|
||||
6
|
||||
);
|
||||
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
|
||||
assert_eq!(
|
||||
hidout_pad(&HidOutput::AudioCtl {
|
||||
pad: 7,
|
||||
flags: 0,
|
||||
raw: [0; 6]
|
||||
}),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
|
||||
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
|
||||
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
|
||||
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
|
||||
#[test]
|
||||
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
|
||||
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
|
||||
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
|
||||
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
|
||||
// bits1..4 (0b1011) → flag0 bits 4..7.
|
||||
assert_eq!(p[0], 0b1011_0000);
|
||||
assert_eq!(
|
||||
p[0] & 0x03,
|
||||
0,
|
||||
"haptics-select must NOT replay into p[0] bits 0/1"
|
||||
);
|
||||
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
|
||||
assert!(p[1..4].iter().all(|&b| b == 0));
|
||||
assert!(p[10..].iter().all(|&b| b == 0));
|
||||
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
|
||||
assert_eq!(p[0], 0);
|
||||
assert_eq!(&p[4..10], &raw);
|
||||
// The tier-A activation packet is the all-clear: every enable bit off — per
|
||||
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
|
||||
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Ds5Feedback`]'s three packet builders. The host-side parser, the Android writer and the Apple
|
||||
/// writer are all pinned by their own suites; this writer had nothing, despite being the one that
|
||||
/// hand-shifts every offset by the report-id length.
|
||||
#[cfg(test)]
|
||||
mod ds5_feedback_tests {
|
||||
use super::*;
|
||||
|
||||
/// The USB output report offsets, written out independently of the implementation. A DS5
|
||||
/// effects payload is the same block with the leading report id removed, so every offset is
|
||||
/// exactly one lower — this is the relationship the derived constants encode.
|
||||
#[test]
|
||||
fn ds5_offsets_track_the_usb_report() {
|
||||
for (usb, payload) in [
|
||||
(11usize, Ds5Feedback::RIGHT_TRIGGER),
|
||||
(22, Ds5Feedback::LEFT_TRIGGER),
|
||||
(44, Ds5Feedback::PAD_LIGHTS),
|
||||
(45, Ds5Feedback::LED_RGB),
|
||||
] {
|
||||
assert_eq!(payload, usb - 1, "payload offset for USB byte {usb}");
|
||||
}
|
||||
assert_eq!(Ds5Feedback::TRIGGER_LEN, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightbar_sets_only_its_enable_bit_and_its_three_bytes() {
|
||||
let p = Ds5Feedback::lightbar_packet(0x11, 0x22, 0x33);
|
||||
assert_eq!(p.len(), 47);
|
||||
assert_eq!(p[1], 0x04, "valid_flag1 lightbar bit");
|
||||
assert_eq!(p[0], 0, "must not claim any valid_flag0 field");
|
||||
assert_eq!(
|
||||
(
|
||||
p[Ds5Feedback::LED_RGB],
|
||||
p[Ds5Feedback::LED_RGB + 1],
|
||||
p[Ds5Feedback::LED_RGB + 2]
|
||||
),
|
||||
(0x11, 0x22, 0x33)
|
||||
);
|
||||
// Everything else stays zero — an over-broad packet would blank the triggers/player LEDs
|
||||
// it never meant to touch.
|
||||
let touched = [
|
||||
1,
|
||||
Ds5Feedback::LED_RGB,
|
||||
Ds5Feedback::LED_RGB + 1,
|
||||
Ds5Feedback::LED_RGB + 2,
|
||||
];
|
||||
assert!(p
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &b)| touched.contains(&i) || b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_leds_are_masked_to_five_bits() {
|
||||
let p = Ds5Feedback::player_packet(0xFF);
|
||||
assert_eq!(p[1], 0x10, "valid_flag1 player-indicator bit");
|
||||
assert_eq!(
|
||||
p[Ds5Feedback::PAD_LIGHTS],
|
||||
0x1F,
|
||||
"high bits are not ours to set"
|
||||
);
|
||||
let p = Ds5Feedback::player_packet(0b0000_0101);
|
||||
assert_eq!(p[Ds5Feedback::PAD_LIGHTS], 0b0000_0101);
|
||||
}
|
||||
|
||||
/// which 1 = R2 and which 0 = L2 — and the RIGHT block sits FIRST in the report, which is the
|
||||
/// pairing most likely to be transcribed backwards.
|
||||
#[test]
|
||||
fn trigger_which_selects_the_right_flag_and_offset() {
|
||||
let eff: Vec<u8> = (1..=11).collect();
|
||||
|
||||
let r = Ds5Feedback::trigger_packet(1, &eff);
|
||||
assert_eq!(r[0], 0x04, "valid_flag0 R2 bit");
|
||||
assert_eq!(
|
||||
&r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11],
|
||||
&eff[..]
|
||||
);
|
||||
assert_eq!(
|
||||
r[Ds5Feedback::LEFT_TRIGGER],
|
||||
0,
|
||||
"the other trigger is untouched"
|
||||
);
|
||||
|
||||
let l = Ds5Feedback::trigger_packet(0, &eff);
|
||||
assert_eq!(l[0], 0x08, "valid_flag0 L2 bit");
|
||||
assert_eq!(
|
||||
&l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11],
|
||||
&eff[..]
|
||||
);
|
||||
assert_eq!(l[Ds5Feedback::RIGHT_TRIGGER], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_effect_is_clamped_rather_than_overflowing_into_the_next_field() {
|
||||
let long = vec![0xAAu8; 40];
|
||||
let p = Ds5Feedback::trigger_packet(1, &long);
|
||||
assert_eq!(p.len(), 47);
|
||||
// Exactly TRIGGER_LEN bytes written; the left block must not be scribbled on.
|
||||
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 10], 0xAA);
|
||||
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 11], 0);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_effect_leaves_the_rest_of_the_block_zeroed() {
|
||||
let p = Ds5Feedback::trigger_packet(0, &[0x02, 0x99]);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0x02);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER + 1], 0x99);
|
||||
assert!(
|
||||
p[Ds5Feedback::LEFT_TRIGGER + 2..Ds5Feedback::LEFT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty effect is a well-formed all-zero block: mode 0x00 = release. It must still assert
|
||||
/// its enable bit, or the pad keeps whatever effect it was holding.
|
||||
#[test]
|
||||
fn an_empty_effect_is_a_release_not_a_no_op() {
|
||||
let p = Ds5Feedback::trigger_packet(1, &[]);
|
||||
assert_eq!(p[0], 0x04);
|
||||
assert!(
|
||||
p[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reset_packet_tests {
|
||||
use super::*;
|
||||
|
||||
/// The exact bytes a teardown sends to hand a DualSense back neutral. The *timing* of this
|
||||
/// (slot close) needs a live SDL handle and stays untestable, so pin the payloads: a wrong
|
||||
/// enable flag or a non-zero mode byte would silently leave the effect latched, which is the
|
||||
/// bug this reset exists to prevent.
|
||||
#[test]
|
||||
fn reset_packets_release_the_triggers_and_darken_the_lights() {
|
||||
// Trigger release: mode 0x00 with no parameters, on the side's own enable bit.
|
||||
let l = Ds5Feedback::trigger_packet(0, &[0u8; 11]);
|
||||
assert_eq!(l[0], 0x08, "left-trigger enable bit");
|
||||
assert!(
|
||||
l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0),
|
||||
"an all-zero block is mode 0x00 = no effect"
|
||||
);
|
||||
let r = Ds5Feedback::trigger_packet(1, &[0u8; 11]);
|
||||
assert_eq!(r[0], 0x04, "right-trigger enable bit");
|
||||
assert!(
|
||||
r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
|
||||
// Lightbar off: enable bit set, RGB all zero. The enable bit matters — without it the pad
|
||||
// ignores the payload and keeps the game's last colour.
|
||||
let bar = Ds5Feedback::lightbar_packet(0, 0, 0);
|
||||
assert_eq!(bar[1], 0x04, "lightbar enable bit");
|
||||
assert_eq!(
|
||||
&bar[Ds5Feedback::LED_RGB..Ds5Feedback::LED_RGB + 3],
|
||||
&[0, 0, 0]
|
||||
);
|
||||
|
||||
// Player indicator cleared.
|
||||
let pl = Ds5Feedback::player_packet(0);
|
||||
assert_eq!(pl[1], 0x10, "player-LED enable bit");
|
||||
assert_eq!(pl[Ds5Feedback::PAD_LIGHTS], 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod player_led_tests {
|
||||
use super::*;
|
||||
|
||||
/// Both conventions that reach this wire spell "player N" as N lit LEDs, so the count is the
|
||||
/// player number regardless of WHICH bits a given pad lights. Pinned because the mapping is
|
||||
/// otherwise only obvious once you have seen both patterns side by side.
|
||||
#[test]
|
||||
fn player_index_counts_lit_leds_for_both_conventions() {
|
||||
// DualSense / hid-playstation patterns — non-contiguous, symmetric about the centre LED.
|
||||
assert_eq!(player_index_from_bits(0x04), Some(0)); // player 1
|
||||
assert_eq!(player_index_from_bits(0x0A), Some(1)); // player 2
|
||||
assert_eq!(player_index_from_bits(0x15), Some(2)); // player 3
|
||||
assert_eq!(player_index_from_bits(0x1B), Some(3)); // player 4
|
||||
assert_eq!(player_index_from_bits(0x1F), Some(4)); // player 5
|
||||
|
||||
// Switch/XInput style — a contiguous run of low bits, the same count each time.
|
||||
assert_eq!(player_index_from_bits(0x01), Some(0));
|
||||
assert_eq!(player_index_from_bits(0x03), Some(1));
|
||||
assert_eq!(player_index_from_bits(0x07), Some(2));
|
||||
assert_eq!(player_index_from_bits(0x0F), Some(3));
|
||||
}
|
||||
|
||||
/// No lit LED is "no player", NOT player 0 — the difference between LEDs off and player 1 lit.
|
||||
#[test]
|
||||
fn no_lit_led_is_no_player() {
|
||||
assert_eq!(player_index_from_bits(0x00), None);
|
||||
// Only the low 5 bits are player LEDs; junk above them must not invent a player.
|
||||
assert_eq!(player_index_from_bits(0xE0), None);
|
||||
}
|
||||
|
||||
/// The mask is applied before counting, so out-of-range bits cannot inflate the index past
|
||||
/// the 5 real LEDs.
|
||||
#[test]
|
||||
fn high_bits_are_masked_off_before_counting() {
|
||||
assert_eq!(player_index_from_bits(0xFF), Some(4)); // 0x1F worth of LEDs, not 8
|
||||
assert_eq!(player_index_from_bits(0xE4), Some(0)); // 0x04 with junk on top
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ pub mod os;
|
||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||
// the bindings live on.
|
||||
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
|
||||
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
|
||||
// the tier-A pad registry the gamepad worker feeds it through.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod pad_audio;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod profiles;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -66,6 +66,20 @@ pub struct GameEntry {
|
||||
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
|
||||
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
|
||||
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
|
||||
/// future value must never fail the whole library decode.
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
pub fn is_launcher(&self) -> bool {
|
||||
self.role.as_deref() == Some("launcher")
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,14 @@ pub struct SessionParams {
|
||||
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
|
||||
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
|
||||
pub echo_cancel: bool,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
|
||||
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
|
||||
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
|
||||
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
|
||||
/// off — see [`crate::pad_audio::speaker_active`]).
|
||||
pub pad_speaker: String,
|
||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||
pub clipboard: bool,
|
||||
@@ -356,6 +364,11 @@ fn pump(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
|
||||
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
|
||||
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
|
||||
let pad_speaker_on = crate::pad_audio::speaker_active(¶ms.pad_speaker);
|
||||
let pad_audio_on = params.pad_haptics || pad_speaker_on;
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
@@ -379,6 +392,11 @@ fn pump(
|
||||
0
|
||||
}) | (if params.phase_lock {
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
} else {
|
||||
0
|
||||
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
|
||||
}) | (if pad_audio_on {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
@@ -501,6 +519,20 @@ fn pump(
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
|
||||
// the settings could render. The output device is opened LAZILY once frames actually
|
||||
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
|
||||
// a session without a wired DualSense costs one idle 10 ms poll loop.
|
||||
let pad_audio_thread = pad_audio_on
|
||||
.then(|| {
|
||||
crate::pad_audio::spawn(
|
||||
connector.clone(),
|
||||
stop.clone(),
|
||||
params.pad_haptics,
|
||||
pad_speaker_on,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||
@@ -1066,6 +1098,9 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = pad_audio_thread {
|
||||
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = clipboard_thread {
|
||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||
}
|
||||
|
||||
@@ -91,22 +91,131 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
|
||||
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
/// A sibling temp path unique to this process. The stores below have five whole-file writers
|
||||
/// (WinUI shell, session, console UI, CLI, Decky) and a single shared `.json.tmp` lets two of
|
||||
/// them interleave: on Windows the second `fs::write` hits a sharing violation, and worse, one
|
||||
/// process can rename the OTHER's half-written bytes over the target. The pid keeps each
|
||||
/// writer on its own scratch file; the rename below removes it, so a leftover only survives a
|
||||
/// hard kill.
|
||||
fn temp_sibling(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(format!(".tmp-{}", std::process::id()));
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Write a config file the safe way: a sibling temp file, then a rename over the target. A
|
||||
/// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate
|
||||
/// and the last byte leaves an empty/half file — and these stores are what a client needs to
|
||||
/// find its hosts at all. Rename is atomic within a directory on both Unix and Windows
|
||||
/// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a
|
||||
/// torn one. Same discipline as the host's `session_settings.rs`.
|
||||
///
|
||||
/// **But the rename is not always available, and losing the write is far worse than a torn
|
||||
/// one.** The Windows client ships as an MSIX package, so every path here is rewritten by the
|
||||
/// container's AppData virtualization before it reaches the filesystem — and when the package
|
||||
/// is installed to a secondary drive (Settings ▸ Storage ▸ "New apps will save to: D:"),
|
||||
/// Windows stores that redirected AppData on the *package's* volume, under
|
||||
/// `D:\WpSystem\<SID>\AppData\`. The literal path we name still says `C:\Users\…`, so a rename
|
||||
/// can end up straddling two volumes, and `std::fs::rename` is `MoveFileExW` with
|
||||
/// `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED` — a cross-volume move fails
|
||||
/// outright with `ERROR_NOT_SAME_DEVICE`. Creating and writing files works fine, which is why
|
||||
/// such an install starts, streams and pairs happily while every setting and profile silently
|
||||
/// evaporates (field report 2026-08-05: "it's in read-only mode").
|
||||
///
|
||||
/// So a failed rename falls back to writing the target in place. That is exactly what the
|
||||
/// identity files already do a few lines up — and those demonstrably work on the affected
|
||||
/// installs — so the fallback is a path we know resolves. It gives up crash-atomicity for that
|
||||
/// one write and nothing else: the temp+rename stays the normal route everywhere it works.
|
||||
///
|
||||
/// Writes and reads of one literal path cannot disagree under that redirection — Microsoft
|
||||
/// documents a single private-location-first resolution order for both, so whichever layer a
|
||||
/// write lands in is the layer the next read finds. The fallback still verifies by reading
|
||||
/// back: a silent write is the exact bug being fixed here, and this path only runs on an
|
||||
/// install that has already proven it does something unusual.
|
||||
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
match std::fs::rename(&tmp, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
// Don't leave the temp behind to confuse the next writer (or a backup tool).
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err(e)
|
||||
let tmp = temp_sibling(path);
|
||||
let atomic = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
|
||||
let Err(e) = atomic else {
|
||||
store_health::clear();
|
||||
return Ok(());
|
||||
};
|
||||
// Don't leave the temp behind to confuse the next writer (or a backup tool).
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
match std::fs::write(path, bytes) {
|
||||
Ok(()) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"atomic replace unavailable in this install; wrote the config in place instead",
|
||||
);
|
||||
// Read it straight back. This whole bug was a write that reported success and
|
||||
// vanished, so the fallback does not get to claim success on the strength of an
|
||||
// `Ok(())` alone — on the one layered filesystem we know we run on, that is the
|
||||
// failure mode to be paranoid about. Only on the degraded path, so the normal
|
||||
// route pays nothing.
|
||||
match std::fs::read(path) {
|
||||
Ok(back) if back == bytes => {
|
||||
store_health::clear();
|
||||
Ok(())
|
||||
}
|
||||
Ok(_) => {
|
||||
let e = std::io::Error::other(
|
||||
"the file read back different from what was just written",
|
||||
);
|
||||
store_health::record(path, &e);
|
||||
Err(e)
|
||||
}
|
||||
Err(reread) => {
|
||||
store_health::record(path, &reread);
|
||||
Err(reread)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both routes are gone: the store really is unwritable. Report the direct write's
|
||||
// error — it describes the actual permission/space problem, where the rename's may
|
||||
// only say the two paths landed on different volumes.
|
||||
Err(direct) => {
|
||||
store_health::record(path, &direct);
|
||||
Err(direct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the config store is accepting writes, so a front-end can *say so* when it is not.
|
||||
///
|
||||
/// Every persistence call site in this crate is deliberately fire-and-forget — a failed
|
||||
/// settings write must never take a stream down — which historically meant a client whose
|
||||
/// store was unwritable looked completely normal: toggles moved, profiles appeared, and
|
||||
/// nothing survived a restart. The field report that produced this module had no log file to
|
||||
/// send either, so there was no signal anywhere. Recording the last failure centrally lets the
|
||||
/// UI surface it without unpicking ~15 `let _ = …save()` call sites.
|
||||
pub mod store_health {
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
pub(crate) fn record(path: &Path, err: &std::io::Error) {
|
||||
let msg = format!("{}: {err}", path.display());
|
||||
tracing::error!(store = %path.display(), error = %err, "cannot persist client config");
|
||||
if let Ok(mut slot) = LAST_ERROR.lock() {
|
||||
*slot = Some(msg);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear() {
|
||||
if let Ok(mut slot) = LAST_ERROR.lock() {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recent failure to persist a config file, if the last attempt failed.
|
||||
///
|
||||
/// Tracks the last *attempt*, not a per-file verdict: a store that cannot be written fails
|
||||
/// every file, so this latches for as long as the problem lasts and goes quiet the moment
|
||||
/// any write gets through.
|
||||
pub fn last_error() -> Option<String> {
|
||||
LAST_ERROR.lock().ok().and_then(|s| s.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,6 +1133,21 @@ pub struct Settings {
|
||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
||||
#[serde(default)]
|
||||
pub mic_device: String,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
|
||||
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
|
||||
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
|
||||
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
|
||||
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
|
||||
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
|
||||
/// a wired DS5. `default` so pre-existing stores load with it on.
|
||||
#[serde(default = "default_true")]
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
|
||||
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
|
||||
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
|
||||
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
|
||||
#[serde(default = "default_pad_speaker")]
|
||||
pub pad_speaker: String,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||
@@ -1071,6 +1195,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_pad_speaker() -> String {
|
||||
"pad".into()
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||
@@ -1179,6 +1307,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,
|
||||
@@ -1919,6 +2049,7 @@ mod tests {
|
||||
/// discipline all three client stores now share.
|
||||
#[test]
|
||||
fn write_atomic_replaces_and_cleans_up() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
@@ -1932,7 +2063,112 @@ mod tests {
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
|
||||
write_atomic(&p, b"{\"a\":2}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
|
||||
assert!(!p.with_extension("json.tmp").exists());
|
||||
assert!(!temp_sibling(&p).exists());
|
||||
// Nothing else in the directory either — the scratch file is gone, not renamed aside.
|
||||
let left: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok().map(|e| e.file_name()))
|
||||
.collect();
|
||||
assert_eq!(left, vec![std::ffi::OsString::from("store.json")]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `store_health` is process-global, so the two tests that read it must not run at the same
|
||||
/// time — one's successful write clears the other's recorded failure. Nothing else in the
|
||||
/// crate's tests reaches `write_atomic`, so this lock is the whole serialization needed.
|
||||
fn store_health_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Two processes saving at once must not share one scratch file — the pid keeps them apart.
|
||||
/// (Same-process, so this only proves the name varies with the pid, not the interleaving.)
|
||||
#[test]
|
||||
fn temp_sibling_is_per_process_and_a_sibling() {
|
||||
let p = Path::new("/tmp/pf/client-windows-settings.json");
|
||||
let t = temp_sibling(p);
|
||||
assert_eq!(t.parent(), p.parent());
|
||||
assert_eq!(
|
||||
t.file_name().unwrap().to_str().unwrap(),
|
||||
format!("client-windows-settings.json.tmp-{}", std::process::id())
|
||||
);
|
||||
// Must not collide with the store itself, nor look like one to `load()`.
|
||||
assert_ne!(t, p.to_path_buf());
|
||||
}
|
||||
|
||||
/// **The fix itself.** When the temp+rename route is unavailable, the bytes must still
|
||||
/// reach the target — that is the difference between the field's "read-only mode" and a
|
||||
/// working client. Simulated by parking a DIRECTORY on the (deterministic) temp sibling
|
||||
/// path so the temp leg cannot be written; the field's install fails one step later, at
|
||||
/// the rename, but both funnel into the same fallback, which is what this pins.
|
||||
#[test]
|
||||
fn the_atomic_route_failing_falls_back_to_an_in_place_write() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-inplace-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("store.json");
|
||||
std::fs::write(&p, b"{\"old\":true}").unwrap();
|
||||
|
||||
// Block the scratch path, so the atomic route cannot complete.
|
||||
std::fs::create_dir_all(temp_sibling(&p)).unwrap();
|
||||
assert!(temp_sibling(&p).is_dir());
|
||||
|
||||
// The write must still report success AND actually be readable back — a silent
|
||||
// `Ok(())` that lost the bytes is the bug, not the fix.
|
||||
write_atomic(&p, b"{\"new\":true}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"new\":true}");
|
||||
// Degraded, but not broken: nothing to warn the user about.
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The other end: when the in-place fallback ALSO fails, the error must surface rather
|
||||
/// than be swallowed, because at that point nothing the user does on the page will stick.
|
||||
#[test]
|
||||
fn a_failed_rename_still_persists_the_write() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-fallback-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Sanity: the healthy path reports a healthy store.
|
||||
let ok = dir.join("store.json");
|
||||
write_atomic(&ok, b"{}").unwrap();
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
|
||||
// Now the unwritable case: a directory in the target's place defeats BOTH the rename
|
||||
// and the in-place write, so the error must surface instead of being swallowed.
|
||||
let blocked = dir.join("blocked.json");
|
||||
std::fs::create_dir_all(&blocked).unwrap();
|
||||
std::fs::write(blocked.join("occupant"), b"x").unwrap();
|
||||
assert!(write_atomic(&blocked, b"{\"a\":1}").is_err());
|
||||
let reported = store_health::last_error().expect("an unwritable store must be reported");
|
||||
assert!(
|
||||
reported.contains("blocked.json"),
|
||||
"the report names the store: {reported}"
|
||||
);
|
||||
// No scratch file left behind by the failed attempt.
|
||||
assert!(!temp_sibling(&blocked).exists());
|
||||
|
||||
// And a later success clears it, so the UI stops warning once the store recovers.
|
||||
write_atomic(&ok, b"{\"a\":2}").unwrap();
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
assert_eq!(std::fs::read_to_string(&ok).unwrap(), "{\"a\":2}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,11 @@ fn load_floor(path: &Path, channel: &str) -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
|
||||
/// Raise (never lower) the floor, through the crate's one config writer — this used to
|
||||
/// hand-roll its own tmp+rename, which meant it neither cleaned up its temp on a failed
|
||||
/// rename nor picked up [`crate::trust::write_atomic`]'s in-place fallback, so on an install
|
||||
/// where the rename cannot work the floor silently never rose and a declined update came
|
||||
/// back forever.
|
||||
fn store_floor(path: &Path, channel: &str, serial: u64) {
|
||||
let mut file: FloorFile = std::fs::read(path)
|
||||
.ok()
|
||||
@@ -287,10 +291,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
let _ = crate::trust::write_atomic(path, &bytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- check
|
||||
|
||||
@@ -309,6 +309,24 @@ pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
|
||||
///
|
||||
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
|
||||
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
|
||||
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn valid_passthrough_mime(m: &str) -> bool {
|
||||
let Some((ty, rest)) = m.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
!ty.is_empty()
|
||||
&& !rest.is_empty()
|
||||
&& m.len() <= 255
|
||||
// 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
|
||||
// any non-ASCII byte.
|
||||
&& m.bytes().all(|b| (0x21..=0x7E).contains(&b))
|
||||
}
|
||||
|
||||
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
|
||||
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
|
||||
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
|
||||
@@ -342,7 +360,17 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
|
||||
WIRE_PNG => push("image/png"),
|
||||
WIRE_JPEG => push("image/jpeg"),
|
||||
WIRE_GIF => push("image/gif"),
|
||||
other => push(other),
|
||||
// A MIME we don't canonicalize is passed through verbatim — so it is the one value on
|
||||
// this path the CLIENT fully controls, and it ends up as a Wayland string argument.
|
||||
// The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
|
||||
// so a single interior NUL turns one control message into a host clipboard panic
|
||||
// (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
|
||||
// nothing upstream removes it. Validate here, at the boundary where the value stops
|
||||
// being ours and becomes libwayland's.
|
||||
other if valid_passthrough_mime(other) => push(other),
|
||||
other => {
|
||||
tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
|
||||
@@ -389,6 +417,38 @@ mod tests {
|
||||
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
|
||||
}
|
||||
|
||||
/// One control message must not be able to panic the host clipboard coordinator
|
||||
/// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
|
||||
/// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
|
||||
#[test]
|
||||
fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
|
||||
// The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
|
||||
assert!(!valid_passthrough_mime("image/webp\0"));
|
||||
assert!(!valid_passthrough_mime("\0"));
|
||||
assert!(!valid_passthrough_mime("image/\0webp"));
|
||||
// Other control bytes and whitespace are refused for the same reason.
|
||||
assert!(!valid_passthrough_mime("image/web\np"));
|
||||
assert!(!valid_passthrough_mime("image/web p"));
|
||||
assert!(!valid_passthrough_mime("image/web\tp"));
|
||||
// Shapes that are not a MIME type at all.
|
||||
assert!(!valid_passthrough_mime(""));
|
||||
assert!(!valid_passthrough_mime("noslash"));
|
||||
assert!(!valid_passthrough_mime("/nosubtype"));
|
||||
assert!(!valid_passthrough_mime("notype/"));
|
||||
assert!(!valid_passthrough_mime(&format!(
|
||||
"image/{}",
|
||||
"x".repeat(300)
|
||||
)));
|
||||
// Legitimate uncanonicalized MIMEs still pass through.
|
||||
assert!(valid_passthrough_mime("image/webp"));
|
||||
assert!(valid_passthrough_mime("application/x-custom+json"));
|
||||
assert!(valid_passthrough_mime("text/plain;charset=utf-8"));
|
||||
|
||||
// End to end: the offer list is built without the malformed entry, and does not panic.
|
||||
let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
|
||||
assert_eq!(offers, vec!["image/png".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_wayland_mime_prefers_canonical() {
|
||||
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
|
||||
|
||||
@@ -169,7 +169,27 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
|
||||
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
|
||||
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
|
||||
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
// Bound the DECODE, not just the result.
|
||||
//
|
||||
// These bytes are client-supplied, and `load_from_memory` used the `image` crate's DEFAULT
|
||||
// limits — 512 MiB of decode allowance — while the 32767 dimension check below only ran on the
|
||||
// already-decoded image. So a small, valid PNG declaring enormous dimensions was allocated in
|
||||
// full before anything rejected it: ~1000× amplification from a few KB of wire (2026-08-05
|
||||
// review L-9). Limits applied here make the allocation refuse instead.
|
||||
//
|
||||
// The caps are the clipboard's own contract expressed up front: the same 32767 per side that
|
||||
// is checked below (a CF_DIB cannot express more), and 256 MiB, which is more than the largest
|
||||
// representable 32bpp image anyone pastes and far less than a memory-exhaustion primitive.
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(32767);
|
||||
limits.max_image_height = Some(32767);
|
||||
limits.max_alloc = Some(256 * 1024 * 1024);
|
||||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?;
|
||||
let mut reader = reader;
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||
if w == 0 || h == 0 || w > 32767 || h > 32767 {
|
||||
|
||||
@@ -5,19 +5,42 @@
|
||||
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
|
||||
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the latched rich state is re-emitted even though nothing changed.
|
||||
///
|
||||
/// The 0xCD plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is
|
||||
/// forwarded exactly once, so if that datagram is dropped the game will never produce it again —
|
||||
/// it keeps re-sending the same value and the dedup swallows every copy. The pad is then left
|
||||
/// holding the PREVIOUS value: the last weapon's trigger effect, the last lightbar colour, for as
|
||||
/// long as the game keeps that setting. For a trigger effect that can be the rest of a level.
|
||||
///
|
||||
/// Slow on purpose. This is a repair mechanism, not a transport — at one second a lost update
|
||||
/// costs a noticeable but bounded wrong-feel window, while the steady-state cost is at most four
|
||||
/// small datagrams per second per pad, against a rumble plane that already resends at ~120 ms.
|
||||
const RENEW_EVERY: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
|
||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
||||
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
||||
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
|
||||
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
|
||||
/// fire).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HidoutDedup {
|
||||
led: Option<(u8, u8, u8)>,
|
||||
player_leds: Option<u8>,
|
||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||
trigger: [Option<Vec<u8>>; 2],
|
||||
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
|
||||
audio_ctl: Option<(u8, [u8; 6])>,
|
||||
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
|
||||
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
|
||||
haptics_select_logged: bool,
|
||||
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
|
||||
/// there is nothing to renew. See [`RENEW_EVERY`].
|
||||
last_sent: Option<Instant>,
|
||||
}
|
||||
|
||||
impl HidoutDedup {
|
||||
@@ -29,7 +52,53 @@ impl HidoutDedup {
|
||||
|
||||
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
|
||||
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
|
||||
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
|
||||
///
|
||||
/// `now` only stamps the renewal clock ([`Self::renewals`]) — forwarding a change resets it, so
|
||||
/// a plane the game is actively changing never pays for a renewal it does not need.
|
||||
pub fn should_forward(&mut self, h: &HidOutput, now: Instant) -> bool {
|
||||
let fwd = self.decide(h);
|
||||
if fwd {
|
||||
self.last_sent = Some(now);
|
||||
}
|
||||
fwd
|
||||
}
|
||||
|
||||
/// Re-emit the latched rich state, so one lost datagram cannot strand the pad on the previous
|
||||
/// value. Returns the reports to send (empty until [`RENEW_EVERY`] has passed since anything
|
||||
/// last went out); every one is idempotent, so a client that DID receive the original simply
|
||||
/// re-applies it.
|
||||
///
|
||||
/// One-shots are deliberately absent: replaying a `TrackpadHaptic` pulse would be a *new*
|
||||
/// pulse, not a repair, and `HidRaw` is already re-sent verbatim by the device's own refresh
|
||||
/// cadence (see the note in [`Self::decide`]).
|
||||
pub fn renewals(&mut self, pad: u8, now: Instant) -> Vec<HidOutput> {
|
||||
if self
|
||||
.last_sent
|
||||
.is_none_or(|t| now.duration_since(t) < RENEW_EVERY)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
self.last_sent = Some(now);
|
||||
let mut out = Vec::new();
|
||||
if let Some((r, g, b)) = self.led {
|
||||
out.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if let Some(bits) = self.player_leds {
|
||||
out.push(HidOutput::PlayerLeds { pad, bits });
|
||||
}
|
||||
for (which, effect) in self.trigger.iter().enumerate() {
|
||||
if let Some(effect) = effect {
|
||||
out.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: which as u8,
|
||||
effect: effect.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decide(&mut self, h: &HidOutput) -> bool {
|
||||
match h {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
let v = Some((*r, *g, *b));
|
||||
@@ -60,6 +129,25 @@ impl HidoutDedup {
|
||||
}
|
||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||
HidOutput::TrackpadHaptic { .. } => true,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
let v = Some((*flags, *raw));
|
||||
if self.audio_ctl == v {
|
||||
false
|
||||
} else {
|
||||
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
|
||||
// audio haptics (not plain rumble emulation, whose all-zero audio region
|
||||
// never reaches here) — the trace that tells "the game does audio haptics"
|
||||
// apart from "the client just doesn't render them".
|
||||
if flags & 0x01 != 0 && !self.haptics_select_logged {
|
||||
self.haptics_select_logged = true;
|
||||
tracing::info!(
|
||||
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
|
||||
);
|
||||
}
|
||||
self.audio_ctl = v;
|
||||
true
|
||||
}
|
||||
}
|
||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||
@@ -77,6 +165,7 @@ mod tests {
|
||||
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
|
||||
#[test]
|
||||
fn hidout_dedup_forwards_only_changes() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
let led = |r| HidOutput::Led {
|
||||
pad: 0,
|
||||
@@ -85,15 +174,15 @@ mod tests {
|
||||
b: 0,
|
||||
};
|
||||
// First value forwards; an exact repeat is dropped; a change forwards again.
|
||||
assert!(d.should_forward(&led(10)));
|
||||
assert!(!d.should_forward(&led(10)));
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&led(10), t));
|
||||
assert!(!d.should_forward(&led(10), t));
|
||||
assert!(d.should_forward(&led(20), t));
|
||||
|
||||
// Player LEDs dedup on their own field, independent of the lightbar.
|
||||
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
|
||||
assert!(d.should_forward(&pl(0b101), t));
|
||||
assert!(!d.should_forward(&pl(0b101), t));
|
||||
assert!(!d.should_forward(&led(20), t)); // lightbar still unchanged
|
||||
|
||||
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
|
||||
let trig = |which, byte| HidOutput::Trigger {
|
||||
@@ -101,10 +190,10 @@ mod tests {
|
||||
which,
|
||||
effect: vec![byte, 0, 0],
|
||||
};
|
||||
assert!(d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
|
||||
assert!(d.should_forward(&trig(0, 1), t));
|
||||
assert!(d.should_forward(&trig(1, 1), t)); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1), t));
|
||||
assert!(d.should_forward(&trig(0, 2), t)); // L2 effect changed
|
||||
|
||||
// One-shot haptic pulses are never deduped.
|
||||
let haptic = HidOutput::TrackpadHaptic {
|
||||
@@ -114,13 +203,153 @@ mod tests {
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic, t));
|
||||
assert!(d.should_forward(&haptic, t));
|
||||
|
||||
// `clear` re-arms every kind.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(d.should_forward(&trig(0, 2)));
|
||||
assert!(d.should_forward(&led(20), t));
|
||||
assert!(d.should_forward(&pl(0b101), t));
|
||||
assert!(d.should_forward(&trig(0, 2), t));
|
||||
}
|
||||
|
||||
/// A change is forwarded once and then deduped — so if that one datagram is lost, nothing else
|
||||
/// would ever carry it. The renewal is what repairs that.
|
||||
#[test]
|
||||
fn latched_state_is_renewed_so_a_lost_datagram_is_not_permanent() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
let trig = HidOutput::Trigger {
|
||||
pad: 3,
|
||||
which: 1,
|
||||
effect: vec![0x02, 0x90, 0xA0],
|
||||
};
|
||||
assert!(d.should_forward(&trig, t));
|
||||
assert!(
|
||||
!d.should_forward(&trig, t),
|
||||
"the game re-sends it; the dedup swallows it"
|
||||
);
|
||||
|
||||
// Nothing due yet.
|
||||
assert!(d.renewals(3, t + Duration::from_millis(999)).is_empty());
|
||||
|
||||
// Past the window: the latched state goes out again, addressed to the right pad.
|
||||
let out = d.renewals(3, t + Duration::from_millis(1000));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(matches!(
|
||||
&out[0],
|
||||
HidOutput::Trigger { pad: 3, which: 1, effect } if effect == &vec![0x02, 0x90, 0xA0]
|
||||
));
|
||||
|
||||
// And it keeps repairing on the same cadence, not just once.
|
||||
assert!(d.renewals(3, t + Duration::from_millis(1500)).is_empty());
|
||||
assert_eq!(d.renewals(3, t + Duration::from_millis(2000)).len(), 1);
|
||||
}
|
||||
|
||||
/// Every latched plane is renewed together, and a plane the game is actively driving does not
|
||||
/// pay for renewals it does not need (a forward resets the clock).
|
||||
#[test]
|
||||
fn renewal_covers_every_latched_plane_and_an_active_plane_defers_it() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 9,
|
||||
g: 8,
|
||||
b: 7
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::PlayerLeds {
|
||||
pad: 0,
|
||||
bits: 0b100
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 0,
|
||||
effect: vec![1]
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 1,
|
||||
effect: vec![2]
|
||||
},
|
||||
t
|
||||
));
|
||||
|
||||
let out = d.renewals(0, t + Duration::from_millis(1000));
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
4,
|
||||
"lightbar + player LEDs + both triggers, got {out:?}"
|
||||
);
|
||||
|
||||
// A genuine change re-stamps the clock, so the next renewal is a full window away.
|
||||
let later = t + Duration::from_millis(1500);
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3
|
||||
},
|
||||
later
|
||||
));
|
||||
assert!(d.renewals(0, later + Duration::from_millis(999)).is_empty());
|
||||
assert!(!d
|
||||
.renewals(0, later + Duration::from_millis(1000))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// Nothing latched = nothing to renew; a one-shot pulse must never be replayed as a "repair".
|
||||
#[test]
|
||||
fn renewal_is_silent_with_nothing_latched_and_never_replays_a_pulse() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
assert!(d.renewals(0, t + Duration::from_secs(60)).is_empty());
|
||||
|
||||
let pulse = HidOutput::TrackpadHaptic {
|
||||
pad: 0,
|
||||
side: 0,
|
||||
amplitude: 1,
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&pulse, t));
|
||||
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
|
||||
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
|
||||
}
|
||||
|
||||
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
|
||||
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
|
||||
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
|
||||
#[test]
|
||||
fn audio_ctl_dedups_by_value() {
|
||||
let mut d = HidoutDedup::default();
|
||||
let t = Instant::now();
|
||||
let audio = |flags, vol| HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags,
|
||||
raw: [vol, 0, 0, 0, 0, 0],
|
||||
};
|
||||
// Identical twice → exactly one emission.
|
||||
assert!(d.should_forward(&audio(0x17, 0x50), t));
|
||||
assert!(!d.should_forward(&audio(0x17, 0x50), t));
|
||||
// Either half changing (flags, or the raw region) forwards again.
|
||||
assert!(d.should_forward(&audio(0x16, 0x50), t));
|
||||
assert!(d.should_forward(&audio(0x16, 0x60), t));
|
||||
// The other kinds' state is untouched by audio traffic.
|
||||
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }, t));
|
||||
// `clear` (pad re-plug) re-arms the value dedup.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&audio(0x16, 0x60), t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ use super::dualsense_proto::{
|
||||
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
||||
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
@@ -24,27 +29,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
|
||||
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
|
||||
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
|
||||
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
|
||||
|
||||
@@ -18,6 +18,11 @@ use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
||||
DS4_TOUCH_W, DS4_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -25,20 +30,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
|
||||
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
|
||||
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
|
||||
@@ -144,12 +135,6 @@ const DS4_RDESC: &[u8] = &[
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
|
||||
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
|
||||
pub struct DualShock4Pad {
|
||||
|
||||
@@ -254,13 +254,45 @@ fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window a played effect occupies: `replay.delay` of silence, then `replay.length` of rumble.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Playback {
|
||||
/// When the effect starts contributing — `play + replay.delay`. Until then it is armed but
|
||||
/// silent, which is the whole point of the delay.
|
||||
starts: Instant,
|
||||
/// When it stops, or `None` for replay length 0 (until explicitly stopped).
|
||||
ends: Option<Instant>,
|
||||
}
|
||||
|
||||
/// One FF effect a game uploaded: rumble magnitudes + playback state.
|
||||
struct Effect {
|
||||
strong: u16,
|
||||
weak: u16,
|
||||
/// `Some(deadline)` while playing (replay length 0 = until stopped).
|
||||
playing: Option<Option<Instant>>,
|
||||
/// `Some(window)` while playing.
|
||||
playing: Option<Playback>,
|
||||
replay_ms: u16,
|
||||
/// `replay.delay` — how long after the play command the effect stays silent. Decoded from the
|
||||
/// upload since forever and, until now, never acted on: the effect started immediately and
|
||||
/// ended `replay.length` later, so anything scheduling a delayed effect (DirectInput under
|
||||
/// Wine does this routinely) fired early AND finished early by the same amount.
|
||||
delay_ms: u16,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// The window a play command at `at` opens: silent for `replay.delay`, then `replay.length` of
|
||||
/// rumble (or until stopped, when the length is 0).
|
||||
///
|
||||
/// `replay.length` is measured from the END of the delay, not from the play command, so the
|
||||
/// delay shifts the whole window instead of eating into it. Split out from the `EV_FF` handler
|
||||
/// purely so this is testable — the handler itself needs a live uinput fd.
|
||||
fn window(&self, at: Instant) -> Playback {
|
||||
let starts = at + Duration::from_millis(self.delay_ms as u64);
|
||||
Playback {
|
||||
starts,
|
||||
ends: (self.replay_ms > 0)
|
||||
.then(|| starts + Duration::from_millis(self.replay_ms as u64)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
|
||||
@@ -268,7 +300,6 @@ struct Effect {
|
||||
/// the policy is pure and unit-testable without a live uinput fd.
|
||||
struct FfState {
|
||||
effects: HashMap<i16, Effect>,
|
||||
next_effect_id: i16,
|
||||
gain: u32,
|
||||
/// Last `(low, high)` reported, to dedup.
|
||||
last_mix: (u16, u16),
|
||||
@@ -284,7 +315,6 @@ impl FfState {
|
||||
fn new() -> FfState {
|
||||
FfState {
|
||||
effects: HashMap::new(),
|
||||
next_effect_id: 0,
|
||||
gain: 0xFFFF,
|
||||
last_mix: (0, 0),
|
||||
last_activity: Instant::now(),
|
||||
@@ -299,17 +329,29 @@ impl FfState {
|
||||
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
|
||||
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
|
||||
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
|
||||
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
|
||||
let quiet_since = |t: Instant| idle.is_some_and(|d| now.duration_since(t) >= d);
|
||||
let plane_stale = quiet_since(self.last_activity);
|
||||
let (mut strong, mut weak) = (0u32, 0u32);
|
||||
for e in self.effects.values_mut() {
|
||||
let Some(deadline) = e.playing else { continue };
|
||||
match deadline {
|
||||
let Some(p) = e.playing else { continue };
|
||||
// Still inside `replay.delay`: armed, silent, and NOT a candidate for expiry or the
|
||||
// abandoned-effect force-off — it has not had its turn yet.
|
||||
if now < p.starts {
|
||||
continue;
|
||||
}
|
||||
match p.ends {
|
||||
Some(d) if now >= d => e.playing = None,
|
||||
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
|
||||
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
|
||||
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
|
||||
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
|
||||
None if stale => {
|
||||
//
|
||||
// "Abandoned" needs the effect to have been AUDIBLE for the window too, not just
|
||||
// the plane quiet: the play command is itself the last activity, so an effect with
|
||||
// a `replay.delay` longer than the window would otherwise be force-stopped the
|
||||
// instant it finally started — silent the whole time it waited, then killed on its
|
||||
// first contributing tick.
|
||||
None if plane_stale && quiet_since(p.starts) => {
|
||||
tracing::info!(
|
||||
strong = e.strong,
|
||||
weak = e.weak,
|
||||
@@ -531,11 +573,13 @@ impl VirtualPad {
|
||||
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
||||
up.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||
let mut e = up.effect;
|
||||
if e.id == -1 {
|
||||
e.id = self.ff.next_effect_id;
|
||||
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
|
||||
}
|
||||
let e = up.effect;
|
||||
// No `id == -1` fallback: ff-core's `input_ff_upload` picks a free slot and
|
||||
// writes it into the effect BEFORE handing the request to uinput, so what
|
||||
// arrives here is always an assigned id. The fallback that used to allocate
|
||||
// one from a local counter could therefore never run, and a local counter is
|
||||
// the wrong answer anyway — the kernel owns that id space.
|
||||
debug_assert!(e.id >= 0, "uinput handed us an unassigned FF effect id");
|
||||
if e.type_ == FF_RUMBLE {
|
||||
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
||||
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
||||
@@ -544,10 +588,12 @@ impl VirtualPad {
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
slot.strong = strong;
|
||||
slot.weak = weak;
|
||||
slot.replay_ms = e.replay_length;
|
||||
slot.delay_ms = e.replay_delay;
|
||||
}
|
||||
up.effect.id = e.id; // hand the assigned slot back to the kernel
|
||||
up.retval = 0;
|
||||
@@ -574,14 +620,7 @@ impl VirtualPad {
|
||||
(EV_FF, code) => {
|
||||
self.ff.note_activity();
|
||||
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
|
||||
e.playing = if ev.value != 0 {
|
||||
Some((e.replay_ms > 0).then(|| {
|
||||
Instant::now()
|
||||
+ std::time::Duration::from_millis(e.replay_ms as u64)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
e.playing = (ev.value != 0).then(|| e.window(Instant::now()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -802,15 +841,34 @@ mod ff_state_tests {
|
||||
ff
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, until explicitly stopped.
|
||||
fn playing(at: Instant) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, for `len`.
|
||||
fn playing_for(at: Instant, len: Duration) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: Some(at + len),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
// Playing since before the window: "abandoned" means audible AND unattended, so an
|
||||
// effect that only just started is not a candidate however stale the plane is.
|
||||
playing: playing(now - Duration::from_millis(2600)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let now = Instant::now();
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
|
||||
// The game goes silent on the FF plane past the idle window: cut, exactly once.
|
||||
@@ -825,8 +883,9 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x4000,
|
||||
weak: 0,
|
||||
playing: Some(Some(now + Duration::from_secs(10))),
|
||||
playing: playing_for(now, Duration::from_secs(10)),
|
||||
replay_ms: 10_000,
|
||||
delay_ms: 0,
|
||||
});
|
||||
// FF plane long stale, but the effect declared a finite replay — the declared duration is
|
||||
// the contract (a real pad honors it too), so it keeps playing…
|
||||
@@ -842,26 +901,135 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now - Duration::from_millis(3000)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
ff.last_activity = now - Duration::from_millis(3000);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
||||
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
|
||||
ff.last_activity = now;
|
||||
ff.effects.get_mut(&0).unwrap().playing = Some(None);
|
||||
ff.effects.get_mut(&0).unwrap().playing = playing(now);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
}
|
||||
|
||||
/// `replay.delay` shifts the whole window: silent until it elapses, then the FULL
|
||||
/// `replay.length`. Before this the delay was decoded and dropped, so a delayed effect both
|
||||
/// started early and finished early — DirectInput under Wine schedules these routinely.
|
||||
#[test]
|
||||
fn replay_delay_holds_the_effect_off_then_gives_it_its_full_length() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_millis(500);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback {
|
||||
starts,
|
||||
ends: Some(starts + Duration::from_secs(1)),
|
||||
}),
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
});
|
||||
// Inside the delay: armed but silent.
|
||||
assert_eq!(ff.mix(now, IDLE), None);
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(499), IDLE), None);
|
||||
// Delay elapsed: it plays.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(501), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
// Still playing at 1400 ms — it gets its full second FROM the delay, not from the play.
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(1400), IDLE), None);
|
||||
// And ends at delay + length, not at length.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(1600), IDLE),
|
||||
Some((0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// The window a play opens, straight from the uploaded fields — this is the half that reads
|
||||
/// `replay.delay` at all. Pinned separately because the `EV_FF` handler that calls it needs a
|
||||
/// live uinput fd, so a test driving `mix` alone would pass with the delay ignored entirely.
|
||||
#[test]
|
||||
fn window_offsets_the_whole_playback_by_replay_delay() {
|
||||
let at = Instant::now();
|
||||
|
||||
let delayed = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
};
|
||||
let w = delayed.window(at);
|
||||
assert_eq!(
|
||||
w.starts,
|
||||
at + Duration::from_millis(500),
|
||||
"delay defers the start"
|
||||
);
|
||||
assert_eq!(
|
||||
w.ends,
|
||||
Some(at + Duration::from_millis(1500)),
|
||||
"length runs from the END of the delay, so the effect keeps its full second"
|
||||
);
|
||||
|
||||
// No delay: starts immediately, unchanged from before.
|
||||
let plain = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 0,
|
||||
};
|
||||
let w = plain.window(at);
|
||||
assert_eq!(w.starts, at);
|
||||
assert_eq!(w.ends, Some(at + Duration::from_millis(1000)));
|
||||
|
||||
// Length 0 = until stopped, but the delay still applies.
|
||||
let infinite = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 250,
|
||||
};
|
||||
let w = infinite.window(at);
|
||||
assert_eq!(w.starts, at + Duration::from_millis(250));
|
||||
assert_eq!(w.ends, None);
|
||||
}
|
||||
|
||||
/// A delayed effect must not be force-stopped as "abandoned" while it is still waiting: it has
|
||||
/// not had its turn, and the idle window is shorter than a delay can legitimately be.
|
||||
#[test]
|
||||
fn a_waiting_effect_is_not_cut_by_the_idle_watchdog() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_secs(5);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback { starts, ends: None }),
|
||||
replay_ms: 0,
|
||||
delay_ms: 5000,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(60); // long stale
|
||||
assert_eq!(ff.mix(now, IDLE), None); // silent, but NOT cut
|
||||
// It still plays when its delay elapses.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(5001), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_watchdog_never_cuts() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(600);
|
||||
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
|
||||
|
||||
@@ -1022,10 +1022,21 @@ impl EiState {
|
||||
// Track held state on the wire codes so `release_all` can undo it at
|
||||
// session end (vanished clients must not leave anything latched).
|
||||
match ev.kind {
|
||||
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
|
||||
self.held_keys.push(ev.code);
|
||||
// Track the code we ACTUALLY INJECTED, not the raw wire code.
|
||||
//
|
||||
// Injection truncates (`vk_to_evdev(ev.code as u8)`), so 0x41, 0x141, 0x241 … all
|
||||
// press the same key — but this list stored the full 32 bits, so a KeyUp for 0x41
|
||||
// never matched the entry a KeyDown for 0x141 left behind. A client sending
|
||||
// distinct high bytes therefore appended entries that could never be removed, to a
|
||||
// `Vec` scanned linearly on every keystroke, for the lifetime of the injector
|
||||
// thread — which outlives the session (2026-08-05 review L-4). Tracking the
|
||||
// truncated code makes the list correct AND bounds it at 256 entries by
|
||||
// construction. `release_all` re-injects through the same truncation, so the
|
||||
// release path is unchanged.
|
||||
InputKind::KeyDown if !self.held_keys.contains(&(ev.code & 0xff)) => {
|
||||
self.held_keys.push(ev.code & 0xff);
|
||||
}
|
||||
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
|
||||
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code & 0xff),
|
||||
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
|
||||
self.held_buttons.push(ev.code);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::steam_proto::{
|
||||
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
|
||||
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, request_id, set_report_data, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2,
|
||||
UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2,
|
||||
UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
@@ -32,20 +37,6 @@ use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI — same layout as the DualSense backend.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
|
||||
/// ~450 ms continuous; give margin).
|
||||
const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
@@ -53,11 +44,6 @@ const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
|
||||
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
|
||||
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
|
||||
/// per-pad `b9.6` pulse + guard handle it instead.
|
||||
@@ -214,10 +200,13 @@ impl SteamDeckPad {
|
||||
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
|
||||
let end = (12 + 16).min(UHID_EVENT_SIZE);
|
||||
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
|
||||
let id = request_id(&ev);
|
||||
// SET_REPORT data: [report-id 0, cmd, …]. Take exactly the bytes the kernel
|
||||
// declared — this used to read a fixed 16-byte window, which truncated any
|
||||
// longer report and, for a shorter one, fed the parser whatever the reused
|
||||
// event buffer still held past the payload. Every sibling backend that parses
|
||||
// SET_REPORT already read the size field; this one didn't.
|
||||
if let Some(r) = parse_steam_output(set_report_data(&ev)).rumble {
|
||||
rumble = Some(r);
|
||||
}
|
||||
let _ = self.reply_set_report(id);
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::triton_proto::{
|
||||
triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR,
|
||||
TRITON_WIRED_PRODUCT,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
@@ -30,25 +35,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
|
||||
pub struct TritonPad {
|
||||
fd: File,
|
||||
|
||||
@@ -22,6 +22,10 @@ use super::switch_proto::{
|
||||
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
|
||||
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -29,24 +33,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
|
||||
/// tears down the bound `hid-nintendo` interface).
|
||||
pub struct SwitchProPad {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The `/dev/uhid` event ABI (`linux/uhid.h`), in one place.
|
||||
//!
|
||||
//! Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller and Steam
|
||||
//! Controller 2 — speaks the same kernel protocol, and each carried its own verbatim copy of these
|
||||
//! constants plus its own `put_cstr`. Five copies of one kernel ABI is five chances to drift from
|
||||
//! it, and they already had: `switch_pro` was missing the SET_REPORT pair entirely, and one backend
|
||||
//! read a fixed-size SET_REPORT payload instead of the length the kernel gave it (see
|
||||
//! [`set_report_data`]).
|
||||
//!
|
||||
//! `struct uhid_event` is `__packed__`: a `u32` `type` followed by a union whose largest member is
|
||||
//! `uhid_create2_req` (name 128 + phys 64 + uniq 64 + rd_size 2 + bus 2 + 4×u32 + rd_data 4096 =
|
||||
//! 4372 bytes). Nothing here allocates or parses a whole event — the backends still drive their own
|
||||
//! read/write loops; this module owns the numbers and the two field accessors that are easy to get
|
||||
//! subtly wrong.
|
||||
|
||||
/// The character device every backend opens.
|
||||
pub const UHID_PATH: &str = "/dev/uhid";
|
||||
|
||||
// Event types (`enum uhid_event_type`). Only the ones the backends actually use.
|
||||
pub const UHID_DESTROY: u32 = 1;
|
||||
pub const UHID_OUTPUT: u32 = 6;
|
||||
pub const UHID_GET_REPORT: u32 = 9;
|
||||
pub const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
pub const UHID_CREATE2: u32 = 11;
|
||||
pub const UHID_INPUT2: u32 = 12;
|
||||
pub const UHID_SET_REPORT: u32 = 13;
|
||||
pub const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
|
||||
/// `HID_MAX_DESCRIPTOR_SIZE` — also the cap on a report payload we will copy out of an event.
|
||||
pub const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
/// `size_of::<uhid_event>()`: the `u32` type tag plus the create2 union.
|
||||
pub const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
/// `BUS_USB` from `linux/input.h`.
|
||||
pub const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Offset of the `id` field shared by the GET_REPORT / SET_REPORT request and reply structs.
|
||||
const OFF_ID: usize = 4;
|
||||
/// Offset of `uhid_set_report_req::size` (after `id: u32`, `rnum: u8`, `rtype: u8`).
|
||||
const OFF_SET_REPORT_SIZE: usize = 10;
|
||||
/// Offset of the payload in a SET_REPORT request — and of `data` in the reply structs.
|
||||
const OFF_DATA: usize = 12;
|
||||
/// Offset of `uhid_output_req::size` (the payload follows `data[4096]`).
|
||||
const OFF_OUTPUT_SIZE: usize = 4 + HID_MAX_DESCRIPTOR_SIZE;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer. The buffer is zeroed by the caller, so
|
||||
/// truncation still leaves a NUL terminator.
|
||||
pub fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The request id of a GET_REPORT / SET_REPORT event — what the matching reply must echo.
|
||||
pub fn request_id(ev: &[u8]) -> u32 {
|
||||
u32::from_ne_bytes([ev[OFF_ID], ev[OFF_ID + 1], ev[OFF_ID + 2], ev[OFF_ID + 3]])
|
||||
}
|
||||
|
||||
/// The payload of a `UHID_SET_REPORT` event: exactly the bytes the kernel says are there.
|
||||
///
|
||||
/// Read the length from the event's own `size` field. Assuming a fixed window instead is wrong in
|
||||
/// both directions — a longer report is silently truncated, and a shorter one is parsed together
|
||||
/// with whatever stale bytes the reused event buffer still holds past its end, which for a rumble
|
||||
/// report means acting on numbers the game never wrote.
|
||||
pub fn set_report_data(ev: &[u8]) -> &[u8] {
|
||||
let size = u16::from_ne_bytes([ev[OFF_SET_REPORT_SIZE], ev[OFF_SET_REPORT_SIZE + 1]]) as usize;
|
||||
let end = (OFF_DATA + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
|
||||
&ev[OFF_DATA.min(end)..end]
|
||||
}
|
||||
|
||||
/// The payload of a `UHID_OUTPUT` event (`uhid_output_req`: `data[4096]` then `size`).
|
||||
pub fn output_data(ev: &[u8]) -> &[u8] {
|
||||
let size = u16::from_ne_bytes([ev[OFF_OUTPUT_SIZE], ev[OFF_OUTPUT_SIZE + 1]]) as usize;
|
||||
let end = (4 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
|
||||
&ev[4.min(end)..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blank() -> Vec<u8> {
|
||||
vec![0u8; UHID_EVENT_SIZE]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_report_data_honours_the_events_own_size() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&5u16.to_ne_bytes());
|
||||
for (i, b) in [1u8, 2, 3, 4, 5].iter().enumerate() {
|
||||
ev[OFF_DATA + i] = *b;
|
||||
}
|
||||
// Stale bytes past the payload — a fixed-window read would hand these to the parser.
|
||||
ev[OFF_DATA + 5] = 0xAA;
|
||||
ev[OFF_DATA + 15] = 0xBB;
|
||||
assert_eq!(set_report_data(&ev), &[1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_report_data_is_not_truncated_at_sixteen() {
|
||||
let mut ev = blank();
|
||||
let n = 40usize;
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&(n as u16).to_ne_bytes());
|
||||
for i in 0..n {
|
||||
ev[OFF_DATA + i] = i as u8;
|
||||
}
|
||||
let d = set_report_data(&ev);
|
||||
assert_eq!(
|
||||
d.len(),
|
||||
n,
|
||||
"a report longer than 16 bytes must survive whole"
|
||||
);
|
||||
assert_eq!(d[39], 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_and_empty_sizes_stay_in_bounds() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&u16::MAX.to_ne_bytes());
|
||||
assert!(set_report_data(&ev).len() <= HID_MAX_DESCRIPTOR_SIZE);
|
||||
assert!(OFF_DATA + set_report_data(&ev).len() <= UHID_EVENT_SIZE);
|
||||
|
||||
let ev0 = blank(); // size = 0
|
||||
assert!(set_report_data(&ev0).is_empty());
|
||||
assert!(output_data(&ev0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_data_reads_its_trailing_size_field() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_OUTPUT_SIZE..OFF_OUTPUT_SIZE + 2].copy_from_slice(&3u16.to_ne_bytes());
|
||||
ev[4] = 0x02;
|
||||
ev[5] = 0x11;
|
||||
ev[6] = 0x22;
|
||||
ev[7] = 0x33; // past the declared size
|
||||
assert_eq!(output_data(&ev), &[0x02, 0x11, 0x22]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_id_round_trips() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_ID..OFF_ID + 4].copy_from_slice(&0xDEAD_BEEFu32.to_ne_bytes());
|
||||
assert_eq!(request_id(&ev), 0xDEAD_BEEF);
|
||||
}
|
||||
}
|
||||
@@ -250,11 +250,19 @@ impl DsState {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
// Invert in i16 space, BEFORE the quantisation, rather than as `255 - to_u8(v)`.
|
||||
// 0..=255 has no exact midpoint: `to_u8` puts centre at 0x80, which leaves 128 codes below
|
||||
// it and 127 above, so mirroring the *output* (`255 - 0x80` = 0x7F) lands a centred stick
|
||||
// one LSB off the 0x80 that `DsState::neutral` — and the pad's own resting report — use.
|
||||
// Games idle-poll a centred stick constantly, so that off-by-one showed up as a permanent
|
||||
// sub-deadzone tilt on the Y axes only. Negating first maps centre to centre by
|
||||
// construction and keeps both extremes exact (+32767 → 0, -32768 → 255); the only cost is
|
||||
// that i16::MIN and -32767 share the 255 code, one LSB at the very end of the travel.
|
||||
let mut s = DsState {
|
||||
lx: to_u8(lx),
|
||||
ly: 255 - to_u8(ly),
|
||||
ly: to_u8(ly.saturating_neg()),
|
||||
rx: to_u8(rx),
|
||||
ry: 255 - to_u8(ry),
|
||||
ry: to_u8(ry.saturating_neg()),
|
||||
l2: lt,
|
||||
r2: rt,
|
||||
..DsState::neutral()
|
||||
@@ -471,7 +479,14 @@ fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
#[derive(Default)]
|
||||
pub struct DsFeedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
|
||||
/// `(low, high)` motor levels, if a report carried them.
|
||||
///
|
||||
/// This parser widens the device's 8-bit motor bytes by `<< 8`, so the values it produces are
|
||||
/// `0..=0xFF00` in steps of 0x100 — NOT `0..=0xFFFF`, which is what this said before. The
|
||||
/// Windows backend widens the same bytes by `× 257` and does reach 0xFFFF. Both are correct:
|
||||
/// every consumer narrows with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. Do not
|
||||
/// "fix" one to match the other — see [`crate::uhid_manager::PadFeedback::rumble`], which is
|
||||
/// the type that sees both.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and
|
||||
/// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence +
|
||||
@@ -479,67 +494,119 @@ pub struct DsFeedback {
|
||||
pub resync: bool,
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
/// Field offsets in the DualSense **output** report, as indices into a whole USB report — i.e.
|
||||
/// including the leading report id at `[0]`. This is the one place in Rust the layout is written
|
||||
/// down; index off these rather than repeating the numbers.
|
||||
///
|
||||
/// **The same fields sit at different offsets per transport, and that is not drift.** Every writer
|
||||
/// lays out one common block; what changes is how much header precedes it:
|
||||
///
|
||||
/// | base | where | first payload byte |
|
||||
/// |---|---|---|
|
||||
/// | `0` | USB report, id included — what these constants describe, and what this parser reads | `[1]` |
|
||||
/// | `−1` | SDL `DS5EffectsState_t` — a 47-byte payload with NO report id (`pf-client-core`'s `Ds5Feedback`) | `[0]` |
|
||||
/// | `+2` | Bluetooth report `0x31` — id, sequence, magic, then the block; CRC32 in the last 4 bytes | `[3]` |
|
||||
///
|
||||
/// Subtract or add the base to translate. Mirrors that cannot import this module — Kotlin
|
||||
/// (`DsDevice.kt`, USB base 0) and Swift (`DualSenseHID.swift`, which handles both the USB and
|
||||
/// Bluetooth bases) — carry a pointer back here; keep them in step by hand.
|
||||
pub mod out_report {
|
||||
/// `valid_flag0`: BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2.
|
||||
pub const VALID_FLAG0: usize = 1;
|
||||
/// `valid_flag1`: BIT2 lightbar, BIT4 player indicators.
|
||||
pub const VALID_FLAG1: usize = 2;
|
||||
/// High-frequency (small / right) motor.
|
||||
pub const MOTOR_RIGHT: usize = 3;
|
||||
/// Low-frequency (big / left) motor.
|
||||
pub const MOTOR_LEFT: usize = 4;
|
||||
/// First byte of the RIGHT trigger's parameter block — it precedes the left one in the report.
|
||||
pub const RIGHT_TRIGGER: usize = 11;
|
||||
/// First byte of the LEFT trigger's parameter block.
|
||||
pub const LEFT_TRIGGER: usize = 22;
|
||||
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters.
|
||||
pub const TRIGGER_LEN: usize = 11;
|
||||
/// `valid_flag2`: BIT2 = `COMPATIBLE_VIBRATION2` (the firmware ≥ 2.24 rumble signal).
|
||||
pub const VALID_FLAG2: usize = 39;
|
||||
/// Lit player-indicator bits (low 5).
|
||||
pub const PLAYER_LEDS: usize = 44;
|
||||
/// Lightbar red; green and blue follow.
|
||||
pub const LED_RGB: usize = 45;
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
|
||||
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
|
||||
/// surfaced — adaptive-trigger blocks and the audio-control region are forwarded raw for the client.
|
||||
///
|
||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
||||
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
|
||||
/// broadcast.
|
||||
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
use out_report as o;
|
||||
// data[0] is the report id (0x02). Be defensive about short reports.
|
||||
if data.first() != Some(&0x02) || data.len() < 48 {
|
||||
return;
|
||||
}
|
||||
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
|
||||
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
|
||||
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
|
||||
// that never read the version, stay on flag0. Both conventions must land here: a
|
||||
// rumble dropped on either — including stops — is silently ignored, and a missed
|
||||
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
|
||||
// forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
let flag0 = data[o::VALID_FLAG0]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[o::VALID_FLAG1]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor first, low-frequency (big/left) second.
|
||||
// Widened 0..255 → 0..0xFF00 by `<< 8` (NOT 0xFFFF — see `DsFeedback::rumble`), same
|
||||
// (low, high) convention as the uinput pad's mixer, and routed to the 0xCA plane.
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// instead of flag0 BIT0. Our feature report advertises a version above 2.24
|
||||
// (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater quiet), so the
|
||||
// kernel and SDL write the v2 flag — while older writers, and any that never read the
|
||||
// version, stay on flag0. Both conventions must land here: a rumble dropped on either
|
||||
// — including stops — is silently ignored, and a missed stop buzzes for the rest of
|
||||
// the session (the 500 ms refresh re-sends stale state forever).
|
||||
if flag0 & 0x03 != 0 || data[o::VALID_FLAG2] & 0x04 != 0 {
|
||||
let high = (data[o::MOTOR_RIGHT] as u16) << 8;
|
||||
let low = (data[o::MOTOR_LEFT] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
|
||||
if flag1 & 0x04 != 0 {
|
||||
let (r, g, b) = (data[45], data[46], data[47]);
|
||||
let (r, g, b) = (data[o::LED_RGB], data[o::LED_RGB + 1], data[o::LED_RGB + 2]);
|
||||
fb.hidout.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if flag1 & 0x10 != 0 {
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: data[44] & 0x1F,
|
||||
bits: data[o::PLAYER_LEDS] & 0x1F,
|
||||
});
|
||||
}
|
||||
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
|
||||
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
|
||||
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= 33 {
|
||||
// The RIGHT trigger block comes FIRST in the report — per SDL's DS5EffectsState_t /
|
||||
// inputtino's ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= o::LEFT_TRIGGER + o::TRIGGER_LEN {
|
||||
if flag0 & 0x04 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 1,
|
||||
effect: data[11..22].to_vec(),
|
||||
effect: data[o::RIGHT_TRIGGER..o::RIGHT_TRIGGER + o::TRIGGER_LEN].to_vec(),
|
||||
});
|
||||
}
|
||||
if flag0 & 0x08 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 0,
|
||||
effect: data[22..33].to_vec(),
|
||||
effect: data[o::LEFT_TRIGGER..o::LEFT_TRIGGER + o::TRIGGER_LEN].to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
|
||||
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
|
||||
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
|
||||
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
|
||||
// whenever an audio-valid flag is present or the region carries data; downstream dedup
|
||||
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
|
||||
let raw: [u8; 6] = data[5..11].try_into().unwrap();
|
||||
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
|
||||
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
|
||||
fb.hidout.push(HidOutput::AudioCtl {
|
||||
pad: pad.into(),
|
||||
flags,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -783,6 +850,29 @@ mod tests {
|
||||
assert_eq!(r[53], 0x0A);
|
||||
}
|
||||
|
||||
/// A centred stick must encode as the pad's own neutral on BOTH axes. Inverting the quantised
|
||||
/// byte (`255 - v`) put Y one LSB below it, which games idle-poll constantly — a permanent
|
||||
/// sub-deadzone tilt. Extremes must stay exact either way.
|
||||
#[test]
|
||||
fn centred_sticks_encode_as_neutral_on_every_axis() {
|
||||
let n = DsState::neutral();
|
||||
let s = DsState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!((s.lx, s.ly), (n.lx, n.ly), "left stick centre");
|
||||
assert_eq!((s.rx, s.ry), (n.rx, n.ry), "right stick centre");
|
||||
|
||||
// Y is still inverted (XInput +y = up, DualSense 0 = up) and both ends stay exact.
|
||||
let up = DsState::from_gamepad(0, 0, i16::MAX, 0, i16::MAX, 0, 0);
|
||||
assert_eq!((up.ly, up.ry), (0, 0), "full up = 0");
|
||||
let down = DsState::from_gamepad(0, 0, i16::MIN, 0, i16::MIN, 0, 0);
|
||||
assert_eq!((down.ly, down.ry), (255, 255), "full down = 255");
|
||||
|
||||
// X keeps its existing mapping.
|
||||
let right = DsState::from_gamepad(0, i16::MAX, 0, i16::MAX, 0, 0, 0);
|
||||
assert_eq!((right.lx, right.rx), (255, 255));
|
||||
let left = DsState::from_gamepad(0, i16::MIN, 0, i16::MIN, 0, 0, 0);
|
||||
assert_eq!((left.lx, left.rx), (0, 0));
|
||||
}
|
||||
|
||||
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
|
||||
/// `buttons[2]`.
|
||||
#[test]
|
||||
@@ -842,6 +932,48 @@ mod tests {
|
||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||
}
|
||||
|
||||
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
|
||||
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
|
||||
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
|
||||
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
|
||||
#[test]
|
||||
fn parse_output_surfaces_audio_ctl() {
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
|
||||
data[5] = 0x50; // headphone volume
|
||||
data[6] = 0x60; // speaker volume
|
||||
data[7] = 0x70; // mic volume
|
||||
data[8] = 0x05; // audio routing / enable bits
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(3, &data, &mut fb);
|
||||
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0b1_0111,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
}]
|
||||
);
|
||||
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
|
||||
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
|
||||
// the honest bytes either way.
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[9] = 0x01;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags: 0,
|
||||
raw: [0, 0, 0, 0, 0x01, 0],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
#[test]
|
||||
fn parse_output_rejects_garbage() {
|
||||
|
||||
@@ -183,8 +183,9 @@ impl SteamState {
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
|
||||
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
|
||||
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32767 ([`trigger_u16`]) and set the
|
||||
/// full-pull bit when pressed. Trackpad + motion + the back grips arrive separately
|
||||
/// ([`apply_rich`], the M3 wire).
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
@@ -200,8 +201,8 @@ impl SteamState {
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
..SteamState::neutral()
|
||||
};
|
||||
let mut b = 0u64;
|
||||
@@ -375,8 +376,8 @@ pub fn sc_from_gamepad(
|
||||
ly,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
// The wire right stick becomes a right-pad contact (see the doc above).
|
||||
rpad_x: rx,
|
||||
rpad_y: ry,
|
||||
@@ -466,6 +467,18 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
|
||||
}
|
||||
|
||||
/// Scale a wire trigger (u8 `0..=255`) onto the Deck's full axis (u16 `0..=32767`).
|
||||
///
|
||||
/// This was `v * 128`, which tops out at 32640 — a fully-pulled trigger reported 99.6% and the top
|
||||
/// 127 counts of the declared range were unreachable, so a game reading the axis could never see a
|
||||
/// true full pull. One multiply gets both ends exact (`0 → 0`, `255 → 32767`) and stays monotonic.
|
||||
///
|
||||
/// `serialize_report`'s inverse (`>> 7`, for the legacy u8 trigger bytes) still round-trips both
|
||||
/// ends against this: `32767 >> 7 == 255`.
|
||||
fn trigger_u16(v: u8) -> u16 {
|
||||
((v as u32 * 32767) / 255) as u16
|
||||
}
|
||||
|
||||
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
||||
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
|
||||
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
||||
@@ -473,7 +486,12 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut buf = [0u8; STEAM_REPORT_LEN];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
// `min`, not `clamp(1, 21)`. Clamping the LOW end to 1 and then slicing `bytes[..len]` asks a
|
||||
// zero-byte slice for one byte, which panics — on the service thread, for an input the kernel
|
||||
// already has a graceful answer to. Reporting the true length lets its own validation
|
||||
// (`1 <= reply[1] <= 21`) reject an empty serial and fall back to "XXXXXXXXXX", which is the
|
||||
// documented behaviour for a reply it does not like.
|
||||
let len = bytes.len().min(21);
|
||||
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
@@ -704,7 +722,7 @@ mod tests {
|
||||
assert_ne!(s.buttons & btn::STEAM, 0);
|
||||
assert_ne!(s.buttons & btn::LB, 0);
|
||||
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
|
||||
assert_eq!(s.lt, 255 * 128);
|
||||
assert_eq!(s.lt, 32767); // full pull reaches the TOP of the declared range
|
||||
assert_eq!(s.lx, 1000);
|
||||
assert_eq!(s.ly, -2000);
|
||||
|
||||
@@ -730,6 +748,30 @@ mod tests {
|
||||
assert_eq!(s.accel, [16384, -8192, 0]);
|
||||
}
|
||||
|
||||
/// An empty serial must not panic. `clamp(1, 21)` asked a zero-byte slice for one byte, which
|
||||
/// is an out-of-range slice index — on the service thread. The kernel rejects a zero length by
|
||||
/// its own rule (`1 <= reply[1] <= 21`) and falls back, which is the graceful answer.
|
||||
#[test]
|
||||
fn empty_serial_reply_does_not_panic() {
|
||||
let r = serial_reply("");
|
||||
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE);
|
||||
assert_eq!(
|
||||
r[2], 0,
|
||||
"length the kernel will reject, rather than a panic"
|
||||
);
|
||||
|
||||
// Normal and over-long serials still behave.
|
||||
let r = serial_reply("ABC123");
|
||||
assert_eq!(r[2], 6);
|
||||
assert_eq!(&r[4..10], b"ABC123");
|
||||
let long = "X".repeat(40);
|
||||
assert_eq!(
|
||||
serial_reply(&long)[2],
|
||||
21,
|
||||
"clamped to the protocol maximum"
|
||||
);
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
|
||||
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
|
||||
|
||||
@@ -18,7 +18,12 @@ use std::time::{Duration, Instant};
|
||||
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
|
||||
#[derive(Default)]
|
||||
pub struct PadFeedback {
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
|
||||
/// `(low, high)` motor levels, if the pass saw a rumble report.
|
||||
///
|
||||
/// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that
|
||||
/// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows
|
||||
/// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a
|
||||
/// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
|
||||
@@ -159,6 +164,22 @@ impl OverflowWarn {
|
||||
/// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive
|
||||
/// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a
|
||||
/// rumble write, so its any-activity keying is already rumble-keyed by construction).
|
||||
///
|
||||
/// KNOWN COST, deliberately accepted. That invariant only covers writers that re-assert. A game
|
||||
/// driving the pad through the kernel's *evdev* FF interface does not: `ff-memless` sends one
|
||||
/// output report when an effect starts and one when it stops, with nothing in between, so a finite
|
||||
/// effect longer than this window is cut in half here. The uinput path
|
||||
/// (`linux/gamepad.rs`) exempts exactly that case — but it can, because evdev FF hands it an
|
||||
/// explicit `replay.length`. Nothing equivalent reaches this layer: [`PadFeedback`] carries motor
|
||||
/// levels, and the protocols it speaks (DualSense / DS4 / Deck / Switch Pro) are all
|
||||
/// level-triggered with no duration field anywhere in a report. So the choice is between cutting a
|
||||
/// long finite effect and letting an abandoned residual drone forever, and the residual is the one
|
||||
/// with field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is
|
||||
/// not affected either way — `hid-nintendo` re-sends rumble continuously, and a physical Pro's
|
||||
/// HD-rumble decays faster than this window regardless.
|
||||
///
|
||||
/// Do not "fix" this by widening or disabling the window without evidence about which failure real
|
||||
/// titles actually hit; the hatch below exists for exactly that experiment.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides
|
||||
@@ -338,10 +359,17 @@ impl<B: PadProto> UhidManager<B> {
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (a game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
if self.hidout_dedup[i].should_forward(&h, now) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
// Re-assert the latched rich state on a slow cadence. Deduping a plane that rides
|
||||
// unreliable datagrams means a dropped update is never re-derived from the game — it
|
||||
// keeps sending the same value and the dedup eats every copy — so without this one
|
||||
// lost datagram leaves the pad on the previous weapon's trigger effect indefinitely.
|
||||
for h in self.hidout_dedup[i].renewals(i as u8, now) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +518,7 @@ mod tests {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
|
||||
@@ -819,46 +819,77 @@ impl DriverAttach {
|
||||
|
||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||
///
|
||||
/// Runs on its own thread and returns immediately. The caller is the session's pad service
|
||||
/// thread — the one feeding input and rumble — and everything below is slow: the driver-store
|
||||
/// check waits up to [`INVENTORY_WAIT`] for a `pnputil` enumeration that can take tens of
|
||||
/// seconds, and the devnode lookup is a synchronous PnP call. Blocking there stalled input for
|
||||
/// up to two seconds *per unattached pad* (the wait is a deadline, not a one-off: while the
|
||||
/// enumeration is still outstanding every pad pays it again), at exactly the moment a session
|
||||
/// is already going wrong. Diagnostics must never be able to hurt the thing they diagnose.
|
||||
///
|
||||
/// Off the hot path the wait also stops being a compromise — it can afford to be patient and
|
||||
/// report what it actually found rather than "still enumerating".
|
||||
fn diagnose(&self) {
|
||||
let store = match driver_store_has(self.inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => {
|
||||
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log);
|
||||
let shm_name = self.shm_name.clone();
|
||||
let instance_id = self.instance_id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-driver-diagnose".into())
|
||||
.spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
|
||||
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
|
||||
/// draining pad slots even when the driver store is wedged.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
|
||||
/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into
|
||||
/// the closure so the blocking calls stay visible as blocking.
|
||||
fn diagnose_blocking(
|
||||
driver: &'static str,
|
||||
inf: &'static str,
|
||||
driver_log: &'static str,
|
||||
shm_name: &str,
|
||||
instance_id: Option<String>,
|
||||
) {
|
||||
let store = match driver_store_has(inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string(),
|
||||
};
|
||||
tracing::warn!(
|
||||
driver,
|
||||
shm = %shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] waits for the background pnputil query before reporting
|
||||
/// without it. Only [`diagnose_blocking`] waits, and that has a thread to itself, so this is
|
||||
/// generous: pnputil routinely takes longer than a couple of seconds on a busy driver store, and
|
||||
/// the old two-second budget — chosen to limit the damage while this ran on the pad service thread
|
||||
/// — meant the diagnosis usually gave up and printed "still enumerating", which is the one answer
|
||||
/// that helps nobody. Nothing waits on this thread, so patience costs only a late log line.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
|
||||
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
|
||||
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
|
||||
/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query
|
||||
/// still running past [`INVENTORY_WAIT`]) or
|
||||
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
|
||||
fn driver_store_inventory() -> Option<&'static str> {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
|
||||
@@ -457,6 +457,11 @@ pub mod triton_proto;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/triton_usbip.rs"]
|
||||
pub mod triton_usbip;
|
||||
/// Linux: the `/dev/uhid` event ABI shared by every UHID gamepad backend — the constants each
|
||||
/// used to transcribe for itself, plus the field accessors that read a payload's real length.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/uhid_abi.rs"]
|
||||
pub mod uhid_abi;
|
||||
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
|
||||
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
||||
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
||||
|
||||
+92
-17
@@ -70,11 +70,64 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
{
|
||||
let r = std::fs::create_dir_all(dir);
|
||||
#[cfg(windows)]
|
||||
restrict_dir_to_system_admins(dir);
|
||||
restrict_dir_to_system_admins(dir, first_hardening_of(dir));
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
|
||||
/// expensive recursive re-own.
|
||||
///
|
||||
/// A planted config dir is planted once, before the host ever starts, so one deep pass at startup
|
||||
/// closes it; repeating it on every `create_private_dir` call (the library CRUD calls it per write)
|
||||
/// would re-walk the whole config tree — recordings, art cache — for nothing.
|
||||
#[cfg(windows)]
|
||||
fn first_hardening_of(dir: &std::path::Path) -> bool {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static SEEN: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
.lock()
|
||||
.map(|mut s| s.insert(dir.to_path_buf()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to
|
||||
/// Administrators.
|
||||
///
|
||||
/// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first
|
||||
/// ran was never written by us: it is owned by whoever created it, and an owner always retains
|
||||
/// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back.
|
||||
/// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and
|
||||
/// command line (2026-08-05 review H-4). Best-effort and never fatal.
|
||||
#[cfg(windows)]
|
||||
pub fn restrict_existing_secret_file(path: &std::path::Path) {
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let icacls = icacls_path();
|
||||
let _ = std::process::Command::new(&icacls)
|
||||
.arg(path.as_os_str())
|
||||
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
restrict_to_system_admins(path);
|
||||
}
|
||||
|
||||
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
|
||||
/// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is.
|
||||
#[cfg(not(windows))]
|
||||
pub fn restrict_existing_secret_file(_path: &std::path::Path) {}
|
||||
|
||||
/// `icacls` by absolute path — a privileged service must never resolve it through `PATH`.
|
||||
#[cfg(windows)]
|
||||
fn icacls_path() -> String {
|
||||
std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string())
|
||||
}
|
||||
|
||||
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
|
||||
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
|
||||
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
|
||||
@@ -86,17 +139,23 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
|
||||
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
|
||||
#[cfg(windows)]
|
||||
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
|
||||
let icacls = std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string());
|
||||
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
|
||||
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
|
||||
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
|
||||
// needless churn (secret files are individually owner-locked by `write_secret_file`).
|
||||
let _ = std::process::Command::new(&icacls)
|
||||
.arg(dir.as_os_str())
|
||||
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
|
||||
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
|
||||
let icacls = icacls_path();
|
||||
// Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep
|
||||
// OWNER control (an owner always retains WRITE_DAC and can put its access straight back).
|
||||
//
|
||||
// `deep` (once per directory per process — see `first_hardening_of`) also re-owns the CONTENTS.
|
||||
// Re-owning only the directory left every file the attacker had already created still owned by
|
||||
// them, and therefore still theirs to rewrite, which is half of why the 2026-08-05 review's H-4
|
||||
// was exploitable end to end. A planted tree is planted once, before the host first runs, so one
|
||||
// deep pass at startup closes it without re-walking recordings and art cache on every write.
|
||||
let mut own = std::process::Command::new(&icacls);
|
||||
own.arg(dir.as_os_str())
|
||||
.args(["/setowner", "*S-1-5-32-544"]); // BUILTIN\Administrators
|
||||
if deep {
|
||||
own.args(["/T", "/C", "/Q"]); // recurse, continue on error, quiet
|
||||
}
|
||||
let _ = own
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
@@ -108,8 +167,13 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
|
||||
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
|
||||
"/grant:r",
|
||||
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
|
||||
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted
|
||||
// `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
|
||||
// so a file a local user created before the hardening ran stayed writable by them even
|
||||
// after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and
|
||||
// Administrators cover every account that legitimately writes here; a non-elevated
|
||||
// manual run gets read-only config, which is the intended boundary rather than a
|
||||
// regression — this directory drives command execution as SYSTEM.
|
||||
"/grant:r",
|
||||
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
|
||||
])
|
||||
@@ -130,6 +194,19 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
|
||||
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
|
||||
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
|
||||
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
|
||||
///
|
||||
/// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not
|
||||
/// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is
|
||||
/// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists
|
||||
/// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error.
|
||||
///
|
||||
/// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into
|
||||
/// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first
|
||||
/// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The
|
||||
/// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the
|
||||
/// `icacls` below is defence in depth rather than the thing standing between a local user and the
|
||||
/// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this
|
||||
/// window becomes real again.
|
||||
pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
@@ -160,9 +237,7 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
|
||||
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
|
||||
#[cfg(windows)]
|
||||
fn restrict_to_system_admins(path: &std::path::Path) {
|
||||
let icacls = std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string());
|
||||
let icacls = icacls_path();
|
||||
let status = std::process::Command::new(icacls)
|
||||
.arg(path.as_os_str())
|
||||
.args([
|
||||
|
||||
@@ -466,6 +466,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
crate::win32::set_app_user_model_id();
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Hold SDL's Valve HIDAPI drivers off BEFORE SDL_Init: the Deck driver clears the pad's
|
||||
// digital mappings at *enumeration*, which is part of bringing the gamepad subsystem up, so a
|
||||
// hint set after `sdl.gamepad()` — where this used to live, inside GamepadService::pumped —
|
||||
// only detached a driver that had already killed the built-in trackpad-mouse system-wide. The
|
||||
// symptom was the Deck losing its trackpad cursor at the start of every session until the
|
||||
// firmware watchdog restored lizard mode. They are still enabled for an attached session.
|
||||
pf_client_core::gamepad::preinit_disable_valve_hidapi();
|
||||
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
|
||||
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
|
||||
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
|
||||
@@ -1895,6 +1902,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
};
|
||||
|
||||
// Every exit from the loop above converges here, which is why the gamepad teardown belongs
|
||||
// here and not on the individual `break`s. `gamepad.detach()` only queues the detach; the
|
||||
// close — flush, host-side GamepadRemove, and the explicit rumble-stop backstop — runs when
|
||||
// the pump drains it. Single mode broke out of the loop immediately after detaching and
|
||||
// Event::Quit never detached at all, so both left forwarded pads unflushed and, if the game
|
||||
// was rumbling at the time, still buzzing.
|
||||
pump.shutdown();
|
||||
// Join the pump BEFORE the device-wide idle: its decode submissions on the shared
|
||||
// device would race vkDeviceWaitIdle otherwise.
|
||||
if let Some(st) = stream.take() {
|
||||
|
||||
@@ -66,6 +66,11 @@ pf-driver-proto = { path = "../pf-driver-proto" }
|
||||
bytemuck = { version = "1.19", features = ["derive"] }
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
# The single-instance mutex is created with an explicit SDDL DACL and its owner is checked, so
|
||||
# a lower-privileged process (the LocalService plugin runner) can neither open it nor squat the
|
||||
# name unnoticed — see manager/instance.rs (security-review 2026-08-05 L-16).
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Devices_DeviceAndDriverInstallation",
|
||||
"Win32_Devices_Display",
|
||||
"Win32_Graphics_Gdi",
|
||||
|
||||
@@ -2465,11 +2465,18 @@ pub fn ei_socket_file() -> std::path::PathBuf {
|
||||
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
|
||||
}
|
||||
|
||||
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
|
||||
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
|
||||
/// Does this resolved launch command start the Steam **client**? Such a launch needs Steam's single
|
||||
/// instance free before a dedicated spawn (B1), and wants gamescope's `--steam` integration on.
|
||||
/// Pure + unit-tested.
|
||||
///
|
||||
/// The test is the first token, NOT the presence of a `steam://` URI. A `steam_ui` launcher entry
|
||||
/// (design D4) resolves to a bare `steam -gamepadui` / `steam` with no URI at all, and it is *more*
|
||||
/// exposed to the single-instance problem than a game launch is, not less: on a box that autologged
|
||||
/// into game mode, the nested second Steam would see the first and exit, taking the spawn down with
|
||||
/// it. A URI-gated check would silently skip both the instance free and `--steam` for exactly the
|
||||
/// launch that most needs them.
|
||||
fn is_steam_launch(cmd: &str) -> bool {
|
||||
let mut it = cmd.split_whitespace();
|
||||
it.next() == Some("steam") && cmd.contains("steam://")
|
||||
cmd.split_whitespace().next() == Some("steam")
|
||||
}
|
||||
|
||||
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||
@@ -2865,7 +2872,13 @@ mod tests {
|
||||
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
|
||||
assert!(!is_steam_launch("vkcube"));
|
||||
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
|
||||
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
|
||||
// A `steam_ui` LAUNCHER entry (design D4) carries no URI, and must still count: it needs the
|
||||
// single instance freed (B1) and gamescope's `--steam` mode on. Gating on `steam://` would
|
||||
// have skipped both for the one launch that is Big Picture itself.
|
||||
assert!(is_steam_launch("steam -gamepadui"));
|
||||
assert!(is_steam_launch("steam"));
|
||||
// A command that merely mentions steam elsewhere is not a Steam client launch.
|
||||
assert!(!is_steam_launch("mygame --steam-overlay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2891,6 +2904,13 @@ mod tests {
|
||||
shape_dedicated_command("steam -bigpicture"),
|
||||
"steam -bigpicture"
|
||||
);
|
||||
// The `steam_ui` launcher entries (design D4) pass through untouched — the shaping only ever
|
||||
// fires on a `steam://` game launch, so there is no way to end up with `-gamepadui` twice.
|
||||
assert_eq!(
|
||||
shape_dedicated_command("steam -gamepadui"),
|
||||
"steam -gamepadui"
|
||||
);
|
||||
assert_eq!(shape_dedicated_command("steam"), "steam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1764,6 +1764,11 @@ impl VirtualDisplayManager {
|
||||
if let Some(saved) = inner.group.ccd_saved.take() {
|
||||
restore_displays_ccd(&saved);
|
||||
}
|
||||
// Drop the isolate's crash-recovery marker even when there was no snapshot to restore
|
||||
// (a failed `isolate_displays_ccd` leaves `ccd_saved` None, and `restore_displays_ccd`
|
||||
// — which clears it itself — then never runs). The group is gone either way, so no
|
||||
// future host start owes this desk a force-EXTEND.
|
||||
pf_win_display::win_display::isolate_journal::clear();
|
||||
// EXPERIMENTAL `ddc_power_off` wake. OUTSIDE the `ccd_saved` gate, for the same reason
|
||||
// `pnp_disabled` is above it: the panels were commanded dark BEFORE the isolate, and
|
||||
// the isolate can return `None` (its `query_active_config` failed). Nested inside that
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
|
||||
|
||||
use super::*;
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
|
||||
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
|
||||
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
|
||||
@@ -40,16 +41,40 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
|
||||
machine — refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
|
||||
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
|
||||
service stop`) first.";
|
||||
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
|
||||
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
|
||||
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
|
||||
// A name in `Global\` is creatable by ANY principal holding SeCreateGlobalPrivilege — which
|
||||
// includes the LocalService account the plugin runner is forced to (plugins.rs). With `None`
|
||||
// security attributes this object took the DACL from the creating token's default, and a
|
||||
// squatter who got there first (creating the name with a DACL that denies SYSTEM) permanently
|
||||
// and silently disabled every virtual-display session: the host lands in the ACCESS_DENIED arm
|
||||
// below and reports a perfectly reasonable "another instance is managing the driver", which
|
||||
// sends the operator hunting a process that does not exist (2026-08-05 review L-16).
|
||||
//
|
||||
// Two changes: create with an EXPLICIT DACL so lesser principals cannot open ours, and check
|
||||
// the OWNER of a name that already exists so a squat is reported as a squat.
|
||||
let sd = security_descriptor()?;
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: sd.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
// SAFETY: plain FFI create of a named mutex; `sa` (and the descriptor it points at) outlives
|
||||
// the call, the returned handle (checked) is solely owned by the `OwnedHandle`, and
|
||||
// `GetLastError` is read immediately after the create — the documented ERROR_ALREADY_EXISTS
|
||||
// protocol for pre-existing named objects.
|
||||
unsafe {
|
||||
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
|
||||
let h = match CreateMutexW(Some(&sa), false, w!("Global\\punktfunk-vdisplay-manager")) {
|
||||
Ok(h) => h,
|
||||
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
|
||||
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
|
||||
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
|
||||
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
|
||||
// the ALREADY_EXISTS branch — validated on-glass). Legitimately that means an instance
|
||||
// is live; it is ALSO exactly what a squat looks like, so say both.
|
||||
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!(
|
||||
"{IN_USE}\n\nIf no other punktfunk-host is running, the name \
|
||||
`Global\\punktfunk-vdisplay-manager` has been SQUATTED by another process — any \
|
||||
account with SeCreateGlobalPrivilege can create it first and deny us access, \
|
||||
which disables virtual-display streaming until that process exits. Find the \
|
||||
holder with Sysinternals `handle.exe -a punktfunk-vdisplay-manager`."
|
||||
),
|
||||
Err(e) => {
|
||||
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
|
||||
}
|
||||
@@ -57,8 +82,114 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
|
||||
let already = GetLastError() == ERROR_ALREADY_EXISTS;
|
||||
let owned = OwnedHandle::from_raw_handle(h.0 as _);
|
||||
if already {
|
||||
// We opened an existing object — so its DACL let us in, but that says nothing about
|
||||
// who created it. If the owner is not SYSTEM/Administrators it is not one of ours.
|
||||
if let Some(owner) = object_owner_sid(h) {
|
||||
if !is_privileged_sid(&owner) {
|
||||
anyhow::bail!(
|
||||
"the pf-vdisplay single-instance name is held by a NON-ADMINISTRATIVE \
|
||||
process (owner SID {owner}) — this is not another punktfunk-host, it is a \
|
||||
squat on `Global\\punktfunk-vdisplay-manager`, and it blocks all \
|
||||
virtual-display streaming while it is held."
|
||||
);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("{IN_USE}");
|
||||
}
|
||||
Ok(owned)
|
||||
}
|
||||
}
|
||||
|
||||
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)` — a protected DACL (no inheritance) granting Full to SYSTEM and
|
||||
/// BUILTIN\Administrators, and to nobody else. Everything that legitimately manages pf-vdisplay is
|
||||
/// one of those two; a LocalService plugin runner is neither, so it can no longer open our object.
|
||||
fn security_descriptor() -> Result<LocalSd> {
|
||||
use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
|
||||
use windows::Win32::Security::Authorization::SDDL_REVISION_1;
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: the SDDL literal is NUL-terminated (`w!`), and `psd` is a live out-param whose
|
||||
// allocation is taken over by `LocalSd` below.
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:P(A;;GA;;;SY)(A;;GA;;;BA)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)
|
||||
}
|
||||
.context("build the pf-vdisplay single-instance security descriptor")?;
|
||||
Ok(LocalSd(psd.0))
|
||||
}
|
||||
|
||||
/// Owns a `LocalAlloc`'d security descriptor and frees it on drop.
|
||||
struct LocalSd(*mut core::ffi::c_void);
|
||||
|
||||
impl Drop for LocalSd {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
// SAFETY: the pointer came from ConvertStringSecurityDescriptorToSecurityDescriptorW,
|
||||
// which documents LocalFree as the matching deallocation.
|
||||
unsafe {
|
||||
let _ = windows::Win32::Foundation::LocalFree(Some(
|
||||
windows::Win32::Foundation::HLOCAL(self.0),
|
||||
));
|
||||
}
|
||||
self.0 = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner SID of a kernel object, as an SDDL string. `None` when it cannot be read (the handle
|
||||
/// lacks READ_CONTROL) — treated as "unknown", never as "fine".
|
||||
fn object_owner_sid(h: HANDLE) -> Option<String> {
|
||||
use windows::Win32::Foundation::{LocalFree, HLOCAL};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertSidToStringSidW, GetSecurityInfo, SE_KERNEL_OBJECT,
|
||||
};
|
||||
use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSID};
|
||||
|
||||
let mut owner = PSID::default();
|
||||
let mut sd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: `h` is the live mutex handle; the out-params are live locals; `sd` is the single
|
||||
// allocation and is LocalFree'd below.
|
||||
let rc = unsafe {
|
||||
GetSecurityInfo(
|
||||
h,
|
||||
SE_KERNEL_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION,
|
||||
Some(&mut owner),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&mut sd),
|
||||
)
|
||||
};
|
||||
let out = if rc.is_ok() && !owner.is_invalid() {
|
||||
let mut sid_str = windows::core::PWSTR::null();
|
||||
// SAFETY: `owner` points into `sd` and is a valid SID; `sid_str` is a live out-param whose
|
||||
// LocalAlloc'd string is freed immediately below.
|
||||
unsafe {
|
||||
if ConvertSidToStringSidW(owner, &mut sid_str).is_ok() && !sid_str.is_null() {
|
||||
let text = sid_str.to_string().unwrap_or_default();
|
||||
let _ = LocalFree(Some(HLOCAL(sid_str.0 as _)));
|
||||
Some(text)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// SAFETY: `sd` is the LocalAlloc'd descriptor GetSecurityInfo returned (null when it failed,
|
||||
// which LocalFree tolerates).
|
||||
unsafe {
|
||||
let _ = LocalFree(Some(HLOCAL(sd.0)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// SYSTEM, BUILTIN\Administrators, or a member of the Administrators-owned set — the principals a
|
||||
/// legitimate pf-vdisplay manager runs as.
|
||||
fn is_privileged_sid(sid: &str) -> bool {
|
||||
matches!(sid, "S-1-5-18" | "S-1-5-32-544") || sid.starts_with("S-1-5-80-") // service SIDs
|
||||
}
|
||||
|
||||
@@ -1215,6 +1215,186 @@ pub fn target_inventory() -> Vec<TargetInventory> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Crash-recovery journal for the EXCLUSIVE isolate — the marker that lets a *fresh* host undo what
|
||||
/// a *dead* one did.
|
||||
///
|
||||
/// [`isolate_displays_ccd`] deactivates the operator's physical displays and hands the pre-isolate
|
||||
/// topology back to its caller, which restores it at teardown ([`restore_displays_ccd`]). That
|
||||
/// snapshot lives in **process memory only**, so a host that crashes, is killed, or is stopped
|
||||
/// mid-session never restores it. Windows does not restore it either — the isolated topology is
|
||||
/// deliberately never saved to the CCD database, precisely so teardown can put the user's layout
|
||||
/// back. The result was a field-reported dead end: the physical screen stays dark, no timeout ever
|
||||
/// fires, and nothing in the product puts it back (the operator's only recourse was `DisplaySwitch`
|
||||
/// or a reboot).
|
||||
///
|
||||
/// Same shape as [`monitor_devnode`](crate::monitor_devnode)'s PnP journal: write a marker while the
|
||||
/// isolate is live, clear it on a clean restore, and re-light the desk at host startup if a marker
|
||||
/// survived.
|
||||
///
|
||||
/// **Why the EXTEND preset rather than replaying the saved CCD blob.** That blob pins target ids
|
||||
/// *including the virtual display's*, and the crashed host's monitors die with it (startup reaps the
|
||||
/// orphans), so a replay would mostly fail `ERROR_BAD_CONFIGURATION` and land in the very
|
||||
/// force-EXTEND backstop [`restore_displays_ccd`] already keeps for that case. EXTEND re-activates
|
||||
/// every connected display from the OS's own database, needs no struct serialization, and stays
|
||||
/// correct across a reboot — where saved target ids would be stale anyway.
|
||||
pub mod isolate_journal {
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// What we last wrote, so the exclusive re-assert watchdog's repeat isolates don't rewrite the
|
||||
/// file every couple of seconds. `None` = "no marker known to be on disk".
|
||||
static LAST: Mutex<Option<Vec<u32>>> = Mutex::new(None);
|
||||
|
||||
fn path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("display-isolate-active.json")
|
||||
}
|
||||
|
||||
/// Record that `deactivated` physical target(s) are switched off for a live exclusive isolate.
|
||||
/// Best-effort: a journal we cannot write costs crash recovery, not the session.
|
||||
pub fn mark(deactivated: &[u32]) {
|
||||
if deactivated.is_empty() {
|
||||
return; // nothing was deactivated ⇒ nothing for a later host to put back
|
||||
}
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_deref() == Some(deactivated) {
|
||||
return;
|
||||
}
|
||||
let p = path();
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = pf_paths::create_private_dir(dir);
|
||||
}
|
||||
match std::fs::write(
|
||||
&p,
|
||||
serde_json::to_vec_pretty(deactivated).unwrap_or_default(),
|
||||
) {
|
||||
Ok(()) => *last = Some(deactivated.to_vec()),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"display isolate: could not write the crash-recovery journal — if this host dies \
|
||||
mid-session the deactivated panels will stay dark"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The isolate is over (restored, or there was nothing to restore) — drop the marker.
|
||||
/// Idempotent; safe to call when no marker exists.
|
||||
pub fn clear() {
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = std::fs::remove_file(path());
|
||||
*last = None;
|
||||
}
|
||||
|
||||
/// Host-startup crash recovery: if a previous host exited with an exclusive isolate live, its
|
||||
/// physical displays are still deactivated. Re-light them with the EXTEND preset.
|
||||
///
|
||||
/// Call once, early in `serve`, **before** any session touches the topology. Gated on the marker
|
||||
/// rather than on "is anything active", so a legitimately headless host is never forced awake.
|
||||
pub fn startup_recover() {
|
||||
let Some(targets) = pending() else {
|
||||
return;
|
||||
};
|
||||
tracing::warn!(
|
||||
deactivated = ?targets,
|
||||
"display isolate: a previous host exited with the operator's display(s) deactivated for \
|
||||
an EXCLUSIVE session and never restored them — forcing the EXTEND preset so the desk is \
|
||||
not left dark"
|
||||
);
|
||||
super::force_extend_topology();
|
||||
clear();
|
||||
}
|
||||
|
||||
/// The marker a previous host left behind, if any (its deactivated target ids) — the *decision*
|
||||
/// half of [`startup_recover`], split out so the recovery rule is testable without driving a
|
||||
/// real `SetDisplayConfig` against the machine running the test.
|
||||
pub fn pending() -> Option<Vec<u32>> {
|
||||
let bytes = std::fs::read(path()).ok()?;
|
||||
Some(serde_json::from_slice(&bytes).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (which `path()` resolves through) and the `LAST` cache are both
|
||||
/// process-global, so these cases must not interleave.
|
||||
static ENV: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Point the journal at a scratch dir for the duration of one case.
|
||||
fn with_temp_dir(name: &str, f: impl FnOnce(&std::path::Path)) {
|
||||
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("scratch dir");
|
||||
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
|
||||
clear(); // reset the LAST cache + any leftover marker from a previous run
|
||||
f(&dir);
|
||||
clear();
|
||||
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The crash path: a host marks what it switched off and dies. The next start must see the
|
||||
/// marker (and which targets), which is what makes it force the desk back on.
|
||||
#[test]
|
||||
fn a_mark_survives_for_the_next_host_and_clear_retracts_it() {
|
||||
with_temp_dir("roundtrip", |_| {
|
||||
assert_eq!(pending(), None, "a clean box owes no recovery");
|
||||
mark(&[101, 202]);
|
||||
assert_eq!(
|
||||
pending(),
|
||||
Some(vec![101, 202]),
|
||||
"a crashed host's marker must be readable by the next start"
|
||||
);
|
||||
clear();
|
||||
assert_eq!(pending(), None, "a clean teardown retracts the marker");
|
||||
});
|
||||
}
|
||||
|
||||
/// An isolate that deactivated nothing (single-display box: the virtual output is already
|
||||
/// the only head) owes the next start no force-EXTEND — marking there would re-arrange a
|
||||
/// desk we never touched.
|
||||
#[test]
|
||||
fn deactivating_nothing_writes_no_marker() {
|
||||
with_temp_dir("empty", |_| {
|
||||
mark(&[]);
|
||||
assert_eq!(pending(), None);
|
||||
});
|
||||
}
|
||||
|
||||
/// The re-assert watchdog re-isolates every couple of seconds while something fights it;
|
||||
/// that must not mean a disk write per cycle.
|
||||
#[test]
|
||||
fn repeating_the_same_mark_does_not_rewrite_the_file() {
|
||||
with_temp_dir("cached", |dir| {
|
||||
let file = dir.join("display-isolate-active.json");
|
||||
mark(&[7]);
|
||||
// Overwrite behind the journal's back rather than comparing mtimes — a filesystem
|
||||
// whose timestamp resolution is coarser than two back-to-back writes would let an
|
||||
// mtime assertion pass without proving anything.
|
||||
std::fs::write(&file, b"SENTINEL").unwrap();
|
||||
mark(&[7]);
|
||||
assert_eq!(
|
||||
std::fs::read(&file).unwrap(),
|
||||
b"SENTINEL",
|
||||
"an unchanged mark must not rewrite the journal"
|
||||
);
|
||||
// A CHANGED set still lands — the group grew/shrank and recovery must follow it.
|
||||
mark(&[7, 8]);
|
||||
assert_eq!(pending(), Some(vec![7, 8]));
|
||||
});
|
||||
}
|
||||
|
||||
/// A corrupt/truncated journal must still trigger recovery: the FILE's existence is the
|
||||
/// signal ("a host left displays off"), its contents are only diagnostics.
|
||||
#[test]
|
||||
fn an_unparseable_marker_still_asks_for_recovery() {
|
||||
with_temp_dir("corrupt", |dir| {
|
||||
std::fs::write(dir.join("display-isolate-active.json"), b"{ not json").unwrap();
|
||||
assert_eq!(pending(), Some(Vec::new()));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
|
||||
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
|
||||
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
|
||||
@@ -1246,6 +1426,18 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
|
||||
return Some(saved);
|
||||
}
|
||||
|
||||
// Journal what we are about to switch off BEFORE the first apply, not after a verified one: the
|
||||
// window this exists to cover includes dying mid-apply. `saved.0` is the ACTIVE path set
|
||||
// (QDC_ONLY_ACTIVE_PATHS), so everything in it outside the keep set is exactly what teardown
|
||||
// owes the operator back. See `isolate_journal`.
|
||||
let doomed: Vec<u32> = saved
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| p.targetInfo.id)
|
||||
.filter(|id| !keep_target_ids.contains(id))
|
||||
.collect();
|
||||
isolate_journal::mark(&doomed);
|
||||
|
||||
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
|
||||
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
|
||||
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
||||
@@ -1769,6 +1961,15 @@ static DARK_SINKS_FUTILE: std::sync::Mutex<Vec<(u32, String)>> = std::sync::Mute
|
||||
/// removed), re-activating the displays we deactivated.
|
||||
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
|
||||
pub fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
restore_displays_ccd_inner(saved);
|
||||
// Clear the crash-recovery marker only AFTER the restore (and its dark-desk backstop) has run,
|
||||
// never before: a host that dies part-way through the restore must still leave the marker
|
||||
// behind so the next start re-lights the desk. `_inner` has several early returns, which is
|
||||
// why this wraps rather than trailing the body.
|
||||
isolate_journal::clear();
|
||||
}
|
||||
|
||||
fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -56,6 +56,167 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
|
||||
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
|
||||
|
||||
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
|
||||
# `pub const`, so without an entry here names as generic as MAX_PADS, TAG_LEN, ABI_VERSION and
|
||||
# INPUT_MAGIC land in the namespace of every C embedder that includes this header — and, as the
|
||||
# note above says, a clashing #define silently takes the last definition rather than failing to
|
||||
# compile. The table above had been doing this by hand for the handful someone noticed; this is
|
||||
# the rest of them, so the stated rule finally holds for the whole surface.
|
||||
#
|
||||
# NOT covered, deliberately: associated constants (`ColorInfo_CP_BT709`, `ClockResync_ROUNDS`,
|
||||
# `ResyncGuard_MAX_REJECTED_STREAK`). cbindgen already qualifies those with their type name,
|
||||
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
|
||||
# namespaced, just not by us.
|
||||
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
|
||||
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
|
||||
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
|
||||
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
|
||||
"BTN_PADDLE2" = "PUNKTFUNK_BTN_PADDLE2"
|
||||
"BTN_PADDLE3" = "PUNKTFUNK_BTN_PADDLE3"
|
||||
"BTN_PADDLE4" = "PUNKTFUNK_BTN_PADDLE4"
|
||||
"CHROMA_IDC_420" = "PUNKTFUNK_CHROMA_IDC_420"
|
||||
"CHROMA_IDC_444" = "PUNKTFUNK_CHROMA_IDC_444"
|
||||
"CIPHER_AES_128_GCM" = "PUNKTFUNK_CIPHER_AES_128_GCM"
|
||||
"CIPHER_CHACHA20_POLY1305" = "PUNKTFUNK_CIPHER_CHACHA20_POLY1305"
|
||||
"CLIENT_CAP_AUDIO_RED" = "PUNKTFUNK_CLIENT_CAP_AUDIO_RED"
|
||||
"CLIENT_CAP_CURSOR" = "PUNKTFUNK_CLIENT_CAP_CURSOR"
|
||||
"CLIENT_CAP_PHASE_LOCK" = "PUNKTFUNK_CLIENT_CAP_PHASE_LOCK"
|
||||
"CLIP_CANCELLED_CODE" = "PUNKTFUNK_CLIP_CANCELLED_CODE"
|
||||
"CLIP_CHUNK" = "PUNKTFUNK_CLIP_CHUNK"
|
||||
"CLIP_FETCH_CAP" = "PUNKTFUNK_CLIP_FETCH_CAP"
|
||||
"CLIP_FETCH_DENIED" = "PUNKTFUNK_CLIP_FETCH_DENIED"
|
||||
"CLIP_FETCH_OK" = "PUNKTFUNK_CLIP_FETCH_OK"
|
||||
"CLIP_FETCH_STALE" = "PUNKTFUNK_CLIP_FETCH_STALE"
|
||||
"CLIP_FETCH_UNAVAILABLE" = "PUNKTFUNK_CLIP_FETCH_UNAVAILABLE"
|
||||
"CLIP_FILE_INDEX_NONE" = "PUNKTFUNK_CLIP_FILE_INDEX_NONE"
|
||||
"CLIP_FLAG_FILES" = "PUNKTFUNK_CLIP_FLAG_FILES"
|
||||
"CLIP_MAX_KINDS" = "PUNKTFUNK_CLIP_MAX_KINDS"
|
||||
"CLIP_MAX_MIME" = "PUNKTFUNK_CLIP_MAX_MIME"
|
||||
"CLIP_POLICY_FILES" = "PUNKTFUNK_CLIP_POLICY_FILES"
|
||||
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
|
||||
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
|
||||
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
|
||||
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
|
||||
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
|
||||
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
|
||||
"CLIP_STREAM_KIND_FETCH" = "PUNKTFUNK_CLIP_STREAM_KIND_FETCH"
|
||||
"ClockResync_ROUNDS" = "PUNKTFUNK_ClockResync_ROUNDS"
|
||||
"CODEC_AV1" = "PUNKTFUNK_CODEC_AV1"
|
||||
"CODEC_H264" = "PUNKTFUNK_CODEC_H264"
|
||||
"CODEC_HEVC" = "PUNKTFUNK_CODEC_HEVC"
|
||||
"CODEC_PYROWAVE" = "PUNKTFUNK_CODEC_PYROWAVE"
|
||||
"ColorInfo_CP_BT2020" = "PUNKTFUNK_ColorInfo_CP_BT2020"
|
||||
"ColorInfo_CP_BT709" = "PUNKTFUNK_ColorInfo_CP_BT709"
|
||||
"ColorInfo_MC_BT2020_NCL" = "PUNKTFUNK_ColorInfo_MC_BT2020_NCL"
|
||||
"ColorInfo_MC_BT709" = "PUNKTFUNK_ColorInfo_MC_BT709"
|
||||
"ColorInfo_TRC_BT709" = "PUNKTFUNK_ColorInfo_TRC_BT709"
|
||||
"ColorInfo_TRC_HLG" = "PUNKTFUNK_ColorInfo_TRC_HLG"
|
||||
"ColorInfo_TRC_PQ" = "PUNKTFUNK_ColorInfo_TRC_PQ"
|
||||
"CURSOR_RELATIVE_HINT" = "PUNKTFUNK_CURSOR_RELATIVE_HINT"
|
||||
"CURSOR_SHAPE_MAX_SIDE" = "PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE"
|
||||
"CURSOR_STATE_MAGIC" = "PUNKTFUNK_CURSOR_STATE_MAGIC"
|
||||
"CURSOR_VISIBLE" = "PUNKTFUNK_CURSOR_VISIBLE"
|
||||
"FLAG_EOF" = "PUNKTFUNK_FLAG_EOF"
|
||||
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
|
||||
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
|
||||
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
|
||||
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
|
||||
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
|
||||
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
|
||||
"HELLO_NAME_MAX" = "PUNKTFUNK_HELLO_NAME_MAX"
|
||||
"HID_RAW_FEATURE" = "PUNKTFUNK_HID_RAW_FEATURE"
|
||||
"HID_RAW_OUTPUT" = "PUNKTFUNK_HID_RAW_OUTPUT"
|
||||
"HID_REPORT_MAX" = "PUNKTFUNK_HID_REPORT_MAX"
|
||||
"HIDOUT_MAGIC" = "PUNKTFUNK_HIDOUT_MAGIC"
|
||||
"HOST_CAP_AUDIO_RED" = "PUNKTFUNK_HOST_CAP_AUDIO_RED"
|
||||
"HOST_CAP_CLIPBOARD" = "PUNKTFUNK_HOST_CAP_CLIPBOARD"
|
||||
"HOST_CAP_CURSOR" = "PUNKTFUNK_HOST_CAP_CURSOR"
|
||||
"HOST_CAP_GAMEPAD_STATE" = "PUNKTFUNK_HOST_CAP_GAMEPAD_STATE"
|
||||
"HOST_CAP_PEN" = "PUNKTFUNK_HOST_CAP_PEN"
|
||||
"HOST_CAP_TEXT_INPUT" = "PUNKTFUNK_HOST_CAP_TEXT_INPUT"
|
||||
"HOST_TIMING_MAGIC" = "PUNKTFUNK_HOST_TIMING_MAGIC"
|
||||
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
|
||||
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
|
||||
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
|
||||
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
|
||||
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
|
||||
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
|
||||
"MAX_SCALE" = "PUNKTFUNK_MAX_SCALE"
|
||||
"MIC_MAGIC" = "PUNKTFUNK_MIC_MAGIC"
|
||||
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
|
||||
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
|
||||
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
|
||||
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
|
||||
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
|
||||
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
|
||||
"MSG_CLIP_FETCH_HDR" = "PUNKTFUNK_MSG_CLIP_FETCH_HDR"
|
||||
"MSG_CLIP_OFFER" = "PUNKTFUNK_MSG_CLIP_OFFER"
|
||||
"MSG_CLIP_STATE" = "PUNKTFUNK_MSG_CLIP_STATE"
|
||||
"MSG_CLOCK_ECHO" = "PUNKTFUNK_MSG_CLOCK_ECHO"
|
||||
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
|
||||
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
|
||||
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
|
||||
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
|
||||
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
|
||||
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
|
||||
"MSG_PAIR_REQUEST" = "PUNKTFUNK_MSG_PAIR_REQUEST"
|
||||
"MSG_PAIR_RESULT" = "PUNKTFUNK_MSG_PAIR_RESULT"
|
||||
"MSG_PHASE_REPORT" = "PUNKTFUNK_MSG_PHASE_REPORT"
|
||||
"MSG_PROBE_REQUEST" = "PUNKTFUNK_MSG_PROBE_REQUEST"
|
||||
"MSG_PROBE_RESULT" = "PUNKTFUNK_MSG_PROBE_RESULT"
|
||||
"MSG_RECONFIGURE" = "PUNKTFUNK_MSG_RECONFIGURE"
|
||||
"MSG_RECONFIGURED" = "PUNKTFUNK_MSG_RECONFIGURED"
|
||||
"MSG_REQUEST_KEYFRAME" = "PUNKTFUNK_MSG_REQUEST_KEYFRAME"
|
||||
"MSG_RFI_REQUEST" = "PUNKTFUNK_MSG_RFI_REQUEST"
|
||||
"MSG_SET_BITRATE" = "PUNKTFUNK_MSG_SET_BITRATE"
|
||||
"MSG_SHARD_PAYLOAD_ACK" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK"
|
||||
"MSG_SHARD_PAYLOAD_CHANGED" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED"
|
||||
"NO_OUTPUT_KEYFRAME_STREAK" = "PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK"
|
||||
"PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" = "PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE"
|
||||
"PAIR_BOUND_OTHER_CLOSE_CODE" = "PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE"
|
||||
"PAIR_DENIED_CLOSE_CODE" = "PUNKTFUNK_PAIR_DENIED_CLOSE_CODE"
|
||||
"PAIR_NO_IDENTITY_CLOSE_CODE" = "PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE"
|
||||
"PAIR_NOT_ARMED_CLOSE_CODE" = "PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE"
|
||||
"PAIR_RATE_LIMITED_CLOSE_CODE" = "PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE"
|
||||
"PAIR_SUPERSEDED_CLOSE_CODE" = "PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE"
|
||||
"PEN_ANGLE_UNKNOWN" = "PUNKTFUNK_PEN_ANGLE_UNKNOWN"
|
||||
"PEN_BARREL1" = "PUNKTFUNK_PEN_BARREL1"
|
||||
"PEN_BARREL2" = "PUNKTFUNK_PEN_BARREL2"
|
||||
"PEN_BATCH_MAX" = "PUNKTFUNK_PEN_BATCH_MAX"
|
||||
"PEN_DISTANCE_UNKNOWN" = "PUNKTFUNK_PEN_DISTANCE_UNKNOWN"
|
||||
"PEN_IN_RANGE" = "PUNKTFUNK_PEN_IN_RANGE"
|
||||
"PEN_PREDICTED" = "PUNKTFUNK_PEN_PREDICTED"
|
||||
"PEN_SAMPLE_WIRE_LEN" = "PUNKTFUNK_PEN_SAMPLE_WIRE_LEN"
|
||||
"PEN_TILT_UNKNOWN" = "PUNKTFUNK_PEN_TILT_UNKNOWN"
|
||||
"PEN_TOUCH_TIMEOUT_MS" = "PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS"
|
||||
"PEN_TOUCHING" = "PUNKTFUNK_PEN_TOUCHING"
|
||||
"PRESETS" = "PUNKTFUNK_PRESETS"
|
||||
"QUIT_CLOSE_CODE" = "PUNKTFUNK_QUIT_CLOSE_CODE"
|
||||
"REANCHOR_MARKS_TO_LIFT" = "PUNKTFUNK_REANCHOR_MARKS_TO_LIFT"
|
||||
"REJECT_BUSY_CLOSE_CODE" = "PUNKTFUNK_REJECT_BUSY_CLOSE_CODE"
|
||||
"ResyncGuard_MAX_REJECTED_STREAK" = "PUNKTFUNK_ResyncGuard_MAX_REJECTED_STREAK"
|
||||
"RFI_MAX_RANGE" = "PUNKTFUNK_RFI_MAX_RANGE"
|
||||
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
|
||||
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
|
||||
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
|
||||
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
|
||||
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
|
||||
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"
|
||||
"USER_FLAG_CHUNK_ALIGNED" = "PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED"
|
||||
"USER_FLAG_RECOVERY_ANCHOR" = "PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR"
|
||||
"USER_FLAG_RECOVERY_POINT" = "PUNKTFUNK_USER_FLAG_RECOVERY_POINT"
|
||||
"USER_FLAG_SLICE_STREAM" = "PUNKTFUNK_USER_FLAG_SLICE_STREAM"
|
||||
"VIDEO_CAP_10BIT" = "PUNKTFUNK_VIDEO_CAP_10BIT"
|
||||
"VIDEO_CAP_444" = "PUNKTFUNK_VIDEO_CAP_444"
|
||||
"VIDEO_CAP_CHACHA20" = "PUNKTFUNK_VIDEO_CAP_CHACHA20"
|
||||
"VIDEO_CAP_HDR" = "PUNKTFUNK_VIDEO_CAP_HDR"
|
||||
"VIDEO_CAP_HOST_TIMING" = "PUNKTFUNK_VIDEO_CAP_HOST_TIMING"
|
||||
"VIDEO_CAP_MULTI_SLICE" = "PUNKTFUNK_VIDEO_CAP_MULTI_SLICE"
|
||||
"VIDEO_CAP_PROBE_SEQ" = "PUNKTFUNK_VIDEO_CAP_PROBE_SEQ"
|
||||
"VIDEO_CAP_STREAMED_AU" = "PUNKTFUNK_VIDEO_CAP_STREAMED_AU"
|
||||
"WIRE_VERSION" = "PUNKTFUNK_WIRE_VERSION"
|
||||
"WIRE_VERSION_CLOSE_CODE" = "PUNKTFUNK_WIRE_VERSION_CLOSE_CODE"
|
||||
|
||||
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
|
||||
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
|
||||
[enum]
|
||||
|
||||
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
@@ -698,7 +704,10 @@ pub struct PunktfunkHidOutput {
|
||||
/// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
|
||||
pub effect_len: u8,
|
||||
/// Trigger: the raw DualSense trigger parameter block (mode + params).
|
||||
pub effect: [u8; 11],
|
||||
/// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is
|
||||
/// exported precisely so embedders can size their own buffers against it, and it declaring one
|
||||
/// number while the struct it describes hardcoded another was the whole hazard.
|
||||
pub effect: [u8; PUNKTFUNK_HID_EFFECT_MAX as usize],
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
@@ -759,6 +768,17 @@ impl PunktfunkHidOutput {
|
||||
out.effect_len = 6;
|
||||
}
|
||||
HidOutput::HidRaw { .. } => return None,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
|
||||
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly
|
||||
// because `HidOutput::decode` refuses one at or above `input::MAX_PADS` (B27) —
|
||||
// it is enforced there, not merely assumed here.
|
||||
out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
|
||||
out.pad = *pad as u8;
|
||||
out.which = *flags;
|
||||
out.effect[0..6].copy_from_slice(raw);
|
||||
out.effect_len = 6;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1172,6 +1192,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
|
||||
/// design/pen-tablet-input.md.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x40;
|
||||
|
||||
/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||
/// stream (a real DualSense's voice coils).
|
||||
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||
/// stream.
|
||||
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
@@ -1186,6 +1225,20 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
|
||||
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
|
||||
// `input::encode_gamepad_arrival` applies).
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
|
||||
);
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
|
||||
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
|
||||
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
|
||||
@@ -1768,6 +1821,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// forward-compatible.
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
|
||||
|
||||
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
||||
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
|
||||
@@ -2312,6 +2372,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||
/// thread (one puller, may run alongside the other planes' pullers).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
/// `buf` is writable for `buf_len` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||
c: *mut PunktfunkConnection,
|
||||
out_pad: *mut u8,
|
||||
out_kind: *mut u8,
|
||||
out_seq: *mut u32,
|
||||
out_pts_ns: *mut u64,
|
||||
buf: *mut u8,
|
||||
buf_len: usize,
|
||||
timeout_ms: u32,
|
||||
) -> i32 {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return -1,
|
||||
};
|
||||
if buf.is_null() && buf_len != 0 {
|
||||
return -1;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Some(f) => {
|
||||
if f.opus.is_empty() || f.opus.len() > buf_len {
|
||||
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
|
||||
// as loss would synthesize concealment) or doesn't fit — report "nothing
|
||||
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
|
||||
// be undecodable anyway).
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
|
||||
// checked before it is written; `buf` is a caller-owned writable region of
|
||||
// `buf_len` bytes and the copy length was just bounds-checked against it.
|
||||
unsafe {
|
||||
if !out_pad.is_null() {
|
||||
*out_pad = f.pad;
|
||||
}
|
||||
if !out_kind.is_null() {
|
||||
*out_kind = f.kind;
|
||||
}
|
||||
if !out_seq.is_null() {
|
||||
*out_seq = f.seq;
|
||||
}
|
||||
if !out_pts_ns.is_null() {
|
||||
*out_pts_ns = f.pts_ns;
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
|
||||
}
|
||||
f.opus.len() as i32
|
||||
}
|
||||
// `None` folds timeout and closed; the shutdown flag tells them apart so the
|
||||
// embedder's plane loop can exit instead of polling a dead session forever.
|
||||
None if c.inner.is_session_ended() => -1,
|
||||
None => 0,
|
||||
}
|
||||
}));
|
||||
r.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||
/// before. Latest-wins per pad; unknown bits are masked off.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u8,
|
||||
audio_caps: u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
c.inner.set_pad_audio_caps(pad, audio_caps);
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||
@@ -2497,10 +2668,12 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
|
||||
/// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
|
||||
/// shared rumble policy engine instead of forking it (typically called at controller attach).
|
||||
/// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
|
||||
/// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
|
||||
/// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
|
||||
/// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user);
|
||||
/// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller
|
||||
/// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`:
|
||||
/// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
|
||||
/// actuator.
|
||||
/// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that
|
||||
/// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
@@ -4412,3 +4585,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quic"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||
#[test]
|
||||
fn hidout_abi_maps_audio_ctl() {
|
||||
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0x17,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
|
||||
assert_eq!(out.pad, 3);
|
||||
assert_eq!(out.which, 0x17);
|
||||
assert_eq!(out.effect_len, 6);
|
||||
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
|
||||
assert_eq!(out.effect[6..], [0; 5]);
|
||||
// A raw passthrough report still has no C representation (skipped at the pull site).
|
||||
assert!(
|
||||
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: 0,
|
||||
data: vec![0x80],
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,17 @@ const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
|
||||
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
|
||||
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
|
||||
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// A deciding window that DELIVERED under `current / STARVED_DELIVERY_DIV` is STARVED: the
|
||||
/// stream barely flowed (a host-side capture stall, an outage, a mid-window pause), so whatever
|
||||
/// distress the window carries — a flush, a keyframe-ask burst — is starvation-shaped, not
|
||||
/// rate-shaped, and the decoder decoded almost nothing at the nominal rate. Such a window may
|
||||
/// still back off (real damage deserves the safe response) but must never be a decode-knee
|
||||
/// sample: latching `current_kbps` off a starved window teaches a phantom decoder cap at
|
||||
/// whatever rate the stall interrupted (the periodic-capture-stall field case: every 5 s cycle
|
||||
/// offers another pair of "backoffs" at the same rate — a bogus latch that then fights the
|
||||
/// re-probe ladder for minutes). Deliberately far below the ×¾ utilization bar climbs require:
|
||||
/// the band between them is ambiguous and keeps today's behavior.
|
||||
const STARVED_DELIVERY_DIV: u32 = 4;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -697,6 +708,10 @@ impl BitrateController {
|
||||
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|
||||
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|
||||
|| (flushed && (decode_bad || decode_mean_us.is_none()));
|
||||
// Starved deciding window (see [`STARVED_DELIVERY_DIV`]): the stream barely flowed,
|
||||
// so the window says nothing about what the decoder can hold at this rate.
|
||||
let starved =
|
||||
(actual_kbps as u64) * (STARVED_DELIVERY_DIV as u64) < self.current_kbps as u64;
|
||||
if !self.climb_since_backoff {
|
||||
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
|
||||
// so this window's rate is one the decoder never choked at while keeping up —
|
||||
@@ -708,6 +723,17 @@ impl BitrateController {
|
||||
"adaptive bitrate: backoff without an intervening climb — draining the \
|
||||
previous choke, not a knee sample"
|
||||
);
|
||||
} else if starved {
|
||||
// Same "not a knee sample either way" treatment as the draining arm: neither
|
||||
// latch against a starved window nor let it erase the reference a real knee
|
||||
// set — the next genuine choke at that rate must still find its pair.
|
||||
tracing::debug!(
|
||||
at_kbps = self.current_kbps,
|
||||
actual_kbps,
|
||||
reference_kbps = self.decode_backoff_kbps,
|
||||
"adaptive bitrate: backoff in a starved window (delivery a fraction of \
|
||||
the target) — starvation-shaped distress, not a knee sample"
|
||||
);
|
||||
} else if decode_evidence {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
@@ -2084,6 +2110,100 @@ mod tests {
|
||||
rate - rate / 16
|
||||
}
|
||||
|
||||
/// One capture-stall-shaped window at the current rate: almost nothing delivered
|
||||
/// (current/10), nothing decoded, no loss — but a jump-to-live flush and a keyframe-ask
|
||||
/// storm (the stall edge's damage signature). SEVERE, so it backs off; STARVED, so it must
|
||||
/// never be a knee sample.
|
||||
fn stall_choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
|
||||
*tick += 2;
|
||||
let r = c.on_window(
|
||||
ticks(start, *tick),
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
c.current_kbps / 10,
|
||||
true,
|
||||
RECOVERY_KF_SEVERE,
|
||||
);
|
||||
*tick += 1;
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_stall_windows_never_latch_a_decode_cap() {
|
||||
// The periodic-capture-stall field case (RDNA4 standby-sink, 5 s cycle): every stall
|
||||
// edge offers another flush + kf-storm "backoff" at the SAME rate — without the starved
|
||||
// guard that pair latches a phantom decoder knee at whatever rate the display driver
|
||||
// happened to interrupt, and the session then fights the re-probe ladder for minutes.
|
||||
let mut c = BitrateController::new(240_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
climb_to(&mut c, start, &mut t, 400_000);
|
||||
let at = c.current_kbps;
|
||||
let r1 = stall_choke(&mut c, start, &mut t).expect("stall damage still backs off");
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"one starved window must not latch"
|
||||
);
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, 0,
|
||||
"a starved window is not a knee sample — no reference recorded"
|
||||
);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, at - at / DECODE_CAP_SIMILAR_DIV);
|
||||
let r2 = stall_choke(&mut c, start, &mut t).expect("second stall edge backs off too");
|
||||
c.on_ack(r2);
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"a starved pair at the same rate must not latch a phantom knee"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starved_window_preserves_the_knee_reference() {
|
||||
// A REAL knee sample, then a stall edge, then the genuine re-climb choke: the starved
|
||||
// window in the middle must neither latch nor ERASE the reference the real choke set —
|
||||
// the genuine pair must still find each other around it.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
let knee = c.current_kbps;
|
||||
let r1 = choke(&mut c, start, &mut t).expect("real choke backs off");
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, knee,
|
||||
"real choke records the reference"
|
||||
);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let r2 = stall_choke(&mut c, start, &mut t).expect("stall edge backs off");
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, knee,
|
||||
"the starved window must not erase the real reference"
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none(), "and must not latch against it");
|
||||
c.on_ack(r2);
|
||||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let rate = c.current_kbps;
|
||||
choke(&mut c, start, &mut t).expect("genuine re-climb choke backs off");
|
||||
assert_eq!(
|
||||
c.decode_cap_kbps,
|
||||
Some(rate - rate / 16),
|
||||
"the genuine pair still latches around the starved interruption"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
|
||||
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
|
||||
RfiRequest, RichInput,
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
|
||||
ProbeRequest, RfiRequest, RichInput,
|
||||
};
|
||||
use crate::session::Frame;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{
|
||||
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
|
||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||
use self::planes::{
|
||||
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
|
||||
};
|
||||
use self::probe::ProbeState;
|
||||
use self::pump::run_pump;
|
||||
@@ -122,6 +124,14 @@ pub struct NativeClient {
|
||||
rumble_sched: Arc<rumble::RumbleShared>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Mutex<Receiver<HidOutput>>,
|
||||
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
|
||||
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
|
||||
pad_audio: Mutex<Receiver<PadAudioFrame>>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
|
||||
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
||||
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
||||
@@ -418,6 +428,10 @@ impl NativeClient {
|
||||
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
||||
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (pad_audio_tx, pad_audio_rx) =
|
||||
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
|
||||
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
|
||||
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
|
||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||
let (host_timing_tx, host_timing_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
||||
@@ -459,6 +473,7 @@ impl NativeClient {
|
||||
let clock_offset_w = clock_offset.clone();
|
||||
let decode_lat_w = decode_lat.clone();
|
||||
let live_bitrate_w = live_bitrate.clone();
|
||||
let pad_audio_caps_w = pad_audio_caps.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
@@ -508,6 +523,8 @@ impl NativeClient {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps: pad_audio_caps_w,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -556,6 +573,8 @@ impl NativeClient {
|
||||
rumble: Mutex::new(rumble_rx),
|
||||
rumble_sched,
|
||||
hidout: Mutex::new(hidout_rx),
|
||||
pad_audio: Mutex::new(pad_audio_rx),
|
||||
pad_audio_caps,
|
||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||
host_timing: Mutex::new(host_timing_rx),
|
||||
cursor_shape: Mutex::new(cursor_shape_rx),
|
||||
@@ -1061,6 +1080,33 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
|
||||
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
|
||||
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
|
||||
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
|
||||
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
|
||||
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
|
||||
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
|
||||
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
|
||||
/// puller per the plane contract.
|
||||
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
|
||||
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
|
||||
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
|
||||
/// Call at controller attach, BEFORE the pad's arrival is sent (like
|
||||
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
|
||||
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
|
||||
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
|
||||
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
|
||||
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
|
||||
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
|
||||
slot.store(audio_caps & 0x03, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
||||
/// the host sent for an HDR session; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
||||
|
||||
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
pub(crate) const HIDOUT_QUEUE: usize = 32;
|
||||
|
||||
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
|
||||
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
|
||||
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
|
||||
/// newest frame (the renderer conceals the gap).
|
||||
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
|
||||
|
||||
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
||||
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
||||
pub(crate) const HDR_META_QUEUE: usize = 8;
|
||||
|
||||
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
|
||||
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
|
||||
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
|
||||
// (see [`input_task`]).
|
||||
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
|
||||
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
|
||||
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
|
||||
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||
tokio::spawn(input_task::run(
|
||||
conn.clone(),
|
||||
input_rx,
|
||||
gamepad_snapshots,
|
||||
pad_audio_arrivals,
|
||||
pad_audio_caps,
|
||||
));
|
||||
|
||||
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
||||
// Self-healing latency bound: every frame still queued once this task catches up is standing
|
||||
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
encode_lat.clone(),
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) async fn run(
|
||||
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
||||
rumble_feed: super::super::rumble::RumbleFeed,
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||
@@ -60,22 +61,28 @@ pub(super) async fn run(
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||
// A pad index the client cannot represent is dropped outright, before either
|
||||
// consumer sees it. It used to be waved through: the seq gate was skipped (its
|
||||
// per-pad cursor has no slot for it) and it was handed to the legacy queue,
|
||||
// while the policy engine silently discarded it on its own bounds check — so
|
||||
// "both consumers are fed" below was false for exactly these, and an embedder
|
||||
// draining the queue could be handed an index it would use to subscript its
|
||||
// own per-pad array. The host never emits one; this is malformed or hostile.
|
||||
let idx = u.pad as usize;
|
||||
if idx >= crate::input::MAX_PADS {
|
||||
continue;
|
||||
}
|
||||
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||
let fresh = match u.envelope {
|
||||
Some(env) => {
|
||||
let idx = u.pad as usize;
|
||||
if idx < crate::input::MAX_PADS {
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
true // out-of-range pad (host never sends these): no gate
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
@@ -94,6 +101,11 @@ pub(super) async fn run(
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
|
||||
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
|
||||
let _ = pad_audio_tx.try_send(f);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||
let _ = hdr_meta_tx.try_send(m);
|
||||
|
||||
@@ -15,8 +15,16 @@ pub(super) async fn run(
|
||||
conn: quinn::Connection,
|
||||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
gamepad_snapshots: bool,
|
||||
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
|
||||
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
|
||||
// so unexpected high bits would make it drop the kind declaration entirely.
|
||||
pad_audio: bool,
|
||||
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
|
||||
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
|
||||
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
|
||||
) {
|
||||
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||
use std::sync::atomic::Ordering;
|
||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||
@@ -37,6 +45,28 @@ pub(super) async fn run(
|
||||
const ARRIVAL_RESENDS: u8 = 2;
|
||||
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
||||
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
|
||||
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
|
||||
// byte-identical to the plain index — the pre-pad-audio wire.
|
||||
// B7: the caps a pad's LAST arrival actually carried. `set_pad_audio_caps` only stores into
|
||||
// the registry — it cannot reach this task — so a declaration that lands after the arrival
|
||||
// burst has drained (the renderer commits the trade only once its sink opens, which is well
|
||||
// past the two 100 ms ticks) used to never reach the host at all: the client believed it had
|
||||
// pad audio and the host emitted nothing on 0xD1, silently, forever. Comparing this against
|
||||
// the live registry on every tick re-arms the burst by itself, with no new plumbing and no
|
||||
// extra traffic when nothing changed.
|
||||
let mut arrival_caps_sent: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
let caps_now = |idx: usize| -> u8 {
|
||||
if pad_audio {
|
||||
pad_audio_caps[idx].load(Ordering::Relaxed)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let arrival_flags = |idx: usize| -> u32 {
|
||||
let caps = caps_now(idx);
|
||||
crate::input::encode_gamepad_arrival(idx as u8, caps)
|
||||
};
|
||||
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
@@ -81,30 +111,56 @@ pub(super) async fn run(
|
||||
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
||||
// so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
continue;
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
|
||||
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
|
||||
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
|
||||
// the usual source). Fold event-carried bits into the registry so the re-send
|
||||
// burst keeps them, then send with the negotiation-gated flags word.
|
||||
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_PADS {
|
||||
if ev_caps != 0 {
|
||||
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
|
||||
}
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send
|
||||
// burst so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
arrival_caps_sent[idx] = caps_now(idx);
|
||||
let arr = crate::input::InputEvent {
|
||||
flags: arrival_flags(idx),
|
||||
..ev
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
for idx in 0..MAX_PADS {
|
||||
// B7: caps declared after the burst drained — re-announce this pad's arrival.
|
||||
// Only for a pad that HAS an arrival (so it is a live, declared controller),
|
||||
// and only when the value actually moved, so a steady session sends nothing.
|
||||
if arrival[idx].is_some()
|
||||
&& arrival_owed[idx] == 0
|
||||
&& caps_now(idx) != arrival_caps_sent[idx]
|
||||
{
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
}
|
||||
// Re-send an owed kind declaration (independent of whether the pad has state
|
||||
// yet — it may be idle-but-connected). Idempotent on the host.
|
||||
if arrival_owed[idx] > 0 {
|
||||
if let Some(kind) = arrival[idx] {
|
||||
arrival_owed[idx] -= 1;
|
||||
arrival_caps_sent[idx] = caps_now(idx);
|
||||
let arr = crate::input::InputEvent {
|
||||
kind: InputKind::GamepadArrival,
|
||||
_pad: [0; 3],
|
||||
code: kind as u32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: idx as u32,
|
||||
flags: arrival_flags(idx),
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
} else {
|
||||
|
||||
@@ -36,6 +36,22 @@ pub const LEGACY_STALE_MS: u64 = 1000;
|
||||
/// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall).
|
||||
const BACKSTOP_LEGACY_MS: u32 = 2000;
|
||||
|
||||
/// The longest lease the engine honours, whatever the envelope claims — the receiver-side mirror of
|
||||
/// the host's own `RUMBLE_TTL_CEIL_MS`.
|
||||
///
|
||||
/// No host built from this tree can exceed it (the `PUNKTFUNK_RUMBLE_TTL_MS` hatch is clamped to
|
||||
/// `[150, 5000]` before it reaches the wire), so this is defence in depth against a third-party or
|
||||
/// modified sender that stamps a long TTL and then wedges its renewal pump while the connection
|
||||
/// stays up. It matters on exactly the platforms that sustain a level for the whole lease: Apple,
|
||||
/// whose renderer deliberately keeps no staleness policy of its own, and a Deck slot, whose
|
||||
/// keepalive re-kicks the actuator until the lease ends. Duration-parameterized embedders (SDL,
|
||||
/// Android) already self-terminate at the clamped backstop.
|
||||
///
|
||||
/// Deliberately NOT `pub`: an embedder has no use for it, and every `pub` const in this crate is
|
||||
/// emitted into `include/punktfunk_core.h` as an UNPREFIXED `#define` — a collision hazard the
|
||||
/// header already has ~170 instances of, and one this has no reason to add to.
|
||||
const MAX_LEASE_MS: u16 = 5_000;
|
||||
|
||||
/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net
|
||||
/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits
|
||||
/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself
|
||||
@@ -53,10 +69,25 @@ pub struct RumbleCommand {
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ActuatorQuirks {
|
||||
/// Re-emit an unchanged non-zero level every this many ms — for actuators whose hardware
|
||||
/// output decays between wire renewals (Steam Deck ≈ 40, macOS DualSense-over-HID BT ≈ 900).
|
||||
/// `0` = no keepalive (the common case).
|
||||
/// output decays between wire renewals. `0` = no keepalive (the common case).
|
||||
///
|
||||
/// The one in-tree producer is the Steam Deck's ≈ 40 ms (`pf-client-core`'s slot open, paired
|
||||
/// with `dedup_jitter`). The macOS DualSense-over-HID Bluetooth decay is NOT served by this
|
||||
/// quirk, though it reads like the obvious second example: the Apple client keeps its own
|
||||
/// ≈ 900 ms keepalive down in `RumbleRenderer` (`RumbleTuning.hidKeepaliveSeconds`) because
|
||||
/// the re-emit has to happen BELOW the command layer. An engine keepalive arrives as a
|
||||
/// command carrying the same levels, and that renderer skips a HID write whose levels are
|
||||
/// unchanged — so the re-emit would be swallowed by the very dedupe it exists to defeat
|
||||
/// (`dedup_jitter` is the Deck's answer to the same problem one layer up).
|
||||
pub keepalive_ms: u16,
|
||||
/// Floor for `backstop_ms` on non-zero commands (Android's `createOneShot` throws on 0).
|
||||
/// Floor for `backstop_ms` on non-zero commands.
|
||||
///
|
||||
/// **No in-tree producer sets this non-zero** — it is reachable only through the C ABI
|
||||
/// (`punktfunk_connection_set_rumble_quirks`), for embedders whose duration-taking API
|
||||
/// rejects short values. The case it was written for is handled elsewhere: Android's
|
||||
/// `createOneShot` does throw on a non-positive duration, but the Kotlin renderer floors the
|
||||
/// duration itself at the call, and that path never declares quirks at all. Kept because it
|
||||
/// is exported ABI, and because a floor belongs here rather than re-invented per embedder.
|
||||
pub min_pulse_ms: u16,
|
||||
/// Alternate the low motor's LSB on keepalive re-emits (imperceptible) so an SDL-class layer
|
||||
/// that no-ops identical values still writes the device — the Deck's dedupe-defeat.
|
||||
@@ -75,8 +106,11 @@ struct PadState {
|
||||
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
|
||||
dirty: bool,
|
||||
next_keepalive: Option<Instant>,
|
||||
/// Current jitter phase (see [`ActuatorQuirks::dedup_jitter`]).
|
||||
jitter: bool,
|
||||
/// The exact value last handed to an embedder. `(0, 0)` ⇔ the engine believes this actuator is
|
||||
/// silent. It replaces a free-running jitter phase because one field answers all three live
|
||||
/// questions: would re-sending this be a no-op device write (the dedupe nudge), is a stop
|
||||
/// redundant, and would the nudge synthesize the reserved stop.
|
||||
last_emit: (u16, u16),
|
||||
quirks: ActuatorQuirks,
|
||||
}
|
||||
|
||||
@@ -88,7 +122,7 @@ impl PadState {
|
||||
legacy_wire: None,
|
||||
dirty: false,
|
||||
next_keepalive: None,
|
||||
jitter: false,
|
||||
last_emit: (0, 0),
|
||||
quirks: ActuatorQuirks {
|
||||
keepalive_ms: 0,
|
||||
min_pulse_ms: 0,
|
||||
@@ -112,6 +146,7 @@ impl PadState {
|
||||
self.legacy_wire = None;
|
||||
self.next_keepalive = None;
|
||||
self.dirty = false;
|
||||
self.last_emit = (0, 0);
|
||||
RumbleCommand {
|
||||
pad,
|
||||
low: 0,
|
||||
@@ -119,6 +154,40 @@ impl PadState {
|
||||
backstop_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the command for the pad's current level, and record what we handed out.
|
||||
///
|
||||
/// On a `dedup_jitter` actuator, re-emitting the value the device last took is a no-op write on
|
||||
/// an SDL-class layer, so the low motor's LSB is nudged. Keying that on `last_emit` rather than
|
||||
/// on a free-running phase is what makes it work on EVERY emit path. Previously the nudge lived
|
||||
/// only in the keepalive branch, so a host renewal — which arrives every `ttl*3/10` ms, 120 ms
|
||||
/// at the 400 ms default and 60 ms at the hatch floor — re-emitted the raw level, collided with
|
||||
/// the last jittered write, was swallowed, AND re-anchored the keepalive. That stretched the
|
||||
/// gap between *distinct* device writes to 80 ms at the default cadence and 100 ms at the
|
||||
/// floor, on an actuator whose quirk declares 40.
|
||||
///
|
||||
/// The nudge is refused when it would synthesize the reserved `(0, 0)` stop. That is level
|
||||
/// `(1, 0)` and only that: `high` must already be 0, and `low ^ 1 == 0` implies `low == 1`.
|
||||
/// There the LSB steps up instead, so the phase still alternates (1 ↔ 3, two parts in 65535)
|
||||
/// and the pad never receives a stop the policy did not order.
|
||||
fn emit(&mut self, pad: u16) -> RumbleCommand {
|
||||
let (mut low, high) = self.level;
|
||||
if self.quirks.dedup_jitter && (low, high) == self.last_emit {
|
||||
let alt = low ^ 1;
|
||||
low = if (alt, high) == (0, 0) {
|
||||
low | 0b10
|
||||
} else {
|
||||
alt
|
||||
};
|
||||
}
|
||||
self.last_emit = (low, high);
|
||||
RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: self.backstop(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy
|
||||
@@ -156,6 +225,8 @@ impl RumbleEngine {
|
||||
p.dirty = true;
|
||||
match ttl_ms {
|
||||
Some(t) => {
|
||||
// Never honour a lease longer than [`MAX_LEASE_MS`], whatever the sender claims.
|
||||
let t = t.min(MAX_LEASE_MS);
|
||||
p.ttl_ms = t;
|
||||
p.legacy_wire = None;
|
||||
p.deadline = if (low, high) != (0, 0) {
|
||||
@@ -214,22 +285,25 @@ impl RumbleEngine {
|
||||
if p.dirty {
|
||||
p.dirty = false;
|
||||
if p.level == (0, 0) {
|
||||
return (Some(p.silence(pad)), None);
|
||||
// Relay a stop only if the actuator is, as far as the engine knows, still
|
||||
// buzzing. A zero on an already-silent pad heals nothing and costs every
|
||||
// embedder a command — Android an unconditional log line plus a binder
|
||||
// `cancel()`. Two senders produce them: the host's deliberate
|
||||
// `RUMBLE_STOP_BURST` re-sends after the first stop already landed, and (behind
|
||||
// `PUNKTFUNK_RUMBLE_ENVELOPE=0`) the legacy flat 500 ms refresh, which re-sends
|
||||
// zeros for every latched pad for the rest of the session. The burst still
|
||||
// heals the case it exists for: a LOST first stop leaves the pad buzzing, so
|
||||
// `last_emit != (0, 0)` and the re-send does emit.
|
||||
if p.last_emit != (0, 0) {
|
||||
return (Some(p.silence(pad)), None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if p.quirks.keepalive_ms > 0 {
|
||||
p.next_keepalive =
|
||||
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
|
||||
}
|
||||
let (low, high) = p.level;
|
||||
return (
|
||||
Some(RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: p.backstop(),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
return (Some(p.emit(pad)), None);
|
||||
}
|
||||
// 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired
|
||||
// or stale pad was silenced before reaching here, so a keepalive can never sustain a
|
||||
@@ -239,20 +313,7 @@ impl RumbleEngine {
|
||||
let due = *p.next_keepalive.get_or_insert(now + ka);
|
||||
if now >= due {
|
||||
p.next_keepalive = Some(now + ka);
|
||||
let (mut low, high) = p.level;
|
||||
if p.quirks.dedup_jitter {
|
||||
p.jitter = !p.jitter;
|
||||
low ^= p.jitter as u16;
|
||||
}
|
||||
return (
|
||||
Some(RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: p.backstop(),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
return (Some(p.emit(pad)), None);
|
||||
}
|
||||
merge_wake(&mut wake, due);
|
||||
}
|
||||
@@ -357,6 +418,22 @@ pub(crate) struct Closed;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Steam Deck's declared quirks — the only shipping actuator with `dedup_jitter`.
|
||||
const DECK: ActuatorQuirks = ActuatorQuirks {
|
||||
keepalive_ms: 40,
|
||||
min_pulse_ms: 0,
|
||||
dedup_jitter: true,
|
||||
};
|
||||
|
||||
/// Drain the engine the way an embedder does: poll until nothing is due.
|
||||
fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> {
|
||||
let mut out = Vec::new();
|
||||
while let (Some(c), _) = e.poll(t) {
|
||||
out.push((c.low, c.high));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ms(v: u64) -> Duration {
|
||||
Duration::from_millis(v)
|
||||
}
|
||||
@@ -527,4 +604,133 @@ mod tests {
|
||||
);
|
||||
assert_eq!(shared.next_command(ms(10)), Err(Closed));
|
||||
}
|
||||
|
||||
/// A host renewal must not repeat the value the device last took, or an SDL-class layer
|
||||
/// swallows the write. Before the jitter moved onto every emit path it lived only in the
|
||||
/// keepalive branch, so each renewal collided with the last jittered write and was deduped.
|
||||
#[test]
|
||||
fn renewal_keeps_the_dedupe_jitter_alternating() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(101, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(80)), vec![(100, 200)]);
|
||||
// The renewal at the 120 ms default cadence: same level, must still be a distinct write.
|
||||
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]);
|
||||
}
|
||||
|
||||
/// Phase-robust version of the same property, at the TTL hatch's 60 ms renewal floor: no two
|
||||
/// consecutive DISTINCT device writes may be further apart than the declared 40 ms cadence.
|
||||
#[test]
|
||||
fn renewal_never_gaps_distinct_writes_at_the_60ms_floor() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
let (mut last, mut last_write, mut worst) = ((0u16, 0u16), 0u64, 0u64);
|
||||
for tick in 0..=360u64 {
|
||||
let t = t0 + ms(tick);
|
||||
if tick % 60 == 0 {
|
||||
e.wire_update(t, 0, 100, 200, Some(400));
|
||||
}
|
||||
for v in drain(&mut e, t) {
|
||||
assert_ne!(v, (0, 0), "a live lease must never emit the stop sentinel");
|
||||
if v != last {
|
||||
worst = worst.max(tick - last_write);
|
||||
last_write = tick;
|
||||
last = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
worst <= 41,
|
||||
"worst distinct-write gap {worst} ms exceeds the 40 ms declared cadence"
|
||||
);
|
||||
}
|
||||
|
||||
/// The nudge must stay behind `dedup_jitter`: an off-by-one amplitude on a default-quirks pad
|
||||
/// would land in Apple's identical-target comparison and Android's one-shot amplitudes.
|
||||
#[test]
|
||||
fn default_quirks_pads_get_the_level_verbatim_on_every_renewal() {
|
||||
let mut e = RumbleEngine::new(); // Apple / Android / plain SDL
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
|
||||
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
|
||||
assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800)));
|
||||
}
|
||||
|
||||
/// Level `(1, 0)` is the one value whose LSB flip is the reserved stop. The nudge steps up
|
||||
/// instead, so the phase still alternates and no stop is invented under a live lease.
|
||||
#[test]
|
||||
fn jitter_never_synthesizes_the_stop_sentinel() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 1, 0, Some(400));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 1, 0, 800)));
|
||||
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 3, 0, 800)));
|
||||
assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 1, 0, 800)));
|
||||
}
|
||||
|
||||
/// A zero for a pad the engine already believes is silent is dropped: it heals nothing and
|
||||
/// costs every embedder a command. The deliberate stop-burst heal is unaffected, because a
|
||||
/// LOST stop leaves the pad buzzing and the re-send therefore does emit.
|
||||
#[test]
|
||||
fn a_redundant_stop_is_dropped_but_the_burst_still_heals_a_lost_one() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
|
||||
// First stop reaches the embedder…
|
||||
e.wire_update(t0 + ms(10), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(10)), vec![(0, 0)]);
|
||||
// …and the burst re-sends behind it are now silent.
|
||||
e.wire_update(t0 + ms(20), 0, 0, 0, Some(0));
|
||||
e.wire_update(t0 + ms(30), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(30)), Vec::new());
|
||||
|
||||
// But if the pad is buzzing (the stop that mattered was lost), a re-send still emits.
|
||||
e.wire_update(t0 + ms(40), 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]);
|
||||
e.wire_update(t0 + ms(50), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]);
|
||||
}
|
||||
|
||||
/// The client bounds the host's lease. `RUMBLE_TTL_CEIL_MS` is sender-side only, so a modified
|
||||
/// or third-party host could otherwise stamp a huge TTL and wedge its pump, leaving Apple and
|
||||
/// the Deck buzzing for the whole of it.
|
||||
#[test]
|
||||
fn an_overlong_lease_is_clamped_to_the_ceiling() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(u16::MAX));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 5000)));
|
||||
// Silenced at the ceiling, not at the 65 s the sender asked for.
|
||||
assert!(e.poll(t0 + ms(MAX_LEASE_MS as u64 - 1)).0.is_none());
|
||||
assert_eq!(
|
||||
e.poll(t0 + ms(MAX_LEASE_MS as u64)).0,
|
||||
Some(cmd(0, 0, 0, 0)),
|
||||
"the lease must end at the ceiling"
|
||||
);
|
||||
}
|
||||
|
||||
/// A v2 envelope carrying `ttl_ms == 0` on a LIVE level. The audit suspected the zero would be
|
||||
/// mistaken for the legacy sentinel in `backstop()`; it cannot, because the expiry check
|
||||
/// preempts the relay branch — the pad silences on the same poll and never reaches a backstop.
|
||||
/// Pinned so that ordering stays load-bearing rather than incidental.
|
||||
#[test]
|
||||
fn a_zero_ttl_envelope_silences_rather_than_taking_the_legacy_backstop() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(0));
|
||||
assert_eq!(
|
||||
e.poll(t0).0,
|
||||
Some(cmd(0, 0, 0, 0)),
|
||||
"a zero-length lease must expire immediately, not emit with a legacy backstop"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::Result;
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{HdrMeta, HidOutput};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
|
||||
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
|
||||
/// closed, so the command API always observes connection teardown.
|
||||
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
|
||||
/// [`NativeClient::next_pad_audio`].
|
||||
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
|
||||
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
|
||||
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
|
||||
|
||||
@@ -64,7 +64,11 @@ pub enum InputKind {
|
||||
GamepadRemove = 13,
|
||||
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||
/// Sent when the client opens a pad slot — before that pad's
|
||||
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, (flags >> 24) as u8)
|
||||
}
|
||||
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||
/// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||
/// it drop the declaration).
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
|
||||
|
||||
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
|
||||
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
|
||||
/// pre-pad-audio wire bytes exactly.
|
||||
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
|
||||
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
|
||||
}
|
||||
|
||||
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
|
||||
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
|
||||
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
|
||||
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
|
||||
/// An old-format word (index only) yields `audio_caps = 0`.
|
||||
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, ((flags >> 8) & 0x03) as u8)
|
||||
}
|
||||
|
||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||
///
|
||||
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
|
||||
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
|
||||
kind: u8,
|
||||
/// LI_CCAP_* bits (0x02 = rumble).
|
||||
capabilities: u16,
|
||||
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
|
||||
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
|
||||
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
|
||||
/// express pad audio and always sets `0`, as does an old client.
|
||||
audio_caps: u8,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -443,6 +480,31 @@ mod tests {
|
||||
assert_eq!((pad, seq), (9, 123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_arrival_flags_roundtrip() {
|
||||
// The capability bits ride bits 8/9; the index stays the low byte.
|
||||
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
|
||||
let flags = encode_gamepad_arrival(pad, caps);
|
||||
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
|
||||
assert_eq!(flags & 0xFF, pad as u32);
|
||||
}
|
||||
assert_eq!(
|
||||
encode_gamepad_arrival(2, 0b11),
|
||||
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
|
||||
// old host) is byte-identical to the plain index, and decodes with caps 0.
|
||||
assert_eq!(encode_gamepad_arrival(5, 0), 5);
|
||||
assert_eq!(decode_gamepad_arrival(5), (5, 0));
|
||||
// Undefined high bits (a future extension) never leak into the index OR the caps.
|
||||
assert_eq!(
|
||||
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
|
||||
(9, 1)
|
||||
);
|
||||
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
|
||||
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_snapshot_roundtrip() {
|
||||
let s = GamepadSnapshot {
|
||||
|
||||
@@ -107,6 +107,10 @@ pub use stats::Stats;
|
||||
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
|
||||
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
|
||||
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
/// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
|
||||
/// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
|
||||
/// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
|
||||
/// unchanged. (Documented late — the bump shipped without its line here.)
|
||||
/// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
|
||||
/// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
|
||||
/// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
|
||||
@@ -120,7 +124,21 @@ pub use stats::Stats;
|
||||
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 14;
|
||||
/// v15: versions the shared rumble policy engine's C surface —
|
||||
/// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
|
||||
/// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
|
||||
/// still read 7 and no bump was made, so every core since has exported them while advertising a
|
||||
/// version that never promised them. That cannot be corrected retroactively — a shipped binary
|
||||
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
/// v16: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||
/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||
/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 16;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -121,6 +121,15 @@ pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
||||
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
||||
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
|
||||
/// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||
/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||
/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||
/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||
/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||
/// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
|
||||
/// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
|
||||
pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
@@ -154,6 +163,16 @@ pub const HOST_CAP_PEN: u8 = 0x10;
|
||||
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
||||
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
||||
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
|
||||
/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||
/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||
/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||
/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||
/// capable client marks its pads' render capabilities on their arrivals
|
||||
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||
/// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
|
||||
/// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
|
||||
/// `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
pub const HOST_CAP_PAD_AUDIO: u8 = 0x40;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
@@ -337,6 +356,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_cap_bits_are_distinct() {
|
||||
// The new pad-audio bits pack into the existing caps bytes without colliding with any
|
||||
// taken bit (a collision would silently negotiate an unrelated feature).
|
||||
assert_eq!(
|
||||
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
HOST_CAP_PAD_AUDIO
|
||||
& (HOST_CAP_GAMEPAD_STATE
|
||||
| HOST_CAP_CLIPBOARD
|
||||
| HOST_CAP_TEXT_INPUT
|
||||
| HOST_CAP_CURSOR
|
||||
| HOST_CAP_PEN),
|
||||
0
|
||||
);
|
||||
// Single-bit values (a multi-bit cap would OR neighbours in).
|
||||
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
|
||||
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xD1):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
|
||||
//! cursor state, pad audio.
|
||||
|
||||
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
/// (0xCE, host→client).
|
||||
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
/// host→client).
|
||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
@@ -401,11 +404,22 @@ impl RichInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
|
||||
/// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
|
||||
/// into its report.
|
||||
///
|
||||
/// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
|
||||
/// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
|
||||
/// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
|
||||
/// been bounded on both ends all along.
|
||||
pub const TRIGGER_EFFECT_MAX: usize = 11;
|
||||
|
||||
const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
const HIDOUT_HID_RAW: u8 = 0x05;
|
||||
const HIDOUT_AUDIO_CTL: u8 = 0x06;
|
||||
|
||||
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||
@@ -431,6 +445,14 @@ pub enum HidOutput {
|
||||
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
|
||||
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
|
||||
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
|
||||
///
|
||||
/// **STAGED SCAFFOLDING — deliberately unreachable today, do not delete.** Nothing on the host
|
||||
/// produces this variant and no client renders it; it codes/decodes and round-trips in tests
|
||||
/// and nothing else. It stays because `HIDOUT_TRACKPAD_HAPTIC` is an allocated tag on a
|
||||
/// SHIPPED wire: removing the variant would not reclaim the tag (a future peer could still
|
||||
/// send it), it would only lose the decoder that keeps such a datagram from being mistaken
|
||||
/// for something else. The producer is the Steam Controller coil path; the renderer is the
|
||||
/// client-side coil write. Wire up either half and this becomes live with no format change.
|
||||
TrackpadHaptic {
|
||||
pad: u8,
|
||||
side: u8,
|
||||
@@ -446,6 +468,16 @@ pub enum HidOutput {
|
||||
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
||||
/// firmware watchdog — a lost datagram heals on the next refresh.
|
||||
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
||||
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
|
||||
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
|
||||
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
|
||||
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
|
||||
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
|
||||
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
|
||||
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
|
||||
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
|
||||
/// state on every output report.
|
||||
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -460,7 +492,7 @@ impl HidOutput {
|
||||
}
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||
out.extend_from_slice(effect);
|
||||
out.extend_from_slice(&effect[..effect.len().min(TRIGGER_EFFECT_MAX)]);
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
@@ -478,6 +510,12 @@ impl HidOutput {
|
||||
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
||||
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
||||
}
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
out.push(HIDOUT_AUDIO_CTL);
|
||||
out.extend_from_slice(&pad.to_le_bytes());
|
||||
out.push(*flags);
|
||||
out.extend_from_slice(raw);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -497,10 +535,17 @@ impl HidOutput {
|
||||
pad: b[2],
|
||||
bits: b[3],
|
||||
}),
|
||||
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
|
||||
// `> 4`, not `>= 4`: a body with no effect bytes at all is malformed, and decoding it
|
||||
// as an EMPTY effect was actively harmful — downstream an empty block is written as an
|
||||
// all-zero trigger report, which is mode 0x00, which RELEASES a held effect. A
|
||||
// truncated datagram could therefore silently cancel the trigger a game was holding.
|
||||
// A genuine "no effect" is a full-length zero block and still decodes fine.
|
||||
HIDOUT_TRIGGER if b.len() > 4 => Some(HidOutput::Trigger {
|
||||
pad: b[2],
|
||||
which: b[3],
|
||||
effect: b[4..].to_vec(),
|
||||
// Bounded like `HidRaw` below: at most the parameter block is kept from the
|
||||
// (attacker-sized) tail.
|
||||
effect: b[4..b.len().min(4 + TRIGGER_EFFECT_MAX)].to_vec(),
|
||||
}),
|
||||
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
|
||||
pad: b[2],
|
||||
@@ -515,6 +560,22 @@ impl HidOutput {
|
||||
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
|
||||
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
|
||||
}),
|
||||
// B27: the pad is the only u16 index on this plane, and every consumer narrows it
|
||||
// with `as u8` on the stated assumption that pads are 0..MAX_PADS. Nothing enforced
|
||||
// that, so wire pad 256 silently ALIASED onto slot 0 — a malformed or hostile
|
||||
// datagram steering a real controller's speaker volumes. Rejected here, at the one
|
||||
// place the u16 exists, so the narrowings downstream are lossless by construction
|
||||
// (the same fix R10 applied to the rumble plane).
|
||||
HIDOUT_AUDIO_CTL
|
||||
if b.len() >= 11
|
||||
&& u16::from_le_bytes([b[2], b[3]]) < crate::input::MAX_PADS as u16 =>
|
||||
{
|
||||
Some(HidOutput::AudioCtl {
|
||||
pad: u16::from_le_bytes([b[2], b[3]]),
|
||||
flags: b[4],
|
||||
raw: b[5..11].try_into().unwrap(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -773,6 +834,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
|
||||
|
||||
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
|
||||
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
|
||||
|
||||
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
|
||||
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
|
||||
/// against the main audio plane.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PadAudioFrame {
|
||||
/// Gamepad index (the wire pad space, same as rumble/HID-output).
|
||||
pub pad: u8,
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
|
||||
pub kind: u8,
|
||||
pub seq: u32,
|
||||
pub pts_ns: u64,
|
||||
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
|
||||
pub opus: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Pad-audio datagram, host → client:
|
||||
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
|
||||
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
|
||||
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
|
||||
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
|
||||
b.push(PAD_AUDIO_MAGIC);
|
||||
b.push(pad);
|
||||
b.push(kind);
|
||||
b.extend_from_slice(&seq.to_le_bytes());
|
||||
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
b.extend_from_slice(opus);
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
|
||||
/// length bounds every read before it happens).
|
||||
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
|
||||
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
|
||||
return None;
|
||||
}
|
||||
Some(PadAudioFrame {
|
||||
pad: buf[1],
|
||||
kind: buf[2],
|
||||
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
|
||||
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
|
||||
opus: buf[15..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::quic::*;
|
||||
@@ -981,6 +1108,82 @@ mod tests {
|
||||
assert!(decode_rumble_datagram(&d[..6]).is_none());
|
||||
}
|
||||
|
||||
/// `Trigger` is the only variable-length variant that used to be bounded on NEITHER side.
|
||||
/// Pinned here because both halves matter: an over-long effect must be clamped on the way out
|
||||
/// AND on the way in, and a body with no effect bytes must not decode at all.
|
||||
#[test]
|
||||
fn trigger_effect_is_clamped_on_both_encode_and_decode() {
|
||||
// Encode clamps: a caller handing over an over-long block cannot put it on the wire.
|
||||
let long = HidOutput::Trigger {
|
||||
pad: 1,
|
||||
which: 0,
|
||||
effect: vec![0xAB; 200],
|
||||
};
|
||||
let d = long.encode();
|
||||
assert_eq!(
|
||||
d.len(),
|
||||
4 + TRIGGER_EFFECT_MAX,
|
||||
"magic + kind + pad + which + at most the parameter block"
|
||||
);
|
||||
|
||||
// Decode clamps independently of encode — a hostile peer does not use our encoder.
|
||||
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 1, 0];
|
||||
hostile.extend_from_slice(&[0xCD; 500]);
|
||||
match HidOutput::decode(&hostile) {
|
||||
Some(HidOutput::Trigger { effect, .. }) => {
|
||||
assert_eq!(effect.len(), TRIGGER_EFFECT_MAX, "tail is bounded");
|
||||
}
|
||||
other => panic!("expected a clamped Trigger, got {other:?}"),
|
||||
}
|
||||
|
||||
// An exact-length effect survives untouched, and round-trips.
|
||||
let ok = HidOutput::Trigger {
|
||||
pad: 2,
|
||||
which: 1,
|
||||
effect: vec![0x02, 0x90, 0xA0, 0xFF, 0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
assert_eq!(HidOutput::decode(&ok.encode()), Some(ok));
|
||||
}
|
||||
|
||||
/// A body with no effect bytes is malformed and must be REJECTED, not read as an empty effect:
|
||||
/// downstream an empty block becomes an all-zero trigger report, which is mode 0x00 — it
|
||||
/// releases whatever effect the game was holding. A truncated datagram must not do that.
|
||||
#[test]
|
||||
fn a_trigger_with_no_effect_bytes_is_rejected_not_read_as_cancel() {
|
||||
let empty = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0];
|
||||
assert_eq!(HidOutput::decode(&empty), None);
|
||||
|
||||
// One byte of effect is a legitimate short block (consumers zero-pad it) and still decodes.
|
||||
let one = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0, 0x02];
|
||||
assert_eq!(
|
||||
HidOutput::decode(&one),
|
||||
Some(HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 0,
|
||||
effect: vec![0x02]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// `HidRaw`'s bound was already correct on both sides — pinned alongside `Trigger` so the pair
|
||||
/// cannot drift apart again.
|
||||
#[test]
|
||||
fn hid_raw_stays_bounded_on_both_sides() {
|
||||
let long = HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: HID_RAW_OUTPUT,
|
||||
data: vec![0x11; 500],
|
||||
};
|
||||
assert_eq!(long.encode().len(), 4 + HID_REPORT_MAX);
|
||||
|
||||
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_HID_RAW, 0, HID_RAW_FEATURE];
|
||||
hostile.extend_from_slice(&[0x22; 900]);
|
||||
match HidOutput::decode(&hostile) {
|
||||
Some(HidOutput::HidRaw { data, .. }) => assert_eq!(data.len(), HID_REPORT_MAX),
|
||||
other => panic!("expected a clamped HidRaw, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
|
||||
// v2 envelope round-trips seq + ttl.
|
||||
@@ -1180,6 +1383,12 @@ mod tests {
|
||||
f
|
||||
},
|
||||
},
|
||||
// The DS5 audio-control region (haptics-select + speaker volume asserted).
|
||||
HidOutput::AudioCtl {
|
||||
pad: 1,
|
||||
flags: 0b0_0101,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
@@ -1198,6 +1407,92 @@ mod tests {
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_ctl_wire_layout_and_truncation() {
|
||||
// The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes].
|
||||
// The pad is deliberately a REPRESENTABLE one: this used to assert that 0x0201 (513)
|
||||
// round-tripped, which pinned B27's aliasing in place as if it were the contract.
|
||||
let a = HidOutput::AudioCtl {
|
||||
pad: 0x000B,
|
||||
flags: 0x17,
|
||||
raw: [1, 2, 3, 4, 5, 6],
|
||||
};
|
||||
let d = a.encode();
|
||||
assert_eq!(d, [0xCD, 0x06, 0x0B, 0x00, 0x17, 1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(HidOutput::decode(&d), Some(a));
|
||||
// Truncated buffers are rejected outright (fixed length — never a partial read).
|
||||
for n in 2..d.len() {
|
||||
assert_eq!(HidOutput::decode(&d[..n]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_datagram_roundtrip_and_truncation() {
|
||||
let opus = [0x5Au8; 61];
|
||||
let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus);
|
||||
assert_eq!(d[0], PAD_AUDIO_MAGIC);
|
||||
assert_eq!(d.len(), 15 + opus.len());
|
||||
let f = decode_pad_audio_datagram(&d).unwrap();
|
||||
assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999));
|
||||
assert_eq!(f.opus, opus);
|
||||
// Truncated headers are rejected outright (never partially read).
|
||||
for n in 0..15 {
|
||||
assert_eq!(decode_pad_audio_datagram(&d[..n]), None);
|
||||
}
|
||||
// Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa.
|
||||
assert!(decode_audio_datagram(&d).is_none());
|
||||
assert!(decode_mic_datagram(&d).is_none());
|
||||
assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
|
||||
// Empty payload (DTX) is legal — header-only datagram.
|
||||
let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]);
|
||||
assert_eq!(hdr.len(), 15);
|
||||
assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty());
|
||||
}
|
||||
|
||||
/// B27: the pad is the only u16 index on the 0xCD plane and every consumer narrows it with
|
||||
/// `as u8`. An out-of-range one used to alias onto a real slot instead of being refused —
|
||||
/// wire pad 256 steering pad 0's speaker volumes.
|
||||
#[test]
|
||||
fn audio_ctl_rejects_a_pad_outside_the_index_space() {
|
||||
let ok = HidOutput::AudioCtl {
|
||||
pad: (crate::input::MAX_PADS - 1) as u16,
|
||||
flags: 0x12,
|
||||
raw: [1, 2, 3, 4, 5, 6],
|
||||
};
|
||||
assert_eq!(
|
||||
HidOutput::decode(&ok.encode()),
|
||||
Some(ok),
|
||||
"the last valid pad must still decode"
|
||||
);
|
||||
|
||||
// Anything at or above MAX_PADS is refused outright, not truncated.
|
||||
for pad in [crate::input::MAX_PADS as u16, 256, u16::MAX] {
|
||||
let d = HidOutput::AudioCtl {
|
||||
pad,
|
||||
flags: 0x12,
|
||||
raw: [1, 2, 3, 4, 5, 6],
|
||||
}
|
||||
.encode();
|
||||
assert_eq!(HidOutput::decode(&d), None, "pad {pad} must not decode");
|
||||
}
|
||||
|
||||
// The specific alias the bug produced: 256 as u8 == 0.
|
||||
let d = HidOutput::AudioCtl {
|
||||
pad: 256,
|
||||
flags: 0,
|
||||
raw: [0; 6],
|
||||
}
|
||||
.encode();
|
||||
assert!(
|
||||
!matches!(
|
||||
HidOutput::decode(&d),
|
||||
Some(HidOutput::AudioCtl { pad: 0, .. })
|
||||
),
|
||||
"wire pad 256 must never surface as pad 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_state_roundtrip() {
|
||||
for (flags, x, y) in [
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
||||
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
||||
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF plane codecs,
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xD1 plane codecs,
|
||||
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
|
||||
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
||||
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
|
||||
|
||||
@@ -259,6 +259,17 @@ windows = { version = "0.62", features = [
|
||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||
"Win32_System_Com",
|
||||
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
|
||||
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
|
||||
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
|
||||
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
|
||||
# ACL repair + the devnode's pad-index marker value.
|
||||
"Win32_Media_Audio",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Variant",
|
||||
"Win32_Devices_Properties",
|
||||
"Win32_System_Registry",
|
||||
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
|
||||
@@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||
// `pad-endpoint` devtest.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/pad_endpoint.rs"]
|
||||
pub(crate) mod pad_endpoint;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
|
||||
@@ -143,6 +143,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
}
|
||||
|
||||
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
|
||||
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
|
||||
/// devnode marker, registry-only reads); this is just the per-pass collection.
|
||||
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
renders
|
||||
.iter()
|
||||
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
|
||||
.map(|(_, id)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||
@@ -159,6 +170,10 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
// The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters
|
||||
// them out of every role. Identity is platform data (stamped container / devnode marker),
|
||||
// so it is collected HERE and passed in, like the candidate lists themselves.
|
||||
let pad_ids = pad_render_ids(&renders);
|
||||
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
||||
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
||||
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
||||
@@ -179,6 +194,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
|
||||
// cannot carry stereo cannot carry 5.1 either.
|
||||
2,
|
||||
&pad_ids,
|
||||
);
|
||||
let done = |wiring: Wiring| WiredPlan {
|
||||
wiring,
|
||||
@@ -245,7 +261,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
if let Some((mic_name, mic_id)) = &wiring.mic_render {
|
||||
if default_render_id().as_deref() == Some(mic_id.as_str()) {
|
||||
// Audible preference = the host_audio plan's loopback pick (real hardware first).
|
||||
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
|
||||
match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render {
|
||||
Some((name, id)) => match set_default_endpoint(&id) {
|
||||
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
|
||||
"default playback was the virtual-mic target — moved it so desktop \
|
||||
@@ -302,8 +318,10 @@ fn park_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default.prev")
|
||||
}
|
||||
|
||||
/// The current default RENDER endpoint id, if any.
|
||||
fn default_render_id() -> Option<String> {
|
||||
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
|
||||
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
|
||||
/// default playback device).
|
||||
pub(crate) fn default_render_id() -> Option<String> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.ok()?
|
||||
.get_default_device(&Direction::Render)
|
||||
@@ -430,11 +448,13 @@ pub(crate) fn restore_default_playback() {
|
||||
}
|
||||
|
||||
/// Open a device by endpoint id, with a name for error context.
|
||||
///
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||
/// docs), so it fails at random on ids that are perfectly valid.
|
||||
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
|
||||
.get_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
|
||||
super::pad_endpoint::open_wasapi_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
@@ -481,8 +501,9 @@ const _: () = {
|
||||
|
||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
|
||||
/// fails.
|
||||
fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default
|
||||
/// through the same machinery.
|
||||
pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user