Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61547b512a | ||
|
|
166c93c079 | ||
|
|
8749bd1396 |
@@ -1,68 +0,0 @@
|
||||
#!/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,30 +160,6 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# Clippy for the ANDROID target. Like the kit tests below, this was running NOWHERE: ci.yml
|
||||
# lints `--workspace` on the host, where `clients/android/native` and every
|
||||
# `#[cfg(target_os = "android")]` module elsewhere compile out, and this workflow only ever
|
||||
# built. Discovered in 2026-08 with five lints already resident — code no gate had ever read.
|
||||
#
|
||||
# Placed BEFORE assembleDebug deliberately: a lint failure should cost the ~10 s the lint
|
||||
# takes, not the full three-ABI build first. It shares sccache and the target dir with the
|
||||
# build that follows, so the compile is not paid twice.
|
||||
#
|
||||
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
|
||||
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
|
||||
# both pointer widths are load-bearing and why the environment must not be duplicated here.
|
||||
- name: Clippy (Android target, deny warnings)
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:cargoNdkClippy --stacktrace
|
||||
|
||||
# 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,23 +41,9 @@ jobs:
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
|
||||
# is a raw textual substitution performed BEFORE the shell sees the line, so a
|
||||
# workflow_dispatch input containing shell syntax executes as this step — and this is the
|
||||
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
|
||||
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="$INPUT_TAG"
|
||||
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$TAG" in
|
||||
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
TAG="${{ inputs.tag }}"
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
@@ -81,7 +67,4 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
|
||||
# Same reasoning as the publish step above: the input is data in the environment, never
|
||||
# text spliced into the command line.
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
|
||||
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
|
||||
|
||||
@@ -173,31 +173,6 @@ jobs:
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
|
||||
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
|
||||
# each into `libavcodec.so=<soname>-<arch>` from the built binary's DT_NEEDED; if that
|
||||
# rewrite ever stops happening — Arch dropping the soname `provides`, someone "tidying" the
|
||||
# entries out of `depends`, a makepkg change — the dep silently degrades to an unversioned
|
||||
# name that ANY ffmpeg satisfies. That is precisely the 2026-08-08 state in which `pacman
|
||||
# -Syu` walked every Arch/CachyOS install across the FFmpeg 8 -> 9 soname bump and left the
|
||||
# host unable to start (exit 127 before main(), restart loop). The failure is invisible in a
|
||||
# green build and only shows up as a bricked box weeks later, so assert it here.
|
||||
- name: Assert the host pins the FFmpeg soname
|
||||
run: |
|
||||
PKG="$(ls "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst | head -1)"
|
||||
DEPS="$(bsdtar -xOf "$PKG" .PKGINFO | sed -n 's/^depend = //p')"
|
||||
echo "$DEPS" | sed 's/^/ depend = /'
|
||||
for lib in libavcodec libavutil; do
|
||||
echo "$DEPS" | grep -qE "^$lib\.so=[0-9]+-[0-9]+$" || {
|
||||
echo "::error::punktfunk-host declares no VERSIONED $lib.so dependency."
|
||||
echo "::error::makepkg did not expand the bare soname from DT_NEEDED, so pacman can"
|
||||
echo "::error::upgrade FFmpeg across a soname break and brick the install."
|
||||
echo "::error::See the depends comment in packaging/arch/PKGBUILD."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
|
||||
|
||||
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
|
||||
# completely different dependency set, published into the same repo so `pacman -S
|
||||
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
|
||||
|
||||
@@ -29,9 +29,4 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tier-3 GPU stream benchmark
|
||||
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
|
||||
# substituted before the shell parses the line, so an input carrying shell syntax would run
|
||||
# as this step (2026-08-05 review H-6).
|
||||
env:
|
||||
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
|
||||
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
|
||||
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
|
||||
|
||||
@@ -252,11 +252,6 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
|
||||
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
|
||||
# that only a browser rejects, which nothing else here would catch.
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
docs-site:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -280,31 +275,3 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
|
||||
# web/bun.nix and sdk/bun.nix are GENERATED from their bun.lock (bun2nix) and committed; the Nix
|
||||
# build fetches node_modules from nothing else. They regenerate only on a local `bun install` that
|
||||
# runs lifecycle scripts — never under CI's `--ignore-scripts`, and never on a merge or rebase,
|
||||
# which happily carries a lockfile change past a bun.nix generated before it. That is not
|
||||
# theoretical: web/bun.nix sat stale on main for 553 commits (2026-07-27 → 2026-08-05) with
|
||||
# `nix build .#punktfunk-web` broken, and was repaired only by accident when an advisory bump
|
||||
# happened to rerun a real `bun install`.
|
||||
#
|
||||
# Deliberately UNFILTERED and in ci.yml rather than nix.yml: it needs no Nix, takes well under a
|
||||
# minute, and the whole point is that the drift arrives through commits that look unrelated to
|
||||
# Nix. The Nix-toolchain gates (flake eval + building the bun packages) live in nix.yml.
|
||||
bun-nix:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
|
||||
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
|
||||
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
|
||||
- name: bun.nix drift gate
|
||||
run: sh scripts/ci/check-bun-nix.sh
|
||||
|
||||
@@ -119,15 +119,10 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
||||
# No libvulkan-dev: nothing here compiles or links against Vulkan (ash dlopens
|
||||
# libvulkan and pf-vkdecode binds nothing at build time), so neither the compile nor
|
||||
# dpkg-shlibdeps — which resolves DT_NEEDED sonames only — ever asks for it. The
|
||||
# client's `Depends: libvulkan1` is added by hand in packaging/debian/build-client-deb.sh
|
||||
# precisely because a dlopen is invisible to shlibdeps.
|
||||
# No libav*-dev: the client links no FFmpeg since M10 (§6 of
|
||||
# design/client-native-decode.md).
|
||||
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
|
||||
# (FFmpeg's hwcontext_vulkan.h includes it).
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
|
||||
|
||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||
- name: Cache keys
|
||||
|
||||
@@ -46,10 +46,7 @@ env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PACKAGE: punktfunk-decky # generic-registry package name
|
||||
# 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
|
||||
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
|
||||
+21
-114
@@ -3,18 +3,13 @@
|
||||
# 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 — 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.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
@@ -22,38 +17,8 @@
|
||||
#
|
||||
# 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).
|
||||
# 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.
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
|
||||
# the LAN registry is unauthenticated inside the LAN).
|
||||
#
|
||||
# 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
|
||||
@@ -77,10 +42,7 @@ 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:
|
||||
@@ -136,45 +98,21 @@ 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_PUSH/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
|
||||
ci
|
||||
|
||||
# Gated like Build/Push: only the docker CLI needs this login (Reconcile and Tag-for-release
|
||||
# authenticate via curl -u), so a cache-hit job with nothing to push must not be able to fail
|
||||
# on a login it never uses — proven on run 16013, where a host with a misconfigured daemon
|
||||
# failed exactly here on a hit=true leg.
|
||||
- name: Log in to the LAN registry
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
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_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 }}
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
|
||||
|
||||
# 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).
|
||||
@@ -186,19 +124,8 @@ 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 -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
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
|
||||
# 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
|
||||
@@ -237,28 +164,15 @@ jobs:
|
||||
run: |
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
|
||||
-t "$CI_REGISTRY/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY/$IMAGE:latest" \
|
||||
.
|
||||
|
||||
# Same gate as the builders job above: the login only serves Push.
|
||||
- name: Log in to the LAN registry
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
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_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 }}
|
||||
docker push "$CI_REGISTRY/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY/$IMAGE:latest"
|
||||
|
||||
- name: Tag for release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -268,15 +182,8 @@ 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 -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
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
|
||||
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
|
||||
apps:
|
||||
|
||||
@@ -34,10 +34,7 @@ on:
|
||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
|
||||
# binary's dependency closure must be listed here — including the native decode rungs, or a
|
||||
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
|
||||
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
|
||||
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
|
||||
# binary's dependency closure must be listed here.
|
||||
paths:
|
||||
- 'clients/linux/**'
|
||||
- 'clients/session/**'
|
||||
@@ -45,9 +42,6 @@ on:
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-vaadec/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/flatpak.yml'
|
||||
@@ -134,7 +128,7 @@ jobs:
|
||||
# authselect trigger fires — so this line alone was never the fix for the failures
|
||||
# below. See the retry.sh bump for the real cause.
|
||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable and llvm20 extensions.
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
|
||||
#
|
||||
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
|
||||
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
|
||||
@@ -153,7 +147,7 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
# This job was the fleet's single heaviest network consumer: every run re-downloaded
|
||||
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and
|
||||
# the GNOME runtime + SDK + llvm/rust/ffmpeg extensions (multi-GB from Flathub) and
|
||||
# every crate source. Both live in well-defined directories, both are idempotently
|
||||
# verified/extended by the steps below, and the central cache server restores them
|
||||
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
|
||||
@@ -257,8 +251,7 @@ jobs:
|
||||
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
|
||||
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
|
||||
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
|
||||
# extensions. (No codec extension: the client links no FFmpeg — see the
|
||||
# manifest header.)
|
||||
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
|
||||
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
# Nix packaging gate. Until this existed, NOTHING in CI ever evaluated flake.nix: the word "nix"
|
||||
# appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows
|
||||
# step. Every Nix regression therefore reached main invisibly and was found by hand on a Nix box —
|
||||
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
|
||||
# in ci.yml for that story).
|
||||
#
|
||||
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push:
|
||||
#
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
|
||||
# the NixOS module without building them. Catches the failures that actually happen to
|
||||
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
|
||||
# error, a package attribute dropped from packages.nix.
|
||||
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
|
||||
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
|
||||
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
|
||||
# the generated bun.nix really does materialise a working node_modules offline — it
|
||||
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
|
||||
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
|
||||
#
|
||||
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
|
||||
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
||||
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
||||
#
|
||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
|
||||
# workflow parser is not a place to bet on anchor support. Keep them in step by hand.
|
||||
name: nix
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "packaging/nix/**"
|
||||
- "**/bun.lock"
|
||||
- "**/bun.nix"
|
||||
- "**/package.json"
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".gitea/workflows/nix.yml"
|
||||
- "scripts/ci/check-bun-nix.sh"
|
||||
pull_request:
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "packaging/nix/**"
|
||||
- "**/bun.lock"
|
||||
- "**/bun.nix"
|
||||
- "**/package.json"
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".gitea/workflows/nix.yml"
|
||||
- "scripts/ci/check-bun-nix.sh"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-rust:
|
||||
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
flake:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
# NOT nixos/nix. That image contains nix and essentially nothing else — in particular no
|
||||
# /bin/sleep, and Gitea's act_runner starts every job container with
|
||||
# `entrypoint=["/bin/sleep","10800"]`. The container therefore never starts:
|
||||
# failed to create shim task: OCI runtime create failed: unable to start container
|
||||
# process: exec: "/bin/sleep": stat /bin/sleep: no such file or directory
|
||||
# and — the part that makes this expensive to debug — every step is then reported as
|
||||
# `cancelled` rather than failed, which reads exactly like a superseded run.
|
||||
#
|
||||
# node:22-bookworm instead: a full Debian with coreutils (so the entrypoint exists) and a
|
||||
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
|
||||
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
|
||||
image: node:22-bookworm
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# The flake needs both experimental features. Also baked into the installer's --extra-conf
|
||||
# below; this covers any step that shells out before that config is read.
|
||||
NIX_CONFIG: "experimental-features = nix-command flakes"
|
||||
# Absolute path rather than $GITHUB_PATH: one less runner behaviour to assume.
|
||||
NIX: /nix/var/nix/profiles/default/bin/nix
|
||||
# `--init none` installs Nix with NO daemon running, but the installer still writes a profile
|
||||
# script that exports NIX_REMOTE=daemon. Anything that sources it (any `-l` login shell) then
|
||||
# dies on `cannot connect to socket at '/nix/var/nix/daemon-socket/socket'` — which is exactly
|
||||
# how the installer's own self-test fails during this step, harmlessly, and would be a
|
||||
# confusing first thing to read in the log. The steps below never source that profile, but pin
|
||||
# the empty value so a future step cannot reintroduce it. Empty = talk to the local store
|
||||
# directly, which works because the job runs as root (MEASURED: "Store URL: local, Trusted: 1",
|
||||
# and a real `nix build` of a trivial derivation succeeds).
|
||||
NIX_REMOTE: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
|
||||
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
|
||||
# against a future slim-image swap, and costs one cached apt call.)
|
||||
- name: Installer prerequisites
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
|
||||
|
||||
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
|
||||
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
|
||||
# hand-verification box stay on the same distribution.
|
||||
- name: Install Nix
|
||||
run: |
|
||||
curl -fsSL https://install.determinate.systems/nix -o /tmp/nix-installer.sh
|
||||
sh /tmp/nix-installer.sh install linux --init none --no-confirm \
|
||||
--extra-conf "experimental-features = nix-command flakes"
|
||||
"$NIX" --version
|
||||
|
||||
# Nix reads the flake through libgit2 and refuses a checkout owned by another uid
|
||||
# ("detected dubious ownership"), which is the normal case for a container job.
|
||||
- name: Trust the checkout
|
||||
run: git config --global --add safe.directory "$PWD"
|
||||
|
||||
# Diagnostics. This fleet ran a runner out of disk on 2026-08-06 (the ci.yml `web` job died
|
||||
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
|
||||
# here — so record the headroom, or a future failure is a guess.
|
||||
- name: Environment
|
||||
run: df -h / /nix /tmp || true
|
||||
|
||||
# Evaluates + instantiates every flake output without building any of it.
|
||||
- name: nix flake check (eval only)
|
||||
run: |
|
||||
"$NIX" flake check --no-build --show-trace
|
||||
|
||||
# The bun packages, built for real. This is the leg that would have caught the stale
|
||||
# web/bun.nix end to end: the derivation's offline `bun install` runs against a store cache
|
||||
# built strictly from bun.nix, so a lockfile that cache does not cover fails here.
|
||||
# Path-filtered, so it runs only when the packaging or a lockfile actually moves. If it ever
|
||||
# starts going red on runner disk rather than on real defects, demote it to the dispatch
|
||||
# opt-in below rather than leaving an infra-red gate on the board.
|
||||
- name: Build the bun packages
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-web .#punktfunk-scripting
|
||||
|
||||
# Both launchers exec pkgs.bun from the store; confirm they were produced and are real entry
|
||||
# points rather than dangling wrappers.
|
||||
- name: Smoke the built launchers
|
||||
run: |
|
||||
set -eu
|
||||
web=$("$NIX" path-info .#punktfunk-web)
|
||||
scripting=$("$NIX" path-info .#punktfunk-scripting)
|
||||
test -x "$web/bin/punktfunk-web-server" || { echo "no punktfunk-web-server in $web" >&2; exit 1; }
|
||||
test -x "$scripting/bin/punktfunk-scripting" || { echo "no punktfunk-scripting in $scripting" >&2; exit 1; }
|
||||
# The console must be the bun bundle, not a node one — the same assertion packages.nix
|
||||
# makes at build time, re-checked on the installed output.
|
||||
grep -q 'Bun\.serve' "$web/share/punktfunk-web/.output/server/index.mjs" \
|
||||
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
|
||||
echo "bun packages OK: $web $scripting"
|
||||
|
||||
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
|
||||
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
|
||||
- name: Build the Rust packages (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
@@ -66,13 +66,6 @@ jobs:
|
||||
test -f node_modules/@punktfunk/host/package.json
|
||||
test -f node_modules/@punktfunk/host/dist/index.d.ts
|
||||
|
||||
# The kit had no biome config and no lint step, while every plugin repo that consumes it does
|
||||
# — so its source drifted (unused imports, formatting) with nothing to catch it. Now gated
|
||||
# here, on the same config and pinned biome version the plugins use.
|
||||
- name: Lint & format
|
||||
working-directory: plugin-kit
|
||||
run: bun run check
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: plugin-kit
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -96,12 +96,9 @@ jobs:
|
||||
- name: Prep
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers.
|
||||
# The host's Vulkan encode hand-rolls its structs, pyrowave-sys bindgens its own vendored
|
||||
# copy, and both host and client reach Vulkan through ash, which dlopens the loader. (The
|
||||
# HDR gamescope leg further down does need them, and pulls them itself via `dnf builddep
|
||||
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
|
||||
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#
|
||||
# What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in
|
||||
# both Rust workspaces + the JS trees + Swift Package.resolved) merged with
|
||||
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, gamescope).
|
||||
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, VB-CABLE, gamescope).
|
||||
name: sbom
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
@@ -38,18 +38,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
|
||||
#
|
||||
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
|
||||
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
|
||||
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
|
||||
# the whole step reproducible: bump the tag in both places together.
|
||||
- name: Install syft
|
||||
env:
|
||||
SYFT_VERSION: v1.49.0
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
|
||||
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
|
||||
| sh -s -- -b /usr/local/bin v1.49.0
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
@@ -141,15 +141,20 @@ jobs:
|
||||
# observed on a clean build on this very runner (2026-07-17). No-op for compliant
|
||||
# projects (libvpl-sys pins 3.13+).
|
||||
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
|
||||
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
|
||||
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
|
||||
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
|
||||
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||
# by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||
if (-not $env:FFMPEG_DIR) {
|
||||
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
# VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by
|
||||
# provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the
|
||||
# streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR
|
||||
# (the daemon env only refreshes on a runner-task restart).
|
||||
if (-not $env:VBCABLE_DIR) {
|
||||
"VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||
@@ -399,6 +404,7 @@ jobs:
|
||||
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
|
||||
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
|
||||
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
|
||||
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
|
||||
)
|
||||
$missing = @()
|
||||
foreach ($x in $need) {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
|
||||
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
|
||||
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
|
||||
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
|
||||
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, FFmpeg
|
||||
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
|
||||
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
|
||||
#
|
||||
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
|
||||
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
|
||||
# BOM/MAX_PATH runner gotchas.
|
||||
#
|
||||
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
|
||||
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
|
||||
# (windows-host.yml) is unchanged.
|
||||
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
|
||||
# windows.yml for the cross-build rationale + the BOM/MAX_PATH runner gotchas.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
@@ -53,9 +49,7 @@ on:
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-msix.yml'
|
||||
@@ -86,10 +80,12 @@ jobs:
|
||||
include:
|
||||
- arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
ffmpeg: C:\Users\Public\ffmpeg
|
||||
td: C:\t
|
||||
session_flags: ''
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
ffmpeg: C:\Users\Public\ffmpeg-arm64
|
||||
td: C:\t-a64
|
||||
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
|
||||
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
|
||||
@@ -98,7 +94,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
@@ -106,9 +102,12 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
|
||||
# crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10),
|
||||
# and pack-msix.ps1 no longer copies runtime DLLs from one.
|
||||
# crates (see windows.yml). FFMPEG_DIR selects the arch's import libs + is read by
|
||||
# pack-msix.ps1 for the runtime DLLs. All via GITHUB_ENV.
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
|
||||
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
|
||||
# no-op once already present, so any runner with that label works with no manual dispatch step
|
||||
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
|
||||
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
|
||||
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg,
|
||||
# Vulkan-Headers, WDK, Inno Setup, the ARM64 rustup target - self-provision via the "Ensure
|
||||
# Windows toolchain" step below, a fast no-op once already present, so any runner with that label
|
||||
# works with no manual dispatch step first). Build + clippy + fmt + test BOTH client binaries:
|
||||
# the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
|
||||
# (pf-presenter/pf-client-core/pf-console-ui/pf-ffvk — every stream runs in it, spawned by the
|
||||
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
|
||||
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
|
||||
# flip when skia-binaries adds the target.
|
||||
#
|
||||
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
|
||||
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
|
||||
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
|
||||
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
|
||||
# `amf-qsv` leg link-imports them.
|
||||
#
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
|
||||
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
|
||||
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
|
||||
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
|
||||
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
|
||||
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
|
||||
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
|
||||
# so fmt + test run only for x64.
|
||||
#
|
||||
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
|
||||
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
|
||||
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
|
||||
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
|
||||
# / per-arch vars are set in a step:
|
||||
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
|
||||
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
|
||||
@@ -32,6 +29,7 @@
|
||||
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
|
||||
# root keeps every nested path well under the limit (per-arch so the two
|
||||
# matrix legs don't share a target dir).
|
||||
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
|
||||
#
|
||||
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
|
||||
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
|
||||
@@ -57,9 +55,7 @@ on:
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
@@ -71,9 +67,7 @@ on:
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
@@ -116,7 +110,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
@@ -126,13 +120,21 @@ jobs:
|
||||
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
|
||||
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
|
||||
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
|
||||
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
|
||||
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
|
||||
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
|
||||
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
|
||||
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
|
||||
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
|
||||
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
|
||||
# starts before this job's "Ensure Windows toolchain" step ever writes that file, so its
|
||||
# PATH doesn't include this yet on a first run (confirmed live: STATUS_DLL_NOT_FOUND).
|
||||
"$ff\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustc --version
|
||||
cargo --version
|
||||
Write-Output "target ${{ matrix.target }} target-dir $td"
|
||||
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
|
||||
|
||||
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||
@@ -150,10 +152,7 @@ jobs:
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
|
||||
# dependency but only LINTS the packages it is given, so a decode crate that starts the
|
||||
# run but is missing from this list would be gated by nothing.
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
@@ -161,9 +160,9 @@ jobs:
|
||||
- name: Rustfmt check
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
|
||||
- name: Test
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
|
||||
-699
@@ -1,699 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
Protocol, ABI, driver and embedder detail, one section per stable release, newest first.
|
||||
|
||||
This is the **technical** half of a release. The other half — what changed for people who *use*
|
||||
Punktfunk — is `docs/releases/vX.Y.Z.md`, and it deliberately contains no internal names. The two
|
||||
were one document through v0.24.0; they split at v0.25.0 because the engineering section had grown
|
||||
long enough to bury the user-facing half it was appended to. See `docs/releases/README.md`.
|
||||
|
||||
If you embed `punktfunk-core`, package Punktfunk, or write a plugin, this file is for you. Start
|
||||
with the version table of the release you are moving to, then read **Breaking changes**.
|
||||
|
||||
---
|
||||
|
||||
## v0.25.0
|
||||
|
||||
407 commits since v0.24.0.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.24.0 | v0.25.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged — every addition below is optional or capability-gated |
|
||||
| C ABI | 14 | **17** | three steps; see below |
|
||||
| Workspace crate dirs | 22 | **26** | `pf-bitstream` (+ vendored `cros-codecs`), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec` added; `pf-ffvk` removed |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.23.0 | **0.24.0** | tracks API edits, lags one release by convention |
|
||||
|
||||
`crates/pf-driver-proto` is byte-for-byte identical to v0.24.0 — if you ship the virtual-display
|
||||
driver or the gamepad channel, nothing in this release touches you.
|
||||
|
||||
**Why the wire did not move.** It grew a lot and still did not break: an optional trailing
|
||||
`max_shard_payload: u16` on `Hello` (absent/0 = legacy, doubling as the renegotiation capability
|
||||
flag and the jumbo receive ceiling); two control messages `ShardPayloadChanged` (`0x08`) and
|
||||
`ShardPayloadAck` (`0x09`); a redundant desktop-audio datagram tag `0xD2` beside the plain `0xC9`; a
|
||||
controller-audio plane at `0xD1`; a new `0xCD` kind `0x06`; arrival flag bits 8/9; and
|
||||
`MAX_DATAGRAM_BYTES` 2048 → 9216. Old peers never send or read any of it. Bump `WIRE_VERSION` only
|
||||
when the handshake or planes change *incompatibly* — riding a C-ABI bump onto the wire once locked
|
||||
every new client out of every deployed host (`ABI mismatch: client 3 host 2`, observed live).
|
||||
|
||||
### C ABI 14 → 17
|
||||
|
||||
- **v15 — the rumble policy engine's C surface.** `punktfunk_connection_next_rumble_cmd`,
|
||||
`punktfunk_connection_set_rumble_quirks`, `PUNKTFUNK_RUMBLE_QUIRK_*`. These symbols are **not
|
||||
new**: they landed while the constant still read 7 and no bump was made, so every core since has
|
||||
exported them while advertising a version that never promised them. A shipped binary says what it
|
||||
says, so this cannot be corrected retroactively — **v15 is the floor that guarantees them.** At or
|
||||
above 15 the surface is present; below it, probe for the symbol. No code changed with this bump.
|
||||
- **v16 — the controller-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.
|
||||
- **v17 — session end reason.** `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*`
|
||||
vocabulary: after a session ends, ask *why* — this client closed it, the host's launched game
|
||||
exited (its close carried `APP_EXITED_CLOSE_CODE`, which the host had been sending for a long time
|
||||
with nothing consuming it), the host ended it cleanly, the host reported a failure, or the
|
||||
connection was lost. Purely a read of state the core already had: **no new call is required of an
|
||||
embedder**, a client that never calls it is unchanged, and the host sends identical bytes either
|
||||
way.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**1. 149 unprefixed macros are now `PUNKTFUNK_`-prefixed** (139 `#define`s renamed in the checked-in
|
||||
header). Names as generic as `MAX_PADS`, `TAG_LEN`, `ABI_VERSION`, `WIRE_VERSION`, `INPUT_MAGIC` and
|
||||
the whole `BTN_*` / `AXIS_*` family were landing in the namespace of every program that included the
|
||||
header.
|
||||
|
||||
*What to do:* add the prefix. Values are identical; the change is mechanical.
|
||||
|
||||
*It cannot break silently.* The old spellings cease to exist, so this is always an
|
||||
undeclared-identifier error, never a wrong value — which is precisely the failure being removed. A
|
||||
colliding `#define` does **not** fail to compile: the preprocessor silently takes the last
|
||||
definition, so an embedder whose own header defined `MAX_PADS` previously got a wrong value at
|
||||
runtime. Associated constants are untouched; the generator already qualifies those by type name.
|
||||
|
||||
**2. Linux hosts: the virtual Steam Deck controller moved to its own `punktfunk` group.** The
|
||||
capability rode on `input`, which every gamepad guide tells users to join — but it can emulate
|
||||
arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops
|
||||
attaching. Ordinary virtual gamepads are unaffected.
|
||||
|
||||
**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a
|
||||
shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that
|
||||
populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead.
|
||||
|
||||
**4. Plugin UIs moved to their own origin** on a second listener (default `PORT + 1`,
|
||||
`PUNKTFUNK_UI_PLUGIN_PORT`). Reverse proxies and firewalls must forward that port; a self-signed
|
||||
console needs it trusted separately.
|
||||
|
||||
### Capability bits
|
||||
|
||||
Four added, all in the handshake's client/host capability bytes:
|
||||
|
||||
| Bit | Constant | Meaning |
|
||||
|---|---|---|
|
||||
| client `0x04` | `CLIENT_CAP_AUDIO_RED` | can decode the redundant desktop-audio plane |
|
||||
| host `0x20` | `HOST_CAP_AUDIO_RED` | is sending it |
|
||||
| client `0x08` | `CLIENT_CAP_PAD_AUDIO` | can render controller audio |
|
||||
| host `0x40` | `HOST_CAP_PAD_AUDIO` | is sending it |
|
||||
|
||||
⚠ **Pressure worth watching:** `client_caps` has four bits free; **`host_caps` is down to its last
|
||||
one (`0x80`)**; `video_caps` has been full since 0.23.0 (`VIDEO_CAP_MULTI_SLICE = 0x80`). The next
|
||||
video capability needs a second byte *and* an ABI bump — plan for it rather than discovering it.
|
||||
|
||||
### Wire planes
|
||||
|
||||
- **Controller audio, `0xD1`** — `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]`,
|
||||
one Opus frame per datagram behind a 15-byte header. `PAD_AUDIO_KIND_HAPTICS = 0` is the pad's
|
||||
BACK channel pair (the voice coils) at 5 ms frames; `PAD_AUDIO_KIND_SPEAKER = 1` is the FRONT pair
|
||||
at 10 ms. Best-effort like every audio plane: loss is a sequence gap concealed by the gap tracker,
|
||||
silence is a frozen sequence under the mic-mute discipline, host gating at −60 dBFS with a 250 ms
|
||||
hangover. `0xD2` (redundant desktop audio) deliberately skipped `0xD1` to reserve it for this.
|
||||
- **`HidOutput::AudioCtl`** — `0xCD` kind `0x06`, carrying the DualSense output report's
|
||||
volume/routing bytes, change-only and value-deduped. Older clients drop it as an unknown kind.
|
||||
- **Arrival flags** — bits 8 (haptics) and 9 (speaker), sent only toward a `HOST_CAP_PAD_AUDIO` host.
|
||||
- **Adaptive-trigger effects are length-bounded** on encode and decode against one shared constant;
|
||||
the header emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]` in place of a literal `11` (same value,
|
||||
so the struct layout is byte-identical). A zero-length effect body is now rejected rather than
|
||||
decoding as an empty — that is, a *release* — effect.
|
||||
- Out-of-range pad indices are dropped before **either** rumble consumer sees them. The reorder gate
|
||||
bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it
|
||||
would use to subscript its own array. The client also clamps the host's rumble lease receive-side
|
||||
at 5 s, where the ceiling had been sender-side only.
|
||||
|
||||
### Host environment variables
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_AUDIO_QUALITY` | `high` | `low`/`standard`/`high`; `high` = stereo 256 kbps. `standard` reproduces the pre-0.25 encoder exactly for an A/B. A typo warns once rather than silently downgrading. |
|
||||
| `PUNKTFUNK_AUDIO_REDUNDANCY` | unset = automatic | on when the client supports it and the budget allows |
|
||||
| `PUNKTFUNK_AUDIO_OUTPUT_MODE` | `client_only` | `client_only`/`host_and_client`/`follow_default`. **Windows host only.** |
|
||||
| `PUNKTFUNK_PAD_AUDIO` | on | `0` disables controller audio host-wide |
|
||||
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1` | max 4; multi-pad needs an operator to raise it |
|
||||
| `PUNKTFUNK_PAD_AUDIO_STAMPS` | unset | debug bisect hook |
|
||||
| `PUNKTFUNK_WIRE_MTU` | unset | pins on-wire IP MTU for all sessions; above 1500 also enables jumbo |
|
||||
| `PUNKTFUNK_JUMBO` | unset (off) | fixed 9000-MTU profile |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | `PORT + 1` | the plugin-UI origin |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | platform default | art-serving roots; POSIX now defaults to `$HOME` |
|
||||
| `PUNKTFUNK_DECODER` | client | **values changed**: `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software`. Legacy `vulkan`/`vaapi`/`d3d11va` still accepted and migrated. Now **trimmed** — a trailing space used to fall through to `auto` silently. |
|
||||
| `PUNKTFUNK_VAAPI_DEVICE` | client | **new** — pin the VAAPI render node |
|
||||
| `PUNKTFUNK_DUMP_VIDEO` / `PUNKTFUNK_AU_DUMP` | client | **new** — capture exact decoder input / the AU as it arrived from the host |
|
||||
| `PUNKTFUNK_AU_FAULT=drop\|truncate\|flip[:period]` | client | **new** — deliberate decoder-input corruption for recovery testing; native rungs only |
|
||||
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1` | host | **new** — opt-in live split-encode arbitration (Linux-wired) |
|
||||
| `PUNKTFUNK_NO_AUDIO_MINT` | host (Win) | **new** — opt out of minted endpoints; restores the name ladder |
|
||||
| `PUNKTFUNK_GPU_PRIORITY` | host (Win) | **removed** — superseded by `PUNKTFUNK_GPU_PRIORITY_CLASS`, a strict superset |
|
||||
| `PUNKTFUNK_FFMPEG_LOG` | client | **removed** with the av_log machinery |
|
||||
|
||||
Legacy `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work, mapping to
|
||||
`host_and_client` and `follow_default`; `follow_default` wins if both are set. New devtest command:
|
||||
`punktfunk-host pad-endpoint ensure|remove|status`.
|
||||
|
||||
### Security
|
||||
|
||||
- **Origin isolation.** A second listener serves `/plugin-ui/**` and nothing else; the console origin
|
||||
refuses those paths and the plugin origin refuses everything else, `/api/**` above all. Different
|
||||
origin (scheme+host+port) so same-origin policy *is* the boundary; same site so the `SameSite=Lax`
|
||||
session cookie still flows. Bind failure disables plugin UIs rather than falling back.
|
||||
`x-pf-listener` is stripped inbound and set by the entry; active ports republish as
|
||||
`*_PORT_ACTIVE`; the plugin origin's CSP names the console as its only `frame-ancestors`; the proxy
|
||||
allowlist drops the plugin's `Clear-Site-Data`, `Access-Control-Allow-Origin` and `Set-Cookie`.
|
||||
⚠ The kit's `postMessage(..., "*")` is **load-bearing** — narrowing it to `location.origin` would
|
||||
target the plugin's own origin and drop every message.
|
||||
- **Authorization is an allowlist with a build-time gate.** `plugin_may_access` is a list of
|
||||
permitted `(method, path)` pairs with `{}` segment matching, enforced by a test that walks the live
|
||||
route table and **fails the build on any unclassified route** — the block-list it replaces let new
|
||||
endpoints through silently. Field authority is tracked separately from route reachability:
|
||||
requests carry the lane that authorized them, and `prep` / `launch.kind = "command"` are
|
||||
operator-token only.
|
||||
- **Art serving** gained an extension whitelist plus magic-byte sniffing, canonicalize-or-refuse, UNC
|
||||
refusal, config-dir exclusion and root checking, with `file://` percent-decoded *before*
|
||||
canonicalization so `%2e%2e` cannot hide. Validation also runs at write time, so an unservable path
|
||||
can no longer be persisted.
|
||||
|
||||
### Native decode — FFmpeg is gone from the client
|
||||
|
||||
268 files, +129k / −25k. `cargo tree -p punktfunk-client-session` finds zero `ffmpeg`. **The host
|
||||
keeps `libavcodec` unconditionally** (pf-encode); no host workflow, packaging script or licence file
|
||||
was touched.
|
||||
|
||||
| Platform | v0.24.0 | v0.25.0 |
|
||||
|---|---|---|
|
||||
| Linux desktop | ffmpeg-next: Vulkan hwcontext (`pf-ffvk`) → VAAPI → libavcodec sw | `pf-vkdecode` (ash, presenter's own `VkDevice`, zero-copy) → `pf-vaadec` (dlopen'd libva, DRM-PRIME dmabuf) → `openh264` + `rav1d` |
|
||||
| Windows desktop | ffmpeg-next Vulkan → libavcodec D3D11VA half | `pf-vkdecode` → `pf-dxvadec` (plans into `ID3D11VideoDecoder`) → `openh264` + `rav1d` |
|
||||
| Android | MediaCodec (never had FFmpeg) | unchanged |
|
||||
| Apple | VideoToolbox (never had FFmpeg) | unchanged |
|
||||
|
||||
**Workspace members:** added `pf-bitstream` (+ vendored `cros-codecs`, compiler-enforced
|
||||
`unsafe`-free), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec`; removed `pf-ffvk`. **Deleted:**
|
||||
`video_vulkan.rs`, `video_vaapi.rs`, `video_libav.rs`, the libavcodec half of `video_d3d11.rs`, the
|
||||
`av_log` machinery, `ffmpeg::codec::Id` as decoder vocabulary, `DecodedImage::VkFrame`/`::Dmabuf`,
|
||||
the `ffmpeg-fallback` feature, and swscale — and with it the BT.601 default its correction code
|
||||
existed to undo.
|
||||
|
||||
**Software rung:** `openh264 = "0.9"` (BSD-2) and `rav1d = { version = "1", default-features =
|
||||
false, features = ["bitdepth_8"] }` (BSD-2). `dav1d-sys` was rejected because it is `system-deps`-
|
||||
only and would add a system library plus a `.pc` to every client package. `default-features = false`
|
||||
drops `asm` — rav1d's `build.rs` *panics* without nasm, unlike openh264-sys2, which degrades quietly.
|
||||
**`bitdepth_8` only** ⇒ software AV1 refuses 10-bit by contract, read from the sequence header before
|
||||
any byte reaches the decoder.
|
||||
|
||||
**⚠ HEVC has no CPU floor.** An HEVC session that exhausts its hardware rungs tears down and re-dials
|
||||
advertising HEVC-less caps, and the host picks H.264 (`last_rung_verdict` / `NoSoftwareRung`). This is
|
||||
a first-class path, not a failure.
|
||||
|
||||
**Rung × codec × hardware evidence** (`native_evidence`) — the admission filter is driven by this, so
|
||||
an unproven rung yields only to one that is both verified for the codec and usable on the device:
|
||||
|
||||
| Rung | Codecs | Evidence |
|
||||
|---|---|---|
|
||||
| `native-vulkan` | H.264, H.265 Main/Main10/4:4:4 | **yes** — bit-exact vs libavcodec, 250/250 AUs on 3 drivers + 92-min soak |
|
||||
| | AV1 | **yes** — 250/250 bit-identical on one vendor, no soak |
|
||||
| `native-d3d11va` | H.264, H.265 | **yes** — frame-hash parity on RTX 4090 + AMD iGPU, 30-min soak |
|
||||
| | AV1 | **not proven** — decoded 4K60 once, no parity, no soak ⇒ excluded from the filter |
|
||||
| `native-vaapi` | H.264, H.265, AV1 | **NO — has never decoded a frame anywhere**; no VAAPI hardware was reachable |
|
||||
| `software` | H.264 (openh264), AV1 (rav1d) | **not proven**; openh264 has never run on glass. No HEVC at all. |
|
||||
|
||||
Vendor order (unchanged): Linux NVIDIA/AMD `vk → vaapi → sw`; Linux Intel/unknown
|
||||
`vaapi → vk → sw`; Windows NVIDIA/AMD `vk → d3d11va → sw`; Windows Intel/unknown
|
||||
`d3d11va → vk → sw`.
|
||||
|
||||
**AV1 advertisement** now answers from device facts (`av1_hardware_decodable`: Vulkan `DECODE_AV1`
|
||||
queue op, or the Windows D3D11 import path) rather than `ffmpeg::decoder::find(AV1)`, which was true
|
||||
on any build linking libdav1d. **Settings migration:** stored `vulkan`/`vaapi`/`d3d11va` migrate to
|
||||
`native-*` at decoder construction *and* at each dialog's lookup — the second is load-bearing, since
|
||||
an unmatched value renders as "Automatic" and a save would silently rewrite the preference.
|
||||
|
||||
### The three decode data-loss bugs
|
||||
|
||||
**AV1 sub-frame truncation — shipped in v0.24.0, host-side.** NVENC sub-frame readback has two halves
|
||||
armed by *different* conditions: `build_init_params` arms the writer from `subframe_on` alone, while
|
||||
the chunked reader additionally requires `slices >= 2` — and `resolve_slices` returns `1` for AV1
|
||||
unconditionally, because AV1 partitions via tiles, not slices. So an AV1 session told the driver to
|
||||
publish tile-by-tile and then took only the first tile. Measured at 4K60: every AU carried a header
|
||||
declaring two tile rows plus a single Tile Group OBU with `tg_start = tg_end = 0`; libdav1d rejected
|
||||
**835/836** AUs. NVIDIA's *hardware* decoder accepts it (so Vulkan Video looked healthy at 60 fps);
|
||||
its DXVA path did not. 1080p is one tile and unaffected; 4K splits into two tile rows and loses half
|
||||
the picture. Fixed by disarming sub-frame for AV1 while leaving `split_mode` untouched — AV1 keeps
|
||||
every engine. Arming the reader instead is *not* a drop-in: the reader cuts at
|
||||
`bitstreamSizeInBytes` on the reasoning that slices are contiguous Annex-B, which AV1 OBUs are not.
|
||||
Post-fix 654/654 clean. The test that had pinned the old behaviour as *correct* is replaced by one
|
||||
pinning the disarm, plus one comparing the reader's gate against the writer's — the comparison
|
||||
nothing made.
|
||||
|
||||
**HEVC DPB from the level ceiling — new in this release, client-side.** `dpb_limit` computed
|
||||
`max(A-2_level_ceiling, sps_max_dec_pic_buffering_minus1 + 1)`. HEVC equation A-2 is a **ceiling on
|
||||
what an SPS may legally signal**, not a statement of need, and it branches on picture size against
|
||||
the *level's* `MaxLumaPs`. The host is blameless: NVENC autoselects L5.1 because the bitrate exceeds
|
||||
L5.0's ceiling, and signals six pictures at every resolution. At 720p and 1080p the A-2 branch yields
|
||||
16 frames / **17 slots** — one more than NVIDIA's `maxDpbSlots` of 16 — so every AU fell outside
|
||||
device caps, flushed, waited for an IRAP, and the fresh IDR needed 17 again; rungs exhausted, and
|
||||
there is no software HEVC. It hid because the path was only ever exercised at 4K, the one size that
|
||||
falls through to the honest answer. Fixed to `buffering.min(16)`: the `max()` bought no tolerance,
|
||||
since `Dpb::needs_bumping` already evicts at the signalled depth — it only over-allocated ten
|
||||
surfaces per 1080p session. **H.264 escaped by luck** (its ceiling lands at 13 for 1080p) and is left
|
||||
alone, because H.264's DPB size genuinely *is* level-derived absent a VUI `bitstream_restriction`.
|
||||
|
||||
**rav1d aborts the process — new in this release, client-side.** rav1d 1.1.0 `abort()`s on *any*
|
||||
decode error while holding one frame context: the `c.fc.len() == 1` branch decodes inline, always
|
||||
finishes in `rav1d_decode_frame_exit` which unconditionally takes `frame_hdr`, then on `Err` re-enters
|
||||
an `on_error` whose first act is `frame_hdr.as_ref().unwrap()` on the `None` it just left. The panic
|
||||
unwinds into `dav1d_send_data`, which is `extern "C"` ⇒ `panic_cannot_unwind` ⇒ `abort()`. **No
|
||||
`catch_unwind`, no rung demotion and no refusal can catch it**, and every `rav1d_*` entry is
|
||||
`pub(crate)`, so no in-process guard is possible. 4K was only *where* the first error happened — the
|
||||
CPU rung does 35–39 fps against a 60 fps stream, the backlog stopped draining, the pump flushed to
|
||||
live, and the next AU referenced undecoded frames. Fixed by opening with `n_fc >= 2` and asking
|
||||
`dav1d_get_frame_delay` what the settings actually bought. Decode now drains **past** the first
|
||||
`EAGAIN`, which is why two frame contexts cost no latency (20–42 ms/unit at `n_fc=2` vs 21–53 at
|
||||
`n_fc=1`). On glass: 4K60 AV1 was SIGABRT on the second frame every run; after, exit 0 with 1204
|
||||
frames and 13 decode errors recovered across 17 backlog flushes. Reported upstream as **rav1d#1497**
|
||||
with a reproducer. Does **not** make the CPU rung panic-proof.
|
||||
|
||||
**Settings loader BOM — shipped in v0.24.0, client-side.** `.and_then(|s| from_str(&s).ok())` turned
|
||||
every parse failure into `Default`. `Set-Content -Encoding UTF8` writes `EF BB BF`, serde_json
|
||||
correctly rejects at byte 0, and every setting vanished silently. A shared `load_json_or_default` now
|
||||
strips the BOM and warns with path plus serde line/column, covering settings, known-hosts (where a
|
||||
BOM silently unpaired every host) and profiles on both desktop clients. The result is deliberately
|
||||
still `Default`, never an error.
|
||||
|
||||
### Other decode/encode
|
||||
|
||||
- **Intel Arc pNext ordering.** `vkGetPhysicalDeviceVideoCapabilitiesKHR` was called with the codec
|
||||
caps struct chained *before* `VkVideoDecodeCapabilitiesKHR` (`push_next` prepends). Arc/Windows
|
||||
fills those two **by position, not by sType**, and returned them swapped — we read a level as a
|
||||
capability bitmask. Measured A/B: `decode_flags_raw=12 max_level_idc=1` before,
|
||||
`decode_flags_raw=1 max_level_idc=12` after. NVIDIA and RADV dispatch by sType, which is why the
|
||||
fleet stayed green. ⚠ **This does not yet give Arc Vulkan Video** — the refusal only moves down: the
|
||||
device advertises only COINCIDE, and its NV12 coincide entry does not advertise `SAMPLED` usage,
|
||||
which the zero-copy presenter needs. Unresolved whether that is ours or an Intel constraint.
|
||||
- **NVENC split encode.** The 10-bit rule sat *above* the pixel-rate arm and took no codec, so it
|
||||
vetoed 10-bit 4K120 — the exact case the pixel-rate arm exists for — and applied an
|
||||
HEVC-Main10-on-Ada result to AV1 10-bit, which has no such measurement. Re-measured on Ada and
|
||||
Blackwell: 4K60 2.06×, 5120×1440@240 1.31×, 4K120 1.89× — **split wins at every mode on both
|
||||
architectures, including the configuration the veto came from.** New order: env override →
|
||||
pixel-rate arm (now taking `max_forced_split_mode(engines)`, not a hard-coded 2) →
|
||||
HEVC-Main10-below-the-bar → AUTO. Operator over-asks are clamped with a warning because **the driver
|
||||
honours an over-ask and silently encodes narrower**. Also newly logged: HEVC + plain AUTO +
|
||||
sub-frame is **silently single-engine** — the fleet's default shape, and nothing said so.
|
||||
⚠ **Unvalidated consequence:** 5120×1440@240 Main10 now clears the pixel-rate bar and *will* be
|
||||
forced to split — the exact configuration the old veto came from. `PUNKTFUNK_SPLIT_ENCODE=0` is the
|
||||
escape.
|
||||
- **PyroWave on Windows stamped over the host's GPU scheduling policy.** It raised the process WDDM
|
||||
class to HIGH at every session open, while `auto_priority_gate` already owns that process-wide —
|
||||
starting at HIGH, *upgrading* to REALTIME once safe, and leaving a monitor that drops back when VRAM
|
||||
tightens (REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang). Opening PyroWave
|
||||
stamped HIGH back and **orphaned the monitor's decision**. Removed rather than reconciled.
|
||||
- **A `pf-vkdecode` AV1 use-after-free fix had stabilised the wrong pointer** —
|
||||
`OwnedStdAv1SequenceHeader` kept the Std struct *inline*, so `pStdSequenceHeader` was a dead stack
|
||||
address; it worked only because NVIDIA happened to retain `pColorConfig` instead. Std structs are
|
||||
now boxed inside each owning wrapper, and create-time arrays are fields of the stored parameters
|
||||
assembled at their final address. The same shape was fixed pre-emptively in H.264/H.265.
|
||||
|
||||
### A/V sync — it did not previously exist
|
||||
|
||||
The host has always stamped `pts_ns` on every audio datagram. **Every client decoded it into
|
||||
`AudioPacket` / `AudioPCM` and never read it.** Video's `pts_ns` was used end to end; audio free-ran
|
||||
at whatever depth its jitter ring reached; nothing compared them. The A/V offset was an emergent
|
||||
property of buffer depths — it moved whenever the ring ratcheted under underrun pressure, and it got
|
||||
**worse every time video got faster**, because a quicker decoder lowers the video leg and leaves
|
||||
audio's where it was. That is why shaving milliseconds off the audio budget had never helped.
|
||||
|
||||
Two host defects were prerequisites:
|
||||
- **`pts_ns` was stamped at encode time**, inside the loop draining an already-accumulated chunk, so
|
||||
every frame of a chunk carried near-identical timestamps describing *when we got round to
|
||||
encoding*. Now derived from the chunk's arrival instant minus queued-frame duration, re-anchored
|
||||
per chunk.
|
||||
- **The host did not pace.** One capture callback hands over a whole quantum (5 ms honoured, **21.3 ms
|
||||
on a VM**, where stock PipeWire raises `min-quantum` to 1024), drained into back-to-back
|
||||
`send_datagram` calls — a 4–5 frame burst then ~21 ms of nothing, which a ring could only absorb by
|
||||
standing a burst period deep. Frames now leave on the audio clock (`FRAME_INTERVAL` 5 ms,
|
||||
`PACE_MAX_SLEEP` 10 ms, `PACE_REANCHOR` 100 ms). Costs no average latency.
|
||||
|
||||
```
|
||||
audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
|
||||
av_offset = audio_e2e − video_e2e (> 0 ⇒ audio behind the picture)
|
||||
```
|
||||
|
||||
`AvSync` EWMAs it (`AV_EWMA_TAU_MS = 2000`), ignores anything inside `AV_DEADBAND_MS = 10`, waits
|
||||
`AV_MIN_OBSERVATIONS = 100` before a first correction, and **refuses rather than clamps** beyond
|
||||
`AV_SANE_LIMIT_MS = 1000` — a wall-clock step must not steer the ring.
|
||||
|
||||
⭐ **Video is the master, and continuity outranks sync.** `JitterPolicy::set_sync_target` takes only a
|
||||
*request*, clamped between the existing underrun-driven adaptive floor and the hard cap: a link whose
|
||||
jitter genuinely needs more buffer than the picture is away keeps its buffer, and the residual is
|
||||
reported rather than forced. `None`/`nil` reproduces prior behaviour bit-identically, which is how
|
||||
the four rings adopted it one at a time.
|
||||
|
||||
Per client: the Rust desktop reference is a new `video_e2e_ns` atomic beside `clock_offset`, written
|
||||
by the presenter and read by the audio thread. **Android** publishes `OnFrameRendered` — the one
|
||||
place that knows a frame *latched* — **raw, not floor-shaved** (the HUD shaves the OS present floor;
|
||||
sound must reach the ear when light reaches the eye), and stays inert below API 33 rather than
|
||||
substituting the release instant, which targets a future vsync 8–21 ms ahead of glass. **Apple**
|
||||
publishes its `LatencyMeter` sample as an *expiring level*, because that client has a backgrounded
|
||||
keep-alive that keeps audio playing while dropping video decode; its clamp raises the ceiling to the
|
||||
floor rather than `min(max(…))`, which on a device whose callback quantum alone exceeds the hard cap
|
||||
would otherwise hand back the cap, silently below the continuity floor.
|
||||
|
||||
Escape hatches: `PUNKTFUNK_NO_AV_SYNC=1` everywhere, plus
|
||||
`adb shell setprop debug.punktfunk.no_av_sync 1` on Android (a launcher-started app inherits no
|
||||
environment). Observability: `buffer_ms`/`target_ms` had only ever been a `tracing::debug!` line —
|
||||
and on a Deck the client runs under Steam's `reaper` with stdout on a pipe nobody can read, so the
|
||||
one number identifying a deep ring was unobtainable *on the device reporting the latency*. Now on the
|
||||
HUD and in the 1 Hz stats log on every client.
|
||||
|
||||
### Decode-target aliasing — caught before it shipped
|
||||
|
||||
⚠ **None of this ever shipped.** `git ls-tree v0.24.0 crates/` has no `pf-vkdecode`, `pf-dxvadec`,
|
||||
`pf-vaadec` or `pf-bitstream`; v0.24.0's decode rungs were libavcodec. This was a ship-blocker for
|
||||
the new stack, cleared — not a field bug.
|
||||
|
||||
Three of the four native rungs released a picture's surface **inside the plan→submission
|
||||
conversion**, then assigned the decode target a slot. `SlotMap::assign` returns the *lowest free
|
||||
slot* — the one just vacated. The submission then named one surface as both decode target and its own
|
||||
reference: `CurrPicTextureIndex == RefFrameMapTextureIndex[k]` on DXVA, or `pSetupReferenceSlot`
|
||||
sharing an array layer with `pReferenceSlots` on Vulkan. **Decode into the surface you are predicting
|
||||
from.**
|
||||
|
||||
- **AV1 / D3D11VA** — AV1 applies `refresh_frame_flags` *after* decode (7.20), so "read a slot then
|
||||
overwrite it" is the ordinary case: **268 of the vendored vector's 274 frames**, first at frame 6.
|
||||
- **H.264 / both Vulkan and D3D11VA** — `H264Planner` snapshots `dpb_refs` in `begin_picture`, before
|
||||
8.2.5 marking and the C.4.5.3 bump, so a picture the sliding window unmarks and the bump evicts
|
||||
lands in *both* `dpb_refs` and `dpb.removed`. Both conditions coincide only in low-delay H.264 —
|
||||
and NVENC guarantees it (`max_num_ref_frames = 3` alongside `max_dec_frame_buffering = 3`, plus
|
||||
`max_num_reorder_frames = 0`). Result: **297 of every 300 access units of every stream a punktfunk
|
||||
host emits**, at every resolution, on both rungs.
|
||||
- **H.265 is exempt, now measured rather than argued** — 0 of 120 aliases, with a counterfactual that
|
||||
moves the snapshot one call earlier and reproduces 115 of 120.
|
||||
- **VAAPI's exemption was incidental**: the precondition is fully present (117 of 120 AUs) but
|
||||
`plan_to_va` never invents a surface. That held only because three call sites happened to write
|
||||
`free_surface()` and `surface_table()` adjacently; `acquire_target` now returns index, surface and
|
||||
table together so a later edit cannot split them.
|
||||
|
||||
Fix is uniform: the plans grow `release_after_decode`, conversions hand removals back, callers
|
||||
release once the decode op is issued. Costs no slot (`SlotMap::new` allocates `max_dpb_frames + 1`).
|
||||
Both rungs hold the `Result` rather than `?`-ing it so the deferred release runs on failure paths —
|
||||
seven exits sat between conversion and release, each of which would have leaked a slot.
|
||||
|
||||
**Why four gates missed it**, all recorded: the conformance vector is *structurally blind* (level 1.3,
|
||||
no VUI `bitstream_restriction` ⇒ a 7-frame DPB against 2 reference frames, and it reorders) and
|
||||
passed 250/250 for two milestones; **a test had encoded the bug as correct**; another assertion was
|
||||
*vacuous* (it asserted the decode target was never also a reference while handing every picture its
|
||||
own never-reused surface id — distinct integers cannot collide); and **it streamed clean** — *"the
|
||||
2026-08-07 field sessions that looked clean were looking at wrong pixels."*
|
||||
|
||||
`gpu_parity` is now **11 legs** (not 9 — that note was written mid-PR): each decodes a vendored stream,
|
||||
reads back every output frame's NV12, crops to the display region and SHA-256s in *display order*
|
||||
against libavcodec goldens, frame count and flush tail included. The three new legs are our own
|
||||
encoder's output rather than conformance vectors — H.264 because the vector is blind to the shape,
|
||||
H.265 because an exemption with no stream behind it is how the H.264 defect survived two milestones,
|
||||
AV1 because the vector is one tile on all 274 frames while our encoder splits 4K into two tile rows,
|
||||
so every tile array the conversions fill had only ever been written at index 0. `video_vaapi_native`
|
||||
parity is new entirely: 7 legs, bit-identical on RDNA3.
|
||||
|
||||
⚠ Promoting D3D11VA AV1 to `verified` **changes rung selection** on Windows Intel/unknown vendors, not
|
||||
just a label. VAAPI stays `verified = false` deliberately — one vendor, never soaked; flipping it
|
||||
would move `auto` off Vulkan Video on every Linux AMD/Intel client including the Deck.
|
||||
|
||||
### FFmpeg 9, and the Arch soname trap
|
||||
|
||||
`pf-encode` now builds against **FFmpeg 9**. The host still links libavcodec unconditionally; the
|
||||
client has none (see above).
|
||||
|
||||
⚠ **`pacman` is the only one of our packaging formats that does not derive dependencies from ELF
|
||||
`DT_NEEDED`.** rpm auto-generates `libavcodec.so.62()(64bit)`, `dpkg-shlibdeps` emits `libavcodec62`,
|
||||
nix pins the closure — but a bare `depends=('ffmpeg')` let `pacman -Syu` walk the host across a
|
||||
soname bump with no warning and no conflict. FFmpeg 8 → 9 (`2:9.0-5`: libavutil .60→.61, libavcodec
|
||||
.62→.63, libavfilter .11→.12, libavdevice .62→.63, libswscale .9→.10) therefore **bricked every
|
||||
Arch/CachyOS install**: the dynamic loader cannot start the binary, so it is **exit 127 before
|
||||
`main()`** in a systemd restart loop, with nothing in the host's own log to explain it.
|
||||
`ldd /usr/bin/punktfunk-host | grep "not found"` is the one-line diagnosis.
|
||||
|
||||
⭐ The fix is **SONAME deps, not a hand-written version bound**: `depends=(… 'libavcodec.so'
|
||||
'libavutil.so' …)`. Arch's ffmpeg declares matching `provides=(libavcodec.so=63-64 …)`, and makepkg
|
||||
rewrites each bare `libfoo.so` into `libfoo.so=<soname>-<arch>` by reading the built binary's
|
||||
`DT_NEEDED` — so the bound tracks whatever FFmpeg the builder linked against with nothing to
|
||||
maintain across the next bump. A literal `ffmpeg<2:9` would go stale on every bump. pacman now
|
||||
refuses the upgrade instead of bricking the install. All seven libs are listed even though
|
||||
`--as-needed` currently drops two: an unlinked soname is left bare by makepkg and satisfied by any
|
||||
ffmpeg, so listing it costs nothing and a future link picks up the bound automatically.
|
||||
|
||||
### Linux playback filled the buffer ceiling
|
||||
|
||||
The PipeWire playback callback sized its writes from the mapped buffer's **capacity** — PipeWire's
|
||||
quantum limit, 8192 frames ≈ 170 ms — instead of the graph's per-cycle ask (`pw_buffer.requested`).
|
||||
Every cycle queued up to 170 ms of PCM downstream of the ring **and** taught `JitterPolicy` that the
|
||||
device drains 170 ms per callback, so the underrun floor (want + one frame) rose above any depth the
|
||||
A/V sync loop could request: sync measured audio ~280 ms late and was then forbidden — **by its own
|
||||
continuity rule** — from draining it. The first on-glass run of the latency overhaul showed exactly
|
||||
that: `audio buffer 272 ms, a/v +284 ms`, stable. Now honours `requested` (capacity remains both the
|
||||
ceiling and the fallback when `requested == 0`) and logs requested-vs-capacity once per stream.
|
||||
Needs libpipewire ≥ 0.3.49; every ship target clears it.
|
||||
|
||||
### Windows audio substrate
|
||||
|
||||
The host now mints its **own** devnodes from Valve's INFs (`SteamStreamingSpeakers.inf` /
|
||||
`SteamStreamingMicrophone.inf` under `{CommonProgramFiles(x86)}\Steam\drivers\Windows10\…`) instead
|
||||
of bundling VB-CABLE.
|
||||
|
||||
- **Two persistent endpoints**, `Punktfunk Speakers` (client-only loopback sink — the wiring plan
|
||||
parks the default playback on it during a stream, its WASAPI loopback feeds the encoder, the host
|
||||
stays silent) and `Punktfunk Microphone` (host writes decoded client voice into the render side;
|
||||
the capture side surfaces as the mic). Both survive host restarts and re-resolve by marker.
|
||||
- **Identity is the recorded endpoint id, never the name** — a minted instance is name-identical to
|
||||
Steam's primaries. Durable marker `PunktfunkAudioRole` (1 = Speakers, 2 = Mic) under Device
|
||||
Parameters. Name stamping is device-desc + device-name **only**: a wider stamp set makes
|
||||
`AudioEndpointBuilder` re-mint under a new GUID. Best-effort via the SYSTEM ACL route; on failure
|
||||
the endpoint still wires and simply keeps the driver's default name.
|
||||
- **Format stamps are per-direction.** Render gets the PCM16-device / float-mix stereo split; capture
|
||||
gets the **device-format key only** — mix and host-format keys are render-engine properties, and
|
||||
stamping them onto a capture endpoint breaks its shared-mode graph (`IsFormatSupported` reports
|
||||
2ch/48k fine, `Initialize` then fails `0x88890008`).
|
||||
- **`MintedIds` is tier-0 in the wiring plan.** The mic takes its minted device outright (paired by
|
||||
provider id — a name search cannot distinguish it from the primary); the loopback prefers the
|
||||
minted sink at the head of the silent tier. Below that the old ladder is unchanged: Steam primaries
|
||||
→ cable → real hardware. `PUNKTFUNK_MIC_DEVICE` still beats everything.
|
||||
- **Mic-vs-loopback arbitration**: the mic may hold the Streaming Microphone only while the loopback
|
||||
still gets a non-last-resort pick; otherwise the loopback takes it and `mic_withheld` is set. This
|
||||
fixes a field case where a headless Steam-only host streamed **silence**.
|
||||
- **New `AudioReadiness`** — `Full` / `AudioOnly` / `MicOnly` / `Nothing`, logged on every plan
|
||||
change and surfaced at `GET /api/v1/status` → `RuntimeStatus.audio` (`AudioWiring`, Windows-only,
|
||||
absent before the first wiring pass; a status poll triggers no COM work or `IPolicyConfig` writes).
|
||||
The console Dashboard renders it as an "Audio wiring" card.
|
||||
- **Requires Steam installed** (never running) — without the INFs the host streams video only, and
|
||||
picks the drivers up automatically if Steam is installed later. Opt out entirely with
|
||||
`PUNKTFUNK_NO_AUDIO_MINT`, which restores the previous name-based ladder exactly.
|
||||
- ⚠ **VB-CABLE is no longer bundled but is deliberately NOT uninstalled** — it is a third-party
|
||||
shared component other apps may use, and it stays in the ladder as a live fallback. Demoting it was
|
||||
considered and rejected: on a box where minting transiently fails, that would let the Steam
|
||||
Streaming Microphone outrank an installed cable, steal the silent sink and make stream audio
|
||||
audible on the host.
|
||||
- ⚠ **The minted endpoints survive Punktfunk's uninstall by design** (they are plain instances of
|
||||
Steam's drivers and are inert without the host). There is no user-facing removal path; cleanup is
|
||||
the devtest `punktfunk-host audio-probe cleanup`.
|
||||
- New devtest: `punktfunk-host audio-probe ssm|sink|sss-primary|mint|plan|micpitch|micpins|cleanup`.
|
||||
`plan` is the field-triage command; `micpins` maps exclusive+shared `IsFormatSupported` across
|
||||
{1,2}ch × {16,32}bit × {44.1,48,96}kHz on both mic pins.
|
||||
|
||||
### Apple audio
|
||||
|
||||
- **The microphone was never in the render graph.** On the combined (voice-processing) engine — made
|
||||
default a week earlier and never run on a device — the input node carried a tap and **no
|
||||
connection**, so nothing pulled it: the IO unit came up, the recording indicator lit for a beat,
|
||||
and not one buffer ever reached the tap, with no error and no failed start. The 10 s silence
|
||||
tripwire counts *captured* frames, so it never fired. Input now runs through a silent sink into the
|
||||
main mixer at `outputVolume = 0` (Apple's own voice-processing sample topology). Two more: the tap
|
||||
read the input format **before** `prepare()`, and enabling voice processing swaps in the VPIO unit
|
||||
and renegotiates, so the pre-swap read could be 0 Hz / 0 ch; and a mic-chain failure on the
|
||||
voice-processed engine took the whole uplink down for the session — it now falls back to the split
|
||||
path, because **the mic outranks the AEC**.
|
||||
- **No packet-loss concealment on the one client that decodes Opus in core.** Linux, Windows and
|
||||
Android all feed an `AudioGapTracker` and synthesize libopus PLC; the in-core path had the tracker
|
||||
sitting unused in the same crate and decoded only packets that arrived. At ~200 packets/s of 5 ms
|
||||
frames every lost datagram was a hard time-domain gap — one click per loss. The redundant plane
|
||||
(`0xD2`) hides single losses, so the survivors were exactly the burstier gaps that most needed
|
||||
concealing. Concealed frames now land in front of the arriving frame in one contiguous buffer, a
|
||||
DTX marker advances accounting without being decoded, and the output buffer is pre-sized for a full
|
||||
concealment run so the borrow-until-next-call pointer cannot dangle (50 ms cap).
|
||||
- **The Apple jitter ring never grew.** The shared Rust `JitterPolicy` has an adaptive target floor;
|
||||
the hand-written Apple mirror mirrored the *shed* half but not the *growth* half, pinning its
|
||||
target at the 20 ms base forever. On Wi-Fi that bunches arrivals, 20 ms is regularly shorter than
|
||||
one delivery stall, so the ring re-primed through every stall for the whole session. Now the full
|
||||
`note_read` mirror: 3 underruns in a 5 s window grow the target 10 ms (capped at CoreAudio's 70),
|
||||
30 s of quiet steps back, and the write-side hard trim follows the grown target.
|
||||
|
||||
### Clients
|
||||
|
||||
- **Nothing in the desktop console had ever been clickable.** `SkiaOverlay::handle_event` matched
|
||||
only `KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell past the console
|
||||
into the run loop, which routes pointer input exclusively at `stream.capture` — `None` while
|
||||
browsing. New `Overlay::handle_pointer` carries mouse/touch in swapchain pixels; the run loop
|
||||
converts (it owns the window and hence display scale); the console hit-tests the rects it drew last
|
||||
frame. Only **direct** touch devices are offered — an indirect trackpad already drives the mouse.
|
||||
Widgets act on **press**, not release, because both carousels scroll the focused item toward centre
|
||||
and what you pressed would slide out from under your finger. Host menu on Up from a saved tile;
|
||||
`UpdateHost` edits **in place** (remove-and-re-add would silently drop the fingerprint, learned MAC,
|
||||
pinned cards and profile binding), and `ForgetHost` arms on first press and fires on second.
|
||||
- **Discovery went permanently deaf three ways**, each needing an app relaunch: a failed resolve was
|
||||
never retried (`browseResultsChangedHandler` fires only when the result *set* changes, and a host
|
||||
whose resolve failed is still in the set); a stuck resolve never ended (`NWConnection` has no
|
||||
timeout, so the throwaway UDP flow could sit in `.preparing` forever, and a service with a
|
||||
connection in flight was skipped); and an `NWBrowser` parking in `.waiting` was ignored — **which is
|
||||
exactly where iOS's local-network privacy prompt lands on first launch, and granting it does not
|
||||
revive the browser that was already waiting.** A 1 Hz sweep now times out stuck resolves, retries
|
||||
failed ones on a 1→30 s backoff, and re-arms a dead browser; the advert's TXT is re-read on every
|
||||
browse report. `discovery::Rescan` forces a fresh mdns-sd query — the browse otherwise re-queries on
|
||||
a doubling backoff **capped at one hour**, so a long-lived browse is effectively passive. ⚠
|
||||
`clients/windows/src/discovery.rs` is a **second copy** of the browse that the earlier IPv4 pinning
|
||||
missed; it took an arbitrary first address, so a host whose OS responder answered AAAA rendered a
|
||||
card that failed on every click.
|
||||
- **Phone gyro mirror**, off by default, player 1 / wire pad 0 only, and only while that pad has no
|
||||
motion source of its own. iOS/iPadOS only on Apple (`DeviceGyro` wraps `CMDeviceMotion` at ~100 Hz
|
||||
on a dedicated serial queue — the controller path's main-queue delivery is a known jitter source);
|
||||
Android phones with a gyroscope at ~200 Hz with `maxReportLatencyUs = 0`, since batching is poison
|
||||
for gyro aim. Both rotate from the device's natural frame into the controller frame by interface
|
||||
orientation, and both send **one zero-gyro sample on stand-down** — the host holds motion as state
|
||||
and re-emits it, so a leftover nonzero angular velocity reads as endless rotation.
|
||||
- **Safe-area resolution** is purely a *sizing* change — no layout change, no input change; pointer
|
||||
mapping follows for free since both clients derive the picture rect from the live host mode. Full
|
||||
native height, width less left+right safe insets. Portrait settings screens report the housing on
|
||||
`top` with zero horizontal insets, so the portrait top inset stands in (gated so an iPad's status
|
||||
bar never fabricates one). Android adds the rounded-corner radius, which it does not count as
|
||||
cutout. Both even-floor and clamp, because `validate_dimensions` rejects odd dimensions and an inset
|
||||
subtraction lands odd about half the time.
|
||||
- **Gamepad UI**: six sections (Stream · Video · Audio · Controller · Interface · Profiles, plus Input
|
||||
on the desktop console) walked with L1/R1 with per-section cursor memory; 12 palettes under one
|
||||
shared `ui_palette` key, Violet keeping its explicit sixteen colours so existing installs are an
|
||||
identity transform. Presentation only → **device preference, never part of a profile**. Palette
|
||||
maths ported three times (Rust/Swift/Kotlin) with the same assertions pinned in each language;
|
||||
`every_palette_is_multi_tone` fails under 45° hue spread and caught Ember at 35° and Graphite at 3°.
|
||||
Three render-only findings: additive blending blows out over a pale ground, a white scrim at the
|
||||
dark field's strength bleaches the gradient, and white glass over a bright field needs more body.
|
||||
|
||||
### Session and game lifetime
|
||||
|
||||
- **`PunktfunkEndReason` replaces a single "closed" bit** (ABI 17, additive, wire untouched). Five
|
||||
values — local, game exited, host ended, host error, lost — classified by the connection watcher
|
||||
from close codes already on the wire (`APP_EXITED_CLOSE_CODE` had been sent for a long time with
|
||||
nothing consuming it). **Latched before the shutdown flag**, because the two are read by different
|
||||
threads and the reason must never arrive second. Exposed as `punktfunk_connection_end_reason` +
|
||||
`is_normal()`. Shells fall back to the old wording when there is no verdict (older core, or a close
|
||||
that raced the read).
|
||||
- **The Steam `Running` registry hint was an unbounded veto.** Honouring it reset the absence window
|
||||
every pass, so a flag Steam left set — Steam crashed, was closed first, the game re-parented —
|
||||
pinned a lease in `running` for the life of the host process. The absence timer now runs
|
||||
regardless; past `VETO_LIMIT` (30 s) with nothing of the game on the box, the session ends anyway
|
||||
and logs at WARN. Extracted as a pure `exit_confirmed(gone_for, hint_running)` with tests — the
|
||||
watch loop polls a live process table and cannot be unit-tested, which is exactly how the
|
||||
unbounded veto shipped.
|
||||
- **New `launchreg.rs`: one record per `(client fingerprint, library id)`**, written at launch and
|
||||
independent of the termination policy. The old fingerprint-keyed reclaim only ran under
|
||||
`GameOnSessionEnd::Always`, so under the default `Keep` nothing was recorded — and a client retry
|
||||
re-sent `Hello::launch` verbatim, which the host obeyed unconditionally. Steam/Epic URIs hid it
|
||||
(the launcher just focuses the running copy) but a `gog:`/`custom:` target genuinely started a
|
||||
second instance over the same save files. The same retry also minted a fresh `launch_stamp`, so
|
||||
procscan refused to adopt a game older than 2 s and **a reconnected session lost game-exit
|
||||
detection for the rest of its life.** Identity now flows backwards from the watcher, which
|
||||
publishes the concrete `ProcRef`s it adopted; liveness is `Scanner::alive` over that recorded set,
|
||||
re-verified by `(pid, start)`. Tradeoffs: a `custom:` command with no detection hints stays
|
||||
`Unknown` forever (trading exit detection for not double-spawning), and `IN_FLIGHT_WINDOW` is a
|
||||
fixed 90 s, deliberately not `disconnect_grace_seconds`.
|
||||
- **A launcher entry is `LeaseKind::Untracked` unconditionally**, checked ahead of
|
||||
`nested`/`child`/`spec`. Its lifetime previously depended on invisible state: launcher not running
|
||||
→ live child → `Child` lease → quitting the launcher ended the session; launcher already running →
|
||||
command forwards and exits inside `SHIM_WINDOW` → `Untracked` → session persists. Steam Big Picture
|
||||
is a *mode*, not a process (and on a Deck it is always running); Heroic is single-instance
|
||||
Electron. The real trap was the GameStream path, whose `GsApp` intermediate silently dropped the
|
||||
field.
|
||||
|
||||
### Library and plugins
|
||||
|
||||
- **Store claims keep identity across the scanner-to-plugin handover.** `library.json` gains a v2
|
||||
`{entries, claims}` shape that reads the old bare array unchanged and rewrites on first mutation.
|
||||
`PUT /library/provider/{p}?store=<s>` claims a store; entries then surface as
|
||||
`<store>:<external_id>` rather than `custom:<id>`, so entry ids, GameStream app ids, client art
|
||||
caches and Moonlight pins all survive. One provider per store (409 otherwise); while a claim is
|
||||
held the matching built-in scanner is skipped, so the two never double-list.
|
||||
- `GET/PUT /library/scanners` is now a **sources** endpoint over the same disabled-set file.
|
||||
- New entry fields: `role: game|launcher`; launch kinds `steam_ui` (`bigpicture|desktop`),
|
||||
`launcher_ui` (platform-gated, 400 on invalid) and `xbox`.
|
||||
- **Plugin kit 0.3.0** adds a `./library` subpath: `defineLibraryPlugin` plus ported total parsers —
|
||||
text VDF/ACF, the binary `shortcuts.vdf` walker with CRC-32 appid derivation, read-only immutable
|
||||
SQLite, a registry wrapper that refuses HKCU, path-confinement joins. `GET/PUT /__config` returns
|
||||
`{schema, value}` and persists raw, so a plugin with settings need not ship an SPA.
|
||||
|
||||
### Platform and packaging
|
||||
|
||||
- **The client's config writer** falls back to an in-place write when the atomic replace is
|
||||
unavailable, verifies it by reading the bytes back, and records the last persistence failure
|
||||
centrally so the UI can surface it. Scratch files are now per-process, closing a real collision
|
||||
between the five processes that write these stores (shell, session, console UI, CLI, Decky) — one
|
||||
could previously rename its half-written temp over another's target.
|
||||
- **Host send pacing** gained a pure, unit-tested budget function: oversized frames are budgeted at
|
||||
the pacing rate with a 100 ms absolute ceiling rather than compressed into one frame interval.
|
||||
Steady-state schedules are byte-identical, the legacy behaviour stays reachable via an environment
|
||||
escape hatch, and the GameStream-compatible path is untouched.
|
||||
- **Mid-session shard renegotiation is gated off for PyroWave sessions**, which parse the video
|
||||
stream in windows fixed at session start — re-sizing mid-stream would corrupt the parse. Those
|
||||
sessions get the next-session clamp only and are excluded from jumbo. The ABR decode-cap latch
|
||||
likewise does not apply to PyroWave, where adaptive bitrate is open-loop by design.
|
||||
- **The Deck's Vulkan compatibility layer is built from source**, pinned to the same upstream
|
||||
revision as the host's own packaged build — bump both together. ~4 MB of app content replaces a
|
||||
94 MB external extension, and Flathub is no longer needed at install time. ⚠ `subprojects/vkroots`
|
||||
is a gamescope **submodule** and flatpak-builder clones submodules by default; declaring it again
|
||||
as an explicit source breaks the build during extraction. `glm` and `stb` are `.wrap` files, not
|
||||
submodules, and *do* need explicit sources.
|
||||
- **Build-container images push to an authenticated registry endpoint**, and `:latest` is reconciled
|
||||
against the content key on every push to main — an out-of-band tag move is detected and repaired
|
||||
rather than silently inherited.
|
||||
- **Windows pad drivers** publish their sequence counters with release ordering (the host was already
|
||||
loading with acquire and pairing with nothing) and serialize the output-ring publish. The
|
||||
`/dev/uhid` event ABI, previously transcribed into all five Linux gamepad backends, is consolidated
|
||||
into one module.
|
||||
|
||||
### Verification status
|
||||
|
||||
Honest about what has and has not been on hardware, because several things in this release have not:
|
||||
|
||||
- **Controller audio has never run on a real DualSense.** Its entire verification is unit tests and
|
||||
compile checks, and its rumble arbitration rests on an explicitly retracted assumption about
|
||||
whether the voice coils and the rumble motors are the same actuators. The evidence-based 500 ms
|
||||
idle window is correct either way, but the underlying exclusivity is unsettled. Android's arbiter
|
||||
is the evidence-based one; the desktop twin and the coil restore on Android's stop path are owed.
|
||||
Some Android OEM kernels refuse the isochronous claim outright, which degrades to ordinary rumble.
|
||||
- The **plugin-UI origin split** is validated against a fake console and a fake plugin, not yet in a
|
||||
real browser.
|
||||
- The **packaging default-on changes** have had no installer run or package build.
|
||||
- **No launcher tile has been clicked on a real host** — the first source that would publish one does
|
||||
not exist yet.
|
||||
- Desktop-audio, packet-sizing and iPad-pointer work is build-verified only.
|
||||
- ⚠ **The FFmpeg-deletion milestone itself has never executed on a GPU.** It was gated on
|
||||
cross-clippy, 160 tests, a workspace check and an `ffmpeg` count of 0 in the client / 2 in the host.
|
||||
The software on-glass check, the D3D11 and VAAPI AV1 hardware legs and the field bake were all owed
|
||||
at merge; later commits closed some of that but not all. The "no FFmpeg" claim is verified by
|
||||
`cargo tree` and a notices-generator mention count, not by inspecting a shipped binary.
|
||||
- ⚠ **`pf-vaadec` has never decoded a frame anywhere** — no VAAPI hardware was reachable. It is the
|
||||
*first* rung on Linux/Intel and unknown vendors; the evidence filter bars it there in favour of
|
||||
`pf-vkdecode`, but an explicit pin reaches it.
|
||||
- **openh264 has never run on glass**; the H.264 software rung is unit-tested only.
|
||||
- **`native-d3d11va` AV1 is deliberately `verified = false`** — one 25 s 4K60 session, no parity.
|
||||
- **Split arbitration is opt-in and Linux-wired only**; the Windows arm is built and unit-tested but
|
||||
not on hardware. The 5120×1440@240 Main10 behaviour flip is explicitly unvalidated and is named as
|
||||
the first thing to re-measure.
|
||||
- **Software throughput is unmeasured in general** — the CPU rung does 35–39 fps at 4K AV1 against a
|
||||
60 fps stream, which is why the backlog flush that triggered the rav1d abort happens at all.
|
||||
- **The Apple mic fix is a proven root cause, not a verified session.** Its own commits call it "a
|
||||
strong inference plus one proven logic defect rather than a confirmed fix" and close "awaiting the
|
||||
reporter's on-device confirmation" — which nothing later in the range records. It also leaves a
|
||||
known gap: nothing reports whether the uplink actually opened, so the HUD still offers a Mute
|
||||
Microphone button over a session that may be sending nothing.
|
||||
- **The Windows audio substrate is, by contrast, well-evidenced on hardware** — repeated "measured on
|
||||
the target box", a live bisect on a fresh endpoint, and a `micpitch` proof reading 440 Hz in →
|
||||
440 Hz out at exact peak. The one thing not evidenced is a real client speaking through the minted
|
||||
microphone end to end; the pitch proof is probe-driven.
|
||||
- **The phone-gyro mirror is not recorded as hardware-verified** — remap matrices are pinned by unit
|
||||
tests in both languages, but there is no "played a game with a clip-on pad" evidence in the tree.
|
||||
- **The iOS gamepad-UI pale-palette sweep on glass is still owed**, per its own commit.
|
||||
- ⚠ **The CI runner scripts are hand-installed** (`/usr/local/bin/ci-docker-prune.sh`,
|
||||
`/usr/local/sbin/ci-docker-reclaim.sh`). Merging does not deploy them — both runner hosts need the
|
||||
files copied out of `scripts/ci/`, and the missing `192.168.1.58:5011` insecure-registry entry on
|
||||
one host is routed around, not fixed.
|
||||
+3
-5
@@ -46,11 +46,9 @@ sudo apt install build-essential clang libclang-dev pkg-config cmake \
|
||||
libvulkan-dev
|
||||
```
|
||||
|
||||
(The last two groups are the Linux client shell and the Vulkan session presenter; skip them only
|
||||
if you never build those crates. `libvulkan-dev` is for the LOADER's pkg-config/soname — ash
|
||||
dlopens it, and the client links no FFmpeg at all, so no libav*-dev appears here.
|
||||
`scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway, PipeWire —
|
||||
and is not a substitute for the list above.)
|
||||
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
|
||||
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
|
||||
PipeWire — and is not a substitute for the list above.)
|
||||
|
||||
## Before you push
|
||||
|
||||
|
||||
Generated
+80
-366
@@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"env_filter 0.1.4",
|
||||
"env_filter",
|
||||
"log",
|
||||
]
|
||||
|
||||
@@ -204,12 +204,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert_matches"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -347,26 +341,6 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atomig"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0f41f4bb89f5c6450325e283fb78c4a3d042181b54f3855ee2f872919f9863"
|
||||
dependencies = [
|
||||
"atomig-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomig-macro"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49c98dba06b920588de7d63f6acc23f1e6a9fade5fd6198e564506334fb5a4f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audiopus_sys"
|
||||
version = "0.2.2"
|
||||
@@ -472,7 +446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"annotate-snippets",
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
@@ -501,12 +475,6 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
@@ -570,12 +538,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -594,7 +556,7 @@ version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -647,9 +609,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.1"
|
||||
version = "1.2.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -923,15 +885,6 @@ dependencies = [
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cros-codecs"
|
||||
version = "0.0.5"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -994,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1038,37 +991,6 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1114,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -1179,29 +1101,6 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter 2.0.0",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1290,20 +1189,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-next"
|
||||
version = "9.0.0"
|
||||
version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a"
|
||||
checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"ffmpeg-sys-next",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-sys-next"
|
||||
version = "9.0.0"
|
||||
version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b"
|
||||
checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
@@ -1341,9 +1240,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.10"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
@@ -1685,7 +1584,7 @@ version = "0.22.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1878,7 +1777,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy 0.8.52",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2228,42 +2127,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2358,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2428,7 +2291,7 @@ version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"cc",
|
||||
"convert_case",
|
||||
"cookie-factory",
|
||||
@@ -2463,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2498,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2623,7 +2486,6 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"log",
|
||||
]
|
||||
|
||||
@@ -2631,7 +2493,7 @@ dependencies = [
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
@@ -2655,7 +2517,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -2668,7 +2530,7 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -2986,17 +2848,9 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3017,41 +2871,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"libc",
|
||||
"libloading",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"openh264",
|
||||
"opus",
|
||||
"pf-bitstream",
|
||||
"pf-dxvadec",
|
||||
"pf-ffvk",
|
||||
"pf-update-check",
|
||||
"pf-vaadec",
|
||||
"pf-vkdecode",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"rand 0.9.4",
|
||||
"rav1d",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3069,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3088,19 +2934,9 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
"pf-vkdecode",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3122,9 +2958,18 @@ dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3136,7 +2981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3150,11 +2995,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3183,20 +3028,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"pf-client-core",
|
||||
"pf-vkdecode",
|
||||
"pf-ffvk",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"tracing",
|
||||
@@ -3205,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3213,7 +3058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3223,22 +3068,13 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
"pf-vkdecode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -3265,20 +3101,9 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3290,7 +3115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3327,7 +3152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"libc",
|
||||
"libspa",
|
||||
"libspa-sys",
|
||||
@@ -3381,7 +3206,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -3425,21 +3250,6 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3461,7 +3271,7 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy 0.8.52",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3500,7 +3310,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
@@ -3513,7 +3323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3524,7 +3334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3536,13 +3346,11 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3559,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3574,9 +3382,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3593,7 +3402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3619,13 +3428,13 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"zerocopy 0.8.52",
|
||||
"zerocopy",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3710,7 +3519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3724,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3747,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -3914,36 +3723,6 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1d"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1932f060d5e7bd49dc9f8b272c1dc5e9ce0ffe141c28be900265d3989b36c9ed"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"atomig",
|
||||
"bitflags 2.13.0",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"nasm-rs",
|
||||
"parking_lot",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"strum",
|
||||
"to_method",
|
||||
"zerocopy 0.7.35",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3995,7 +3774,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4152,7 +3931,7 @@ version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
@@ -4191,7 +3970,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -4345,7 +4124,7 @@ version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"libc",
|
||||
"sdl3-image-sys",
|
||||
"sdl3-mixer-sys",
|
||||
@@ -4440,7 +4219,7 @@ version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -4646,7 +4425,7 @@ version = "0.87.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"lazy_static",
|
||||
"skia-bindings",
|
||||
]
|
||||
@@ -4739,28 +4518,6 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4964,12 +4721,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "to_method"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
@@ -5234,14 +4985,6 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5321,14 +5064,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
@@ -5547,7 +5282,7 @@ version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
@@ -5559,7 +5294,7 @@ version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
@@ -5571,7 +5306,7 @@ version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
@@ -5584,7 +5319,7 @@ version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
@@ -5936,7 +5671,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags",
|
||||
"widestring",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@@ -6428,34 +6163,13 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.7.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"zerocopy-derive 0.7.35",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive 0.8.52",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.7.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-6
@@ -5,12 +5,11 @@ members = [
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-bitstream",
|
||||
"crates/pf-bitstream/vendor/cros-codecs",
|
||||
"crates/pf-client-core",
|
||||
"crates/pf-clipboard",
|
||||
"crates/pf-presenter",
|
||||
"crates/pf-console-ui",
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"crates/pf-paths",
|
||||
"crates/pf-update",
|
||||
@@ -24,9 +23,6 @@ members = [
|
||||
"crates/pf-capture",
|
||||
"crates/pf-inject",
|
||||
"crates/pf-vdisplay",
|
||||
"crates/pf-vkdecode",
|
||||
"crates/pf-dxvadec",
|
||||
"crates/pf-vaadec",
|
||||
"crates/pyrowave-sys",
|
||||
"crates/libvpl-sys",
|
||||
"clients/probe",
|
||||
@@ -57,7 +53,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.25.0"
|
||||
version = "0.23.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 unom - Enrico Bühler
|
||||
Copyright 2026 unom
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 unom - Enrico Bühler
|
||||
Copyright (c) 2026 unom
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -84,9 +84,7 @@ mid-stream mode renegotiation and a wall-clock skew handshake so latency stays v
|
||||
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
||||
weaknesses). The host is managed through a REST API and web console. The **host** builds against
|
||||
FFmpeg 7 or 8; the **clients** link no FFmpeg at all — they decode natively (Vulkan Video, DXVA,
|
||||
VAAPI, VideoToolbox, MediaCodec, openh264 + rav1d).
|
||||
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
||||
|
||||
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
|
||||
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||
@@ -189,13 +187,10 @@ and the [docs site](https://docs.punktfunk.unom.io).
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
||||
pf-client-core/ shared client plumbing (Linux + Windows): session pump · native decode ladder · audio · SDL3 gamepads · trust · discovery
|
||||
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
|
||||
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
|
||||
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
|
||||
pf-bitstream/ H.264 / H.265 / AV1 bitstream parsing + per-AU decode plans — the one parser every native rung submits from
|
||||
pf-vkdecode/ native Vulkan Video decode (H.264 / H.265 / AV1) on the presenter's own device
|
||||
pf-dxvadec/ native DXVA buffer layouts + AuPlan → picparams conversion (the Windows D3D11VA rung)
|
||||
pf-vaadec/ native libva buffer layouts + AuPlan → picparams conversion (the Linux VAAPI rung)
|
||||
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
|
||||
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
|
||||
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
|
||||
clients/
|
||||
@@ -250,10 +245,9 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
||||
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows **host** build also
|
||||
bundles FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||
notice ship in the installed `licenses/` folder). The **clients** bundle no FFmpeg — they link
|
||||
none.
|
||||
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
||||
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||
notice ship in the installed `licenses/` folder).
|
||||
|
||||
### Trademarks
|
||||
|
||||
|
||||
+230
-573
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
THIRD-PARTY SOFTWARE NOTICES
|
||||
============================================================================
|
||||
|
||||
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
The binaries it ships statically/dynamically link the third-party Rust crates below.
|
||||
Each is distributed under its own permissive license; full texts follow.
|
||||
Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand.
|
||||
|
||||
+2
-14
@@ -4,22 +4,10 @@
|
||||
# cargo about generate about.hbs > THIRD-PARTY-NOTICES.txt # (or use scripts/gen-third-party-notices.sh)
|
||||
#
|
||||
# `accepted` is the allow-list of SPDX licenses permitted in the dependency tree. CI fails if a crate
|
||||
# carries anything not listed here — the regression guard against a copyleft dependency silently
|
||||
# entering the linked set. All entries
|
||||
# carries anything not listed here — which is exactly the regression guard we want against a copyleft
|
||||
# dependency silently entering the linked set. All entries
|
||||
# below are permissive / attribution-only; deliberately NO GPL/LGPL/AGPL/MPL-link/SSPL/EPL.
|
||||
#
|
||||
# ⚠ KNOW THE LIMIT OF THIS GATE. cargo-about walks the CARGO graph, so it sees CRATES. A native
|
||||
# library linked through a permissively-licensed `-sys` crate is INVISIBLE to it, licence and all.
|
||||
# FFmpeg is precisely that shape: `ffmpeg-sys-next` is WTFPL and passes cleanly, while the LGPL
|
||||
# libavcodec/libavutil/swscale it link-imports — and which the Windows host installer bundles as
|
||||
# DLLs — never appear in the harvest at all. This gate did not catch FFmpeg entering the tree and
|
||||
# would not catch the next such library. Copyleft arriving as C behind a -sys crate is a REVIEW
|
||||
# question, not a CI one; the LGPL obligations we do carry are discharged by hand (the notice files
|
||||
# and the replaceable-DLL linkage, see packaging/windows/punktfunk-host.iss).
|
||||
#
|
||||
# Since M10 this is a HOST-only concern: the client links no FFmpeg, so for every client artifact
|
||||
# the crate graph and the linked set finally coincide and the gate means what it appears to mean.
|
||||
#
|
||||
# The dependency-free fallback is scripts/gen-third-party-notices.py (reads the cargo registry cache),
|
||||
# which is what produced the committed baseline when cargo-about is unavailable offline.
|
||||
|
||||
|
||||
+9
-302
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.24.0"
|
||||
"version": "0.22.3"
|
||||
},
|
||||
"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. 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).",
|
||||
"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.",
|
||||
"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`.\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).",
|
||||
"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`.",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1318,15 +1318,6 @@
|
||||
"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": {
|
||||
@@ -1357,7 +1348,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id, store id, or payload",
|
||||
"description": "Invalid provider id or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1376,16 +1367,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -2189,51 +2170,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/logs": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Ingest runner log lines",
|
||||
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
|
||||
"operationId": "ingestPluginLogs",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginLogBatch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Lines ingested"
|
||||
},
|
||||
"400": {
|
||||
"description": "Batch too large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -4045,51 +3981,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AudioWiring": {
|
||||
"type": "object",
|
||||
"description": "The Windows host's audio wiring verdict — which endpoint carries each role. The names are\nthe endpoints' friendly names as the Sound settings show them (on current hosts the minted\n\"Punktfunk\" instances of Steam's streaming drivers).",
|
||||
"required": [
|
||||
"readiness",
|
||||
"mic_withheld",
|
||||
"last_resort"
|
||||
],
|
||||
"properties": {
|
||||
"last_resort": {
|
||||
"type": "boolean",
|
||||
"description": "The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes."
|
||||
},
|
||||
"loopback": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable."
|
||||
},
|
||||
"mic": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Friendly name of the virtual-mic write target; absent = mic passthrough unavailable."
|
||||
},
|
||||
"mic_withheld": {
|
||||
"type": "boolean",
|
||||
"description": "The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable."
|
||||
},
|
||||
"narrowing": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does."
|
||||
},
|
||||
"readiness": {
|
||||
"type": "string",
|
||||
"description": "`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all.",
|
||||
"example": "full"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AvailableCompositor": {
|
||||
"type": "object",
|
||||
"description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.",
|
||||
@@ -4223,8 +4114,7 @@
|
||||
"tier",
|
||||
"platforms",
|
||||
"compatible",
|
||||
"update_available",
|
||||
"categories"
|
||||
"update_available"
|
||||
],
|
||||
"properties": {
|
||||
"author": {
|
||||
@@ -4237,13 +4127,6 @@
|
||||
],
|
||||
"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?"
|
||||
@@ -4251,13 +4134,6 @@
|
||||
"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",
|
||||
@@ -4444,17 +4320,6 @@
|
||||
],
|
||||
"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"
|
||||
}
|
||||
@@ -4499,10 +4364,6 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
@@ -4561,17 +4422,6 @@
|
||||
"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",
|
||||
@@ -4592,15 +4442,6 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4829,27 +4670,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
@@ -5300,10 +5120,6 @@
|
||||
],
|
||||
"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\"`.",
|
||||
@@ -5435,14 +5251,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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).",
|
||||
@@ -6430,50 +6238,6 @@
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PluginLogBatch": {
|
||||
"type": "object",
|
||||
"description": "A batch of runner log lines.",
|
||||
"required": [
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginLogLine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginLogLine": {
|
||||
"type": "object",
|
||||
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
|
||||
"required": [
|
||||
"ts_ms",
|
||||
"level",
|
||||
"source",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
|
||||
},
|
||||
"ts_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginRegistration": {
|
||||
"type": "object",
|
||||
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||
@@ -6481,13 +6245,6 @@
|
||||
"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)."
|
||||
@@ -6520,13 +6277,6 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The plugin's kind — see [`PluginRegistration::category`]."
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6765,10 +6515,6 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
@@ -6850,17 +6596,6 @@
|
||||
"description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
|
||||
"minimum": 0
|
||||
},
|
||||
"audio": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AudioWiring",
|
||||
"description": "The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime."
|
||||
}
|
||||
]
|
||||
},
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
@@ -6956,46 +6691,26 @@
|
||||
},
|
||||
"ScannerInfo": {
|
||||
"type": "object",
|
||||
"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.",
|
||||
"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.",
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"enabled",
|
||||
"origin"
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"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
|
||||
"description": "Whether this host runs the scanner (default true)."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"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.",
|
||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7158,14 +6873,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
|
||||
+1
-15
@@ -9,26 +9,12 @@
|
||||
# from the last image rebuild instead of a fresh -Syu per run. That is the same staleness
|
||||
# the gamescope cache already embraces ("a stale binary against newer system libs is the
|
||||
# same risk the distro's own package carries between rebuilds"), and any ci/ edit — or
|
||||
# bumping the date in this line (refreshed: 2026-08-08) — re-keys and re-snapshots it.
|
||||
#
|
||||
# ⚠ That staleness has a sharp edge, and 2026-08-08 is why the date above moved: this snapshot is
|
||||
# what decides which FFmpeg the HOST links, and arch.yml deliberately runs no -Syu, so the builder
|
||||
# stayed frozen on ffmpeg 8 (libavcodec 62) even after Arch shipped 2:9.0-5 (libavcodec 63) to
|
||||
# every user. A canary built from the old snapshot therefore CANNOT satisfy the soname dep that
|
||||
# packaging/arch/PKGBUILD now derives from the link (libavcodec.so=62-64 against a box that has
|
||||
# 63-64), so it would simply refuse to install rather than start. Re-keying this image is the step
|
||||
# that makes the ffmpeg-9 bump actually reach the package — a Cargo.toml bump alone does nothing
|
||||
# here. Whenever Arch moves to an FFmpeg major, bump the date in the same commit.
|
||||
# bumping the date in this line (refreshed: 2026-07-29) — re-keys and re-snapshots it.
|
||||
FROM docker.io/library/archlinux:base-devel
|
||||
|
||||
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
|
||||
# deps (second list) — both copied verbatim from what arch.yml installed in-job, where
|
||||
# they now no-op as `--needed` guards.
|
||||
# vulkan-headers rides the first list only because arch.yml's copy does; the package it actually
|
||||
# serves is the gamescope companion (packaging/gamescope/PKGBUILD makedepends). punktfunk itself
|
||||
# needs no system Vulkan headers — pyrowave-sys bindgens its own vendored copy and ash dlopens the
|
||||
# loader — but arch.yml builds gamescope with `makepkg -d`, so an absent makedepend would not be
|
||||
# reported as a missing dependency, only as a compile failure. Keep it.
|
||||
RUN pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
|
||||
@@ -27,10 +27,8 @@ RUN dnf -y install \
|
||||
mesa-libGL-devel mesa-libgbm-devel \
|
||||
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
||||
gtk4-devel libadwaita-devel SDL3-devel \
|
||||
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers
|
||||
# (pyrowave-sys bindgens its own vendored copy; host and client both reach Vulkan through
|
||||
# ash, which dlopens the loader), and packaging/rpm/punktfunk.spec BuildRequires none.
|
||||
# rpm.yml's HDR gamescope leg needs them and pulls them with `dnf builddep gamescope`.
|
||||
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
|
||||
vulkan-headers \
|
||||
&& dnf clean all
|
||||
|
||||
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||
|
||||
@@ -29,16 +29,15 @@ RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.l
|
||||
&& dpkg --add-architecture arm64
|
||||
|
||||
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
|
||||
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon). No
|
||||
# Vulkan dev package: nothing compiles or links against Vulkan — ash dlopens the loader, and
|
||||
# pyrowave-sys bindgens its own vendored headers.
|
||||
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon,
|
||||
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
crossbuild-essential-arm64 \
|
||||
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
|
||||
libavfilter-dev:arm64 libavdevice-dev:arm64 \
|
||||
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
|
||||
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
|
||||
libwayland-dev:arm64 libxkbcommon-dev:arm64 \
|
||||
libwayland-dev:arm64 libxkbcommon-dev:arm64 libvulkan-dev:arm64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
|
||||
|
||||
@@ -45,14 +45,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||
#
|
||||
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
|
||||
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
|
||||
# — and this .deb is the one package with NO exposure to the soname break that motivated the bump: it
|
||||
# BUNDLES these libs into /usr/lib/punktfunk-host behind an rpath and strips the libav* sonames from
|
||||
# its Depends, so nothing the user's apt does can move them underneath it. Bumping this tag would
|
||||
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
|
||||
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
|
||||
ARG FFMPEG_TAG=n8.0
|
||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||
|
||||
@@ -13,9 +13,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
|
||||
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
|
||||
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
|
||||
# ffmpeg-next 8 (system FFmpeg 8 / libavcodec 62 on 26.04)
|
||||
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \
|
||||
libavdevice-dev \
|
||||
# capture / audio / display stacks (+xkbcommon for the wlr input backend)
|
||||
@@ -24,9 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl-dev libegl-dev libgbm-dev \
|
||||
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
# No libvulkan-dev: nothing in the workspace compiles or links against Vulkan (pyrowave-sys
|
||||
# bindgens its own vendored headers, and both host and client reach Vulkan through ash, which
|
||||
# dlopens the loader), so neither the build nor deb.yml's dpkg-shlibdeps ever asks for it.
|
||||
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
|
||||
libvulkan-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,6 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -48,7 +47,6 @@ import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
import io.unom.punktfunk.kit.link.HostResolution
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.Tab
|
||||
@@ -63,11 +61,6 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// so the stream screen never re-reads the store behind its own connect's back.
|
||||
var session by remember { mutableStateOf<ActiveSession?>(null) }
|
||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||
// Set when a session ends because its game exited and it began as a library launch: the host
|
||||
// whose library the console shell should come back to. Held HERE because the shell's own
|
||||
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
|
||||
// later manual Back out of the library is not undone by a stale value.
|
||||
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||
@@ -105,15 +98,6 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// The console backdrop's colour family, published once from the live settings rather than
|
||||
// threaded through every screen that draws a backdrop. Because it is read from the SAME
|
||||
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
|
||||
// the field behind that very row.
|
||||
val palette = GamepadPalette.named(settings.uiPalette)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = session,
|
||||
transitionSpec = {
|
||||
@@ -123,20 +107,7 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
) { active ->
|
||||
if (active != null) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(active) { reason ->
|
||||
// A game launched from a library exiting is a normal finish, and the player is
|
||||
// almost certainly after the next title — so send them back to that library rather
|
||||
// than all the way out to host selection. The console shell's own screen state does
|
||||
// not survive the stream (StreamScreen replaces it in the composition, discarding
|
||||
// its `remember`s), so the intent is hoisted here and handed back on the way in.
|
||||
reopenLibraryHostId =
|
||||
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
|
||||
active.hostId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
session = null
|
||||
}
|
||||
StreamScreen(active, onDisconnect = { session = null })
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
settings = settings,
|
||||
@@ -144,8 +115,6 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
onConnected = { session = it },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
reopenLibraryHostId = reopenLibraryHostId,
|
||||
onReopenLibraryHandled = { reopenLibraryHostId = null },
|
||||
)
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
@@ -232,16 +201,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console backdrop's colour family for everything under [App] — provided from the live
|
||||
* settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults
|
||||
* to the brand violet, which is also what a preview or a test composition gets.
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
|
||||
@@ -257,32 +218,11 @@ fun GamepadShell(
|
||||
onConnected: (ActiveSession) -> Unit,
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
/**
|
||||
* Open this saved host's library instead of Home on the way in — set when a game launched from
|
||||
* it has just exited. Null (the default) starts on Home exactly as before.
|
||||
*/
|
||||
reopenLibraryHostId: String? = null,
|
||||
onReopenLibraryHandled: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||
|
||||
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
|
||||
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
|
||||
// A host that has since been forgotten simply leaves us on Home rather than failing.
|
||||
LaunchedEffect(reopenLibraryHostId) {
|
||||
val id = reopenLibraryHostId ?: return@LaunchedEffect
|
||||
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
|
||||
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
|
||||
// either order happens to work — but this one cannot be broken by a later edit that adds a
|
||||
// suspending call. A host that has since been forgotten just leaves us on Home.
|
||||
KnownHostStore(context).all()
|
||||
.firstOrNull { it.id == id }
|
||||
?.let { libraryHost = it; screen = GamepadScreen.Library }
|
||||
onReopenLibraryHandled()
|
||||
}
|
||||
|
||||
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
|
||||
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
|
||||
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
|
||||
|
||||
@@ -168,9 +168,9 @@ internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onD
|
||||
title = { Text("Allow local network access") },
|
||||
text = {
|
||||
Text(
|
||||
"Android blocks Punktfunk from talking to devices on your network, so it can't " +
|
||||
"Android blocks punktfunk from talking to devices on your network, so it can't " +
|
||||
"find or reach any host until you allow it. If no prompt appears when you tap " +
|
||||
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
|
||||
"Allow, enable “Nearby devices” for punktfunk in system settings.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -191,7 +191,6 @@ internal fun ConnectTakeover(
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val copy = connectCopy(phase)
|
||||
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||
|
||||
@@ -213,7 +212,7 @@ internal fun ConnectTakeover(
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = ink.fg(0.9f),
|
||||
tint = Color.White.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(46.dp),
|
||||
)
|
||||
}
|
||||
@@ -222,14 +221,14 @@ internal fun ConnectTakeover(
|
||||
}
|
||||
Text(
|
||||
copy.title,
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
copy.subtitle,
|
||||
color = ink.fg(0.65f),
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||
@@ -250,7 +249,6 @@ internal fun ConnectTakeover(
|
||||
*/
|
||||
@Composable
|
||||
private fun PulsingSpinner() {
|
||||
val ink = LocalGamepadInk.current
|
||||
val transition = rememberInfiniteTransition(label = "connectPulse")
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
@@ -264,14 +262,14 @@ private fun PulsingSpinner() {
|
||||
for (i in 0..1) {
|
||||
val p = (pulse + i * 0.5f) % 1f
|
||||
drawCircle(
|
||||
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
|
||||
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
|
||||
radius = maxR * (0.42f + p * 0.58f),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
CircularProgressIndicator(
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(54.dp),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -171,7 +168,8 @@ fun ConnectScreen(
|
||||
lnpPrompt = false
|
||||
// The browse started while blocked (its sockets failed or received nothing) — restart it
|
||||
// now that the grant makes them work.
|
||||
discovery.restart()
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
} else {
|
||||
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
|
||||
}
|
||||
@@ -193,27 +191,12 @@ fun ConnectScreen(
|
||||
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
|
||||
DisposableEffect(Unit) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
// Whether we've actually been away. ON_RESUME also fires on first entry, right after the
|
||||
// effect below starts the browse — restarting it there would be pure churn.
|
||||
var wasPaused = false
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> wasPaused = true
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (!lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.restart()
|
||||
} else if (wasPaused) {
|
||||
// Coming back from the background: the browse may have been sitting idle
|
||||
// (or had its multicast socket torn out from under it) while we were away,
|
||||
// and its own re-query interval has kept doubling. Re-arm and ask again,
|
||||
// so returning to the screen is enough — no app restart.
|
||||
discovery.restart()
|
||||
}
|
||||
wasPaused = false
|
||||
}
|
||||
else -> {}
|
||||
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
@@ -625,32 +608,6 @@ fun ConnectScreen(
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// "Copy link" — the self-emitted form every other client already hands out
|
||||
// (design/client-deep-links.md §4): the host's STABLE id first, with `host=` and `fp=` alongside,
|
||||
// so a link written today still lands on the right box after the host changes address or this
|
||||
// client is reinstalled. A PINNED card copies its own profile with it, because that combination
|
||||
// is the thing being copied; a host card copies no profile at all and so keeps honouring the
|
||||
// host's binding, exactly like a tap on it does.
|
||||
fun copyLink(kh: KnownHost, pin: StreamProfile?) {
|
||||
val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl()
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
val copied = clipboard != null && runCatching {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
|
||||
}.isSuccess
|
||||
// Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is
|
||||
// the platform's own documented anti-pattern. Below it nothing visible happens at all unless
|
||||
// we say so — a silent menu item reads as a broken one.
|
||||
if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
|
||||
val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard."
|
||||
// The console home renders neither the notice nor the status banner, so there it has to be a
|
||||
// toast; the touch grid has both, and a success dressed as an error banner is a small lie.
|
||||
when {
|
||||
gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
copied -> notice = message
|
||||
else -> status = message
|
||||
}
|
||||
}
|
||||
|
||||
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
|
||||
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
|
||||
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
|
||||
@@ -659,7 +616,6 @@ fun ConnectScreen(
|
||||
if (pin == null) {
|
||||
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
|
||||
}
|
||||
add(HostMenuItem("Copy link") { copyLink(kh, pin) })
|
||||
if (profiles.isEmpty()) return@buildList
|
||||
if (pin != null) {
|
||||
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
|
||||
@@ -941,7 +897,7 @@ fun ConnectScreen(
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
|
||||
"Android blocks punktfunk from finding or reaching hosts until you allow it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -1053,28 +1009,20 @@ fun ConnectScreen(
|
||||
// rather than looking idle/empty. Suppressed while local network access is denied —
|
||||
// a spinner would be a lie there (the browse can't receive anything); the banner above
|
||||
// owns that state.
|
||||
// Scan again is offered whether or not anything turned up: the case that sends people
|
||||
// here is ONE expected host missing, not an empty list, and a browse that quietly went
|
||||
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
|
||||
// exactly like a network without that host on it.
|
||||
if (lnpGranted && !connecting) {
|
||||
if (lnpGranted && !connecting && discovered.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (discovered.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1192,7 +1140,6 @@ fun ConnectScreen(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
|
||||
onEdit = { optionsTarget = null; editTarget = kh },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh)
|
||||
|
||||
@@ -191,7 +191,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
dsUsb?.let { DsRow(it) }
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. Punktfunk can only forward devices Android " +
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||
"or hub may show up under \"Other input devices\" below with the adapter's " +
|
||||
"identity, or not at all.",
|
||||
@@ -410,68 +410,17 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ fun GamepadAddHostScreen(
|
||||
suggestedMacs: List<String> = emptyList(),
|
||||
onSave: ((KnownHost) -> Unit)? = null,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val context = LocalContext.current
|
||||
val isTv = remember { isTvDevice(context) }
|
||||
val isEdit = editHost != null
|
||||
@@ -246,7 +245,7 @@ fun GamepadAddHostScreen(
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.55f),
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
@@ -307,7 +306,6 @@ private fun TvAddHostForm(
|
||||
onAdd: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BackHandler(onBack = onDismiss)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -321,11 +319,11 @@ private fun TvAddHostForm(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg)
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.55f),
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = onName, singleLine = true,
|
||||
@@ -364,7 +362,6 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
|
||||
|
||||
@Composable
|
||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
@@ -378,26 +375,25 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg)
|
||||
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
f.value.ifEmpty { f.placeholder },
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
|
||||
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (editing) Text(" |", color = ink.accent)
|
||||
if (editing) Text(" |", color = Color(0xFF8678F5))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val labelColor by animateColorAsState(
|
||||
if (enabled) ink.accent else ink.fg(0.35f),
|
||||
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||
tween(160),
|
||||
label = "addLabel",
|
||||
)
|
||||
@@ -429,7 +425,6 @@ private fun KeyboardGrid(
|
||||
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
|
||||
onKey: (Int, Int) -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val gap = if (compact) 5.dp else 7.dp
|
||||
Column(
|
||||
@@ -438,7 +433,7 @@ private fun KeyboardGrid(
|
||||
.widthIn(max = 640.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0x1FFFFFFF))
|
||||
.border(1.dp, ink.fg(0.12f), shape)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
|
||||
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
|
||||
verticalArrangement = Arrangement.spacedBy(gap),
|
||||
) {
|
||||
@@ -459,15 +454,14 @@ private fun KeyboardGrid(
|
||||
|
||||
@Composable
|
||||
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
|
||||
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
|
||||
val bg by animateColorAsState(
|
||||
if (focused) ink.accent else ink.glass,
|
||||
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
|
||||
tween(90),
|
||||
label = "keyBg",
|
||||
)
|
||||
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
|
||||
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(if (compact) 34.dp else 44.dp)
|
||||
|
||||
@@ -14,8 +14,6 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -25,9 +23,6 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -36,9 +31,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -72,12 +65,9 @@ import kotlin.math.sin
|
||||
// connected-controller status chip. One look across every screen is what makes the console UI read
|
||||
// as a coherent mode rather than a set of themed pages.
|
||||
|
||||
/**
|
||||
* One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer
|
||||
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
|
||||
* draw time, so the field always shows several of that palette's tones at once.
|
||||
*/
|
||||
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
|
||||
private class AuroraBlob(
|
||||
val color: Color,
|
||||
val baseX: Float,
|
||||
val baseY: Float,
|
||||
val driftX: Float,
|
||||
@@ -90,80 +80,50 @@ private class AuroraBlob(
|
||||
)
|
||||
|
||||
private val auroraBlobs = listOf(
|
||||
AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
|
||||
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
|
||||
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
|
||||
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
|
||||
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
|
||||
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
|
||||
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
|
||||
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||
)
|
||||
|
||||
/**
|
||||
* The living console backdrop: soft blobs from the palette's ramp drifting over its ground on
|
||||
* slow, seamless loops, finished with a centre-pooling vignette and top/bottom legibility scrims.
|
||||
* A Compose approximation of the Apple client's MeshGradient aurora — same colour families, same
|
||||
* "ambience, never content" role, and the same [GamepadPalette] setting recolours both.
|
||||
*
|
||||
* [calm] is what the FORM screens wear: the pools dim onto the ground so the glass rows keep real
|
||||
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose
|
||||
* — only the contrast differs, so moving between screens can't make the field jump.
|
||||
*
|
||||
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
|
||||
* same courtesy the Apple client pays Reduce Motion.
|
||||
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val palette = LocalGamepadPalette.current
|
||||
val animated = animationsEnabled()
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
val transition = rememberInfiniteTransition(label = "aurora")
|
||||
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the
|
||||
// wrap so the field never visibly jumps when the animation restarts.
|
||||
val swept by transition.animateFloat(
|
||||
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||
// so the field never visibly jumps when the animation restarts.
|
||||
val angle by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2 * PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "angle",
|
||||
)
|
||||
val angle = if (animated) swept else 0f
|
||||
val tones = palette.blobColors
|
||||
val ground = palette.groundColor
|
||||
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
|
||||
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
|
||||
// half — the same scrim strength the desktop console's shader carries.
|
||||
val scrim = if (palette.light) ink.fg else Color.Black
|
||||
val strength = if (palette.light) 0.45f else 1f
|
||||
Canvas(modifier) {
|
||||
drawRect(ground)
|
||||
drawRect(Color.Black)
|
||||
val span = max(size.width, size.height)
|
||||
for ((i, b) in auroraBlobs.withIndex()) {
|
||||
for (b in auroraBlobs) {
|
||||
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||
val r = span * b.radiusFrac
|
||||
// Calm scales each blob's contribution rather than dimming the whole canvas: the
|
||||
// ground stays put and only the pools come down to meet it, which is the same "lower
|
||||
// the contrast, keep the colour" the desktop console's `calm` uniform does.
|
||||
val alpha = if (calm) b.alpha * 0.62f else b.alpha
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
|
||||
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
// Additive only works over a DARK ground; over a pale one every blob
|
||||
// saturates to white and the field turns grey. Pale palettes tint instead.
|
||||
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
|
||||
blendMode = BlendMode.Plus,
|
||||
)
|
||||
}
|
||||
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
|
||||
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
|
||||
// toward the edges, where crushing them just eats the list.
|
||||
// Cinematic vignette: pool light centre, sink the corners.
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
|
||||
),
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
radius = span * 0.92f,
|
||||
),
|
||||
@@ -171,108 +131,43 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false
|
||||
// Top/bottom legibility scrim for the pinned title + hint bar.
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
0.0f to scrim.copy(alpha = 0.40f * strength),
|
||||
0.30f to scrim.copy(alpha = 0.05f * strength),
|
||||
0.70f to scrim.copy(alpha = 0.06f * strength),
|
||||
1.0f to scrim.copy(alpha = 0.42f * strength),
|
||||
0.0f to Color.Black.copy(alpha = 0.40f),
|
||||
0.30f to Color.Black.copy(alpha = 0.05f),
|
||||
0.70f to Color.Black.copy(alpha = 0.06f),
|
||||
1.0f to Color.Black.copy(alpha = 0.42f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `false` when the user has turned animations off system-wide (Developer options' animator duration
|
||||
* scale, or the accessibility "Remove animations" switch, which sets the same global). Read once
|
||||
* per composition — it needs a settings trip to the system, and it changes about never.
|
||||
*/
|
||||
@Composable
|
||||
private fun animationsEnabled(): Boolean {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
runCatching {
|
||||
android.provider.Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) != 0f
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo
|
||||
* base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that
|
||||
* colour and luminance under the glass rows, honours the palette setting on every screen rather
|
||||
* than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors
|
||||
* the Apple client's GamepadFormBackground, which made the same substitution.
|
||||
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
GamepadAuroraBackground(modifier, calm = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The horizontal section switcher above a console list. Purely presentational — the SCREEN owns
|
||||
* which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never
|
||||
* has to squeeze the pills, and the selected one is always brought into view whether it was reached
|
||||
* by shoulder button or tap.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleTabStrip(
|
||||
titles: List<String>,
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring
|
||||
* on the selected pill so it's clear left/right now walks sections rather than values — the
|
||||
* route a D-pad remote, which has no shoulder buttons, needs.
|
||||
*/
|
||||
focused: Boolean = false,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(selected) {
|
||||
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
|
||||
}
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = ConsoleEdgeInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(titles) { i, title ->
|
||||
val active = i == selected
|
||||
val background by animateColorAsState(
|
||||
if (active) ink.accent(0.85f) else ink.glass,
|
||||
tween(180),
|
||||
label = "tabBg",
|
||||
)
|
||||
// Not `ink` — that name is the palette's, and shadowing it here cost a compile.
|
||||
val labelColor by animateColorAsState(
|
||||
if (active) ink.onAccent else ink.fg(0.55f),
|
||||
tween(180),
|
||||
label = "tabInk",
|
||||
)
|
||||
val ring by animateColorAsState(
|
||||
ink.fg(if (active && focused) 0.85f else 0f),
|
||||
tween(180),
|
||||
label = "tabRing",
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = labelColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(background)
|
||||
.border(1.5.dp, ring, RoundedCornerShape(50))
|
||||
.clickable { onSelect(i) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
Canvas(modifier) {
|
||||
val span = max(size.width, size.height)
|
||||
drawRect(Color(0xFF131126))
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +176,7 @@ fun ConsoleTabStrip(
|
||||
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||
* it cross-fades between screens.
|
||||
*/
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp)
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||
|
||||
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||
val ConsoleEdgeInset = 24.dp
|
||||
@@ -292,7 +187,6 @@ val ConsoleEdgeInset = 24.dp
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
|
||||
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
|
||||
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
|
||||
@@ -300,7 +194,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
|
||||
@@ -357,22 +251,21 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C
|
||||
*/
|
||||
@Composable
|
||||
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (active) 1f else 0.98f,
|
||||
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "consoleScale",
|
||||
)
|
||||
val background by animateColorAsState(
|
||||
if (active) ink.accent(0.20f) else ink.glass,
|
||||
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
|
||||
tween(160),
|
||||
label = "consoleBg",
|
||||
)
|
||||
val border by animateColorAsState(
|
||||
when {
|
||||
editing -> ink.accent(0.70f)
|
||||
active -> ink.fg(0.28f)
|
||||
else -> ink.fg(0.06f)
|
||||
editing -> Color(0xB38678F5)
|
||||
active -> Color.White.copy(alpha = 0.28f)
|
||||
else -> Color.White.copy(alpha = 0.06f)
|
||||
},
|
||||
tween(160),
|
||||
label = "consoleBorder",
|
||||
@@ -387,19 +280,18 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val travel by animateFloatAsState(
|
||||
targetValue = if (on) 1f else 0f,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
|
||||
label = "switchKnob",
|
||||
)
|
||||
val track by animateColorAsState(
|
||||
if (on) ink.accent else Color(0x26FFFFFF),
|
||||
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
|
||||
tween(200),
|
||||
label = "switchTrack",
|
||||
)
|
||||
val outline by animateColorAsState(
|
||||
ink.fg(if (focused) 0.45f else 0.15f),
|
||||
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
|
||||
tween(160),
|
||||
label = "switchOutline",
|
||||
)
|
||||
@@ -421,7 +313,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
|
||||
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
|
||||
.size(knob)
|
||||
.clip(CircleShape)
|
||||
.background(ink.fg),
|
||||
.background(Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -429,7 +321,6 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
|
||||
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||
@Composable
|
||||
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
@@ -439,7 +330,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
|
||||
) {
|
||||
Text(
|
||||
glyph.toString(),
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (size.value * 0.52f).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -450,12 +341,11 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
|
||||
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
|
||||
@Composable
|
||||
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Box(
|
||||
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape))
|
||||
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +410,6 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp)
|
||||
*/
|
||||
@Composable
|
||||
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Box(
|
||||
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -532,17 +421,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
|
||||
val corner = RoundedCornerShape(2.dp)
|
||||
Box(
|
||||
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
|
||||
.border(1.4.dp, ink.fg(0.9f), corner),
|
||||
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
|
||||
.clip(corner).background(PadButtonFace)
|
||||
.border(1.4.dp, ink.fg(0.9f), corner),
|
||||
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||
)
|
||||
}
|
||||
Gamepad.PadStyle.NINTENDO -> Text(
|
||||
"−",
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (size.value * 0.62f).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -551,7 +440,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
|
||||
Modifier
|
||||
.size(width = size * 0.58f, height = size * 0.30f)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)),
|
||||
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -563,7 +452,6 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
||||
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
||||
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
|
||||
@@ -576,19 +464,14 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
||||
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||
val frosted = if (hazeState != null) {
|
||||
modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f))
|
||||
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
|
||||
} else {
|
||||
modifier.clip(shape).background(ink.shade(0.55f))
|
||||
modifier.clip(shape).background(Color(0x8C14122A))
|
||||
}
|
||||
Row(
|
||||
modifier = frosted
|
||||
.border(1.dp, ink.fg(0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
// The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a
|
||||
// screen whose legend grew a cell) it scrolls rather than running off the edge and
|
||||
// silently eating the last hint — which is exactly what the settings screen's new
|
||||
// Section cell did on a 360 dp phone.
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
@@ -614,7 +497,7 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
Text(
|
||||
h.text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = ink.fg(0.9f),
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
|
||||
)
|
||||
@@ -626,25 +509,24 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
|
||||
@Composable
|
||||
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(ink.fg(0.08f))
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.SportsEsports,
|
||||
contentDescription = null,
|
||||
tint = ink.fg(0.75f),
|
||||
tint = Color.White.copy(alpha = 0.75f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ink.fg(0.75f),
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,12 +50,10 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -85,7 +83,6 @@ fun GamepadDialog(
|
||||
actions: List<DialogAction>,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
|
||||
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
|
||||
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
|
||||
@@ -118,11 +115,11 @@ fun GamepadDialog(
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -140,7 +137,6 @@ fun GamepadDialog(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scale by animateFloatAsState(
|
||||
if (focused) 1.02f else 1f,
|
||||
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
@@ -154,19 +150,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
|
||||
val bg by animateColorAsState(
|
||||
when {
|
||||
focused -> ink.accent
|
||||
primary -> ink.accent(0.20f)
|
||||
else -> ink.glass
|
||||
focused -> Color(0xFF6656F2)
|
||||
primary -> Color(0x336656F2)
|
||||
else -> Color(0x14FFFFFF)
|
||||
},
|
||||
tween(160),
|
||||
label = "btnBg",
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
when {
|
||||
!enabled -> ink.fg(0.35f)
|
||||
focused -> ink.fg
|
||||
primary -> ink.accent
|
||||
else -> ink.fg(0.85f)
|
||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||
focused -> Color.White
|
||||
primary -> Color(0xFF8678F5)
|
||||
else -> Color.White.copy(alpha = 0.85f)
|
||||
},
|
||||
tween(160),
|
||||
label = "btnFg",
|
||||
@@ -200,14 +196,13 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
/** Body text helper — a dimmed paragraph. */
|
||||
@Composable
|
||||
private fun DialogText(text: String) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Copy
|
||||
* link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console
|
||||
* counterpart of the touch host card's overflow menu.
|
||||
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit,
|
||||
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
|
||||
* the touch host card's overflow menu.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHostOptionsDialog(
|
||||
@@ -217,12 +212,6 @@ fun GamepadHostOptionsDialog(
|
||||
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
|
||||
onEdit: () -> Unit,
|
||||
onForget: () -> Unit,
|
||||
/**
|
||||
* Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other
|
||||
* actions it says nothing about the host, it hands out the shortcut this very tile already is
|
||||
* (profile included), which is exactly what a pin is for.
|
||||
*/
|
||||
onCopyLink: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSpeedTest: (() -> Unit)? = null,
|
||||
/**
|
||||
@@ -239,14 +228,12 @@ fun GamepadHostOptionsDialog(
|
||||
actions = buildList {
|
||||
if (onUnpin != null) {
|
||||
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
|
||||
add(DialogAction("Copy link", onClick = onCopyLink))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
return@buildList
|
||||
}
|
||||
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
|
||||
add(DialogAction("Copy link", onClick = onCopyLink))
|
||||
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||
add(DialogAction("Forget", onClick = onForget))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
@@ -263,141 +250,6 @@ fun GamepadHostOptionsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
|
||||
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
|
||||
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
|
||||
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
|
||||
* carousel's unpin uses, never the profile itself and never the host's default binding.
|
||||
*
|
||||
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
|
||||
* always what the store holds — the row can't disagree with the carousel it feeds.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GamepadPinHostsDialog(
|
||||
profileName: String,
|
||||
hosts: List<KnownHost>,
|
||||
pinned: (KnownHost) -> Boolean,
|
||||
onToggle: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
|
||||
// Done, so it starts focused).
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < hosts.size) focus++
|
||||
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
|
||||
// direction can't oscillate; asking for the state it's already in is a no-op.
|
||||
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
|
||||
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
|
||||
}
|
||||
},
|
||||
onActivate = {
|
||||
val kh = hosts.getOrNull(focus)
|
||||
if (kh != null) onToggle(kh) else onDismiss()
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Pin “$profileName”",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (hosts.isEmpty()) {
|
||||
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
|
||||
} else {
|
||||
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
|
||||
hosts.forEachIndexed { i, kh ->
|
||||
PinHostRow(
|
||||
label = kh.name,
|
||||
on = pinned(kh),
|
||||
focused = i == focus,
|
||||
onClick = { onToggle(kh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
DialogButton(
|
||||
"Done",
|
||||
focused = focus == hosts.size,
|
||||
primary = true,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ConsoleSwitch(on = on, focused = focused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
@@ -463,11 +315,11 @@ fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDis
|
||||
),
|
||||
) {
|
||||
DialogText(
|
||||
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
|
||||
"Android blocks punktfunk from talking to devices on your network, so it can't find " +
|
||||
"or reach any host until you allow it.",
|
||||
)
|
||||
DialogText(
|
||||
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
|
||||
"If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " +
|
||||
"system settings.",
|
||||
)
|
||||
}
|
||||
@@ -531,7 +383,6 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on
|
||||
|
||||
@Composable
|
||||
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
GamepadDialog(
|
||||
title = "Waiting for approval",
|
||||
onDismiss = onCancel,
|
||||
@@ -539,8 +390,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
|
||||
Text("Approve this device on $hostLabel.", color = ink.fg)
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
Text("Approve this device on $hostLabel.", color = Color.White)
|
||||
}
|
||||
DialogText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
|
||||
@@ -556,7 +407,6 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
|
||||
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||
@@ -602,16 +452,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
Column(
|
||||
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
|
||||
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(
|
||||
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||
@@ -630,14 +480,13 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
|
||||
@Composable
|
||||
private fun PinSlot(value: Int, focused: Boolean) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Box(
|
||||
Modifier.size(54.dp, 66.dp).clip(shape)
|
||||
.background(if (focused) ink.accent(0.20f) else ink.glass)
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
|
||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
|
||||
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,10 +247,9 @@ fun GamepadHome(
|
||||
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||
@Composable
|
||||
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
val wash = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
|
||||
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||
}
|
||||
@@ -259,7 +258,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(wash)
|
||||
.border(1.dp, ink.fg(0.16f), shape)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.padding(22.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
@@ -270,7 +269,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = "Paired",
|
||||
tint = ink.fg(0.7f),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(end = 6.dp).size(15.dp),
|
||||
)
|
||||
}
|
||||
@@ -287,14 +286,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
tile.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
tile.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.55f),
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -303,10 +302,9 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun MonogramBadge(tile: HomeTile) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
val fill = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(ink.accent, ink.accent))
|
||||
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||
}
|
||||
@@ -318,18 +316,18 @@ private fun MonogramBadge(tile: HomeTile) {
|
||||
tile.connecting -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
)
|
||||
tile.isAdd -> Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
tint = if (tile.filled) ink.fg else ink.accent,
|
||||
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||
)
|
||||
else -> Text(
|
||||
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (tile.filled) ink.fg else ink.accent,
|
||||
color = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// The ink the console (gamepad) UI draws with under the chosen background palette.
|
||||
//
|
||||
// The console screens were white-on-dark throughout with the brand violet hardcoded as the accent.
|
||||
// Both had to become palette-derived at once: a pale field needs dark text or it is unreadable,
|
||||
// and a violet focus wash on a copper field is exactly the clash this exists to fix.
|
||||
//
|
||||
// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card)
|
||||
// can ask for the right colour without every caller in between knowing about palettes. The Apple
|
||||
// client uses an environment value and `pf-console-ui` a thread-local for the same reason.
|
||||
|
||||
/** Everything about the console's look that follows the chosen palette. */
|
||||
class GamepadInk(
|
||||
/** Primary text/glyph colour. */
|
||||
val fg: Color,
|
||||
/** Focus wash, selected tab pill, switch track — the palette's own accent. */
|
||||
val accent: Color,
|
||||
/** What reads ON the accent (a filled pill's label, a switch knob). */
|
||||
val onAccent: Color,
|
||||
/** The base fill every glass surface starts from, at its resting opacity. */
|
||||
val glass: Color,
|
||||
/** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */
|
||||
val shade: Color,
|
||||
/**
|
||||
* How hard those washes go. A pale field needs far less — mixing toward white at the dark
|
||||
* field's strength bleaches the chroma straight out of the gradient.
|
||||
*/
|
||||
val shadeScale: Float,
|
||||
/** True when the field is pale, for the few places that branch rather than blend. */
|
||||
val isLight: Boolean,
|
||||
) {
|
||||
/** The foreground at [alpha]. */
|
||||
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
|
||||
|
||||
/** The accent at [alpha]. */
|
||||
fun accent(alpha: Float): Color = accent.copy(alpha = alpha)
|
||||
|
||||
/** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */
|
||||
fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale)
|
||||
|
||||
companion object {
|
||||
fun of(p: GamepadPalette): GamepadInk {
|
||||
val accent = p.accentColor
|
||||
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
|
||||
// GLASS, not against the field.
|
||||
val accentLuma =
|
||||
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
|
||||
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
|
||||
if (!p.light) {
|
||||
return GamepadInk(
|
||||
fg = Color.White,
|
||||
accent = accent,
|
||||
onAccent = onAccent,
|
||||
glass = Color.White.copy(alpha = 0.08f),
|
||||
shade = Color.Black,
|
||||
shadeScale = 1f,
|
||||
isLight = false,
|
||||
)
|
||||
}
|
||||
val (gr, gg, gb) = p.ground
|
||||
return GamepadInk(
|
||||
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
|
||||
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
|
||||
accent = accent,
|
||||
onAccent = onAccent,
|
||||
// More body than the dark glass carries: white frost over a bright gradient has
|
||||
// far less separating it from its backdrop than dark glass over a dark one.
|
||||
glass = Color.White.copy(alpha = 0.55f),
|
||||
shade = Color.White,
|
||||
shadeScale = 0.45f,
|
||||
isLight = true,
|
||||
)
|
||||
}
|
||||
|
||||
/** The shipped dark look — what a preview or a test composition gets. */
|
||||
val DARK = of(GamepadPalette.named("violet"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ink of the palette currently drawing, for everything under [App]. Provided from the live
|
||||
* settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks
|
||||
* every console surface at once.
|
||||
*/
|
||||
val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK }
|
||||
@@ -152,9 +152,8 @@ fun GamepadNavEffect(
|
||||
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||
* [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) — a step SIDEWAYS out of the list, which
|
||||
* the settings screen uses for its section tabs. B is left to MainActivity's BACK remap → the
|
||||
* screen's BackHandler (so B "peels one layer": close the keyboard, then the screen).
|
||||
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||
* one layer": close the keyboard, then the screen).
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect2D(
|
||||
@@ -163,7 +162,6 @@ fun GamepadNavEffect2D(
|
||||
onActivate: () -> Unit,
|
||||
onTertiary: () -> Unit = {},
|
||||
onSecondary: () -> Unit = {},
|
||||
onShoulder: (Int) -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
@@ -171,7 +169,6 @@ fun GamepadNavEffect2D(
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
val currentOnShoulder by rememberUpdatedState(onShoulder)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
@@ -199,10 +196,7 @@ fun GamepadNavEffect2D(
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
|
||||
else -> false // B → MainActivity (remapped to BACK → BackHandler)
|
||||
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// The console (gamepad) UI's background colour families, and the ink each one calls for.
|
||||
//
|
||||
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
|
||||
// field samples that ramp so several tones show at once and pool into each other, the way a real
|
||||
// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every
|
||||
// non-default palette read flat and monotone.
|
||||
//
|
||||
// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch
|
||||
// colour, and [light] flips the ink so a pale field gets dark text instead of white.
|
||||
//
|
||||
// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
|
||||
// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look
|
||||
// on every client. Keep the three copies in step: a palette added here without the others is a
|
||||
// value the other clients silently render as Violet.
|
||||
|
||||
/** One background colour family. */
|
||||
class GamepadPalette(
|
||||
/** The stored `ui_palette` value ([Settings.uiPalette]). */
|
||||
val id: String,
|
||||
/** What the settings row shows. */
|
||||
val name: String,
|
||||
/**
|
||||
* The colour ramp, dark end first. Empty = the brand default's explicit field, kept
|
||||
* bit-identical to what every install already sees.
|
||||
*/
|
||||
val stops: List<Triple<Double, Double, Double>>,
|
||||
/** The field's ground — what it settles onto and what the calm mix lifts toward. */
|
||||
val ground: Triple<Double, Double, Double>,
|
||||
/** The UI accent: focus wash, selected tab pill, switch track. */
|
||||
val accent: Triple<Double, Double, Double>,
|
||||
/** A pale field: the UI flips to dark ink and the legibility scrims go white. */
|
||||
val light: Boolean,
|
||||
) {
|
||||
/** Four drifting blob colours, spread across the ramp so the field shows several hues. */
|
||||
val blobColors: List<Color> by lazy {
|
||||
val s = stops.ifEmpty { VIOLET_BLOBS }
|
||||
(0..3).map { color(ramp(s, 0.15 + 0.25 * it)) }
|
||||
}
|
||||
|
||||
/** The field's ground as a Compose colour. */
|
||||
val groundColor: Color by lazy { color(ground) }
|
||||
|
||||
/** The accent as a Compose colour. */
|
||||
val accentColor: Color by lazy { color(accent) }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
|
||||
* here so the three ports stay one table even though this client approximates the field
|
||||
* with blobs.
|
||||
*/
|
||||
val CELL_RAMP = listOf(
|
||||
0.10, -0.06, 0.04, -0.12,
|
||||
-0.08, 0.14, -0.10, 0.06,
|
||||
0.06, -0.12, 0.16, -0.04,
|
||||
-0.10, 0.08, -0.06, 0.12,
|
||||
)
|
||||
|
||||
/** The brand default's blob ramp — the colours the pre-palette field used. */
|
||||
private val VIOLET_BLOBS = listOf(
|
||||
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
|
||||
Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96),
|
||||
)
|
||||
|
||||
/**
|
||||
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
||||
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
|
||||
*/
|
||||
val ALL = listOf(
|
||||
// --- dark fields (white ink) ---
|
||||
GamepadPalette(
|
||||
"violet", "Violet", emptyList(),
|
||||
ground = Triple(0.075, 0.060, 0.160),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
"nebula", "Nebula",
|
||||
listOf(
|
||||
Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72),
|
||||
Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68),
|
||||
),
|
||||
ground = Triple(0.055, 0.040, 0.135),
|
||||
accent = Triple(0.95, 0.42, 0.72), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Ink-blue water: teal → cerulean → a violet undertow.
|
||||
"abyss", "Abyss",
|
||||
listOf(
|
||||
Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63),
|
||||
Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58),
|
||||
),
|
||||
ground = Triple(0.018, 0.070, 0.130),
|
||||
accent = Triple(0.26, 0.76, 0.92), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Banked coals: plum embers → crimson → burnt orange → gold.
|
||||
"ember", "Ember",
|
||||
listOf(
|
||||
Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06),
|
||||
Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18),
|
||||
),
|
||||
ground = Triple(0.090, 0.035, 0.040),
|
||||
accent = Triple(0.98, 0.62, 0.26), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Forest floor into moss and a lime break.
|
||||
"moss", "Moss",
|
||||
listOf(
|
||||
Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31),
|
||||
Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31),
|
||||
),
|
||||
ground = Triple(0.025, 0.085, 0.070),
|
||||
accent = Triple(0.48, 0.86, 0.46), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Neutral, but never flat: barely-there saturation that still travels from a cool
|
||||
// charcoal to a warm stone.
|
||||
"graphite", "Graphite",
|
||||
listOf(
|
||||
Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35),
|
||||
Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49),
|
||||
),
|
||||
ground = Triple(0.055, 0.055, 0.070),
|
||||
accent = Triple(0.78, 0.80, 0.86), light = false,
|
||||
),
|
||||
// --- pale fields (dark ink) ---
|
||||
GamepadPalette(
|
||||
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
|
||||
"holo", "Holo",
|
||||
listOf(
|
||||
Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99),
|
||||
Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00),
|
||||
),
|
||||
ground = Triple(0.96, 0.92, 0.99),
|
||||
accent = Triple(0.42, 0.28, 0.86), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
|
||||
"sunset", "Sunset",
|
||||
listOf(
|
||||
Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34),
|
||||
Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22),
|
||||
),
|
||||
ground = Triple(0.98, 0.74, 0.34),
|
||||
accent = Triple(0.64, 0.13, 0.44), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Peach into blush and lilac — the softest of the set.
|
||||
"bloom", "Bloom",
|
||||
listOf(
|
||||
Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89),
|
||||
Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99),
|
||||
),
|
||||
ground = Triple(0.99, 0.90, 0.89),
|
||||
accent = Triple(0.72, 0.24, 0.55), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// First light: pale gold → coral → lilac.
|
||||
"dawn", "Dawn",
|
||||
listOf(
|
||||
Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62),
|
||||
Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95),
|
||||
),
|
||||
ground = Triple(1.00, 0.93, 0.82),
|
||||
accent = Triple(0.82, 0.33, 0.28), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Sea glass: mint → aqua → a pale sky.
|
||||
"mint", "Mint",
|
||||
listOf(
|
||||
Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95),
|
||||
Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00),
|
||||
),
|
||||
ground = Triple(0.90, 0.98, 0.96),
|
||||
accent = Triple(0.04, 0.42, 0.40), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
|
||||
"opal", "Opal",
|
||||
listOf(
|
||||
Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95),
|
||||
Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99),
|
||||
),
|
||||
ground = Triple(0.97, 0.96, 0.99),
|
||||
accent = Triple(0.36, 0.32, 0.44), light = true,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* The palette stored under [id], falling back to the brand default — an unknown name is a
|
||||
* palette a newer client shipped, not a reason to draw nothing.
|
||||
*/
|
||||
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
|
||||
|
||||
/** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */
|
||||
fun ramp(
|
||||
stops: List<Triple<Double, Double, Double>>,
|
||||
t: Double,
|
||||
): Triple<Double, Double, Double> {
|
||||
if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0)
|
||||
if (stops.size == 1) return stops[0]
|
||||
val x = t.coerceIn(0.0, 1.0) * (stops.size - 1)
|
||||
val i = x.toInt().coerceAtMost(stops.size - 2)
|
||||
val f = x - i
|
||||
val (ar, ag, ab) = stops[i]
|
||||
val (br, bg, bb) = stops[i + 1]
|
||||
return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f)
|
||||
}
|
||||
|
||||
fun color(c: Triple<Double, Double, Double>): Color =
|
||||
Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat())
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -57,43 +56,15 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with
|
||||
// the touch settings.
|
||||
//
|
||||
// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone.
|
||||
// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant
|
||||
// walking past Display and Audio to reach the controller settings. The tab names match the desktop
|
||||
// console's and the Apple client's, so a setting is found under the same word wherever you look.
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
|
||||
/**
|
||||
* The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
* match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`.
|
||||
*/
|
||||
enum class GpTab(val title: String) {
|
||||
STREAM("Stream"),
|
||||
VIDEO("Video"),
|
||||
AUDIO("Audio"),
|
||||
CONTROLLER("Controller"),
|
||||
INTERFACE("Interface"),
|
||||
PROFILES("Profiles"),
|
||||
}
|
||||
|
||||
internal class GpRow(
|
||||
private class GpRow(
|
||||
val id: String,
|
||||
val tab: GpTab,
|
||||
/**
|
||||
* A sub-heading above this row, for the few tabs that hold more than one group. Most rows have
|
||||
* none: the tab pill already names the section, and repeating it would be a second label
|
||||
* saying the same word.
|
||||
*/
|
||||
val header: String?,
|
||||
val label: String,
|
||||
val value: String,
|
||||
@@ -101,19 +72,8 @@ internal class GpRow(
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
)
|
||||
|
||||
/**
|
||||
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
|
||||
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
|
||||
* already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing
|
||||
* else, and every dimmed row still stepped its setting.
|
||||
*/
|
||||
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
|
||||
rows.getOrNull(index)?.takeIf { it.enabled }
|
||||
|
||||
@Composable
|
||||
fun GamepadSettingsScreen(
|
||||
initial: Settings,
|
||||
@@ -127,69 +87,11 @@ fun GamepadSettingsScreen(
|
||||
val context = LocalContext.current
|
||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates "Gyro from this phone" the same way — a TV box has no gyroscope to mirror from.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
|
||||
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
|
||||
// The catalog is read once per screen entry: this screen can't create or edit profiles
|
||||
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
|
||||
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
|
||||
// on each toggle, keeping the "Pinned to N hosts" counts honest.
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
val profiles = remember { profileStore.all() }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
|
||||
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
|
||||
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
|
||||
|
||||
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
|
||||
// only: pin appends at the end (card order), unpin removes, and the host's default binding
|
||||
// (profileId) is never touched.
|
||||
fun togglePin(kh: KnownHost, profile: StreamProfile) {
|
||||
val pins = if (profile.id in kh.pinnedProfileIds) {
|
||||
kh.pinnedProfileIds - profile.id
|
||||
} else {
|
||||
kh.pinnedProfileIds + profile.id
|
||||
}
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
var tab by remember { mutableStateOf(GpTab.STREAM) }
|
||||
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
|
||||
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
|
||||
// shoulder buttons at all (and is exactly what a TV box ships with).
|
||||
var tabFocused by remember { mutableStateOf(false) }
|
||||
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
|
||||
val rows = allRows.filter { it.tab == tab }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
|
||||
|
||||
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
fun selectTab(next: GpTab) {
|
||||
if (next == tab) return
|
||||
tabFocus[tab] = focus
|
||||
tab = next
|
||||
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
|
||||
// outlive the row it pointed at.
|
||||
focus = (tabFocus[next] ?: 0)
|
||||
.coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0))
|
||||
}
|
||||
fun stepTab(delta: Int) {
|
||||
val all = GpTab.entries
|
||||
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
|
||||
}
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
// value text slides in its AnimatedContent, so the motion matches the button press.
|
||||
var adjustDir by remember { mutableIntStateOf(1) }
|
||||
@@ -199,33 +101,21 @@ fun GamepadSettingsScreen(
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
// drops its probes — the pattern ConnectScreen's dialogs use.
|
||||
active = navActive && pinProfile == null,
|
||||
active = navActive,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus-- else tabFocused = true
|
||||
NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++
|
||||
// On the strip, left/right walks sections; on a row it steps the value. A disabled
|
||||
// row is INERT, not just dim — the step is refused instead of writing a setting
|
||||
// that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT ->
|
||||
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT ->
|
||||
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
// A on the strip drops into the section you picked, which is what "confirm" means there.
|
||||
onActivate = {
|
||||
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
|
||||
},
|
||||
// The shoulders work from either place — a real pad never has to visit the strip.
|
||||
onShoulder = { delta -> stepTab(delta) },
|
||||
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
// +1 accounts for the heading being item 0.
|
||||
LaunchedEffect(focus, tab) {
|
||||
LaunchedEffect(focus) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
@@ -244,21 +134,9 @@ fun GamepadSettingsScreen(
|
||||
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation now, and a switcher you have to scroll back up to find isn't one. The
|
||||
// title stays in the scrolling list (landscape has no height to spare, and the
|
||||
// selected pill already says which section you are in).
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
focused = tabFocused,
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize().systemBarsPadding(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
@@ -269,19 +147,9 @@ fun GamepadSettingsScreen(
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(
|
||||
row,
|
||||
focused = index == focus && !tabFocused,
|
||||
adjustDir = adjustDir,
|
||||
onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it
|
||||
// (so its detail explains itself) but never flips it.
|
||||
tabFocused = false
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
},
|
||||
)
|
||||
}
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,74 +162,28 @@ fun GamepadSettingsScreen(
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
// The shoulders always change section, so that cell leads on every row. Tappable too,
|
||||
// like the others — a user without a working pad can still reach every tab.
|
||||
// Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up
|
||||
// into the strip) and a touch user taps a pill, so on those the cell would be both a
|
||||
// lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an
|
||||
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val sections = listOfNotNull(
|
||||
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
GamepadHintBar(
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
|
||||
PadGlyph.hint('A', "Open") { tabFocused = false },
|
||||
listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
) else sections + when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
|
||||
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
|
||||
// touch UI's per-profile pin toggles in the host edit sheet.
|
||||
pinProfile?.let { p ->
|
||||
GamepadPinHostsDialog(
|
||||
profileName = p.name,
|
||||
hosts = savedHosts,
|
||||
pinned = { kh -> p.id in kh.pinnedProfileIds },
|
||||
onToggle = { kh -> togglePin(kh, p) },
|
||||
onDismiss = { pinProfile = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
|
||||
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
|
||||
val chevronAlpha by animateFloatAsState(
|
||||
if (focused && row.adjustable) 0.6f else 0f,
|
||||
tween(160),
|
||||
label = "chevrons",
|
||||
)
|
||||
// focus arrives; the value colour cross-fades with them.
|
||||
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
|
||||
val valueColor by animateColorAsState(
|
||||
ink.fg(if (focused) 1f else 0.6f),
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
label = "valueColor",
|
||||
)
|
||||
@@ -370,7 +192,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
Text(
|
||||
row.header.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ink.fg(0.45f),
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 1.4.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
@@ -394,9 +216,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
|
||||
// so its detail line can still explain what would go here.
|
||||
color = ink.fg(if (row.enabled) 1f else 0.45f),
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -404,7 +224,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text("‹ ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
// The value slides in the direction it was stepped and its width animates, so
|
||||
// cycling a choice reads as motion through a list rather than a text swap.
|
||||
AnimatedContent(
|
||||
@@ -425,7 +245,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(" ›", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
}
|
||||
}
|
||||
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||
@@ -438,7 +258,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
Text(
|
||||
row.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = ink.fg(0.6f),
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
@@ -448,26 +268,23 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
|
||||
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
|
||||
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
private fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
hasGyroscope: Boolean,
|
||||
av1Capable: Boolean,
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, tab, header, label,
|
||||
id, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
adjust = { delta ->
|
||||
if (idx < 0) {
|
||||
options.firstOrNull()?.let { write(it.first) } != null
|
||||
@@ -483,25 +300,47 @@ internal fun buildSettingsRows(
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
value: Boolean, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, tab, header, label,
|
||||
id, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||
activate = { write(!value) },
|
||||
toggled = value,
|
||||
)
|
||||
|
||||
// Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface /
|
||||
// Profiles), so a setting sits under the same word whichever client you found it on. The ROWS
|
||||
// stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for
|
||||
// the sake of symmetry would be parity in name only.
|
||||
// Grouped and ordered by the cross-client category map (General / Display / Audio /
|
||||
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
|
||||
// so a setting sits in the same place whichever surface you found it on. The ROWS stay the
|
||||
// couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of
|
||||
// symmetry would be parity in name only.
|
||||
return listOf(
|
||||
choice(
|
||||
"resolution", GpTab.STREAM, null, "Resolution",
|
||||
"hud", "General · Statistics", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", "General · Session", "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", "General · Library", "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", "General · Interface", "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"resolution", "Display · Resolution", "Resolution",
|
||||
"The host creates a virtual display at exactly this size — no scaling. " +
|
||||
"Custom sizes are typed in the touch settings.",
|
||||
// A custom size (typed in the touch settings) leads the list so it stays visible and
|
||||
@@ -515,86 +354,62 @@ internal fun buildSettingsRows(
|
||||
s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||
choice(
|
||||
"refresh", GpTab.STREAM, null, "Refresh rate",
|
||||
"Frame rate the host renders and streams at.",
|
||||
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||
REFRESH_OPTIONS, s.hz,
|
||||
) { update(s.copy(hz = it)) },
|
||||
|
||||
choice(
|
||||
"bitrate", GpTab.STREAM, null, "Bitrate",
|
||||
"bitrate", "Display · Quality", "Bitrate",
|
||||
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
|
||||
"link and set an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"compositor", GpTab.STREAM, "Host output", "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"codec", GpTab.VIDEO, null, "Video codec",
|
||||
"codec", null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", GpTab.VIDEO, null, "10-bit HDR",
|
||||
"hdr", null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
|
||||
toggle(
|
||||
"lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode",
|
||||
"lowLatency", "Display · Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"audio", GpTab.AUDIO, null, "Audio channels",
|
||||
"The speaker layout requested from the host.",
|
||||
"compositor", "Display · Host output", "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||
) { update(s.copy(audioChannels = it)) },
|
||||
toggle(
|
||||
"mic", GpTab.AUDIO, null, "Microphone",
|
||||
"Send this device's microphone to the host's virtual mic.",
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
toggle(
|
||||
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
|
||||
"echoCancel", null, "Echo cancellation",
|
||||
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", GpTab.CONTROLLER, null, "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
s.gamepadForwarding,
|
||||
) { update(s.copy(gamepadForwarding = it)) },
|
||||
// Everything below the master switch follows it — dim and inert while nothing is being
|
||||
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
|
||||
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
|
||||
// the pad rows kept stepping settings that had nothing to act on.
|
||||
choice(
|
||||
"padType", GpTab.CONTROLLER, null, "Controller type",
|
||||
"padType", "Controllers", "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(guideGesture = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
|
||||
"phoneRumble", null, "Rumble on this phone",
|
||||
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||
"for clip-on pads without rumble motors.",
|
||||
s.rumbleOnPhone,
|
||||
@@ -602,130 +417,14 @@ internal fun buildSettingsRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// The rumble mirror's sibling, data flowing the other way — needs a gyroscope to
|
||||
// mirror FROM, which a TV box lacks.
|
||||
if (hasGyroscope) {
|
||||
toggle(
|
||||
"phoneGyro", GpTab.CONTROLLER, null, "Gyro from this phone",
|
||||
"When the controller has no gyro of its own, send this phone's motion " +
|
||||
"sensors as controller 1's — for clip-on pads without one.",
|
||||
s.gyroOnPhone,
|
||||
) { update(s.copy(gyroOnPhone = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
toggle(
|
||||
"sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture, enabled = s.gamepadForwarding,
|
||||
s.sc2Capture,
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
// The SC2 row's twin, and missing here until now: the touch settings have carried both
|
||||
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
|
||||
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
|
||||
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
|
||||
toggle(
|
||||
"dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
|
||||
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
|
||||
// The palette leads Interface: it is the one row whose effect you can see while you step
|
||||
// it (the backdrop behind this very list recolours), so it wants to be the first thing
|
||||
// found in the section.
|
||||
choice(
|
||||
"palette", GpTab.INTERFACE, null, "Background",
|
||||
"The colour family this backdrop drifts through — it changes as you step, so pick by " +
|
||||
"looking. Appearance only.",
|
||||
GamepadPalette.ALL.map { it.id to it.name },
|
||||
GamepadPalette.named(s.uiPalette).id,
|
||||
) { update(s.copy(uiPalette = it)) },
|
||||
choice(
|
||||
"hud", GpTab.INTERFACE, null, "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", GpTab.INTERFACE, null, "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", GpTab.INTERFACE, null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
*/
|
||||
private fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
openPinPicker: (StreamProfile) -> Unit,
|
||||
): List<GpRow> {
|
||||
val createHint = if (tv) {
|
||||
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
|
||||
"and use the standard interface."
|
||||
} else {
|
||||
"Profiles are created and edited in the touch interface."
|
||||
}
|
||||
if (profiles.isEmpty()) {
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
"one-press connect cards here. " + createHint,
|
||||
adjust = { false },
|
||||
activate = {},
|
||||
adjustable = false,
|
||||
enabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.map { p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
1 -> "Pinned to 1 host"
|
||||
else -> "Pinned to $pins hosts"
|
||||
},
|
||||
detail = "Pin this profile to a host and it appears as its own card — one press " +
|
||||
"connects with it. " + createHint,
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +84,6 @@ suspend fun connectToHost(
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ fun LibraryScreen(
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BackHandler(onBack = onBack)
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -146,14 +145,7 @@ fun LibraryScreen(
|
||||
launching = false
|
||||
if (handle != 0L) {
|
||||
onLaunched(
|
||||
ActiveSession(
|
||||
handle,
|
||||
settings,
|
||||
host.clipboardSync,
|
||||
hostId = host.id,
|
||||
// Where to come back to when this game exits.
|
||||
launchedFromLibrary = true,
|
||||
),
|
||||
ActiveSession(handle, settings, host.clipboardSync),
|
||||
)
|
||||
}
|
||||
else Toast.makeText(
|
||||
@@ -178,8 +170,8 @@ fun LibraryScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
CircularProgressIndicator(color = ink.fg)
|
||||
Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge)
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,19 +195,17 @@ fun LibraryScreen(
|
||||
|
||||
@Composable
|
||||
private fun LoadingState() {
|
||||
val ink = LocalGamepadInk.current
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
CircularProgressIndicator(color = ink.fg)
|
||||
Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageState(text: String) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Text(
|
||||
text,
|
||||
color = ink.fg(0.75f),
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
@@ -229,7 +219,6 @@ private fun Coverflow(
|
||||
navActive: Boolean,
|
||||
onLaunch: (GameEntry) -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
||||
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||
@@ -252,22 +241,7 @@ private fun Coverflow(
|
||||
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
|
||||
)
|
||||
|
||||
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
|
||||
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
|
||||
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
|
||||
// library actually has both groups — otherwise the screen is exactly what it was.
|
||||
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
if (bothGroups) {
|
||||
Text(
|
||||
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 2.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(coverWidth),
|
||||
@@ -325,16 +299,15 @@ private fun Coverflow(
|
||||
current?.title ?: " ",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (current != null) {
|
||||
Text(
|
||||
if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER"
|
||||
else current.storeLabel.uppercase(),
|
||||
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ink.fg(0.5f),
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
}
|
||||
@@ -346,7 +319,6 @@ private fun Coverflow(
|
||||
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
|
||||
@Composable
|
||||
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val candidates = game.art.posterCandidates
|
||||
var idx by remember(game.id) { mutableStateOf(0) }
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
@@ -354,7 +326,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color(0xFF241F3D))
|
||||
.border(1.dp, ink.fg(0.12f), shape),
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (idx < candidates.size) {
|
||||
@@ -367,29 +339,24 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
// Store badge, top-start.
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
game.storeLabel,
|
||||
if (game.isCustom) "Custom" else "Steam",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ink.fg,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
if (game.isLauncher) MaterialTheme.colorScheme.primary
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,9 +43,6 @@ data class SettingsOverlay(
|
||||
val mouseMode: MouseMode? = null,
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val systemButtons: String? = null,
|
||||
val guideGesture: String? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
@@ -79,9 +76,6 @@ data class SettingsOverlay(
|
||||
mouseMode = mouseMode ?: base.mouseMode,
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
systemButtons = systemButtons ?: base.systemButtons,
|
||||
guideGesture = guideGesture ?: base.guideGesture,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
@@ -116,11 +110,6 @@ data class SettingsOverlay(
|
||||
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
|
||||
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
|
||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
|
||||
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
@@ -147,9 +136,6 @@ data class SettingsOverlay(
|
||||
"mouse_mode" -> copy(mouseMode = null)
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"system_buttons" -> copy(systemButtons = null)
|
||||
"guide_gesture" -> copy(guideGesture = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
@@ -173,9 +159,6 @@ data class SettingsOverlay(
|
||||
if (mouseMode != null) add("mouse_mode")
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (systemButtons != null) add("system_buttons")
|
||||
if (guideGesture != null) add("guide_gesture")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
@@ -207,9 +190,6 @@ data class SettingsOverlay(
|
||||
mouseMode?.let { j.put("mouse_mode", it.storedName) }
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
systemButtons?.let { j.put("system_buttons", it) }
|
||||
guideGesture?.let { j.put("guide_gesture", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
@@ -225,9 +205,7 @@ data class SettingsOverlay(
|
||||
private val KNOWN = setOf(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"system_buttons", "guide_gesture",
|
||||
"stats_verbosity",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
|
||||
@@ -249,9 +227,6 @@ data class SettingsOverlay(
|
||||
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
systemButtons = j.optStringOrNull("system_buttons"),
|
||||
guideGesture = j.optStringOrNull("guide_gesture"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
|
||||
@@ -34,31 +34,6 @@ data class Settings(
|
||||
val hdrEnabled: Boolean = true,
|
||||
val compositor: Int = 0,
|
||||
val gamepad: Int = 0,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all. Default on — that was the
|
||||
* unconditional behaviour before this became a setting.
|
||||
*
|
||||
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
|
||||
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
|
||||
* there gives the host two controllers for one pair of hands, and games read both. It also
|
||||
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/**
|
||||
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
|
||||
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
|
||||
* the app on most devices) | `"forward"` | `"local"`.
|
||||
*/
|
||||
val systemButtons: String = "auto",
|
||||
/**
|
||||
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
|
||||
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
|
||||
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
|
||||
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
|
||||
* shell intercepts the physical guide button.
|
||||
*/
|
||||
val guideGesture: String = "auto",
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -105,16 +80,6 @@ data class Settings(
|
||||
* client's `libraryEnabled`.
|
||||
*/
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||
* a palette this build doesn't know.
|
||||
*/
|
||||
val uiPalette: String = "violet",
|
||||
/**
|
||||
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||
@@ -158,16 +123,6 @@ data class Settings(
|
||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val rumbleOnPhone: Boolean = false,
|
||||
/**
|
||||
* Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has
|
||||
* none of its own — for clip-on gamepads without an IMU, where the phone body moves with the
|
||||
* player's hands. The rumble mirror's sibling, data flowing the other way. Off by default;
|
||||
* read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only
|
||||
* when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture
|
||||
* link (USB DualSense / SC2 — pads with a real gyro). The toggle is hidden on devices
|
||||
* without a gyroscope (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val gyroOnPhone: Boolean = false,
|
||||
|
||||
/**
|
||||
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
||||
@@ -190,26 +145,6 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -281,9 +216,6 @@ class SettingsStore(context: Context) {
|
||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
|
||||
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
|
||||
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
@@ -304,17 +236,13 @@ class SettingsStore(context: Context) {
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
gyroOnPhone = prefs.getBoolean(K_GYRO_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
|
||||
@@ -334,9 +262,6 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_HDR, s.hdrEnabled)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
|
||||
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
|
||||
.putString(K_GUIDE_GESTURE, s.guideGesture)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
@@ -345,17 +270,13 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putString(K_PRESENT_PRIORITY, s.presentPriority)
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone)
|
||||
.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()
|
||||
@@ -370,9 +291,6 @@ class SettingsStore(context: Context) {
|
||||
const val K_HDR = "hdr_enabled"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
|
||||
const val K_SYSTEM_BUTTONS = "system_buttons"
|
||||
const val K_GUIDE_GESTURE = "guide_gesture"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
@@ -385,7 +303,6 @@ class SettingsStore(context: Context) {
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
/**
|
||||
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||
@@ -402,11 +319,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_SMOOTH_BUFFER = "smooth_buffer"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_GYRO_ON_PHONE = "gyro_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. */
|
||||
@@ -450,96 +364,6 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture
|
||||
* clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode],
|
||||
* exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real
|
||||
* size; distinct from the UI's `-1` "Custom…" sentinel.
|
||||
*/
|
||||
const val SAFE_AREA_MODE = -2
|
||||
|
||||
/**
|
||||
* Safe-area stream geometry — the pure part, so it is unit-testable without a Display.
|
||||
*
|
||||
* The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded
|
||||
* corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge
|
||||
* (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio
|
||||
* (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect:
|
||||
*
|
||||
* * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe
|
||||
* regions — which is why the presets have always "just worked".
|
||||
* * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners
|
||||
* included. That is the mode that loses its corners.
|
||||
*
|
||||
* So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing
|
||||
* aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput
|
||||
* derives the picture rect from the live video size, not from the window).
|
||||
*/
|
||||
object SafeArea {
|
||||
/** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */
|
||||
const val MIN_WIDTH = 320
|
||||
|
||||
/**
|
||||
* [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the
|
||||
* host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and
|
||||
* on a landscape phone that axis is always the horizontal one — insetting height as well would
|
||||
* shrink the picture without uncovering anything.
|
||||
*/
|
||||
fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int {
|
||||
val inset = perSideInsetPx.coerceAtLeast(0)
|
||||
return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-side inset, in pixels, that the **landscape** stream must clear on this display.
|
||||
*
|
||||
* Two contributions, and the larger wins:
|
||||
* * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on
|
||||
* `left`/`right`. The settings screen may be portrait though, where the very same housing is
|
||||
* reported on `top`/`bottom` and the horizontal insets read zero — which would compute "no inset
|
||||
* needed" for exactly the devices that need one. The stream is always landscape, so a vertical
|
||||
* inset now becomes a horizontal one then: fall back to it.
|
||||
* * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the
|
||||
* horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the
|
||||
* display boundary sits at `x = r`, so anything left of that is clipped. Not conservative — it is
|
||||
* the precise requirement for a picture that spans the full height.
|
||||
*
|
||||
* `0` when the display has neither, which makes the safe mode identical to the native one.
|
||||
*/
|
||||
private fun displaySideInsetPx(context: Context): Int {
|
||||
val display = probeDisplay(context) ?: return 0
|
||||
var inset = 0
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
display.cutout?.let { cut ->
|
||||
val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight)
|
||||
val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom)
|
||||
inset = maxOf(inset, if (horizontal > 0) horizontal else vertical)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
for (position in intArrayOf(
|
||||
android.view.RoundedCorner.POSITION_TOP_LEFT,
|
||||
android.view.RoundedCorner.POSITION_TOP_RIGHT,
|
||||
android.view.RoundedCorner.POSITION_BOTTOM_LEFT,
|
||||
android.view.RoundedCorner.POSITION_BOTTOM_RIGHT,
|
||||
)) {
|
||||
display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) }
|
||||
}
|
||||
}
|
||||
return inset
|
||||
}
|
||||
|
||||
/**
|
||||
* The native mode narrowed to clear the cutout and the rounded corners — the [SAFE_AREA_MODE]
|
||||
* resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode];
|
||||
* only the width moves.
|
||||
*/
|
||||
fun safeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
val (w, h, hz) = nativeDisplayMode(context)
|
||||
return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this device's display can actually present HDR10, so we should advertise HDR to the
|
||||
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
|
||||
@@ -574,21 +398,12 @@ fun displaySupportsHdr(context: Context): Boolean {
|
||||
return supported
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to
|
||||
* request. The safe-area sentinel is checked first because it resolves BOTH axes together — it is one
|
||||
* mode, not an independent width and height, and mixing half of it with a native height would ask
|
||||
* for a size neither sentinel means.
|
||||
*/
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||
val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) {
|
||||
safeDisplayMode(context)
|
||||
} else {
|
||||
nativeDisplayMode(context)
|
||||
}
|
||||
val w = if (width > 0) width else base.first
|
||||
val h = if (height > 0) height else base.second
|
||||
val hz = if (hz > 0) hz else base.third
|
||||
val native = nativeDisplayMode(context)
|
||||
val w = if (width > 0) width else native.first
|
||||
val h = if (height > 0) height else native.second
|
||||
val hz = if (hz > 0) hz else native.third
|
||||
return Triple(w, h, hz)
|
||||
}
|
||||
|
||||
@@ -642,10 +457,9 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it)
|
||||
|
||||
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||
|
||||
/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */
|
||||
/** (width, height, label). `(0,0)` = native display. */
|
||||
val RESOLUTION_OPTIONS = listOf(
|
||||
Triple(0, 0, "Native display"),
|
||||
Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"),
|
||||
Triple(1280, 720, "1280 × 720"),
|
||||
Triple(1920, 1080, "1920 × 1080"),
|
||||
Triple(2560, 1440, "2560 × 1440"),
|
||||
@@ -711,15 +525,6 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
|
||||
* Android — the press reaches the app on most devices, and where the shell shows its own UI
|
||||
* for it that's the shell's business. */
|
||||
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
|
||||
|
||||
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
|
||||
* host); "on" is for devices whose shell intercepts the physical guide button. */
|
||||
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
@@ -802,17 +607,3 @@ val GAMEPAD_OPTIONS = listOf(
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
|
||||
)
|
||||
|
||||
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
|
||||
val SYSTEM_BUTTON_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"forward" to "Send to host",
|
||||
"local" to "This device",
|
||||
)
|
||||
|
||||
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
|
||||
val GUIDE_GESTURE_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"on" to "On",
|
||||
"off" to "Off",
|
||||
)
|
||||
|
||||
@@ -77,7 +77,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -604,10 +603,6 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
@Composable
|
||||
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
// The safe-area row carries its resolved size the same way the native row does. On a display with
|
||||
// no cutout and square corners this equals the native mode — the row stays, honestly showing that
|
||||
// it changes nothing here, rather than silently vanishing on some devices and not others.
|
||||
val (sw, sh, _) = safeDisplayMode(context)
|
||||
// "Custom…" picked while the stored size is still a preset — keeps the size fields visible
|
||||
// until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected
|
||||
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
|
||||
@@ -616,13 +611,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
SettingsGroup("Resolution") {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
||||
(w to h) to when (w) {
|
||||
0 -> "$lbl ($nw × $nh)"
|
||||
SAFE_AREA_MODE -> "$lbl ($sw × $sh)"
|
||||
else -> lbl
|
||||
}
|
||||
} +
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
|
||||
// The (-1, -1) sentinel can't collide with a real size; once a custom size is
|
||||
// stored its label carries the live value, like the native row carries ($nw × $nh).
|
||||
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
|
||||
@@ -631,10 +620,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
caption = "The host makes a display exactly this size — no scaling. Native follows " +
|
||||
"this device's panel.",
|
||||
) { (w, h) ->
|
||||
// ONLY -1 is "Custom…". The other negative value is the safe-area sentinel, which is a
|
||||
// stored mode like any preset — a blanket `w < 0` here would open the custom fields for it
|
||||
// and overwrite it with a concrete size.
|
||||
if (w == -1) {
|
||||
if (w < 0) {
|
||||
// Seed from the current *effective* size so the fields start from something
|
||||
// sensible (the resolved native mode, not the 0 × 0 placeholder).
|
||||
customPicked = true
|
||||
@@ -832,46 +818,14 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
@Composable
|
||||
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||
SettingsGroup(footer = "Applies from the next session.") {
|
||||
// The master switch, above everything it governs. Profileable, so it shows in both
|
||||
// scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
ToggleRow(
|
||||
title = "Forward controllers",
|
||||
subtitle = "Send this device's controllers to the host. Turn it off when your " +
|
||||
"controller already reaches the host another way — USB passthrough such as " +
|
||||
"VirtualHere, or a pad plugged into the host — so games don't see two of them",
|
||||
checked = s.gamepadForwarding,
|
||||
field = "gamepad_forwarding",
|
||||
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
|
||||
)
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS,
|
||||
selected = s.gamepad,
|
||||
field = "gamepad",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player. An X-Box type has no " +
|
||||
"gyroscope, so pick a DualSense-class one if you want motion.",
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
options = SYSTEM_BUTTON_OPTIONS,
|
||||
selected = s.systemButtons,
|
||||
field = "system_buttons",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
|
||||
"Automatic sends them to the host whenever this device delivers them.",
|
||||
) { v -> update(s.copy(systemButtons = v)) }
|
||||
SettingDropdown(
|
||||
label = "Hold Select for guide",
|
||||
options = GUIDE_GESTURE_OPTIONS,
|
||||
selected = s.guideGesture,
|
||||
field = "guide_gesture",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
|
||||
"slightly delayed. For devices that intercept the real guide button.",
|
||||
) { v -> update(s.copy(guideGesture = v)) }
|
||||
DeviceScopeOnly {
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
@@ -890,18 +844,6 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// The rumble mirror's sibling, data flowing the other way: needs a gyroscope to
|
||||
// mirror FROM — a TV box has none, so the row would be a silent no-op there.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
if (hasGyroscope) {
|
||||
ToggleRow(
|
||||
title = "Gyro from this phone",
|
||||
subtitle = "When the controller has no gyro, send this phone's motion " +
|
||||
"sensors as controller 1's",
|
||||
checked = s.gyroOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(gyroOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
|
||||
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
|
||||
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
|
||||
@@ -910,7 +852,6 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
|
||||
"trackpads, gyro and haptics directly",
|
||||
checked = s.sc2Capture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||
@@ -920,25 +861,8 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||
"plus adaptive triggers, lightbar and gyro",
|
||||
checked = s.dsCapture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// 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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1089,7 +1013,6 @@ private fun <T> SettingDropdown(
|
||||
selected: T,
|
||||
field: String? = null,
|
||||
caption: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -1097,25 +1020,18 @@ private fun <T> SettingDropdown(
|
||||
?: options.firstOrNull()?.second.orEmpty()
|
||||
Column {
|
||||
OverrideBadge(field)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded && enabled,
|
||||
onExpandedChange = { if (enabled) expanded = it },
|
||||
) {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded && enabled,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { (value, lbl) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(lbl) },
|
||||
|
||||
@@ -18,34 +18,21 @@ import kotlin.math.roundToInt
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
|
||||
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 35-double
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 33-double
|
||||
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs,
|
||||
* audioAvOffsetMs]`. Every read
|
||||
* 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), the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
|
||||
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
|
||||
* - [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.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).
|
||||
*/
|
||||
@@ -108,15 +95,9 @@ 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(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
|
||||
Triple(s[24], s[25], "capture→displayed")
|
||||
} else {
|
||||
Triple(s[2], s[3], "capture→decoded")
|
||||
}
|
||||
@@ -139,11 +120,6 @@ 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])})"
|
||||
@@ -167,91 +143,30 @@ internal fun StatsOverlay(
|
||||
"= $hostTerms + $decodeTerm$displayTerm$presents",
|
||||
Color.White,
|
||||
)
|
||||
// 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) {
|
||||
// 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
|
||||
statLine(
|
||||
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
|
||||
Color(0xFF9AA6B8),
|
||||
"≈ 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (detailed) {
|
||||
audioLine(s)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio plane's own latency from the live gauges at 33/34 — `audio buffer 42 ms · a/v +18 ms`,
|
||||
* the same wording the desktop HUD uses. `buffer` is how much decoded audio is queued ahead of the
|
||||
* speaker; `a/v` is where that PUTS it relative to the picture (positive = audio behind). `null`
|
||||
* before any audio has been queued (buffer 0 — audio off, or the ring not yet primed) and on an
|
||||
* older native layout.
|
||||
*
|
||||
* Both terms, not just the depth: a deep ring on a jittery link is correct behaviour — the
|
||||
* underrun-driven floor earned that buffer — and only the offset distinguishes it from a ring that
|
||||
* is simply holding audio late. The offset term is dropped at zero, which is both "aligned" and
|
||||
* "no measurement yet"; the depth alone is still the triage number, and it is the one that did not
|
||||
* exist at all before (the plane published nothing any surface could render, so a "the audio delay
|
||||
* is way too high" report had no instrument behind it).
|
||||
*
|
||||
* NOT shaved by [osFloorMs], unlike every video figure above. That shave is a reporting policy —
|
||||
* metrics report what Punktfunk controls — but sound has to reach the ear when the light reaches
|
||||
* the eye, so the sync loop aligns against the RAW capture→displayed time (see the native
|
||||
* `DisplayTracker`) and this offset is stated in those same terms. Subtracting the floor here would
|
||||
* report an alignment the listener is not getting.
|
||||
*/
|
||||
private fun audioLine(s: DoubleArray): String? {
|
||||
if (s.size < 35) return null
|
||||
val bufferMs = s[33].roundToInt()
|
||||
if (bufferMs <= 0) return null
|
||||
val avOffset = s[34].roundToInt()
|
||||
val avTerm = if (avOffset != 0) " · a/v ${if (avOffset > 0) "+" else ""}$avOffset ms" else ""
|
||||
return "audio buffer $bufferMs ms$avTerm"
|
||||
}
|
||||
|
||||
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
|
||||
@Composable
|
||||
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
|
||||
@@ -259,9 +174,8 @@ private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAt
|
||||
* 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,
|
||||
// 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]
|
||||
// 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]
|
||||
val parts = buildList {
|
||||
add("${s[0].roundToInt()} fps")
|
||||
if (latValid) add("${"%.1f".format(e2eP50)} ms")
|
||||
|
||||
@@ -67,15 +67,12 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.PadSensors
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -89,7 +86,7 @@ import kotlinx.coroutines.delay
|
||||
* the connect that produced this handle.
|
||||
*/
|
||||
@Composable
|
||||
fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) {
|
||||
fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val handle = session.handle
|
||||
val initialSettings = session.settings
|
||||
val micEnabled = initialSettings.micEnabled
|
||||
@@ -139,19 +136,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
micHint = null
|
||||
}
|
||||
}
|
||||
// A captured pad has a gyro this session's virtual controller cannot carry (see
|
||||
// GamepadRouter.onMotionUnreachable). Shown briefly, then gone: the failure is otherwise
|
||||
// completely silent — the gyro simply does nothing, which from the couch is indistinguishable
|
||||
// from a broken sensor — and the fix is a setting, so the notice has to name it.
|
||||
var motionHint by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(motionHint) {
|
||||
if (motionHint) {
|
||||
// Longer than the mic chord's 1.6 s: that one confirms something the user just did,
|
||||
// this one explains something they did not, in a sentence they have to read.
|
||||
delay(6000)
|
||||
motionHint = false
|
||||
}
|
||||
}
|
||||
// The one place mute is toggled — Compose state + the native flag, always together.
|
||||
val setMicMuted = { muted: Boolean ->
|
||||
micMuted = muted
|
||||
@@ -216,32 +200,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
while (true) {
|
||||
delay(1000)
|
||||
if (NativeBridge.nativeSessionEnded(handle)) {
|
||||
// WHY it ended decides what the user is told. This used to show the "host may be
|
||||
// asleep" line for EVERY ending — including a game the player had just quit and a
|
||||
// session the host ended on purpose — which reads as a failure report for
|
||||
// something nobody did wrong. Only a connection that actually died says that now.
|
||||
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
|
||||
when (reason) {
|
||||
SessionEndReason.LOST ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
SessionEndReason.HOST_ERROR ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"The host ended the session with an error.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
// Deliberate endings — the player quit the game, the host was stopped, or we
|
||||
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
|
||||
SessionEndReason.GAME_EXITED,
|
||||
SessionEndReason.HOST_ENDED,
|
||||
SessionEndReason.LOCAL,
|
||||
SessionEndReason.NONE -> {}
|
||||
}
|
||||
onSessionEnded(reason)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
onDisconnect()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
@@ -357,16 +321,13 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(
|
||||
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
|
||||
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
|
||||
)
|
||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
|
||||
// the same way the Back gesture does.
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
@@ -374,9 +335,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
|
||||
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
|
||||
// to mute, and claiming otherwise would be the lie the control exists to avoid).
|
||||
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
|
||||
// moment it is claimed, on the main thread.
|
||||
router.onMotionUnreachable = { motionHint = true }
|
||||
router.onMicChord = {
|
||||
if (micRunning) {
|
||||
val next = !micMuted
|
||||
@@ -473,39 +431,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
router,
|
||||
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||
).also { it.start() }
|
||||
// "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion
|
||||
// while wire pad 0 is a controller without a gyro of its own — the rumble mirror's
|
||||
// sibling, data flowing the other way. The mirror gates itself per sample (it stands
|
||||
// down whenever pad 0's controller has motion of its own — a capture link below, or a
|
||||
// pad whose own sensors PadSensors is reading), so it composes without coordination here.
|
||||
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
|
||||
DeviceGyro(context, handle, router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A Bluetooth controller's OWN gyro, through the platform sensor framework (API 31+):
|
||||
// a BT DualSense / DS4 / Switch Pro / 8BitDo is an ordinary InputDevice, so none of the
|
||||
// capture links below ever sees it and its motion used to go nowhere at all. No separate
|
||||
// setting — this is the pad's own IMU doing what the pad is for, and unlike the USB
|
||||
// captures it claims nothing; forwarding being off is the only thing that silences it.
|
||||
val padSensors = if (initialSettings.gamepadForwarding) {
|
||||
PadSensors(router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends), and take its sensor listeners off with it — the
|
||||
// same callback also fires when a USB capture below CLAIMS the pad, which is what keeps
|
||||
// the claimed pad from being fed motion twice. The router owns hot-plug; the feedback owns
|
||||
// the binds. Assigned before the captures are constructed, so their claims land on it.
|
||||
router.onSlotClosed = { deviceId ->
|
||||
feedback.onDeviceRemoved(deviceId)
|
||||
padSensors?.onSlotClosed(deviceId)
|
||||
}
|
||||
// The other edge: a controller that arrives (or first speaks) mid-session gets its sensors
|
||||
// read too. The pads already connected were swept by PadSensors.start() above — both run
|
||||
// on the main thread with nothing between them, so no controller falls through the gap.
|
||||
router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) }
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
||||
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
||||
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
||||
@@ -514,11 +442,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
|
||||
// claim the interfaces; it resumes in onDispose once the stream releases them.
|
||||
activity?.stopSc2MenuNav()
|
||||
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
|
||||
Sc2Capture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
|
||||
var sc2UsbReceiver: BroadcastReceiver? = null
|
||||
if (sc2 != null) {
|
||||
feedback.onHidRaw = sc2::onHidRaw
|
||||
@@ -568,36 +492,10 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||
// hands over deterministically.
|
||||
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
|
||||
DsCapture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
@@ -635,17 +533,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
feedback.onHidRaw = null
|
||||
feedback.sink = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule
|
||||
// After the mirror, so it cannot resume writing pad 0 in the gap when a pad's own
|
||||
// sensors let go of it; before the router is released, so the parks still find slots.
|
||||
padSensors?.stop()
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.onMicChord = null // same: no mute toggle on buttons released during teardown
|
||||
router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
||||
@@ -691,7 +584,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
|
||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
|
||||
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
|
||||
// suspend a process for going to background, so without this the native worker kept running and
|
||||
@@ -699,14 +592,14 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// host still saw a live client and held the session (and its display + encoder) open until the
|
||||
// OS eventually reclaimed the process, which on a TV box is effectively never.
|
||||
//
|
||||
// Route it through `onSessionEnded()` so the composable's `onDispose` above runs the one real
|
||||
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
|
||||
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
|
||||
// so the host should linger the display and make coming straight back a fast reconnect.
|
||||
DisposableEffect(handle) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
onSessionEnded(SessionEndReason.LOCAL)
|
||||
onDisconnect()
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
@@ -906,11 +799,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
|
||||
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
|
||||
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
|
||||
// notice landing on top of one of those would cost the user both.
|
||||
if (motionHint) {
|
||||
MotionUnreachableHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,28 +885,6 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "This pad's gyro can't reach the game" — shown briefly when a captured controller with motion
|
||||
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
|
||||
* HID contract, so every sample would be decoded and dropped host-side).
|
||||
*
|
||||
* It names the setting because that is the whole point: without it the player has a gyro that
|
||||
* silently does nothing and no way to tell that from a broken sensor. Not a control — the setting
|
||||
* applies from the next session, so offering to change it here would promise something this stream
|
||||
* cannot deliver. [GamepadRouter.onMotionUnreachable] raises it.
|
||||
*/
|
||||
@Composable
|
||||
private fun MotionUnreachableHint(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
"Motion won't reach this session — set Controller type to DualSense",
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
|
||||
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
|
||||
|
||||
@@ -61,16 +61,6 @@ data class ActiveSession(
|
||||
* from "a different host" (a notice; a URL may never preempt a live session).
|
||||
*/
|
||||
val hostId: String? = null,
|
||||
/**
|
||||
* This session was started by launching a title from [hostId]'s library, rather than by
|
||||
* connecting to the host's desktop.
|
||||
*
|
||||
* Decides where the client goes when the session ENDS: a title launched out of a library
|
||||
* belongs back in that library when its game exits — one press from the next one — not on the
|
||||
* host-selection screen. Only meaningful together with a
|
||||
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
|
||||
*/
|
||||
val launchedFromLibrary: Boolean = false,
|
||||
)
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
// The console UI's background palettes. These assertions are the CONTRACT the Rust
|
||||
// (`pf-console-ui::library`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in
|
||||
// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look
|
||||
// on every client.
|
||||
class GamepadPaletteTest {
|
||||
|
||||
private fun luma(c: Triple<Double, Double, Double>) =
|
||||
0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third
|
||||
|
||||
/** Hue angle in degrees, or null for something too grey to have one. */
|
||||
private fun hue(c: Triple<Double, Double, Double>): Double? {
|
||||
val (r, g, b) = c
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val d = max - min
|
||||
if (d < 0.04) return null
|
||||
val h = when (max) {
|
||||
r -> 60.0 * (((g - b) / d) % 6.0)
|
||||
g -> 60.0 * ((b - r) / d + 2.0)
|
||||
else -> 60.0 * ((r - g) / d + 4.0)
|
||||
}
|
||||
return (h + 360.0) % 360.0
|
||||
}
|
||||
|
||||
/** Ids, order and the light/dark split are the cross-client contract. */
|
||||
@Test
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal",
|
||||
),
|
||||
GamepadPalette.ALL.map { it.id },
|
||||
)
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
|
||||
assertEquals(6, firstLight)
|
||||
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||
assertEquals("violet", GamepadPalette.named("").id)
|
||||
// The brand default keeps the shipped field rather than a generated ramp.
|
||||
assertTrue(GamepadPalette.named("violet").stops.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* A palette must read as SEVERAL hues, not one hue at several brightnesses — that was exactly
|
||||
* the complaint about the hue-rotation model this replaced.
|
||||
*/
|
||||
@Test
|
||||
fun everyPaletteIsMultiTone() {
|
||||
for (p in GamepadPalette.ALL) {
|
||||
val stops = p.stops.ifEmpty { continue }
|
||||
val hues = stops.mapNotNull { hue(it) }
|
||||
assertTrue("${p.id}: too few coloured stops", hues.size >= 3)
|
||||
var spread = 0.0
|
||||
for (a in hues) {
|
||||
for (b in hues) {
|
||||
val d = Math.abs(a - b) % 360.0
|
||||
spread = maxOf(spread, minOf(d, 360.0 - d))
|
||||
}
|
||||
}
|
||||
// Graphite and Opal are deliberately near-neutral; the rest must travel.
|
||||
val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0
|
||||
assertTrue("${p.id} spans only $spread° of hue", spread >= floor)
|
||||
}
|
||||
}
|
||||
|
||||
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
|
||||
@Test
|
||||
fun palettesAreHonestAboutLightness() {
|
||||
for (p in GamepadPalette.ALL) {
|
||||
if (p.light) {
|
||||
assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6)
|
||||
assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45)
|
||||
} else {
|
||||
assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2)
|
||||
assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */
|
||||
@Test
|
||||
fun rampInterpolatesBetweenStops() {
|
||||
val stops = listOf(
|
||||
Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0),
|
||||
)
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0))
|
||||
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0))
|
||||
assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5))
|
||||
assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9)
|
||||
// Out of range clamps rather than throwing.
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0))
|
||||
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0))
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5))
|
||||
}
|
||||
|
||||
/** The ink a palette calls for: white on a dark field, near-black on a pale one. */
|
||||
@Test
|
||||
fun inkFollowsTheField() {
|
||||
val dark = GamepadInk.of(GamepadPalette.named("violet"))
|
||||
assertTrue(!dark.isLight)
|
||||
assertEquals(1f, dark.fg.red, 1e-6f)
|
||||
assertEquals(1f, dark.shadeScale, 1e-6f)
|
||||
|
||||
val light = GamepadInk.of(GamepadPalette.named("holo"))
|
||||
assertTrue(light.isLight)
|
||||
assertTrue("pale fields need dark ink", light.fg.red < 0.3f)
|
||||
// A pale field's scrims must pull far less, or they bleach the gradient.
|
||||
assertTrue(light.shadeScale < 0.5f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every settings row lands in exactly one tab — a row missing from the tab map is a setting
|
||||
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(
|
||||
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
|
||||
) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
assertTrue(rows.none { it.tab == GpTab.PROFILES })
|
||||
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
|
||||
assertTrue("$t is empty", rows.any { it.tab == t })
|
||||
}
|
||||
}
|
||||
|
||||
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
assertEquals("Violet", palette().value)
|
||||
assertTrue("already the first = thud", !palette().adjust(-1))
|
||||
assertTrue(palette().adjust(1))
|
||||
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
|
||||
|
||||
// A from the last entry wraps home.
|
||||
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
|
||||
palette().activate()
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
// A store written by a newer client shows the palette that is actually drawing.
|
||||
s = s.copy(uiPalette = "chartreuse")
|
||||
assertEquals("Violet", palette().value)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
|
||||
* governed row is inert rather than merely dim.
|
||||
*
|
||||
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
|
||||
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
|
||||
* these tests pin both halves — the flag AND the refusal to write.
|
||||
*/
|
||||
class GamepadSettingsRowsTest {
|
||||
|
||||
/** Rows for a given forwarding state, capturing whatever a row writes back. */
|
||||
private fun rows(
|
||||
forwarding: Boolean,
|
||||
sink: MutableList<Settings> = mutableListOf(),
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow =
|
||||
rows.first { it.id == id }
|
||||
|
||||
/** Every row that only means something while a controller is actually being forwarded. */
|
||||
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
|
||||
|
||||
@Test
|
||||
fun `forwarding off dims every row that depends on it`() {
|
||||
val off = rows(forwarding = false)
|
||||
for (id in governed) {
|
||||
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
|
||||
}
|
||||
// The master switch itself stays live — otherwise it could never be turned back on.
|
||||
assertTrue(row(off, "padForward").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forwarding on leaves them all live`() {
|
||||
val on = rows(forwarding = true)
|
||||
for (id in governed) {
|
||||
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val off = rows(forwarding = false, sink = writes)
|
||||
for (id in governed) {
|
||||
val i = off.indexOfFirst { it.id == id }
|
||||
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
|
||||
// What the screen actually does on left/right/A — the whole point is that it no-ops.
|
||||
liveRow(off, i)?.adjust(1)
|
||||
liveRow(off, i)?.adjust(-1)
|
||||
liveRow(off, i)?.activate()
|
||||
}
|
||||
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same rows do write once forwarding is on`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val on = rows(forwarding = true, sink = writes)
|
||||
val i = on.indexOfFirst { it.id == "sc2" }
|
||||
assertNotNull(liveRow(on, i))
|
||||
liveRow(on, i)?.activate()
|
||||
assertEquals(1, writes.size)
|
||||
assertFalse("activate flips the toggle", writes[0].sc2Capture)
|
||||
}
|
||||
|
||||
/**
|
||||
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
|
||||
* where this screen is the only one reachable — a TV box has no touch interface to fall back to.
|
||||
*/
|
||||
@Test
|
||||
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
|
||||
val on = rows(forwarding = true)
|
||||
val ids = on.map { it.id }
|
||||
assertTrue("dsCapture row is missing", "dsCapture" in ids)
|
||||
assertEquals(
|
||||
"the two passthrough rows belong side by side",
|
||||
ids.indexOf("sc2") + 1,
|
||||
ids.indexOf("dsCapture"),
|
||||
)
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the safe-area stream geometry ([SafeArea]) and the sentinel that selects it —
|
||||
* the width-only inset that keeps the picture clear of the cutout and the rounded corners.
|
||||
* Run: `./gradlew :app:testDebugUnitTest`.
|
||||
*/
|
||||
class SafeAreaTest {
|
||||
@Test
|
||||
fun insetsBothSidesAndStaysHostValid() {
|
||||
// A punch-hole phone: 2400 px wide, 96 px of unsafe edge per side → 2208.
|
||||
assertEquals(2400 - 96 * 2, SafeArea.insetWidth(2400, 96))
|
||||
// Odd results even-floor — the host rejects odd dimensions outright, and an inset
|
||||
// subtraction lands odd about half the time.
|
||||
assertEquals(0, SafeArea.insetWidth(2401, 95) % 2)
|
||||
// No cutout and square corners → the native width, unchanged.
|
||||
assertEquals(2400, SafeArea.insetWidth(2400, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absurdInsetsCannotDriveTheModeUnderTheHostFloor() {
|
||||
assertEquals(SafeArea.MIN_WIDTH, SafeArea.insetWidth(1280, 5000))
|
||||
// A negative reading is treated as no inset rather than widening past the panel.
|
||||
assertEquals(1280, SafeArea.insetWidth(1280, -40))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun safeModeIsNarrowerThanNativeWheneverThereIsAnInset() {
|
||||
val native = 2556
|
||||
assertTrue(SafeArea.insetWidth(native, 60) < native)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSentinelIsAPresetAndNeverReadsAsCustom() {
|
||||
// The safe-area mode is a stored preset, not a typed size: `isCustomResolution` must be
|
||||
// false for it, or the touch settings would open the custom width/height fields on it and
|
||||
// the gamepad screen would prepend a bogus "Custom · -2 × -2" row.
|
||||
val s = Settings(width = SAFE_AREA_MODE, height = SAFE_AREA_MODE)
|
||||
assertTrue(!s.isCustomResolution())
|
||||
// And it must be distinct from the UI's own "Custom…" sentinel (-1).
|
||||
assertTrue(SAFE_AREA_MODE != -1)
|
||||
assertTrue(RESOLUTION_OPTIONS.any { it.first == SAFE_AREA_MODE && it.second == SAFE_AREA_MODE })
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The stats HUD's audio line — `audio buffer N ms · a/v ±N ms`, from the live gauges at indexes
|
||||
* 33/34 (`design/audio-latency-overhaul.md`).
|
||||
*
|
||||
* Worth pinning because the whole point of the overhaul's stats half is that the audio plane became
|
||||
* OBSERVABLE. Before it, ring depth and A/V offset existed only as a log line, and on a device
|
||||
* launched by a game launcher that goes to a pipe nobody can read — so the single number that
|
||||
* identifies a deep ring was unobtainable on the exact device reporting the latency, and a field
|
||||
* investigation ran to its conclusion without it. A measurement that never reaches a surface is
|
||||
* indistinguishable from no measurement, which is what this asserts.
|
||||
*
|
||||
* `sdk = [36]` for the same reason as the screenshot tests: Robolectric ships android-all jars only
|
||||
* up to API 36 while the app's compileSdk is 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class StatsOverlayAudioTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
/**
|
||||
* A plausible 35-double window with the audio gauges dialled in. Everything before 33 is the
|
||||
* DETAILED-renderable shape the ShotScenes fixture uses; only the last two matter here.
|
||||
*/
|
||||
private fun stats(bufferMs: Double, avOffsetMs: Double, size: Int = 35): DoubleArray {
|
||||
val full = doubleArrayOf(
|
||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
2.0, 1.0, 5.0, 238.0,
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
0.1, 0.3, 0.0,
|
||||
bufferMs, avOffsetMs,
|
||||
)
|
||||
return full.copyOf(size)
|
||||
}
|
||||
|
||||
private fun show(s: DoubleArray, verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
compose.setContent { StatsOverlay(s, verbosity = verbosity) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailedShowsDepthAndOffset() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0))
|
||||
// Positive = audio playing BEHIND the picture, and the sign is explicit so a glance tells
|
||||
// which way the loop still has to move.
|
||||
compose.onNodeWithText("audio buffer 42 ms · a/v +18 ms").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioAheadOfThePictureReadsNegative() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = -12.0))
|
||||
compose.onNodeWithText("audio buffer 42 ms · a/v -12 ms").assertExists()
|
||||
}
|
||||
|
||||
/** Aligned (or not yet measured) drops the offset term; the depth alone is still the triage number. */
|
||||
@Test
|
||||
fun alignedShowsDepthAlone() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 0.0))
|
||||
compose.onNodeWithText("audio buffer 42 ms").assertExists()
|
||||
}
|
||||
|
||||
/** Nothing queued (audio off, or the ring not yet primed) — the line has nothing to say. */
|
||||
@Test
|
||||
fun silentPlaneRendersNoLine() {
|
||||
show(stats(bufferMs = 0.0, avOffsetMs = 0.0))
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** The line is DETAILED-only, like every other per-stage figure. */
|
||||
@Test
|
||||
fun normalTierOmitsTheLine() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0), verbosity = StatsVerbosity.NORMAL)
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** An older native lib emits 33 doubles; the overlay must omit the line, not index past the end. */
|
||||
@Test
|
||||
fun olderNativeLayoutOmitsTheLine() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0, size = 33))
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -106,14 +106,6 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
|
||||
@Test
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -31,12 +31,6 @@ import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
import io.unom.punktfunk.LocalGamepadPalette
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsCategory
|
||||
@@ -355,19 +349,15 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// The full 35-double unified layout — NativeBridge.nativeVideoStats' KDoc is the
|
||||
// authoritative index list: [fps, mbps, e2eP50, e2eP95, latValid, skew, w, h, hz,
|
||||
// lostTotal, bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50,
|
||||
// decodeP50, hostP50, netP50, lost, skipped, fec, frames, dispValid, displayP50,
|
||||
// e2eDispP50, e2eDispP95, paceP50, latchP50, presents, presenterActive, feedP50, codecP50,
|
||||
// skippedOverflow, audioBufferMs, audioAvOffsetMs].
|
||||
// The full 26-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
|
||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames,
|
||||
// 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, 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
|
||||
// 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
|
||||
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
|
||||
// compact loss flag both render.
|
||||
StatsOverlay(
|
||||
@@ -378,12 +368,6 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
// The decode term's own split (feed + codec = 0.4), and no overflow — the one
|
||||
// `skipped` above is benign newest-wins pacing, not a decoder falling behind.
|
||||
0.1, 0.3, 0.0,
|
||||
// The audio plane: a 28 ms ring placed 4 ms behind the picture — a converged sync
|
||||
// loop, i.e. inside the deadband it deliberately leaves alone.
|
||||
28.0, 4.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
@@ -420,25 +404,3 @@ internal fun WakeTimedOutScene() =
|
||||
@Composable
|
||||
internal fun ConnectConsoleScene() =
|
||||
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The real console settings screen — the section tab strip, the glass rows, the focused row's
|
||||
* unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't
|
||||
* stand in for it: this is a different screen with different navigation, and the strip is the part
|
||||
* a layout regression would eat first.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
|
||||
// The scene calls the screen directly, so it has to publish the palette locals `App` would
|
||||
// normally provide — without them a light palette would render with the default DARK ink and
|
||||
// the shot would silently prove nothing.
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
GamepadSettingsScreen(
|
||||
initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,37 +67,30 @@ fun androidSdkDir(): String {
|
||||
return "${System.getProperty("user.home")}/Library/Android/sdk"
|
||||
}
|
||||
|
||||
// Every cargo-ndk invocation needs the same discovery environment, and they must not drift apart:
|
||||
// a lint that ran against a different toolchain/sysroot than the build is a lint about a different
|
||||
// program. Applied by both `registerCargoNdk` (build) and `registerCargoNdkClippy` (lint).
|
||||
fun Exec.cargoNdkEnvironment() {
|
||||
val sdk = androidSdkDir()
|
||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
||||
// cmake (libopus builds via the cmake crate) discoverable explicitly — same as a bare CLI.
|
||||
val cmakeBin = "$sdk/cmake/3.22.1/bin"
|
||||
environment(
|
||||
"PATH",
|
||||
cargoBin + File.pathSeparator + cmakeBin + File.pathSeparator + System.getenv("PATH"),
|
||||
)
|
||||
environment("ANDROID_HOME", sdk)
|
||||
environment("ANDROID_NDK_HOME", "$sdk/ndk/$ndkVer")
|
||||
// CMake's built-in Android support (used by the cmake crate for libopus) finds the NDK via
|
||||
// these, and uses Ninja (bundled next to the SDK cmake) since there's no `make`.
|
||||
environment("ANDROID_NDK_ROOT", "$sdk/ndk/$ndkVer")
|
||||
environment("ANDROID_NDK", "$sdk/ndk/$ndkVer")
|
||||
environment("CMAKE_GENERATOR", "Ninja")
|
||||
// audiopus_sys picks static-vs-dynamic by HOST not target — force the bundled static libopus
|
||||
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||
environment("LIBOPUS_STATIC", "1")
|
||||
environment("LIBOPUS_NO_PKG", "1")
|
||||
}
|
||||
|
||||
fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "rust"
|
||||
description = "cargo-ndk build of punktfunk-client-android (${if (release) "release" else "debug"})"
|
||||
workingDir = repoRoot
|
||||
cargoNdkEnvironment()
|
||||
val sdk = androidSdkDir()
|
||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
||||
// cmake (libopus builds via the cmake crate) discoverable explicitly — same as a bare CLI.
|
||||
val cmakeBin = "$sdk/cmake/3.22.1/bin"
|
||||
environment(
|
||||
"PATH",
|
||||
cargoBin + File.pathSeparator + cmakeBin + File.pathSeparator + System.getenv("PATH"),
|
||||
)
|
||||
environment("ANDROID_HOME", sdk)
|
||||
environment("ANDROID_NDK_HOME", "$sdk/ndk/$ndkVer")
|
||||
// CMake's built-in Android support (used by the cmake crate for libopus) finds the NDK via
|
||||
// these, and uses Ninja (bundled next to the SDK cmake) since there's no `make`.
|
||||
environment("ANDROID_NDK_ROOT", "$sdk/ndk/$ndkVer")
|
||||
environment("ANDROID_NDK", "$sdk/ndk/$ndkVer")
|
||||
environment("CMAKE_GENERATOR", "Ninja")
|
||||
// audiopus_sys picks static-vs-dynamic by HOST not target — force the bundled static libopus
|
||||
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||
environment("LIBOPUS_STATIC", "1")
|
||||
environment("LIBOPUS_NO_PKG", "1")
|
||||
// Resolve cargo by ABSOLUTE path: Gradle's Exec resolves command[0] via the JVM's
|
||||
// inherited PATH, NOT the environment("PATH", …) set above (that only reaches the spawned
|
||||
// child). A GUI Android Studio launch (and any daemon it started) has no ~/.cargo/bin on
|
||||
@@ -120,41 +113,6 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
commandLine(cmd)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Lint the ANDROID target. `punktfunk-client-android` and every `#[cfg(target_os = "android")]`
|
||||
// module elsewhere in the workspace were, until this task existed, **completely unlinted**: ci.yml
|
||||
// runs `cargo clippy --workspace` on the HOST, where all of that code is compiled out, and this
|
||||
// workflow only ever ran `build`. The gap was found in 2026-08 with five lints sitting in
|
||||
// clients/android/native (two of them `unnecessary_cast`, which is exactly the class that decides
|
||||
// whether a cast is redundant BY POINTER WIDTH).
|
||||
//
|
||||
// Both widths are linted, and that is the load-bearing part: arm64-v8a is 64-bit and armeabi-v7a is
|
||||
// 32-bit, so a cast that is redundant on one can be required on the other. Linting only the primary
|
||||
// ABI would license "fixes" that break the 32-bit build — the shipping ABI for the many 32-bit
|
||||
// Google TV / Android TV boxes this client targets. x86_64 is deliberately omitted: it is
|
||||
// emulator-only and shares its pointer width with arm64, so it costs a third of the job's lint time
|
||||
// for no signal these two do not already carry.
|
||||
//
|
||||
// `--all-targets` for the same reason ci.yml spells it out: without it the `#[cfg(test)]` modules
|
||||
// are never compiled, and un-compiled test code drifts silently.
|
||||
fun registerCargoNdkClippy(taskName: String) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "verification"
|
||||
description = "clippy (deny warnings) for punktfunk-client-android on both Android widths"
|
||||
workingDir = repoRoot
|
||||
cargoNdkEnvironment()
|
||||
commandLine(
|
||||
// Absolute cargo path for the same reason as the build task above.
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "armeabi-v7a",
|
||||
"--platform", "28",
|
||||
"clippy", "-p", "punktfunk-client-android", "--all-targets",
|
||||
"--", "-D", "warnings",
|
||||
)
|
||||
}
|
||||
|
||||
val cargoNdkClippy = registerCargoNdkClippy("cargoNdkClippy")
|
||||
|
||||
// Post-link floor check: every undefined symbol in the built .so must exist in the API-28 stubs,
|
||||
// else System.loadLibrary fails on devices at the minSdk floor (see the script header for the
|
||||
// 0.9.0 incident this guards against). Runs right after its cargo-ndk task; the APK build depends
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
|
||||
/**
|
||||
* The opt-in phone-gyro mirror ("Gyro from this phone", off by default): while wire pad 0 is a
|
||||
* controller with no motion source of its own, THIS device's IMU speaks for it on the rich-input
|
||||
* motion plane — for clip-on and third-party pads that ship without a gyro, where the phone body
|
||||
* is rigidly attached to (or simply is) the thing in the player's hands. [GamepadFeedback]'s
|
||||
* rumble-on-phone mirror with the data flowing the other way.
|
||||
*
|
||||
* On Android the only motion sources are the capture links (USB DualSense / SC2 — pads with a
|
||||
* real IMU, claimed as [GamepadRouter.ExternalPad]s), so the stand-down rule is exactly
|
||||
* [GamepadRouter.padHasOwnMotion]: when a capture link holds pad 0, the mirror sends nothing —
|
||||
* two motion writers on one wire pad would fight. It also sends nothing while pad 0 has no slot
|
||||
* at all (motion never creates a host pad; a controller must have arrived first).
|
||||
*
|
||||
* Two properties this class enforces itself:
|
||||
* - samples ride a dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`) —
|
||||
* sensor batching would trade the exact latency gyro aim exists to avoid;
|
||||
* - a stand-down edge (capture link claims pad 0, or [stop]) sends ONE zero-gyro sample, so the
|
||||
* host's virtual pad never keeps integrating an angular velocity this device stopped
|
||||
* producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode).
|
||||
*
|
||||
* Units are the wire contract, converted by [Gamepad.motionGyroWire] / [Gamepad.motionAccelWire] —
|
||||
* the same two functions [PadSensors] uses, so a scale this client ever has to correct is corrected
|
||||
* once for every sender rather than once per sender that someone remembers. The one thing the
|
||||
* phone adds is a frame remap: sensors report in the device's natural-portrait frame, while
|
||||
* the wire wants the controller frame the player sees (x right, y up, z out of the screen), so
|
||||
* each sample is rotated by the current display rotation — a phone clipped landscape must yaw
|
||||
* when the player yaws, not roll. The matrix is derived and pinned by `DeviceGyroTest`;
|
||||
* correctable in one place if on-glass says otherwise.
|
||||
*/
|
||||
class DeviceGyro(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val router: GamepadRouter,
|
||||
) : SensorEventListener {
|
||||
|
||||
private val sensorManager: SensorManager? =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
|
||||
/** For the live rotation; null on contexts without a display association (then portrait). */
|
||||
private val display: Display? = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
context.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(WindowManager::class.java)?.defaultDisplay
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private val thread = HandlerThread("pf-phone-gyro")
|
||||
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample). */
|
||||
private val lastAccel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
|
||||
|
||||
/** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */
|
||||
private var wasWriting = false
|
||||
|
||||
/** Register the listeners; a device without a gyroscope makes this a no-op. */
|
||||
fun start() {
|
||||
val sm = sensorManager ?: return
|
||||
val gyro = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
thread.start()
|
||||
val h = Handler(thread.looper)
|
||||
// ~200 Hz requested (the framework clamps to what the hardware offers), zero report
|
||||
// latency: batching is poison for gyro aim.
|
||||
sm.registerListener(this, gyro, SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(this, it, SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister and join the sensor thread, then park the host pad's rotation at zero if this
|
||||
* mirror was the live writer. Call BEFORE the router is released / the handle freed —
|
||||
* teardown-ordered like the feedback threads.
|
||||
*/
|
||||
fun stop() {
|
||||
sensorManager?.unregisterListener(this)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val rotation = display?.rotation ?: Surface.ROTATION_0
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
// and must not be a capture link's (its own IMU is streaming).
|
||||
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
|
||||
if (!write) {
|
||||
// Stand-down edge: never leave the last angular velocity latched host-side.
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
return
|
||||
}
|
||||
wasWriting = true
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0,
|
||||
Gamepad.motionGyroWire(v[0]),
|
||||
Gamepad.motionGyroWire(v[1]),
|
||||
Gamepad.motionGyroWire(v[2]),
|
||||
lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free-fall. */
|
||||
private fun sendZero() {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0, 0, 0, 0, lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Whether this device can source motion at all — gates the settings rows (a TV box
|
||||
* without an IMU would make the toggle a silent no-op, the rumble mirror's rule). */
|
||||
fun available(context: Context): Boolean =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null
|
||||
|
||||
/**
|
||||
* ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz), and also
|
||||
* the ceiling the framework grants an app without `HIGH_SAMPLING_RATE_SENSORS` (API 31+),
|
||||
* so asking for more would only be silently capped. Shared with [PadSensors].
|
||||
*/
|
||||
internal const val SAMPLING_PERIOD_US = 5000
|
||||
|
||||
/**
|
||||
* Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
* same way under an in-plane rotation) into the controller frame for [rotation]
|
||||
* ([Surface].ROTATION_*). Sensors report in the natural-portrait frame (+x right edge,
|
||||
* +y top, +z out of the screen); the controller frame keeps +z (the screen always faces
|
||||
* the player) and rotates x/y to mean "player's right" and "player's up". ROTATION_90 =
|
||||
* the device physically turned counter-clockwise, top to the player's LEFT.
|
||||
*/
|
||||
fun remap(rotation: Int, x: Float, y: Float, z: Float): FloatArray = when (rotation) {
|
||||
Surface.ROTATION_90 -> floatArrayOf(-y, x, z) // top left: right = bottom, up = +x
|
||||
Surface.ROTATION_270 -> floatArrayOf(y, -x, z) // top right: right = top, up = −x
|
||||
Surface.ROTATION_180 -> floatArrayOf(-x, -y, z)
|
||||
else -> floatArrayOf(x, y, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,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, rescaled
|
||||
* into the wire's units by this pad's own calibration — read once per claim, off the claiming
|
||||
* thread, with the nominal scaling standing in for the millisecond that read is in flight rather
|
||||
* than the UI waiting on a control transfer). 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.
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
@@ -57,13 +54,6 @@ class DsCapture(
|
||||
@Volatile private var model: DsDevice.Model? = null
|
||||
@Volatile private var pad: GamepadRouter.ExternalPad? = null
|
||||
|
||||
/** This pad's factory motion scale, read once per capture on [calReader] and handed to the
|
||||
* link thread, which scales nominally until it lands — see [MotionCalHandoff]. */
|
||||
private val motionCal = MotionCalHandoff()
|
||||
|
||||
/** The thread doing the claim-time calibration read, kept for the teardown wait. */
|
||||
@Volatile private var calReader: Thread? = null
|
||||
|
||||
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
|
||||
private val state = DsDevice.State()
|
||||
private var wireButtons = 0
|
||||
@@ -88,33 +78,6 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -133,11 +96,6 @@ class DsCapture(
|
||||
if (model != null) return false
|
||||
val m = DsDevice.modelFor(dev.productId) ?: return false
|
||||
if (!usb.start(dev)) return false
|
||||
// Before `model`, which is what lets the link thread into the parse at all: opening the
|
||||
// claim forgets the last pad's calibration, so reports arriving while this pad's own read
|
||||
// (below, off this thread) is in flight fall back to the nominal scaling rather than to
|
||||
// another unit's factory numbers.
|
||||
val claim = motionCal.begin()
|
||||
model = m
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
@@ -147,113 +105,20 @@ 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)
|
||||
readMotionCalAsync(m, claim)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Start this claim's calibration read, on its own thread.
|
||||
*
|
||||
* Off the caller's thread because [startUsb] runs on the main one — stream setup, and the
|
||||
* USB-permission broadcast — and the read is a blocking EP0 control transfer: a pad that is
|
||||
* there answers in about a millisecond, but one that is stalling takes the link's whole write
|
||||
* timeout, and the interface must wait for neither. The pad is live throughout, its motion
|
||||
* nominally scaled until this lands ([onReport]), so even a pad that never answers costs
|
||||
* precision rather than the UI or the controller.
|
||||
*
|
||||
* One thread per claim, daemon and named, matching how [HidUsbLink] runs its reader; it is
|
||||
* awaited by [awaitCalRead] before the connection it reads from can be closed.
|
||||
*/
|
||||
private fun readMotionCalAsync(m: DsDevice.Model, claim: Int) {
|
||||
val t = Thread({
|
||||
// A read that throws would otherwise leave the capture on the nominal scaling with
|
||||
// nothing in the log to say why — the one outcome that looks identical to a pad whose
|
||||
// calibration is genuinely nominal. Publish the fallback explicitly, and say so.
|
||||
val cal = runCatching { readMotionCal(m) }.getOrElse {
|
||||
Log.w(TAG, "motion calibration read failed — nominal scaling", it)
|
||||
DsDevice.MotionCal.NOMINAL
|
||||
}
|
||||
// Discarded when the claim is already over (unplug, stop, or a re-claim beat us here):
|
||||
// scaling the NEXT pad by this one's factory numbers would be worse than not reading.
|
||||
if (!motionCal.publish(claim, cal)) {
|
||||
Log.i(TAG, "motion calibration arrived after the claim ended — discarded")
|
||||
}
|
||||
}, "pf-ds-cal")
|
||||
calReader = t
|
||||
t.isDaemon = true
|
||||
t.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an in-flight calibration read to let go of the USB connection, before a teardown
|
||||
* closes it.
|
||||
*
|
||||
* Not politeness: the read is a control transfer on the very connection [HidUsbLink.stop] is
|
||||
* about to close, and closing a descriptor with a transfer in flight pulls it out from under
|
||||
* the kernel — the same rule the pad-audio borrow follows. Bounded, and in every case but a
|
||||
* pad that has stopped answering the thread is long gone, so this returns immediately. It can
|
||||
* never deadlock: the reading thread waits on nothing this one holds ([MotionCalHandoff] has
|
||||
* its own monitor, and the read itself takes no lock).
|
||||
*/
|
||||
private fun awaitCalRead() {
|
||||
val t = calReader ?: return
|
||||
calReader = null
|
||||
if (!t.isAlive) return
|
||||
runCatching { t.join(CAL_JOIN_MS) }
|
||||
if (t.isAlive) Log.w(TAG, "calibration read still in flight at teardown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this pad's IMU calibration — the feature report that says how many raw counts this
|
||||
* individual unit puts on a °/s and on a g ([DsDevice.MotionCal]).
|
||||
*
|
||||
* Once, at claim time, and nowhere else: the calibration is fixed for the life of the
|
||||
* connection, so doing it per input report would buy nothing and cost the capture its latency.
|
||||
* A pad that refuses keeps the nominal scaling rather than losing motion altogether.
|
||||
*/
|
||||
private fun readMotionCal(m: DsDevice.Model): DsDevice.MotionCal {
|
||||
val blob = usb.getReport(HidUsbLink.REPORT_TYPE_FEATURE, m.calReportId, m.calReportLen)
|
||||
val cal = DsDevice.MotionCal.parse(blob, m.calReportId)
|
||||
// Worth a line either way: this is the number the owed on-glass check reads back — a pad
|
||||
// whose blob was read declares its own resolution, the fallback declares the wire's.
|
||||
if (cal === DsDevice.MotionCal.NOMINAL) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"motion calibration 0x%02x unreadable (%d/%d B) — nominal scaling (%s)".format(
|
||||
m.calReportId, blob?.size ?: 0, m.calReportLen, cal,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "motion calibration 0x%02x: %s".format(m.calReportId, cal))
|
||||
}
|
||||
return cal
|
||||
}
|
||||
|
||||
/** 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).
|
||||
// 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)
|
||||
usb.writeControl(stopReport(m))
|
||||
}
|
||||
disarmBackstop()
|
||||
// End the claim before waiting on it: a calibration that lands after this publishes
|
||||
// nothing, and then the wait makes sure nothing is still reading the connection below.
|
||||
motionCal.end()
|
||||
awaitCalRead()
|
||||
usb.stop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
@@ -265,129 +130,21 @@ class DsCapture(
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
// Nominal scaling until this claim's calibration read lands (see MotionCalHandoff): for
|
||||
// that millisecond the pad behaves as it did before the read existed, which nobody can
|
||||
// feel — unlike a pad whose buttons wait on a control transfer.
|
||||
if (!DsDevice.parseState(m, report, len, state, motionCal.effective)) return
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
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
|
||||
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 }
|
||||
// hasGyro: every pad this link captures is a Sony one with an IMU, and its motion goes out
|
||||
// on the rich plane — so a session that cannot carry it is worth saying out loud.
|
||||
val p = router.openExternal(m.pref, hasGyro = true) ?: 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()
|
||||
// As in stop(): end the claim so a late calibration publishes nothing, then wait for the
|
||||
// read to let go of the connection the line below closes.
|
||||
motionCal.end()
|
||||
awaitCalRead()
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -416,8 +173,8 @@ class DsCapture(
|
||||
|
||||
/**
|
||||
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
|
||||
* on change per slot; motion forwarded every report (already in wire units — the parse applies
|
||||
* this pad's calibration, and sensor noise makes per-report dedup pointless).
|
||||
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
|
||||
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
|
||||
*/
|
||||
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
|
||||
for (f in 0 until 2) {
|
||||
@@ -459,24 +216,17 @@ class DsCapture(
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
val stop = low == 0 && high == 0
|
||||
if (!stop) armBackstop(backstopMs)
|
||||
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
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()
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,9 +252,6 @@ 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(
|
||||
@@ -514,38 +261,8 @@ 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) {
|
||||
@@ -567,12 +284,7 @@ class DsCapture(
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
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)
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
@@ -585,13 +297,5 @@ 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
|
||||
|
||||
/** Teardown's budget for an in-flight calibration read. Comfortably past the link's own
|
||||
* EP0 timeout, so it only ever elapses for a pad that has stopped answering entirely. */
|
||||
const val CAL_JOIN_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
|
||||
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
|
||||
@@ -30,168 +28,14 @@ object DsDevice {
|
||||
/**
|
||||
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
|
||||
* the physical one), its output-report size (the descriptor-declared size the firmware
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), its touchpad extent
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
|
||||
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
|
||||
* onto the wire's 0..65535 space, and the IMU-calibration feature report it answers
|
||||
* ([MotionCal]): DS5/Edge `0x05` (id + 40 B), DS4 over USB `0x02` (id + 36 B).
|
||||
* onto the wire's 0..65535 space.
|
||||
*/
|
||||
enum class Model(
|
||||
val pref: Int,
|
||||
val outputSize: Int,
|
||||
val touchW: Int,
|
||||
val touchH: Int,
|
||||
val calReportId: Int,
|
||||
val calReportLen: Int,
|
||||
) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080, 0x05, 41),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080, 0x05, 41),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942, 0x02, 37),
|
||||
}
|
||||
|
||||
/**
|
||||
* One pad's own IMU calibration: the factory scale factors that turn its raw motion counts
|
||||
* into the wire's fixed units (`punktfunk_core::input::gamepad` — 20 LSB per °/s, 10000 LSB
|
||||
* per g), read out of the calibration feature report the pad serves on EP0.
|
||||
*
|
||||
* **Why the pad's blob and not a constant.** Measured on glass 2026-08-07: a DualSense flat
|
||||
* and face up arrived as 0.811 g where 1.000 was owed, because this path forwarded the raw
|
||||
* i16s verbatim. The nominal ×10000/8192 rescale that first closed that gap ([NOMINAL]) still
|
||||
* leaves that unit's factory bias — about 1 % — on acceleration, and provably cannot fix gyro
|
||||
* at all: the same still-average showed this pad's gyro calibration is nowhere near identity,
|
||||
* and a near-identity one would mean 1024 LSB per °/s, i.e. ±32 °/s full scale, which no
|
||||
* controller has. The scale is per unit; only the pad knows it.
|
||||
*
|
||||
* The arithmetic is `hid-playstation`'s, and the host's contract test
|
||||
* (`crates/pf-inject/tests/motion_contract.rs`, `SonyImuCalibration`) is the same math read
|
||||
* from the other end — it applies it to the blobs our *virtual* pads declare and asserts they
|
||||
* land on the wire constants. Per axis: gyro `raw × speed_2x × 20 / (|plus − bias| +
|
||||
* |minus − bias|)`, accel `(raw − (plus − range/2)) × 20000 / range`, where `range = plus −
|
||||
* minus` spans 2 g.
|
||||
*/
|
||||
class MotionCal private constructor(
|
||||
/** Per axis: `speed_2x × 20`, over `|plus − bias| + |minus − bias|`. */
|
||||
private val gyroNumer: LongArray,
|
||||
private val gyroDenom: LongArray,
|
||||
/** Per axis: the raw count the pad reads at 0 g, and the raw span of 2 g. */
|
||||
private val accelBias: LongArray,
|
||||
private val accelRange: LongArray,
|
||||
) {
|
||||
/** Raw gyro count on [axis] (0 = pitch, 1 = yaw, 2 = roll) → the wire's 20 LSB per °/s. */
|
||||
fun gyroToWire(axis: Int, raw: Int): Int =
|
||||
clampWire(raw.toLong() * gyroNumer[axis] / gyroDenom[axis])
|
||||
|
||||
/** Raw acceleration count on [axis] → the wire's 10000 LSB per g, zero point removed. */
|
||||
fun accelToWire(axis: Int, raw: Int): Int =
|
||||
clampWire((raw - accelBias[axis]) * ACCEL_NUMER / accelRange[axis])
|
||||
|
||||
/**
|
||||
* The derived resolutions, for the capture's one-line claim log — the number that says
|
||||
* whether a pad's blob was actually read (a real DualSense declares ≈16 LSB/°·s and ≈8192
|
||||
* LSB/g; the [NOMINAL] fallback reads back as exactly 20 and 8192).
|
||||
*/
|
||||
override fun toString(): String = buildString {
|
||||
append("gyro ")
|
||||
for (i in 0 until 3) {
|
||||
if (i > 0) append('/')
|
||||
append(gyroDenom[i] * WIRE_GYRO_LSB_PER_DEG_S / gyroNumer[i])
|
||||
}
|
||||
append(" LSB/°·s, accel ")
|
||||
for (i in 0 until 3) {
|
||||
if (i > 0) append('/')
|
||||
append(accelRange[i] / 2)
|
||||
}
|
||||
append(" LSB/g at ")
|
||||
append(accelBias.joinToString("/"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Both conversions are a >1 multiplier on every pad measured so far, so a real ±4 g slam
|
||||
* or a fast flick near full scale would otherwise wrap the i16 and read as an impossible
|
||||
* motion in the opposite direction.
|
||||
*/
|
||||
private fun clampWire(v: Long): Int = v.coerceIn(-32768L, 32767L).toInt()
|
||||
|
||||
companion object {
|
||||
/** The pads' nominal acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */
|
||||
private const val RAW_ACCEL_LSB_PER_G = 8192L
|
||||
/**
|
||||
* The wire's gyro scale, taken from [Gamepad] rather than restated. These were literal
|
||||
* `20L` / `10000L` until the sensor path hoisted the same numbers into one place; a
|
||||
* second copy of a unit constant is precisely the defect this whole program opened
|
||||
* with, and two of them in one module would be worse than the original.
|
||||
*
|
||||
* `val`, not `const val`, only because the widening to Long is not a compile-time
|
||||
* constant expression. Long here on purpose: the arithmetic below multiplies raw counts
|
||||
* by the calibration's speed term before dividing, which overflows an Int.
|
||||
*/
|
||||
private val WIRE_GYRO_LSB_PER_DEG_S = Gamepad.MOTION_GYRO_LSB_PER_DEG_S.toLong()
|
||||
/** `MOTION_ACCEL_LSB_PER_G`, doubled — the declared accel range spans 2 g, not 1. */
|
||||
private val ACCEL_NUMER = 2L * Gamepad.MOTION_ACCEL_LSB_PER_G
|
||||
/** Bytes the layout below reads; the reports themselves are longer (41 / 37). */
|
||||
private const val MIN_LEN = 35
|
||||
|
||||
/**
|
||||
* What an unreadable pad gets: gyro straight through and accel on the nominal 8192
|
||||
* LSB/g. Wrong by that unit's factory bias, and for gyro wrong by however far its
|
||||
* scale sits from the wire's 20 — but a pad whose calibration cannot be read is far
|
||||
* better off slightly mis-scaled than silent, so this never zeroes motion.
|
||||
*/
|
||||
val NOMINAL = MotionCal(
|
||||
LongArray(3) { 1 },
|
||||
LongArray(3) { 1 },
|
||||
LongArray(3),
|
||||
LongArray(3) { 2 * RAW_ACCEL_LSB_PER_G },
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse a calibration feature report ([Model.calReportId]) — all little-endian i16:
|
||||
* `[0]` report id, `[1..7)` gyro bias (pitch, yaw, roll), `[7..19)` gyro plus/minus
|
||||
* INTERLEAVED (pitch+, pitch−, yaw+, yaw−, roll+, roll−), `[19..23)` the two speed
|
||||
* words, `[23..35)` accel plus/minus (x+, x−, y+, y−, z+, z−).
|
||||
*
|
||||
* ⚠ Interleaved is the **USB** order. A Bluetooth DualShock 4 groups the three plusses
|
||||
* before the three minuses and consumers switch layout on the transport — this path is
|
||||
* USB-only by construction (see the file header), so do not "generalise" it.
|
||||
*
|
||||
* Falls back to [NOMINAL] for a failed read (null), a truncated or foreign reply, and
|
||||
* per axis for a degenerate declaration — a clone or broken pad that declares zeroes
|
||||
* would otherwise divide by zero (`hid-playstation` guards the same case, for the same
|
||||
* reason).
|
||||
*/
|
||||
fun parse(blob: ByteArray?, reportId: Int): MotionCal {
|
||||
if (blob == null || blob.size < MIN_LEN) return NOMINAL
|
||||
if ((blob[0].toInt() and 0xFF) != reportId) return NOMINAL
|
||||
val w = { o: Int ->
|
||||
((blob[o + 1].toInt() shl 8) or (blob[o].toInt() and 0xFF)).toShort().toLong()
|
||||
}
|
||||
val speed2x = w(19) + w(21)
|
||||
val gyroNumer = LongArray(3)
|
||||
val gyroDenom = LongArray(3)
|
||||
val accelBias = LongArray(3)
|
||||
val accelRange = LongArray(3)
|
||||
for (i in 0 until 3) {
|
||||
val bias = w(1 + 2 * i)
|
||||
val denom = abs(w(7 + 4 * i) - bias) + abs(w(9 + 4 * i) - bias)
|
||||
if (speed2x > 0 && denom > 0) {
|
||||
gyroNumer[i] = speed2x * WIRE_GYRO_LSB_PER_DEG_S
|
||||
gyroDenom[i] = denom
|
||||
} else {
|
||||
gyroNumer[i] = 1 // passthrough, as before any calibration existed
|
||||
gyroDenom[i] = 1
|
||||
}
|
||||
val plus = w(23 + 4 * i)
|
||||
val range = plus - w(25 + 4 * i)
|
||||
if (range > 0) {
|
||||
accelBias[i] = plus - range / 2
|
||||
accelRange[i] = range
|
||||
} else {
|
||||
accelBias[i] = 0 // nominal, as NOMINAL above
|
||||
accelRange[i] = 2 * RAW_ACCEL_LSB_PER_G
|
||||
}
|
||||
}
|
||||
return MotionCal(gyroNumer, gyroDenom, accelBias, accelRange)
|
||||
}
|
||||
}
|
||||
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
|
||||
}
|
||||
|
||||
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
|
||||
@@ -206,9 +50,8 @@ object DsDevice {
|
||||
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
|
||||
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
|
||||
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel arrive in WIRE units — the wire's
|
||||
* `Motion` is a unit passthrough into the virtual pad's report, so the pad's raw counts are
|
||||
* rescaled during the parse by the [MotionCal] handed to [parseState]. Touch coordinates stay
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
|
||||
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
|
||||
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
|
||||
*/
|
||||
class State {
|
||||
@@ -216,8 +59,8 @@ object DsDevice {
|
||||
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255
|
||||
val gyro = IntArray(3) // wire i16: 20 LSB per °/s (pitch/yaw/roll)
|
||||
val accel = IntArray(3) // wire i16: 10000 LSB per g
|
||||
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
|
||||
val accel = IntArray(3)
|
||||
val touchActive = BooleanArray(2)
|
||||
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
|
||||
val touchY = IntArray(2)
|
||||
@@ -265,25 +108,15 @@ object DsDevice {
|
||||
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
|
||||
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
|
||||
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
|
||||
*
|
||||
* [cal] is this pad's own motion calibration, read once when the capture claims it; the
|
||||
* default is the nominal fallback, which is all a caller without a live pad (the tests) can
|
||||
* have.
|
||||
*/
|
||||
fun parseState(
|
||||
model: Model,
|
||||
report: ByteArray,
|
||||
len: Int,
|
||||
out: State,
|
||||
cal: MotionCal = MotionCal.NOMINAL,
|
||||
): Boolean =
|
||||
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
|
||||
if (model == Model.DUALSHOCK4) {
|
||||
parseDs4(report, len, out, cal)
|
||||
parseDs4(report, len, out)
|
||||
} else {
|
||||
parseDs5(model, report, len, out, cal)
|
||||
parseDs5(model, report, len, out)
|
||||
}
|
||||
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
@@ -319,8 +152,8 @@ object DsDevice {
|
||||
}
|
||||
out.buttons = w
|
||||
if (len >= 28) {
|
||||
for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 16 + 2 * i))
|
||||
for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 22 + 2 * i))
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
|
||||
}
|
||||
if (len >= 41) {
|
||||
unpackTouch(r, 33, out, 0)
|
||||
@@ -329,7 +162,7 @@ object DsDevice {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
@@ -355,8 +188,8 @@ object DsDevice {
|
||||
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
out.buttons = w
|
||||
if (len >= 25) {
|
||||
for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 13 + 2 * i))
|
||||
for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 19 + 2 * i))
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
|
||||
}
|
||||
if (len >= 43) {
|
||||
unpackTouch(r, 35, out, 0)
|
||||
@@ -443,26 +276,11 @@ 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] = wireAmplitudeToByte(high).toByte()
|
||||
it[4] = wireAmplitudeToByte(low).toByte()
|
||||
it[3] = amp8(high).toByte()
|
||||
it[4] = amp8(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -506,11 +324,17 @@ object DsDevice {
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = wireAmplitudeToByte(high).toByte()
|
||||
it[5] = wireAmplitudeToByte(low).toByte()
|
||||
it[4] = amp8(high).toByte()
|
||||
it[5] = amp8(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Android gamepad capture → punktfunk/1 gamepad wire (the `input.rs::gamepad` contract; the host
|
||||
@@ -55,31 +54,6 @@ object Gamepad {
|
||||
const val AXIS_LT = 4
|
||||
const val AXIS_RT = 5
|
||||
|
||||
// Motion wire units — must equal punktfunk-core `input.rs::gamepad::MOTION_*`. Every motion
|
||||
// sender on this client goes through the two converters below, so a scale that ever has to
|
||||
// change changes in ONE place: the gyro program's first finding was a client sending 40× hot
|
||||
// because a second copy of the number had drifted.
|
||||
const val MOTION_GYRO_LSB_PER_DEG_S = 20
|
||||
const val MOTION_ACCEL_LSB_PER_G = 10_000
|
||||
|
||||
/** Standard gravity, `punktfunk-core`'s `G` — the divisor that turns m/s² into g. */
|
||||
const val GRAVITY = 9.80665f
|
||||
|
||||
/** [MOTION_GYRO_LSB_PER_DEG_S] restated for Android's rad/s sensors: 1 rad/s ⇒ ~1145.9 raw. */
|
||||
const val MOTION_GYRO_LSB_PER_RAD_S = MOTION_GYRO_LSB_PER_DEG_S * 180f / Math.PI.toFloat()
|
||||
|
||||
/** One angular-rate component, Android's rad/s → the wire's signed-16 raw units. */
|
||||
fun motionGyroWire(radPerSec: Float): Int =
|
||||
(radPerSec * MOTION_GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767)
|
||||
|
||||
/**
|
||||
* One acceleration component, Android's m/s² → the wire's signed-16 raw units. Android reports
|
||||
* specific force (the axis pointing up reads +1 g at rest), which is the DualSense report's own
|
||||
* convention — no sign flip, and a pad lying flat lands on the host's neutral +1 g exactly.
|
||||
*/
|
||||
fun motionAccelWire(mPerSecSq: Float): Int =
|
||||
(mPerSecSq / GRAVITY * MOTION_ACCEL_LSB_PER_G).roundToInt().coerceIn(-32768, 32767)
|
||||
|
||||
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||
const val PREF_AUTO = 0
|
||||
const val PREF_XBOX360 = 1
|
||||
|
||||
@@ -88,9 +88,6 @@ 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). */
|
||||
@@ -128,51 +125,37 @@ class GamepadFeedback(
|
||||
fun start() {
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
// 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) }
|
||||
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,
|
||||
)
|
||||
}
|
||||
}, "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
|
||||
// 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) }
|
||||
dispatchHidout(buf, n)
|
||||
}
|
||||
}, "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
|
||||
@@ -281,12 +264,12 @@ class GamepadFeedback(
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { m.cancel() } // (0,0) = stop
|
||||
m.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
@@ -311,7 +294,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) {
|
||||
runCatching { lv.cancel() } // (0,0) = stop
|
||||
lv.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
@@ -331,8 +314,8 @@ class GamepadFeedback(
|
||||
*/
|
||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||
val v = deviceVibrator ?: return
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { v.cancel() } // (0,0) = stop
|
||||
return
|
||||
@@ -346,6 +329,12 @@ 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
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -32,99 +31,26 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
*
|
||||
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, [padPresent]/[padHasOwnMotion] from the phone-gyro thread and [deviceMotion] from the
|
||||
* pad-sensor thread, so the slot table is a [ConcurrentHashMap].
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val setting: Int,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
* default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
* passthrough such as VirtualHere, or a pad plugged into the host itself — where forwarding
|
||||
* as well would give the host two pads for one pair of hands.
|
||||
*
|
||||
* Off still opens slots and tracks held state; it only stops the wire sends. That is
|
||||
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
|
||||
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
|
||||
* with this device.
|
||||
*/
|
||||
private val systemForward: Boolean = true,
|
||||
/**
|
||||
* The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on
|
||||
* Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down
|
||||
* until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
|
||||
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
|
||||
* while other buttons are down passes through untouched, so the exit/mic chords keep
|
||||
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
|
||||
*/
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(
|
||||
val index: Int,
|
||||
val mapper: Gamepad.AxisMapper,
|
||||
/**
|
||||
* Whether motion sent for this pad can reach the game at all, asked once at open off the
|
||||
* kind it declared ([NativeBridge.nativePadMotionReaches]). False means the host built it a
|
||||
* backend with no motion plane, so [deviceMotion] drops the sample here rather than paying
|
||||
* to send one the host will decode and discard — at a controller's full sensor rate, for
|
||||
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
|
||||
*/
|
||||
val motionReaches: Boolean = true,
|
||||
) {
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
|
||||
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
|
||||
// timer / a delivered tap's owed release (both on the main handler), and whether
|
||||
// the held Select was transformed into a synthetic guide.
|
||||
var pendingGuide: Runnable? = null
|
||||
var pendingTapUp: Runnable? = null
|
||||
var selectAsGuide = false
|
||||
}
|
||||
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||
|
||||
/**
|
||||
* deviceIds whose own gyro [PadSensors] is currently reading — see [setDeviceHasSensorMotion].
|
||||
* Written on the main thread, read from the phone-gyro thread, hence a concurrent set.
|
||||
*/
|
||||
private val sensorDevices: MutableSet<Int> =
|
||||
Collections.newSetFromMap(ConcurrentHashMap<Int, Boolean>())
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug, a capture link's
|
||||
* [releaseDevice] claim, or session teardown. `StreamScreen` wires this to
|
||||
* `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / lights bindings are
|
||||
* released promptly instead of leaking until the feedback threads stop, and to
|
||||
* [PadSensors.onSlotClosed] so the controller's own sensor listeners come off with it.
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||
*/
|
||||
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot opens for a REAL controller — the
|
||||
* hot-plug callback or the first input from a pad the session started without. Not fired for
|
||||
* [openExternal]: a capture link's pad has no [InputDevice] behind it and streams motion from
|
||||
* its own IMU already. `StreamScreen` wires this to [PadSensors.onSlotOpened].
|
||||
*
|
||||
* Slots opened in `init` (every controller already connected) predate any assignment here, so
|
||||
* a listener must sweep [forwardedDevices] once when it starts. Both happen on the main thread
|
||||
* inside one composition block, so nothing can slip between the sweep and the assignment.
|
||||
*/
|
||||
var onSlotOpened: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||
@@ -149,17 +75,6 @@ class GamepadRouter(
|
||||
*/
|
||||
var onMicChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in
|
||||
* a session whose virtual pad has no motion plane — its motion is not being sent, because every
|
||||
* sample would be decoded and dropped host-side.
|
||||
*
|
||||
* It exists because the failure is otherwise completely silent: the gyro just does nothing, and
|
||||
* from the couch that is indistinguishable from a broken sensor. The fix is the Controller type
|
||||
* setting, so whatever shows this has to name it. `StreamScreen` wires it to a brief notice.
|
||||
*/
|
||||
var onMotionUnreachable: (() -> Unit)? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||
private var pendingExit: Runnable? = null
|
||||
@@ -207,27 +122,8 @@ class GamepadRouter(
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
if (down) {
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
// on release), a combo member (the next button flushes it as a real
|
||||
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
|
||||
// it either way, so the exit/mic chords read as if the gesture didn't
|
||||
// exist (Select+Y still fires the mic toggle: the flush sends Select's
|
||||
// down before Y's).
|
||||
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
|
||||
slot.held = slot.held or bit
|
||||
armGuide(slot)
|
||||
return
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
val wasHeld = slot.held
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
@@ -240,10 +136,7 @@ class GamepadRouter(
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
@@ -253,61 +146,6 @@ class GamepadRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
|
||||
private fun armGuide(slot: Slot) {
|
||||
val r = Runnable {
|
||||
slot.pendingGuide = null
|
||||
slot.selectAsGuide = true
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
|
||||
}
|
||||
}
|
||||
slot.pendingGuide = r
|
||||
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* A second button joined while Select was pending — it was a real Select after all; its
|
||||
* deferred down goes out before the caller sends the new button's, preserving chronology.
|
||||
*/
|
||||
private fun flushPendingSelect(slot: Slot) {
|
||||
val r = slot.pendingGuide ?: return
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select released with gesture state outstanding — true when the gesture owned the
|
||||
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
|
||||
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
|
||||
* can fold into nothing in the host's per-pad input fold).
|
||||
*/
|
||||
private fun consumeSelectRelease(slot: Slot): Boolean {
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
return true
|
||||
}
|
||||
val r = slot.pendingGuide ?: return false
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
val up = Runnable {
|
||||
slot.pendingTapUp = null
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
slot.pendingTapUp = up
|
||||
mainHandler.postDelayed(up, TAP_PRESS_MS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||
private fun armExit() {
|
||||
if (pendingExit != null) return // already counting down
|
||||
@@ -348,7 +186,7 @@ class GamepadRouter(
|
||||
val dev = event.device ?: return false
|
||||
if (!isForwardable(dev)) return false
|
||||
val slot = slotFor(dev) ?: return false
|
||||
if (forwarding) slot.mapper.onMotion(event)
|
||||
slot.mapper.onMotion(event)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -365,82 +203,13 @@ class GamepadRouter(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
|
||||
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
|
||||
|
||||
/**
|
||||
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU — either a
|
||||
* capture-link slot ([ExternalPad] — USB DualSense / SC2; synthetic ids are negative
|
||||
* ([EXTERNAL_ID_BASE]), real [InputDevice] ids positive), or a real controller whose gyro
|
||||
* [PadSensors] is reading through the platform sensor framework (a Bluetooth DualSense /
|
||||
* Switch Pro / 8BitDo). The phone-gyro mirror stands down for both: two motion writers on one
|
||||
* wire pad would fight, and the pad's own IMU is the one attached to the player's hands.
|
||||
* Read from the phone-gyro thread (both tables are concurrent).
|
||||
*/
|
||||
fun padHasOwnMotion(pad: Int): Boolean =
|
||||
slots.any { (id, slot) -> slot.index == pad && (id < 0 || id in sensorDevices) }
|
||||
|
||||
/**
|
||||
* Declare (or withdraw) that real controller [deviceId] is sourcing its own rotation — see
|
||||
* [padHasOwnMotion]. Called by [PadSensors] as it registers and unregisters listeners, on the
|
||||
* main thread; read from the phone-gyro thread, hence the concurrent set. Keyed by device
|
||||
* rather than by pad index so a controller that changes wire index (a lower one freed up while
|
||||
* it was captured) carries the fact with it.
|
||||
*/
|
||||
fun setDeviceHasSensorMotion(deviceId: Int, has: Boolean) {
|
||||
if (has) sensorDevices.add(deviceId) else sensorDevices.remove(deviceId)
|
||||
// This is the first moment we know a Bluetooth pad actually HAS a gyro — `openSlot` only
|
||||
// knows what kind it declared. So it is the honest place to raise the notice when that
|
||||
// gyro has nowhere to go, and the only one that cannot nag about a pad that never had one.
|
||||
if (has && forwarding && slots[deviceId]?.motionReaches == false) {
|
||||
onMotionUnreachable?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One motion sample from real controller [deviceId]'s own sensors, on whatever wire index its
|
||||
* slot currently holds — [ExternalPad.motion] for pads the input stack still owns. Silently
|
||||
* drops when the slot is gone (unplugged, or claimed by a capture link between the sensor
|
||||
* callback and here) rather than writing to an index that may already belong to someone else.
|
||||
* Called from [PadSensors]' sensor thread.
|
||||
*/
|
||||
fun deviceMotion(deviceId: Int, gyro: IntArray, accel: IntArray) {
|
||||
val slot = slots[deviceId] ?: return
|
||||
if (!forwarding) return
|
||||
// The same gate the USB capture path takes: a backend with no motion plane decodes every
|
||||
// sample and discards it, so sending is pure cost. Notified once per pad by
|
||||
// [setDeviceHasSensorMotion], which is where we first know the controller HAS a gyro to
|
||||
// lose — a pad without one must not produce a warning about motion.
|
||||
if (!slot.motionReaches) return
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, slot.index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
accel[0], accel[1], accel[2],
|
||||
)
|
||||
}
|
||||
|
||||
/** Snapshot of the REAL controllers currently forwarded, as deviceIds — the set [PadSensors]
|
||||
* sweeps at start for the pads that were already connected when the session opened. */
|
||||
fun forwardedDevices(): List<Int> = slots.keys.filter { it >= 0 }
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
|
||||
* flush + Remove on [close], and full participation in the emergency exit chord.
|
||||
*/
|
||||
inner class ExternalPad internal constructor(
|
||||
private val syntheticId: Int,
|
||||
val index: Int,
|
||||
/**
|
||||
* Whether this pad's motion can reach the game at all, asked once at open (see
|
||||
* [NativeBridge.nativePadMotionReaches]). False means the host built this pad a backend
|
||||
* without a motion plane, so [motion] drops the sample here instead of paying to send one
|
||||
* the host will decode and discard — at a controller's full report rate, for the whole
|
||||
* session.
|
||||
*/
|
||||
private val motionReaches: Boolean,
|
||||
) {
|
||||
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
|
||||
// Live lookup instead of a captured reference: after [close] (or a router release) the
|
||||
// slot is gone from the table and every entry point below degrades to a safe no-op.
|
||||
private val slot get() = slots[syntheticId]
|
||||
@@ -452,26 +221,24 @@ class GamepadRouter(
|
||||
|
||||
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
|
||||
fun axis(id: Int, value: Int) {
|
||||
if (slot != null && forwarding) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
}
|
||||
|
||||
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
|
||||
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
|
||||
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null && forwarding && motionReaches) {
|
||||
if (slot != null) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
@@ -487,26 +254,15 @@ class GamepadRouter(
|
||||
/**
|
||||
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
|
||||
* indices are taken. Main thread (like the hot-plug callbacks).
|
||||
*
|
||||
* [hasGyro] says whether this link forwards motion on the RICH plane ([ExternalPad.motion]) —
|
||||
* true for the Sony pads, whose IMU is a headline feature, and false for the Steam Controller 2,
|
||||
* whose motion rides inside the opaque passthrough report that [ExternalPad.hidReport] carries
|
||||
* and which nothing here may second-guess. It gates only the notice: a pad that never sends
|
||||
* motion must not produce a warning about motion.
|
||||
*/
|
||||
fun openExternal(pref: Int, hasGyro: Boolean = false): ExternalPad? {
|
||||
fun openExternal(pref: Int): ExternalPad? {
|
||||
val index = lowestFreeIndex() ?: return null
|
||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||
val syntheticId = EXTERNAL_ID_BASE - index
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
// Asked once, here, off the kind this pad just DECLARED — not off the session's resolved
|
||||
// backend, which under Automatic answers for whichever pad happened to be active at dial
|
||||
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
|
||||
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
|
||||
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index, motionReaches)
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,19 +317,9 @@ class GamepadRouter(
|
||||
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
|
||||
// which under Automatic answers for whichever pad happened to be active at dial time. Held
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
)
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
// After the table holds the slot, so a listener that sends on this device the moment it is
|
||||
// told ([PadSensors]) finds an index to send on rather than dropping its first samples.
|
||||
onSlotOpened?.invoke(dev.id)
|
||||
return slot
|
||||
}
|
||||
|
||||
@@ -584,7 +330,7 @@ class GamepadRouter(
|
||||
private fun closeSlot(deviceId: Int) {
|
||||
val slot = slots.remove(deviceId) ?: return
|
||||
releaseHeld(slot)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||
@@ -593,32 +339,14 @@ class GamepadRouter(
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
private fun releaseHeld(slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
|
||||
// release goes out NOW (its down is already on the wire and the handle may not
|
||||
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
|
||||
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
|
||||
slot.pendingGuide = null
|
||||
slot.pendingTapUp?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
slot.pendingTapUp = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
}
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
}
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
}
|
||||
|
||||
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||
@@ -652,14 +380,5 @@ class GamepadRouter(
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
|
||||
const val GUIDE_HOLD_MS = 350L
|
||||
|
||||
/**
|
||||
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
|
||||
* this much, so the pair can't coalesce into no press at all.
|
||||
*/
|
||||
const val TAP_PRESS_MS = 50L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,57 +81,17 @@ 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). See [OutReportQueue] for
|
||||
* what gets discarded when it fills, and why that is not simply "the oldest". */
|
||||
private val outQueue = OutReportQueue()
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
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.
|
||||
@@ -154,7 +114,6 @@ class HidUsbLink(
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
down.set(false)
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
@@ -175,7 +134,10 @@ class HidUsbLink(
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
linkDown()
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,9 +221,6 @@ 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)
|
||||
@@ -336,23 +295,10 @@ class HidUsbLink(
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
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()
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
@@ -368,35 +314,28 @@ 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, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
|
||||
if (data.isEmpty()) return false
|
||||
return when (kind) {
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
|
||||
outQueue.offer(data, coalesce)
|
||||
// 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)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,8 +344,9 @@ 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): Boolean =
|
||||
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (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)
|
||||
@@ -418,84 +358,27 @@ 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,
|
||||
): Boolean {
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
// 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
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one report back OUT of the device — HID `GET_REPORT`, the EP0 mirror of [sendReport].
|
||||
* [type] is [REPORT_TYPE_FEATURE] (or output), [id] the report number, [len] the report's full
|
||||
* declared size INCLUDING its leading id byte, which a numbered report echoes back in byte 0
|
||||
* (hidapi framing). Returns what arrived — truncated if the device answered short — or null
|
||||
* when the device refuses the request or the link is down.
|
||||
*
|
||||
* ⚠ **Once, at claim time; never per input report.** EP0 is independent of the interrupt
|
||||
* endpoints (see [sendReport]), so this is safe alongside the reader thread — but it BLOCKS the
|
||||
* calling thread for up to [WRITE_TIMEOUT_MS], and a blocking control transfer in the report
|
||||
* path would wreck capture latency. The one caller reads a Sony pad's fixed motion calibration
|
||||
* when the capture engages ([DsCapture]).
|
||||
*/
|
||||
fun getReport(type: Int, id: Int, len: Int): ByteArray? {
|
||||
if (len <= 0) return null
|
||||
val conn = connection ?: return null
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return null
|
||||
val buf = ByteArray(len)
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0xA1, // device→host, class, interface
|
||||
0x01, // GET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifId,
|
||||
buf,
|
||||
buf.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return when {
|
||||
n >= len -> buf
|
||||
n > 0 -> buf.copyOf(n)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
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
|
||||
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
|
||||
}
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
@@ -505,13 +388,12 @@ class HidUsbLink(
|
||||
device = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val READ_TIMEOUT_MS = 100L
|
||||
private const val WRITE_TIMEOUT_MS = 250
|
||||
private companion object {
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
private const val ERROR_UNPLUG_MS = 2000L
|
||||
private const val REPORT_TYPE_OUTPUT = 0x02
|
||||
/** HID feature-report type — public for [getReport] callers ([writeRaw] takes a kind). */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The hand-off of one claim's motion calibration, from the thread that reads it off the pad to the
|
||||
* link thread that scales every input report with it.
|
||||
*
|
||||
* [DsCapture] reads a captured Sony pad's calibration feature report **off** the claiming thread —
|
||||
* it is a blocking EP0 control transfer and the claim runs on the UI's thread — so the value lands
|
||||
* a moment after the capture goes live. Reports in that gap are scaled by
|
||||
* [DsDevice.MotionCal.NOMINAL] and forwarded like any other ([effective]): for about a millisecond
|
||||
* the pad behaves exactly as it did before the calibration read existed — acceleration a little
|
||||
* short, gyro unscaled — which nobody can feel, whereas a pad that ignores its buttons until an
|
||||
* EP0 read comes back is very obvious.
|
||||
*
|
||||
* What the hand-off is actually for is the two things that gap must NOT do, neither of which a
|
||||
* plain field gives:
|
||||
*
|
||||
* - **Fall back to the previous pad's numbers instead of the nominal ones.** Calibration is per
|
||||
* unit, so the last controller's scale factors are simply wrong for this one — more wrong, in
|
||||
* general, than the nominal constants. [begin] forgets them, which is what makes the gap
|
||||
* nominal rather than inherited.
|
||||
* - **Let a read that outlived its claim publish.** An unplug, a [DsCapture.stop] and a fast
|
||||
* re-claim can all land while a read is in flight; [publish] only accepts a value whose token is
|
||||
* still the live claim's, so a straggler can never scale a pad it never read.
|
||||
*
|
||||
* Thread-safe: claimed and ended by the claiming thread, published by the reading thread, read by
|
||||
* the link thread.
|
||||
*/
|
||||
internal class MotionCalHandoff {
|
||||
/** Handed out by [begin] and burned by [end] — never reused, so a straggler can't match. */
|
||||
private var token = 0
|
||||
|
||||
@Volatile private var cal: DsDevice.MotionCal? = null
|
||||
|
||||
/**
|
||||
* The calibration to scale the next report with: the live claim's own, or the nominal fallback
|
||||
* while its read is still in flight. Never null — a report is always forwarded, never held
|
||||
* back waiting for a control transfer.
|
||||
*/
|
||||
val effective: DsDevice.MotionCal get() = cal ?: DsDevice.MotionCal.NOMINAL
|
||||
|
||||
/** Open a claim: forget the previous pad's calibration, and take this claim's token. */
|
||||
@Synchronized
|
||||
fun begin(): Int {
|
||||
cal = null
|
||||
return ++token
|
||||
}
|
||||
|
||||
/** End the live claim. Nothing read under an older token can land after this. */
|
||||
@Synchronized
|
||||
fun end() {
|
||||
cal = null
|
||||
token++
|
||||
}
|
||||
|
||||
/** Publish [value] if [claim] is still the live claim; returns whether it landed. */
|
||||
@Synchronized
|
||||
fun publish(claim: Int, value: DsDevice.MotionCal): Boolean {
|
||||
if (claim != token) return false
|
||||
cal = value
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -69,10 +69,6 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -87,18 +83,6 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSessionEnded(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* WHY the session ended, as a [SessionEndReason] ordinal — decode with
|
||||
* [SessionEndReason.fromNative]. `0` (NONE) before it ends, or on a `0` handle.
|
||||
*
|
||||
* The companion to [nativeSessionEnded], which only says THAT it ended. Both are needed: the
|
||||
* flag to leave a dead stream, this to decide what to tell the user. A player quitting their
|
||||
* game and a host falling off the network both end the session, and with no way to separate
|
||||
* them the watchdog said "the host may be asleep" for all of them — wrong for every deliberate
|
||||
* ending. Cheap (one atomic load); UI-safe.
|
||||
*/
|
||||
external fun nativeEndReason(handle: Long): Int
|
||||
|
||||
/**
|
||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||
@@ -264,12 +248,12 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 35 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* Returns 33 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
@@ -285,10 +269,7 @@ object NativeBridge {
|
||||
* the window's on-glass confirm count, and whether the presenter is active at all; 30/31
|
||||
* split `decode` (15) the same way — `feed` = received→queued (hand-off + input-slot wait),
|
||||
* `codec` = queued→decoded, the decoder's own time; 32 is the parked-AU overflow subset of
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing;
|
||||
* 33/34 are the AUDIO plane — the playback ring's live depth in ms and the A/V sync loop's
|
||||
* smoothed offset in ms, positive meaning audio plays BEHIND the picture. Those two are live
|
||||
* gauges, not windowed samples, and the offset reads 0 until the loop has a video reference).
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
@@ -351,46 +332,6 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
@@ -519,23 +460,6 @@ object NativeBridge {
|
||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Whether motion sent for a pad that declared [declaredPref] (the [Gamepad].PREF_* byte passed
|
||||
* to [nativeSendGamepadArrival]) can actually reach the game, or would be decoded and dropped
|
||||
* by a host backend without a motion plane — the X-Box classes have no gyro in their HID
|
||||
* contract.
|
||||
*
|
||||
* Answered natively, off `punktfunk_core::config::pad_motion_reaches`, rather than
|
||||
* reconstructed here from the session's requested/resolved prefs. The rule is subtler than it
|
||||
* looks (the host builds each pad from its OWN declaration and folds what it cannot build, so
|
||||
* neither the declaration nor the session echo answers it alone) and every way of getting it
|
||||
* wrong is silent, so it lives in one place with one set of tests.
|
||||
*
|
||||
* Ask ONCE when a pad opens, not per sample. `true` when the session handle is dead — "don't
|
||||
* suppress" is the safe answer whenever we cannot tell.
|
||||
*/
|
||||
external fun nativePadMotionReaches(handle: Long, declaredPref: Int): Boolean
|
||||
|
||||
/**
|
||||
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
|
||||
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Motion from a controller the Android input stack owns — the Bluetooth pads.
|
||||
*
|
||||
* Before this, the only motion sources on Android were the capture links: [DsCapture] (a Sony pad
|
||||
* claimed over USB, raw HID) and [Sc2Capture] (Steam Controller 2 passthrough). A DualSense, a
|
||||
* DualShock 4, a Switch Pro or an 8BitDo paired over BLUETOOTH is neither — it arrives as an
|
||||
* ordinary [InputDevice], its buttons and sticks work, and its gyro was silently dead. That is a
|
||||
* whole class of controller with no motion at all.
|
||||
*
|
||||
* Android 12 (API 31) exposes those sensors: [InputDevice.getSensorManager] hands back a
|
||||
* [android.hardware.SensorManager] scoped to that one controller, carrying the usual
|
||||
* TYPE_GYROSCOPE / TYPE_ACCELEROMETER. This class registers a listener per forwarded controller
|
||||
* that has a gyroscope, converts each sample to wire units, and sends it on that pad's wire index
|
||||
* through [GamepadRouter.deviceMotion]. Below API 31 nothing is registered and the class is inert —
|
||||
* those pads keep working, minus motion, exactly as they did.
|
||||
*
|
||||
* It follows [DeviceGyro] (the phone-gyro mirror) wherever the two solve the same problem:
|
||||
* - samples ride ONE dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`)
|
||||
* — sensor batching would trade away the exact latency gyro aim exists to avoid, and the main
|
||||
* thread is where Compose recomposition lives;
|
||||
* - a feed torn down while its wire pad is still alive parks the rotation at zero first, because
|
||||
* the host holds motion as STATE and re-emits it in every virtual-pad report: an angular
|
||||
* velocity left behind reads as a pad rotating forever (the gyro sweep's "stale rate re-sent
|
||||
* forever" finding).
|
||||
*
|
||||
* One writer per pad, three ways:
|
||||
* 1. A USB capture claims the physical device away from the input stack; [DsCapture.startUsb]
|
||||
* calls [GamepadRouter.releaseDevice] at claim time, which closes the slot, which fires
|
||||
* `onSlotClosed`, which lands on [onSlotClosed] here and unregisters. The claim also makes the
|
||||
* controller's [InputDevice] vanish outright, so even a reopened slot would find nothing to
|
||||
* register — but the explicit teardown is what makes the ordering deterministic instead of a
|
||||
* race against the platform's own removal callback.
|
||||
* 2. The phone-gyro mirror stands down: registering flips
|
||||
* [GamepadRouter.setDeviceHasSensorMotion], [GamepadRouter.padHasOwnMotion] reports it, and
|
||||
* [DeviceGyro] re-reads that gate on every sample (sending its own zero park on the edge).
|
||||
* 3. Exactly one feed exists per deviceId — [onSlotOpened] is idempotent, and it is the only
|
||||
* thing that ever constructs one.
|
||||
*
|
||||
* Frame: see [gyroToWire] — the mapping is straight through, and NOT yet verified on hardware.
|
||||
*/
|
||||
class PadSensors(private val router: GamepadRouter) {
|
||||
|
||||
/** One controller's live sensor feed: its listener state and the accel it pairs with each
|
||||
* rotation. Its arrays belong to the sensor thread; [stop] reads them only after the join. */
|
||||
private inner class Feed(private val deviceId: Int) : SensorEventListener {
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample).
|
||||
* Starts at the host's neutral — 1 g on the up axis, NOT [0,0,0], which is free fall. */
|
||||
private val accel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
|
||||
private val gyro = IntArray(3)
|
||||
|
||||
/** Whether any rotation has gone out on this pad — gates the park on teardown, so a pad
|
||||
* that never sent motion is not handed a sample it did not earn. */
|
||||
@Volatile
|
||||
var wroteMotion = false
|
||||
private set
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> accelToWire(event.values, accel)
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
gyroToWire(event.values, gyro)
|
||||
// One line per controller per session, on the first sample that carries both
|
||||
// planes: it is the cheapest possible version of the frame measurement
|
||||
// [gyroToWire] asks for. Hold the pad flat and still while a stream starts and
|
||||
// the accel triple says which slot gravity lands on — the one thing that
|
||||
// settles whether the straight-through mapping is right.
|
||||
if (!wroteMotion) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"controller $deviceId first motion sample: " +
|
||||
"gyro ${gyro.joinToString()} accel ${accel.joinToString()}",
|
||||
)
|
||||
}
|
||||
wroteMotion = true
|
||||
router.deviceMotion(deviceId, gyro, accel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free fall. */
|
||||
fun park() {
|
||||
gyro.fill(0)
|
||||
router.deviceMotion(deviceId, gyro, accel)
|
||||
}
|
||||
}
|
||||
|
||||
/** deviceId → live feed. Concurrent: the main thread mutates it while the sensor thread is
|
||||
* running (hot-plug, a capture link's claim). */
|
||||
private val feeds = ConcurrentHashMap<Int, Feed>()
|
||||
|
||||
private val thread = HandlerThread("pf-pad-sensors")
|
||||
private var handler: Handler? = null
|
||||
|
||||
/**
|
||||
* Start the sensor thread and attach to every controller the router already forwards — the
|
||||
* pads connected before the session opened, which will never fire a hot-plug callback.
|
||||
* Everything after that arrives through [onSlotOpened]. Main thread.
|
||||
*/
|
||||
fun start() {
|
||||
if (!supported()) return
|
||||
thread.start()
|
||||
handler = Handler(thread.looper)
|
||||
for (deviceId in router.forwardedDevices()) onSlotOpened(deviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot opened for real controller [deviceId] — attach if it has a gyroscope of its own.
|
||||
* Idempotent, and a no-op before [start] or on a platform without the API. Main thread, from
|
||||
* [GamepadRouter.onSlotOpened].
|
||||
*/
|
||||
fun onSlotOpened(deviceId: Int) {
|
||||
val h = handler ?: return
|
||||
if (feeds.containsKey(deviceId)) return
|
||||
// API 31+ only — getSensorManager does not exist below it. Re-checked here rather than
|
||||
// relying on start()'s gate, so the entry point is safe on its own terms.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
val dev = InputDevice.getDevice(deviceId) ?: return
|
||||
// Declared non-null: a controller with no sensors gets an empty manager, not a null one.
|
||||
val sm = dev.sensorManager
|
||||
// A gyroscope is the entry price; the accelerometer alone does not buy a feed. The rotation
|
||||
// is what gyro aim is for, and an accel-only feed would send gravity while pinning rotation
|
||||
// at zero on a pad the phone-gyro mirror is otherwise entitled to speak for — precisely the
|
||||
// two-writers-on-one-pad fight this program has spent its day unpicking. Such a pad stays
|
||||
// on the mirror's terms instead, where at least the accel agrees with the gyro beside it.
|
||||
// Nothing found here is not proof the pad has no IMU. A DualSense's motion arrives on its
|
||||
// own evdev node, and whether InputReader merges that node onto the gamepad InputDevice
|
||||
// (shared descriptor) or leaves it standing alone is the platform's business, not ours —
|
||||
// and a standalone one is exactly what GamepadRouter.isForwardable filters out, so this
|
||||
// would never see it. Android 12's own controller-sensor documentation cites the DualShock
|
||||
// 4 and DualSense, which says the merge happens; it is not something this code can assert.
|
||||
// If a Bluetooth Sony pad ever turns up here with no gyroscope, THAT is the thing to check.
|
||||
val gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
val feed = Feed(deviceId)
|
||||
feeds[deviceId] = feed
|
||||
// ~200 Hz requested, zero report latency: batching is poison for gyro aim, and 200 Hz is
|
||||
// what the framework grants an app without HIGH_SAMPLING_RATE_SENSORS anyway.
|
||||
sm.registerListener(feed, gyroSensor, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(feed, it, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
// The pad sources its own rotation from here on → the phone-gyro mirror stands down for it.
|
||||
router.setDeviceHasSensorMotion(deviceId, true)
|
||||
Log.i(TAG, "controller $deviceId (${dev.name}) has a gyro — forwarding its motion")
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot for [deviceId] closed — unplug, session teardown, or a capture link claiming the
|
||||
* device. Unregister and hand the pad back to the phone-gyro mirror. Main thread, from
|
||||
* [GamepadRouter.onSlotClosed].
|
||||
*
|
||||
* No park-at-zero here, on purpose: the router removed the slot BEFORE invoking the callback
|
||||
* and has already sent that pad's Remove, so the host tore the virtual pad down and there is no
|
||||
* latched rotation left to clear — while writing to a wire index that is free again would be
|
||||
* addressing whoever claims it next. [stop] is the case where the pad outlives the feed.
|
||||
*/
|
||||
fun onSlotClosed(deviceId: Int) {
|
||||
unregister(deviceId)
|
||||
router.setDeviceHasSensorMotion(deviceId, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister every listener, join the sensor thread, then park at zero each pad that was
|
||||
* rotating. Call BEFORE the router is released and the session handle freed — the same
|
||||
* teardown ordering rule as the feedback poll threads and [DeviceGyro.stop]. The parks come
|
||||
* AFTER the join for two reasons: a sample still in flight would re-latch the rotation just
|
||||
* cleared, and the join is what publishes the sensor thread's writes to this one.
|
||||
*/
|
||||
fun stop() {
|
||||
val parked = feeds.keys.toList().mapNotNull { id -> unregister(id)?.let { id to it } }
|
||||
for ((deviceId, _) in parked) router.setDeviceHasSensorMotion(deviceId, false)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
handler = null
|
||||
for ((_, feed) in parked) if (feed.wroteMotion) feed.park()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop [deviceId]'s listeners, returning the feed that held them (null if there was none).
|
||||
* Safe for a controller that is already gone: the sensor manager is reached through the
|
||||
* [InputDevice], and a vanished device simply leaves nothing to unregister — the platform has
|
||||
* stopped calling the listener either way.
|
||||
*/
|
||||
private fun unregister(deviceId: Int): Feed? {
|
||||
val feed = feeds.remove(deviceId) ?: return null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
InputDevice.getDevice(deviceId)?.sensorManager?.unregisterListener(feed)
|
||||
}
|
||||
return feed
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PadSensors"
|
||||
|
||||
/** Whether this platform can read a controller's own sensors at all (API 31+). */
|
||||
fun supported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* One gyroscope sample (Android: rad/s) → the wire's three signed-16 components, in place.
|
||||
*
|
||||
* The axis frame is straight through, and that is now MEASURED rather than assumed.
|
||||
*
|
||||
* The wire is a unit passthrough into a virtual DualSense report, whose frame was measured
|
||||
* over raw HID on 2026-08-07: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward
|
||||
* toward the player (roll), right-handed. Android hands a controller's own sensors over in
|
||||
* that same frame — which was the documented expectation, but the numbers pass through a
|
||||
* HID driver and InputFlinger's sensor mapper, either of which could have permuted or
|
||||
* negated without saying so.
|
||||
*
|
||||
* Verified 2026-08-07 end to end: a DualSense on Bluetooth to an Android phone, streaming
|
||||
* to a Linux host. This path's own first-sample log read `accel 0, 10000, 0` — exactly 1 g
|
||||
* on slot 1 — and at the far end `hid-playstation` published gravity as +0.991 g on ABS_Y
|
||||
* with every rotation driving its correctly-named axis (yaw→RY, pitch→RX, roll→RZ) and the
|
||||
* signs agreeing with gravity's independent witness on 95 of 100 rotating samples.
|
||||
*
|
||||
* So: no remap. If a future device disagrees, the remap belongs HERE with its own
|
||||
* expectations in `PadSensorsTest` — not spread across callers.
|
||||
*/
|
||||
fun gyroToWire(values: FloatArray, out: IntArray) {
|
||||
for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f })
|
||||
}
|
||||
|
||||
/**
|
||||
* One accelerometer sample (Android: m/s², specific force) → the wire's three signed-16
|
||||
* components, in place. Same measured frame as [gyroToWire] and the same straight-through
|
||||
* mapping; the sign needs no flip, because Android and the DualSense report agree that the
|
||||
* axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]) — which is precisely
|
||||
* what the on-glass run read back, `accel 0, 10000, 0` with the pad lying flat.
|
||||
*/
|
||||
fun accelToWire(values: FloatArray, out: IntArray) {
|
||||
for (i in 0..2) out[i] = Gamepad.motionAccelWire(values.getOrElse(i) { 0f })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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,20 +273,10 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Why a stream session ended — the Kotlin mirror of `punktfunk_core::client::PunktfunkEndReason`,
|
||||
* read via [NativeBridge.nativeEndReason].
|
||||
*
|
||||
* The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a
|
||||
* player quitting their game and a host falling off the network both arrive as "the session
|
||||
* ended". With no way to tell them apart this client showed one message for all of them — and it
|
||||
* was the alarming one ("Connection lost — the host may be asleep"), in front of players who had
|
||||
* just quit their own game.
|
||||
*
|
||||
* Ordinals are an ABI contract with the Rust side: append only, never renumber.
|
||||
*/
|
||||
enum class SessionEndReason {
|
||||
/** Not ended, or ended before a reason could be observed. Also the fallback for an unknown value. */
|
||||
NONE,
|
||||
|
||||
/** This client closed the session — the user pressed back or stop. Nothing to report. */
|
||||
LOCAL,
|
||||
|
||||
/**
|
||||
* The host's launched game exited. A normal finish, and the one reason worth acting on: go back
|
||||
* to the library the title was launched from, so the next one is a tap away.
|
||||
*/
|
||||
GAME_EXITED,
|
||||
|
||||
/** The host ended the session deliberately (an operator "End", or it simply finished). Normal. */
|
||||
HOST_ENDED,
|
||||
|
||||
/** The host closed reporting a failure of its own. Worth showing; the host's log has the detail. */
|
||||
HOST_ERROR,
|
||||
|
||||
/**
|
||||
* The connection died rather than being closed: idle timeout, reset, the network going away.
|
||||
* This — and only this — is the "the host may be asleep, wake it" case.
|
||||
*/
|
||||
LOST;
|
||||
|
||||
/**
|
||||
* Is this an ordinary outcome rather than something to alarm the user about?
|
||||
*
|
||||
* The question nearly every caller actually asks. [LOCAL], [GAME_EXITED] and [HOST_ENDED] were
|
||||
* all meant to happen. [NONE] counts as normal — no evidence of trouble is not evidence of it.
|
||||
*/
|
||||
val isNormal: Boolean
|
||||
get() = this != HOST_ERROR && this != LOST
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Decode the JNI byte. An unrecognized value becomes [NONE] rather than throwing: this
|
||||
* crosses an ABI where the native side may be newer than this code.
|
||||
*/
|
||||
fun fromNative(v: Int): SessionEndReason = entries.getOrNull(v) ?: NONE
|
||||
}
|
||||
}
|
||||
@@ -132,27 +132,6 @@ class HostDiscovery(context: Context) {
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path
|
||||
* for a browse that started while blocked (permission not yet granted, multicast filtered) or
|
||||
* that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and
|
||||
* nothing else would ever retry it).
|
||||
*
|
||||
* It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps
|
||||
* at an hour, so a long-lived browse is effectively passive — a host that appeared since, or
|
||||
* whose announcement was lost to multicast, may never be asked for again.
|
||||
*
|
||||
* The currently-shown host set is left alone across the swap (rather than blinking empty via
|
||||
* [stop]'s notification); the first poll of the new browse publishes the fresh set.
|
||||
*/
|
||||
fun restart() {
|
||||
val keep = onChange
|
||||
onChange = null
|
||||
stop()
|
||||
onChange = keep
|
||||
start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running && nativeHandle == 0L) return
|
||||
running = false
|
||||
|
||||
@@ -37,51 +37,9 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||
}
|
||||
|
||||
/**
|
||||
* One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`).
|
||||
*
|
||||
* [role] is `"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. Kept a plain nullable
|
||||
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
|
||||
* game rather than break the decode (design D4).
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
val store: String,
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
) {
|
||||
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* Display name for the store badge — the same table the other clients use
|
||||
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||
* entry, which a Lutris or GOG title made a lie.
|
||||
*/
|
||||
val storeLabel: String get() = when (store) {
|
||||
"steam" -> "Steam"
|
||||
"custom" -> "Custom"
|
||||
"heroic" -> "Heroic"
|
||||
"lutris" -> "Lutris"
|
||||
"epic" -> "Epic"
|
||||
"gog" -> "GOG"
|
||||
"xbox" -> "Xbox"
|
||||
else -> "Game"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Design D4: launcher entries lead the shelf, keeping the host's title order within each group.
|
||||
* Applied once where the library is fetched, so no screen has to remember the rule — and a library
|
||||
* without launcher entries comes back untouched.
|
||||
*/
|
||||
fun List<GameEntry>.launchersFirst(): List<GameEntry> {
|
||||
val launchers = filter { it.isLauncher }
|
||||
return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher }
|
||||
}
|
||||
|
||||
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||
@@ -150,11 +108,10 @@ object LibraryClient {
|
||||
header = resolveArt(str(art, "header"), base),
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out.launchersFirst()
|
||||
return out
|
||||
}
|
||||
|
||||
/** A present, non-null, non-blank JSON string field, else null. */
|
||||
@@ -170,14 +127,8 @@ object LibraryClient {
|
||||
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||
* defers to normal public trust for any other origin (an external CDN URL).
|
||||
*
|
||||
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
|
||||
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
|
||||
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
|
||||
* certificate for any name is accepted for the host — which is exactly what 2026-08-05 review M-2
|
||||
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
|
||||
* default verifier; the pin is its only credential, on purpose.
|
||||
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||
*/
|
||||
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||
val clientCert = CertificateFactory.getInstance("X.509")
|
||||
@@ -211,26 +162,7 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
|
||||
|
||||
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
val verifier = HostnameVerifier { hostname, session ->
|
||||
if (hostname == host) {
|
||||
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
|
||||
//
|
||||
// This used to be a bare `hostname == host`, which composed with the trust manager's
|
||||
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
|
||||
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
|
||||
// MITM with any free CA-issued cert intercepted the connection, received the client's
|
||||
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
|
||||
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
|
||||
// only Android did not.
|
||||
try {
|
||||
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
// Any other origin (an external CDN art URL) is ordinary public trust: the system
|
||||
// trust manager validated the chain, and this checks the name against it.
|
||||
defaultVerifier.verify(hostname, session)
|
||||
}
|
||||
hostname == host || defaultVerifier.verify(hostname, session)
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.Surface
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone-gyro mirror's device→controller frame remap and its wire-unit constants
|
||||
* ([DeviceGyro]). Pure JVM: [Surface]'s ROTATION_* are compile-time constants and remap is
|
||||
* plain math. The matrix is derived (like the wire scale constants) — if on-glass says an axis
|
||||
* is wrong, fix [DeviceGyro.remap] AND these expectations together.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DeviceGyroTest {
|
||||
/** A distinct value per axis so a swapped or flipped component can't cancel out. */
|
||||
private fun remap(rotation: Int) = DeviceGyro.remap(rotation, 1f, 2f, 3f).toList()
|
||||
|
||||
@Test
|
||||
fun naturalPortraitIsIdentity() = assertEquals(listOf(1f, 2f, 3f), remap(Surface.ROTATION_0))
|
||||
|
||||
@Test
|
||||
fun upsideDownFlipsInPlane() = assertEquals(listOf(-1f, -2f, 3f), remap(Surface.ROTATION_180))
|
||||
|
||||
/** ROTATION_90 = device turned counter-clockwise, top to the player's LEFT:
|
||||
* player-right = device-bottom (−y), player-up = device-right (+x); z never changes. */
|
||||
@Test
|
||||
fun rotation90TopLeft() = assertEquals(listOf(-2f, 1f, 3f), remap(Surface.ROTATION_90))
|
||||
|
||||
/** ROTATION_270 = top to the player's RIGHT: player-right = +y, player-up = −x. */
|
||||
@Test
|
||||
fun rotation270TopRight() = assertEquals(listOf(2f, -1f, 3f), remap(Surface.ROTATION_270))
|
||||
|
||||
/** Every remap stays a proper (right-handed) rotation: x̂ × ŷ = ẑ after mapping. */
|
||||
@Test
|
||||
fun handednessPreserved() {
|
||||
for (r in listOf(
|
||||
Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270,
|
||||
)) {
|
||||
val x = DeviceGyro.remap(r, 1f, 0f, 0f)
|
||||
val y = DeviceGyro.remap(r, 0f, 1f, 0f)
|
||||
assertEquals("left-handed remap at rotation $r", 1f, x[0] * y[1] - x[1] * y[0], 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/** The wire contract, shared with pf-client-core / the Swift client and now with every other
|
||||
* Android motion sender ([Gamepad.motionGyroWire]): 20 LSB/°·s means 1 rad/s ⇒ ~1145.9 raw;
|
||||
* 1 g ⇒ 10000 raw. */
|
||||
@Test
|
||||
fun wireUnitConstants() {
|
||||
assertEquals(20f * 180f / Math.PI.toFloat(), Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0f)
|
||||
assertEquals(1145.9156f, Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0.001f)
|
||||
assertEquals(10_000, Gamepad.MOTION_ACCEL_LSB_PER_G)
|
||||
}
|
||||
}
|
||||
@@ -151,185 +151,6 @@ class DsDeviceTest {
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
|
||||
}
|
||||
|
||||
// ---- IMU calibration (the pad's own scale factors) ----
|
||||
|
||||
/**
|
||||
* A calibration feature report in the pads' USB layout: report id, three gyro bias words, six
|
||||
* INTERLEAVED gyro plus/minus words, the two speed words, six accel plus/minus words — all
|
||||
* little-endian i16, exactly what [DsDevice.MotionCal.parse] reads and what
|
||||
* `crates/pf-inject/tests/motion_contract.rs` writes from the other end.
|
||||
*/
|
||||
private fun calBlob(
|
||||
id: Int,
|
||||
gyroBias: IntArray,
|
||||
gyroPlus: IntArray,
|
||||
gyroMinus: IntArray,
|
||||
speed: Int,
|
||||
accelPlus: IntArray,
|
||||
accelMinus: IntArray,
|
||||
len: Int = 41,
|
||||
): ByteArray = ByteArray(len).also { b ->
|
||||
fun put(o: Int, v: Int) {
|
||||
b[o] = (v and 0xFF).toByte()
|
||||
b[o + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
}
|
||||
b[0] = id.toByte()
|
||||
for (i in 0 until 3) {
|
||||
put(1 + 2 * i, gyroBias[i])
|
||||
put(7 + 4 * i, gyroPlus[i])
|
||||
put(9 + 4 * i, gyroMinus[i])
|
||||
put(23 + 4 * i, accelPlus[i])
|
||||
put(25 + 4 * i, accelMinus[i])
|
||||
}
|
||||
put(19, speed)
|
||||
put(21, speed)
|
||||
}
|
||||
|
||||
/**
|
||||
* A realistic DualSense blob: gyro measured at 512 °/s each way over ±8192 counts about a
|
||||
* small factory bias — 16384/1024 = 16 raw LSB per °/s, the ≈±2000 °/s full scale a real pad
|
||||
* has — and accel spanning about ±8192 counts (`DS_ACC_RES_PER_G`) about a per-axis zero point
|
||||
* that is NOT zero. Both are the shape a nominal constant cannot express.
|
||||
*/
|
||||
private fun realisticCal(): DsDevice.MotionCal = DsDevice.MotionCal.parse(
|
||||
calBlob(
|
||||
id = 0x05,
|
||||
gyroBias = intArrayOf(10, -6, 3),
|
||||
gyroPlus = intArrayOf(10 + 8192, -6 + 8192, 3 + 8192),
|
||||
gyroMinus = intArrayOf(10 - 8192, -6 - 8192, 3 - 8192),
|
||||
speed = 512, // speed_plus + speed_minus = 1024
|
||||
accelPlus = intArrayOf(8300, 8200, 8000),
|
||||
accelMinus = intArrayOf(-8100, -8192, -8384),
|
||||
),
|
||||
0x05,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun calibrationRescalesRawCountsOntoTheWireUnits() {
|
||||
val cal = realisticCal()
|
||||
// 100 °/s at this pad's 16 LSB per °/s = 1600 raw → the wire's 20 LSB per °/s = 2000.
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(2000, cal.gyroToWire(axis, 1600))
|
||||
assertEquals(-2000, cal.gyroToWire(axis, -1600))
|
||||
assertEquals(0, cal.gyroToWire(axis, 0))
|
||||
}
|
||||
// 1 g = the axis's zero point plus half its declared 2 g range → 10000 wire units.
|
||||
val zero = intArrayOf(100, 4, -192) // plus − range/2, per axis
|
||||
val oneG = intArrayOf(8300, 8200, 8000) // = accelPlus
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(10000, cal.accelToWire(axis, oneG[axis]))
|
||||
assertEquals(0, cal.accelToWire(axis, zero[axis]))
|
||||
assertEquals(-10000, cal.accelToWire(axis, zero[axis] - (oneG[axis] - zero[axis])))
|
||||
}
|
||||
// Both rescales are >1 here, so full-scale raw must clamp rather than wrap the i16.
|
||||
assertEquals(32767, cal.gyroToWire(0, 30000))
|
||||
assertEquals(-32768, cal.gyroToWire(0, -30000))
|
||||
assertEquals(32767, cal.accelToWire(0, 30000))
|
||||
// The capture logs this, and it is the discriminator the owed on-glass check reads: a pad
|
||||
// whose blob was read declares its own resolution, the fallback declares the wire's.
|
||||
assertTrue(cal.toString().startsWith("gyro 16/16/16 LSB/°·s"))
|
||||
assertTrue(DsDevice.MotionCal.NOMINAL.toString().startsWith("gyro 20/20/20 LSB/°·s"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's own virtual pads declare `DS_FEATURE_CALIBRATION` (`dualsense_proto.rs`) — a blob
|
||||
* that states the wire's units exactly. Reading it back must therefore be a passthrough: if
|
||||
* this ever stops holding, the client and the host disagree about what a motion sample means.
|
||||
*/
|
||||
@Test
|
||||
fun theHostsOwnBlobIsAPassthrough() {
|
||||
val cal = DsDevice.MotionCal.parse(
|
||||
calBlob(
|
||||
id = 0x05,
|
||||
gyroBias = intArrayOf(0, 0, 0),
|
||||
gyroPlus = intArrayOf(10000, 10000, 10000),
|
||||
gyroMinus = intArrayOf(-10000, -10000, -10000),
|
||||
speed = 500,
|
||||
accelPlus = intArrayOf(10000, 10000, 10000),
|
||||
accelMinus = intArrayOf(-10000, -10000, -10000),
|
||||
),
|
||||
0x05,
|
||||
)
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(2000, cal.gyroToWire(axis, 2000)) // 100 °/s
|
||||
assertEquals(10000, cal.accelToWire(axis, 10000)) // 1 g
|
||||
assertEquals(-1234, cal.gyroToWire(axis, -1234))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything unusable keeps the pre-calibration behaviour — accel on the nominal 8192 LSB/g,
|
||||
* gyro straight through. A pad with no readable calibration is better off slightly mis-scaled
|
||||
* than silent, so nothing here may zero motion.
|
||||
*/
|
||||
@Test
|
||||
fun unusableCalibrationFallsBackInsteadOfZeroing() {
|
||||
val degenerate = calBlob(
|
||||
id = 0x02,
|
||||
gyroBias = intArrayOf(0, 0, 0),
|
||||
gyroPlus = intArrayOf(0, 0, 0),
|
||||
gyroMinus = intArrayOf(0, 0, 0),
|
||||
speed = 0,
|
||||
accelPlus = intArrayOf(0, 0, 0),
|
||||
accelMinus = intArrayOf(0, 0, 0),
|
||||
len = 37,
|
||||
)
|
||||
val cals = listOf(
|
||||
DsDevice.MotionCal.NOMINAL,
|
||||
DsDevice.MotionCal.parse(null, 0x05), // the GET_REPORT failed
|
||||
DsDevice.MotionCal.parse(ByteArray(8) { if (it == 0) 0x05 else 0 }, 0x05), // short reply
|
||||
DsDevice.MotionCal.parse(degenerate, 0x02), // a clone pad's zeroes
|
||||
DsDevice.MotionCal.parse(degenerate, 0x05), // someone else's report id
|
||||
)
|
||||
for (cal in cals) {
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(1234, cal.gyroToWire(axis, 1234)) // passthrough
|
||||
assertEquals(10000, cal.accelToWire(axis, 8192)) // 8192 raw LSB = 1 g
|
||||
assertEquals(-10000, cal.accelToWire(axis, -8192))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The parse applies the calibration at the motion offsets, per model, and defaults to nominal. */
|
||||
@Test
|
||||
fun parseStateAppliesTheCalibration() {
|
||||
val cal = realisticCal()
|
||||
// DS5: gyro at [16..22), accel at [22..28). Pitch = 1600 raw (100 °/s), accel z = 8000 (1 g).
|
||||
val ds5 = ds5Report {
|
||||
it[16] = 0x40; it[17] = 0x06 // 1600
|
||||
it[26] = 0x40; it[27] = 0x1F // 8000
|
||||
}
|
||||
val five = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, five, cal))
|
||||
assertEquals(2000, five.gyro[0])
|
||||
assertEquals(10000, five.accel[2])
|
||||
// DS4: gyro at [13..19), accel at [19..25). Same numbers, same answers.
|
||||
val ds4 = ds4Report {
|
||||
it[13] = 0x40; it[14] = 0x06
|
||||
it[23] = 0x40; it[24] = 0x1F
|
||||
}
|
||||
val four = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4, 64, four, cal))
|
||||
assertEquals(2000, four.gyro[0])
|
||||
assertEquals(10000, four.accel[2])
|
||||
// No calibration argument = the nominal fallback: gyro through, accel ×10000/8192.
|
||||
val nominal = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, nominal))
|
||||
assertEquals(1600, nominal.gyro[0])
|
||||
assertEquals(8000L * 10000 / 8192, nominal.accel[2].toLong())
|
||||
}
|
||||
|
||||
/** Each model asks for the feature report its firmware actually serves over USB. */
|
||||
@Test
|
||||
fun calibrationReportIdentityPerModel() {
|
||||
assertEquals(0x05, DsDevice.Model.DUALSENSE.calReportId)
|
||||
assertEquals(41, DsDevice.Model.DUALSENSE.calReportLen)
|
||||
assertEquals(0x05, DsDevice.Model.DUALSENSE_EDGE.calReportId)
|
||||
assertEquals(41, DsDevice.Model.DUALSENSE_EDGE.calReportLen)
|
||||
assertEquals(0x02, DsDevice.Model.DUALSHOCK4.calReportId)
|
||||
assertEquals(37, DsDevice.Model.DUALSHOCK4.calReportLen)
|
||||
}
|
||||
|
||||
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The claim/read hand-off that lets [DsCapture] read a pad's motion calibration off the claiming
|
||||
* thread. Two things are pinned here, and both are about the gap before the read comes back.
|
||||
*
|
||||
* What the gap DOES: the pad streams, scaled by the nominal calibration — the behaviour that
|
||||
* shipped before the read existed. What it must NOT do: inherit the previous pad's factory numbers
|
||||
* (calibration is per unit), or accept a read that outlived its claim, which an unplug, a stop, or
|
||||
* a re-claim can all cause.
|
||||
*/
|
||||
class MotionCalHandoffTest {
|
||||
/**
|
||||
* A calibration whose gyro reads [rawLsbPerDegS] raw LSB per °/s and whose accel sits at
|
||||
* [accelZero] raw counts at 0 g, so two of them are told apart by what they DO — identity
|
||||
* alone would let a regression that returns the wrong instance still look right.
|
||||
*/
|
||||
private fun cal(rawLsbPerDegS: Int, accelZero: Int = 0): DsDevice.MotionCal {
|
||||
val speed = 500 // speed_plus = speed_minus, so speed_2x = 1000
|
||||
val span = rawLsbPerDegS * 1000 // |plus − bias| + |minus − bias| = span
|
||||
val blob = ByteArray(41)
|
||||
fun put(o: Int, v: Int) {
|
||||
blob[o] = (v and 0xFF).toByte()
|
||||
blob[o + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
}
|
||||
blob[0] = 0x05
|
||||
for (i in 0 until 3) {
|
||||
put(7 + 4 * i, span / 2) // gyro plus
|
||||
put(9 + 4 * i, -span / 2) // gyro minus
|
||||
put(23 + 4 * i, accelZero + 8192) // accel plus / minus: 8192 raw LSB per g
|
||||
put(25 + 4 * i, accelZero - 8192)
|
||||
}
|
||||
put(19, speed)
|
||||
put(21, speed)
|
||||
return DsDevice.MotionCal.parse(blob, 0x05)
|
||||
}
|
||||
|
||||
/** One DS5 input report: cross held, sticks centred, gyro pitch 1600 raw, accel z 8000 raw. */
|
||||
private fun report(): ByteArray = ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[8] = (0x08 or 0x20).toByte() // hat neutral | cross
|
||||
it[16] = 0x40; it[17] = 0x06 // gyro pitch = 1600
|
||||
it[26] = 0x40; it[27] = 0x1F // accel z = 8000
|
||||
it[33] = 0x80.toByte(); it[37] = 0x80.toByte() // no touch contacts
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a claim scales nominally until its read lands`() {
|
||||
val h = MotionCalHandoff()
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val claim = h.begin()
|
||||
assertSame("the read is in flight — scale nominally, do not wait", DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val read = cal(16)
|
||||
assertTrue(h.publish(claim, read))
|
||||
assertSame(read, h.effective)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of scaling nominally instead of holding reports back: a pad answers its
|
||||
* buttons from the first report, and only its motion changes when the calibration arrives.
|
||||
*/
|
||||
@Test
|
||||
fun `a report in the gap is forwarded, nominally scaled, and rescales once the read lands`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
val r = report()
|
||||
|
||||
val gap = DsDevice.State()
|
||||
assertTrue(
|
||||
"a report must still be parsed while the read is in flight",
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, gap, h.effective),
|
||||
)
|
||||
assertEquals("buttons reach the wire immediately", Gamepad.BTN_A, gap.buttons)
|
||||
assertEquals("and so do sticks", 128, gap.lsX)
|
||||
assertEquals("nominal gyro is the raw count", 1600, gap.gyro[0])
|
||||
assertEquals("nominal accel is ×10000/8192", 8000L * 10000 / 8192, gap.accel[2].toLong())
|
||||
|
||||
assertTrue(h.publish(claim, cal(16, accelZero = 100)))
|
||||
val live = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, live, h.effective))
|
||||
assertEquals("buttons do not depend on the calibration", gap.buttons, live.buttons)
|
||||
assertEquals("1600 raw at 16 LSB/°·s = 100 °/s = 2000 wire", 2000, live.gyro[0])
|
||||
assertNotEquals("the same raw report must convert differently now", gap.gyro[0], live.gyro[0])
|
||||
assertNotEquals(gap.accel[2], live.accel[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read that outlived its claim publishes nothing`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
h.end() // unplug, or DsCapture.stop, while the read was in flight
|
||||
assertFalse("a straggler may not publish into a dead claim", h.publish(claim, cal(16)))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new claim scales nominally rather than inheriting the previous pad's calibration`() {
|
||||
val h = MotionCalHandoff()
|
||||
val first = h.begin()
|
||||
val hot = cal(4, accelZero = 400) // a pad reading 4 raw LSB per °/s, well off nominal
|
||||
assertTrue(h.publish(first, hot))
|
||||
assertSame(hot, h.effective)
|
||||
|
||||
// Re-claimed without an end() in between — the pad was swapped while a read was in flight.
|
||||
val second = h.begin()
|
||||
assertNotEquals(first, second)
|
||||
assertSame(
|
||||
"the next pad starts on the nominal scaling, NOT the last pad's factory numbers",
|
||||
DsDevice.MotionCal.NOMINAL,
|
||||
h.effective,
|
||||
)
|
||||
assertFalse("the first pad's read may not scale the second pad", h.publish(first, hot))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
|
||||
// And that fallback is a real difference, not two names for the same numbers: the inherited
|
||||
// calibration would have turned this pad's motion into something else entirely.
|
||||
val r = report()
|
||||
val nominal = DsDevice.State()
|
||||
val inherited = DsDevice.State()
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, nominal, h.effective)
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, inherited, hot)
|
||||
assertNotEquals(inherited.gyro[0], nominal.gyro[0])
|
||||
assertNotEquals(inherited.accel[2], nominal.accel[2])
|
||||
|
||||
val slow = cal(32)
|
||||
assertTrue(h.publish(second, slow))
|
||||
assertSame(slow, h.effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ending a claim twice still refuses every outstanding token`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
h.end() // DsCapture.stop
|
||||
h.end() // …and the unplug that followed it
|
||||
assertFalse(h.publish(claim, cal(16)))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val next = h.begin()
|
||||
assertNotEquals(claim, next)
|
||||
val read = cal(16)
|
||||
assertTrue(h.publish(next, read))
|
||||
assertSame(read, h.effective)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the unit scaling and the axis mapping of the controller-sensor path ([PadSensors]) and the
|
||||
* shared converters it goes through ([Gamepad.motionGyroWire] / [Gamepad.motionAccelWire]). Pure
|
||||
* JVM — the two `*ToWire` functions take plain float arrays and touch no Android class.
|
||||
*
|
||||
* The scale is MEASURED FACT (`punktfunk_core::input::gamepad`: 20 LSB/°·s, 10000 LSB/g) and must
|
||||
* not drift. The axis mapping is straight through and NOT yet verified against hardware — see
|
||||
* [PadSensors.gyroToWire] for the measurement that would settle it. [straightThroughFrame] exists
|
||||
* to make a future remap a deliberate, visible edit rather than a quiet one.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class PadSensorsTest {
|
||||
private fun gyro(x: Float, y: Float, z: Float) =
|
||||
IntArray(3).also { PadSensors.gyroToWire(floatArrayOf(x, y, z), it) }
|
||||
|
||||
private fun accel(x: Float, y: Float, z: Float) =
|
||||
IntArray(3).also { PadSensors.accelToWire(floatArrayOf(x, y, z), it) }
|
||||
|
||||
/** 20 LSB/°·s from Android's rad/s: π rad/s is exactly 180 °/s, so exactly 3600 raw. */
|
||||
@Test
|
||||
fun gyroScaleFromRadiansPerSecond() {
|
||||
assertEquals(3600, gyro(Math.PI.toFloat(), 0f, 0f)[0])
|
||||
assertEquals(-3600, gyro(-Math.PI.toFloat(), 0f, 0f)[0])
|
||||
assertEquals(1146, gyro(1f, 0f, 0f)[0]) // 1 rad/s ⇒ 1145.9156, rounded
|
||||
assertEquals(0, gyro(0f, 0f, 0f)[0])
|
||||
}
|
||||
|
||||
/** 10000 LSB/g from Android's m/s²: standard gravity is exactly 1 g. Android reports specific
|
||||
* force, so a pad at rest reads +1 g on the axis pointing up — no sign flip anywhere. */
|
||||
@Test
|
||||
fun accelScaleFromMetresPerSecondSquared() {
|
||||
assertEquals(10_000, accel(0f, Gamepad.GRAVITY, 0f)[1])
|
||||
assertEquals(-10_000, accel(0f, -Gamepad.GRAVITY, 0f)[1])
|
||||
assertEquals(0, accel(0f, 0f, 0f)[1])
|
||||
}
|
||||
|
||||
/** A controller lying flat and still lands exactly on the host's neutral for a virtual
|
||||
* DualSense — 1 g on wire slot 1 (`punktfunk-core` `MOTION_NEUTRAL_ACCEL = [0, 10000, 0]`),
|
||||
* not the [0,0,0] that means free fall. */
|
||||
@Test
|
||||
fun restingPadIsTheHostNeutral() {
|
||||
assertArrayEquals(intArrayOf(0, 10_000, 0), accel(0f, Gamepad.GRAVITY, 0f))
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame: component i of the sensor sample becomes component i of the wire triple, for both
|
||||
* planes, with no permutation and no negation. UNVERIFIED against hardware — if a Bluetooth
|
||||
* DualSense says otherwise, the remap goes into [PadSensors.gyroToWire] and this test changes
|
||||
* with it. Distinct magnitudes per axis so a swap or a flip cannot cancel out.
|
||||
*/
|
||||
@Test
|
||||
fun straightThroughFrame() {
|
||||
assertArrayEquals(intArrayOf(1146, 2292, 3438), gyro(1f, 2f, 3f))
|
||||
assertArrayEquals(
|
||||
intArrayOf(10_000, 20_000, -30_000),
|
||||
accel(Gamepad.GRAVITY, 2f * Gamepad.GRAVITY, -3f * Gamepad.GRAVITY),
|
||||
)
|
||||
}
|
||||
|
||||
/** Both planes clamp to signed 16 bits rather than wrapping — a flick past 1638 °/s or a knock
|
||||
* past 3.27 g saturates, where a wrap would send a full-speed rotation the other way. */
|
||||
@Test
|
||||
fun clampsToSigned16() {
|
||||
assertArrayEquals(intArrayOf(32767, -32768, 32767), gyro(100f, -100f, 1e9f))
|
||||
assertArrayEquals(intArrayOf(32767, -32768, 32767), accel(1000f, -1000f, 1e9f))
|
||||
}
|
||||
|
||||
/** Rounds to nearest rather than truncating: a truncating converter loses up to a whole LSB
|
||||
* off every sample, always toward zero, and a gyro whose every sample is biased the same way
|
||||
* is a gyro that drifts. */
|
||||
@Test
|
||||
fun roundsToNearestNotTowardZero() {
|
||||
assertEquals(1, gyro(0.0006f, 0f, 0f)[0]) // 0.688 raw — truncation would say 0
|
||||
assertEquals(-1, gyro(-0.0006f, 0f, 0f)[0])
|
||||
assertEquals(1, accel(0.0007f, 0f, 0f)[0]) // 0.714 raw
|
||||
}
|
||||
|
||||
/** A sensor that hands back fewer than three components (or none — the framework reuses one
|
||||
* array across types) contributes zero rather than throwing on the sensor thread. */
|
||||
@Test
|
||||
fun shortSampleIsZeroFilled() {
|
||||
val out = IntArray(3) { 7 }
|
||||
PadSensors.gyroToWire(floatArrayOf(Math.PI.toFloat()), out)
|
||||
assertArrayEquals(intArrayOf(3600, 0, 0), out)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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,14 +64,6 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -12,24 +12,10 @@
|
||||
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
|
||||
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
|
||||
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
|
||||
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
|
||||
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
|
||||
//! XRuns (Google's anti-glitch technique).
|
||||
//!
|
||||
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
|
||||
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
|
||||
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
|
||||
//!
|
||||
//! It is also **A/V synchronised** (`design/audio-latency-overhaul.md`): the decode thread reads the
|
||||
//! host capture `pts_ns` every `AudioPacket` has always carried, compares where this frame will
|
||||
//! actually play against where the picture it belongs with reached glass
|
||||
//! (`decode::DisplayTracker` publishes that), and asks the ring for a depth that closes the gap.
|
||||
//! Only ASKS — `JitterPolicy` clamps the request between its own underrun-driven floor and the hard
|
||||
//! cap, so continuity outranks sync and a link whose jitter genuinely needs more buffer than the
|
||||
//! picture is away keeps its buffer, with the residual reported on the HUD instead of taken out of
|
||||
//! the listener's stream. With no video reference (below API 33 there are no render callbacks, so
|
||||
//! nothing confirms a present) the target stays `None` and the ring behaves exactly as it did.
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
|
||||
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
|
||||
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
|
||||
//! grown on XRuns (Google's anti-glitch technique).
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
@@ -44,30 +30,30 @@ use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// What one playback open attempt yields: the stream, plus both halves of the PCM hand-off — the
|
||||
/// sender the decode thread fills and the receiver that returns drained buffers for refill.
|
||||
///
|
||||
/// Named rather than written inline because the closure's return type trips
|
||||
/// `clippy::type_complexity`, which the Android target is now linted for (`:kit:cargoNdkClippy`)
|
||||
/// after years of nothing checking it.
|
||||
type OpenedPlayback = ndk::audio::Result<(AudioStream, SyncSender<Vec<f32>>, Receiver<Vec<f32>>)>;
|
||||
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
|
||||
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
|
||||
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
|
||||
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
|
||||
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
|
||||
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
|
||||
// underruns/overflows = crackle.
|
||||
//
|
||||
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
|
||||
// the target on the devices that actually underrun instead of every device pre-paying for the worst
|
||||
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
|
||||
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
|
||||
// stayed there — the "audio latency is too high" report.
|
||||
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||
const PRIME_FLOOR_MS: usize = 40;
|
||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||
const PRIME_CEIL_MS: usize = 80;
|
||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||
/// without overflowing.
|
||||
const JITTER_HEADROOM_MS: usize = 80;
|
||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||
const HARD_CAP_MS: usize = 150;
|
||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||
const XRUN_CHECK_EVERY: u32 = 128;
|
||||
|
||||
@@ -112,43 +98,12 @@ impl AudioDec {
|
||||
|
||||
/// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The
|
||||
/// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound).
|
||||
///
|
||||
/// The ring's DEPTH is not here: the A/V sync loop needs the same number in the same units, so it
|
||||
/// is published once through [`punktfunk_core::audio::AudioSyncCell`] and read from there by the
|
||||
/// log line below. One publisher, one reading — a second copy is a second thing to go stale.
|
||||
#[derive(Default)]
|
||||
struct Counters {
|
||||
opus_decoded: AtomicU64, // Opus packets decoded OK (~200/s at 5 ms frames)
|
||||
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
|
||||
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
|
||||
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
|
||||
}
|
||||
|
||||
/// Whether the A/V sync loop runs this session. `false` leaves `JitterPolicy`'s sync target at
|
||||
/// `None`, which reproduces the pre-overhaul ring behaviour exactly — the point of the hatch.
|
||||
///
|
||||
/// Two levers because Android has neither of the other clients' launch surfaces. `PUNKTFUNK_NO_AV_SYNC`
|
||||
/// keeps the contract the desktop clients document (and works when the client is driven from a
|
||||
/// shell), but an app started from the launcher inherits no such environment, so the one a field
|
||||
/// tester can actually reach is the sysprop — `adb shell setprop debug.punktfunk.no_av_sync 1`,
|
||||
/// no rebuild, exactly like `debug.punktfunk.presenter`. A loop that steers PLAYBACK has to be
|
||||
/// bisectable on the device that reports the regression, not only on the bench.
|
||||
fn av_sync_enabled() -> bool {
|
||||
if matches!(
|
||||
std::env::var("PUNKTFUNK_NO_AV_SYNC").as_deref(),
|
||||
Ok("1") | Ok("true")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.no_av_sync".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
!(n > 0 && matches!(&buf[..n as usize], b"1" | b"true"))
|
||||
ring_depth: AtomicU64, // ring sample count at the last callback
|
||||
}
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
|
||||
@@ -171,19 +126,20 @@ impl AudioPlayback {
|
||||
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
|
||||
// denominated jitter-ring depths scale by it.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
|
||||
// Worst transient the ring can hold before the policy trims it.
|
||||
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
|
||||
let prime_floor = PRIME_FLOOR_MS * ms;
|
||||
let prime_ceil = PRIME_CEIL_MS * ms;
|
||||
let jitter_headroom = JITTER_HEADROOM_MS * ms;
|
||||
let hard_cap_max = HARD_CAP_MS * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
// The A/V sync hand-off: the realtime callback owns the ring (so it publishes the depth and
|
||||
// consumes the target), the decode thread owns the timestamps (so it computes the target).
|
||||
// Two atomics, because the callback must not block on the thread that decodes Opus.
|
||||
let sync: Arc<punktfunk_core::audio::AudioSyncCell> = Arc::default();
|
||||
|
||||
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||
// (channels, ring, prime state) is rebuilt per attempt — `open_stream` consumes the builder
|
||||
// AND the callback, so nothing survives a failed try to reuse.
|
||||
let try_open = |sharing: AudioSharingMode| -> OpenedPlayback {
|
||||
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||
AudioStream,
|
||||
SyncSender<Vec<f32>>,
|
||||
Receiver<Vec<f32>>,
|
||||
)> {
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so
|
||||
// the realtime callback never frees heap (Android's Scudo allocator has unbounded free()
|
||||
@@ -194,7 +150,6 @@ impl AudioPlayback {
|
||||
// Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from
|
||||
// a single high-priority thread, and the decode thread only touches `tx`/`free_rx`.
|
||||
let cb_counters = counters.clone();
|
||||
let cb_sync = sync.clone();
|
||||
// Pre-reserve the ring so `extend` never reallocates on the realtime thread. Worst
|
||||
// transient before the trim below = the hard cap plus one full channel of 5 ms (480-f32)
|
||||
// frames — the punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a
|
||||
@@ -202,10 +157,8 @@ impl AudioPlayback {
|
||||
// `decode_loop`.
|
||||
let mut ring: VecDeque<f32> =
|
||||
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
|
||||
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
|
||||
// `if ring.is_empty()` re-prime until now.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
|
||||
let mut primed = false;
|
||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
@@ -220,32 +173,21 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// A/V sync: take whatever depth the decode thread's sync loop last asked for, and
|
||||
// publish where the ring actually is so it can measure the result. The policy
|
||||
// clamps the request between its own underrun floor and the hard cap — continuity
|
||||
// outranks sync, always (see `JitterPolicy::set_sync_target`). Read AFTER the
|
||||
// drain, so the depth is everything a frame queued right now must wait behind.
|
||||
policy.set_sync_target(cb_sync.target());
|
||||
cb_sync.publish_depth(ring.len());
|
||||
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
|
||||
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
|
||||
// target long enough to be drift rather than jitter. Without that shed this ring
|
||||
// had no way back down: it clamped at 120 ms and stayed pinned there.
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
|
||||
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
|
||||
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
|
||||
// quantum.
|
||||
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||
while ring.len() > hard_cap {
|
||||
ring.pop_front();
|
||||
}
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
}
|
||||
if primed {
|
||||
for slot in out.iter_mut() {
|
||||
*slot = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
*slot = ring.pop_front().unwrap_or(0.0);
|
||||
}
|
||||
cb_counters
|
||||
.pcm_written
|
||||
@@ -254,12 +196,20 @@ impl AudioPlayback {
|
||||
out.fill(0.0);
|
||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// No-op while un-primed, so a deliberate priming silence is never counted as an
|
||||
// underrun (which would otherwise drive the adaptive floor up for no reason).
|
||||
policy.note_read(ran_short);
|
||||
// Re-prime only after a RUN of empty callbacks, not a single transient one —
|
||||
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
|
||||
// self-inflicted crackle on any jitter spike).
|
||||
if ring.is_empty() {
|
||||
empties += 1;
|
||||
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||
primed = false;
|
||||
}
|
||||
} else {
|
||||
empties = 0;
|
||||
}
|
||||
cb_counters
|
||||
.target_ms
|
||||
.store(policy.target_ms() as u64, Ordering::Relaxed);
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
|
||||
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
|
||||
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
|
||||
@@ -356,7 +306,7 @@ impl AudioPlayback {
|
||||
let sd = shutdown.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-audio".into())
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels, sync))
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels))
|
||||
.ok();
|
||||
|
||||
Some(AudioPlayback {
|
||||
@@ -387,7 +337,6 @@ fn decode_loop(
|
||||
shutdown: Arc<AtomicBool>,
|
||||
counters: Arc<Counters>,
|
||||
channels: usize,
|
||||
sync: Arc<punktfunk_core::audio::AudioSyncCell>,
|
||||
) {
|
||||
// Fold this Opus→AAudio thread into the client's hot-thread set so the ADPF session the decode
|
||||
// thread opens also keeps audio decode on a fast core (registered before the video pump's first
|
||||
@@ -408,44 +357,9 @@ fn decode_loop(
|
||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||
|
||||
// A/V sync (audio latency overhaul). This thread is the only place holding all three
|
||||
// ingredients at once: the packet's host capture `pts_ns`, the ring depth (via the sync cell)
|
||||
// and the video plane's end-to-end figure. `pts_ns` arrived in every `AudioPacket` and was
|
||||
// dropped on the floor here for the plane's whole existence, which is why audio ran at whatever
|
||||
// depth its jitter ring settled at with nothing ever placing it against the picture.
|
||||
let av_sync_enabled = av_sync_enabled();
|
||||
let mut av = punktfunk_core::audio::AvSync::new(channels as u8);
|
||||
let video_e2e = client.video_e2e_shared();
|
||||
let av_offset_out = client.audio_av_offset_shared();
|
||||
let buffer_ms_out = client.audio_buffer_ms_shared();
|
||||
if !av_sync_enabled {
|
||||
log::info!("audio: A/V sync disabled (PUNKTFUNK_NO_AV_SYNC / debug.punktfunk.no_av_sync)");
|
||||
}
|
||||
'pump: while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_audio(Duration::from_millis(5)) {
|
||||
Ok(pkt) => {
|
||||
// Place this frame against the picture it belongs with, BEFORE it is queued:
|
||||
// `buffered_ahead` is everything that must still play first, so the depth read here
|
||||
// is exactly what delays it.
|
||||
let depth = sync.depth();
|
||||
// Published unconditionally — the ring's depth is worth seeing even with sync off,
|
||||
// and it is what makes a "the audio delay is way too high" report triageable at all.
|
||||
buffer_ms_out.store((depth / ms.max(1)) as u32, Ordering::Relaxed);
|
||||
if av_sync_enabled {
|
||||
let ve2e = video_e2e.load(Ordering::Relaxed);
|
||||
av.observe(punktfunk_core::audio::AvSyncObservation {
|
||||
pts_ns: pkt.pts_ns,
|
||||
now_local_ns: punktfunk_core::client::now_realtime_ns(),
|
||||
clock_offset_ns: client.clock_offset_now_ns(),
|
||||
buffered_ahead: depth,
|
||||
// 0 = nothing confirmed on the glass yet (no render callback below API 33,
|
||||
// or the stream has not presented a frame); no reference, no correction.
|
||||
video_e2e_ns: (ve2e > 0).then_some(ve2e),
|
||||
});
|
||||
sync.set_target(av.desired_depth(depth));
|
||||
av_offset_out.store(av.offset_ms() as i64, Ordering::Relaxed);
|
||||
}
|
||||
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
|
||||
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
|
||||
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
|
||||
@@ -493,17 +407,11 @@ fn decode_loop(
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
// `av_ms` is the sync loop's smoothed placement error (+ = audio behind
|
||||
// the picture); 0 with sync off, or before it has a video reference.
|
||||
// Logged next to the depth because a deep ring on a jittery link is
|
||||
// correct and only the offset separates that from audio held late.
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
(depth / ms.max(1)) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
av.offset_ms(),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
|
||||
@@ -204,15 +204,7 @@ pub(super) fn run_async(
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let meter = Arc::new(PresentMeter::new());
|
||||
// The tracker also publishes each confirmed present's end-to-end into the shared cell the audio
|
||||
// plane steers its jitter ring by (`design/audio-latency-overhaul.md`) — video is the master,
|
||||
// and this is the only point that knows when a frame actually reached glass.
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
client.video_e2e_shared(),
|
||||
meter.clone(),
|
||||
);
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
|
||||
|
||||
@@ -5,7 +5,7 @@ use ndk::media::media_codec::MediaCodec;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::latency::now_realtime_ns;
|
||||
@@ -35,16 +35,6 @@ pub(super) struct DisplayTracker {
|
||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// Where the AUDIO plane reads the video leg it has to land with (ns) — `displayed +
|
||||
/// clock_offset − pts`, published on every confirmed present. Written here, read by
|
||||
/// [`crate::audio`]'s sync loop; the two planes never touch each other directly (the presenter
|
||||
/// must not know about audio, and the audio thread cannot see the glass).
|
||||
///
|
||||
/// Published RAW. The HUD shaves the OS present floor off its shown display / end-to-end
|
||||
/// numbers (`StatsOverlay.osFloorMs` — metrics report what Punktfunk controls), but sound has
|
||||
/// to reach the ear when the light reaches the eye, and a floor-shaved reference would place
|
||||
/// audio a whole latch period early on every device. Presentation policy, not physics.
|
||||
video_e2e: Arc<AtomicU64>,
|
||||
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
|
||||
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
@@ -58,13 +48,11 @@ impl DisplayTracker {
|
||||
pub(super) fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
video_e2e: Arc<AtomicU64>,
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
video_e2e,
|
||||
meter,
|
||||
rendered: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
@@ -117,14 +105,7 @@ pub(super) fn install_render_callback(
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
|
||||
if sym.is_null() {
|
||||
// No confirmed present ⇒ no `display` stage AND no reference for the audio plane's A/V
|
||||
// sync, which then stays inert and leaves the ring exactly as it was. The release
|
||||
// instant is NOT substituted: releases target a future vsync, so it runs a whole latch
|
||||
// period (8-21 ms measured) ahead of glass — well outside the loop's deadband, i.e. it
|
||||
// would place audio early on every frame while looking like it was working.
|
||||
log::info!(
|
||||
"decode: no render callback on this API level (<33) — no display stage, no A/V sync"
|
||||
);
|
||||
log::info!("decode: no render callback on this API level (<33) — no display stage");
|
||||
return None;
|
||||
}
|
||||
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
|
||||
@@ -164,10 +145,8 @@ pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) {
|
||||
/// between the frame rendering and the (batchable) callback delivery — to subtract against the
|
||||
/// receipt/decode stamps and the host capture pts. Records the HUD's `displayed` point:
|
||||
/// `end-to-end` = capture→displayed (skew-corrected) and `display` = decoded→displayed
|
||||
/// (single-clock local) — and publishes that end-to-end figure for the audio plane to align
|
||||
/// against, which is the only place in the client that knows when a frame truly reached glass.
|
||||
/// Panic-free by construction (poison-proof lock, saturating math) — an unwind out of an
|
||||
/// `extern "C"` fn would abort the process.
|
||||
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
|
||||
/// unwind out of an `extern "C"` fn would abort the process.
|
||||
unsafe extern "C" fn on_frame_rendered(
|
||||
_codec: *mut ndk_sys::AMediaCodec,
|
||||
userdata: *mut c_void,
|
||||
@@ -207,28 +186,13 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
|
||||
// Always-on half: the presenter's pf-present line reads these with the HUD off.
|
||||
t.meter.note_latch(latch_us);
|
||||
// The glass-to-glass figure, computed ABOVE the HUD gate: the audio plane steers its ring by it
|
||||
// (see `video_e2e`), and a sync loop that only worked while the overlay was up would be off on
|
||||
// the exact devices that report latency — on a Deck-class report the overlay is precisely what
|
||||
// the field cannot reach. The cost is one relaxed load and some integer arithmetic per confirmed
|
||||
// present (≤ the panel rate); the stats LOCK stays behind the gate, which is what that
|
||||
// early-return was really protecting.
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the skew math + the stats lock
|
||||
}
|
||||
let e2e_ns =
|
||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||
// Same (0, 10 s) clamp as every other e2e sample — a vendor's first render callbacks can carry
|
||||
// a garbage `system_nano`, and here that would step the audio ring rather than just a p95.
|
||||
let e2e_valid = e2e_ns > 0 && e2e_ns < 10_000_000_000;
|
||||
if e2e_valid {
|
||||
t.video_e2e.store(e2e_ns as u64, Ordering::Relaxed);
|
||||
}
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the stats lock
|
||||
}
|
||||
t.stats.note_displayed(
|
||||
e2e_valid.then_some((e2e_ns / 1000) as u64),
|
||||
display_us,
|
||||
latch_us,
|
||||
);
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us, latch_us);
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
|
||||
@@ -185,12 +185,9 @@ pub(super) fn run_sync(
|
||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
// The `video_e2e` cell is the audio plane's alignment reference (see `DisplayTracker`): this
|
||||
// legacy loop feeds it too, so A/V sync works with "Low-latency mode" off as well.
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
client.video_e2e_shared(),
|
||||
std::sync::Arc::new(super::presenter::PresentMeter::new()),
|
||||
);
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
@@ -33,21 +33,8 @@ pub(super) fn now_monotonic_ns() -> i64 {
|
||||
};
|
||||
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||
// Explicit widening: `timespec`'s fields are 32-bit on armv7 (`time_t`/`c_long`) and 64-bit on
|
||||
// arm64, so these casts are REQUIRED on one shipping ABI and redundant on the other.
|
||||
//
|
||||
// `:kit:cargoNdkClippy` lints both widths, so it sees the redundant half and flags it; taking
|
||||
// its advice would break the 32-bit build, which is the ABI for the many 32-bit Google TV /
|
||||
// Android TV boxes this client targets. `i64::from`/`.into()` do not escape it either — they
|
||||
// just trade `unnecessary_cast` for `useless_conversion` on the 64-bit side. So the cast stays
|
||||
// and the lint is answered here rather than in whichever build breaks first.
|
||||
#[allow(
|
||||
clippy::unnecessary_cast,
|
||||
reason = "required on 32-bit ABIs; redundant only on 64-bit"
|
||||
)]
|
||||
{
|
||||
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||
}
|
||||
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
|
||||
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||
}
|
||||
|
||||
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
|
||||
|
||||
@@ -18,29 +18,6 @@ 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;
|
||||
@@ -77,15 +54,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
// A pad 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),
|
||||
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)
|
||||
}
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
@@ -182,74 +156,7 @@ 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,8 +37,6 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
@@ -26,15 +26,6 @@ use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TryS
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// What one capture open attempt yields: the stream, plus both halves of the PCM hand-off — the
|
||||
/// receiver the encode worker drains and the sender that returns emptied buffers for reuse. Note
|
||||
/// the pair is the mirror image of [`crate::audio::OpenedPlayback`]'s: here the callback produces
|
||||
/// and the worker consumes.
|
||||
///
|
||||
/// Named rather than written inline for the same reason as that one — `clippy::type_complexity`,
|
||||
/// now that the Android target is actually linted (`:kit:cargoNdkClippy`).
|
||||
type OpenedCapture = ndk::audio::Result<(AudioStream, Receiver<Vec<f32>>, SyncSender<Vec<f32>>)>;
|
||||
|
||||
const CHANNELS: usize = 1;
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// 10 ms per channel @ 48 kHz — half the desktop clients' 20 ms frame, trading a little Opus
|
||||
@@ -93,7 +84,13 @@ impl MicCapture {
|
||||
|
||||
// One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream`
|
||||
// consumes the builder AND the callback, so each try rebuilds the channels it captures).
|
||||
let try_open = |sharing: AudioSharingMode, voice: bool| -> OpenedCapture {
|
||||
let try_open = |sharing: AudioSharingMode,
|
||||
voice: bool|
|
||||
-> ndk::audio::Result<(
|
||||
AudioStream,
|
||||
Receiver<Vec<f32>>,
|
||||
SyncSender<Vec<f32>>,
|
||||
)> {
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
// Recycle free-list, mirroring the playback path: the realtime capture callback must
|
||||
// not touch the allocator (Android's Scudo has unbounded malloc/free tail latency — an
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,7 +145,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -269,16 +268,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
@@ -301,8 +291,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
@@ -404,31 +392,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeEndReason(handle): Int` — WHY the session ended, as a
|
||||
/// `punktfunk_core::client::PunktfunkEndReason` byte (Kotlin mirrors it in `SessionEndReason`).
|
||||
///
|
||||
/// Companion to `nativeSessionEnded`, which only says THAT it ended. Kotlin's watchdog needs both:
|
||||
/// the flag to leave a dead stream, and this to decide what — if anything — to tell the user. A
|
||||
/// player quitting their game and a host dropping off the network both end the session, and until
|
||||
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
|
||||
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
|
||||
/// atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jint {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.client.end_reason() as jint
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||
|
||||
@@ -361,40 +361,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadMotionReaches(handle, declaredPref)` — whether motion sent for a pad that
|
||||
/// declared `declaredPref` (the `GamepadPref` wire byte it passed to `nativeSendGamepadArrival`) can
|
||||
/// actually reach the game, or would be decoded and dropped by a host backend with no motion plane.
|
||||
///
|
||||
/// The whole question is answered here rather than in Kotlin so the reasoning lives in exactly one
|
||||
/// place — [`punktfunk_core::config::pad_motion_reaches`], which carries the argument and the tests.
|
||||
/// A third transcription of it would be a third thing to get subtly wrong, and every way of getting
|
||||
/// it wrong is silent: too strict kills a working gyro, too lax keeps ~250 Hz of samples flowing
|
||||
/// into a host that drops every one.
|
||||
///
|
||||
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
|
||||
/// the `Auto` rule inside the predicate itself.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
declared_pref: jint,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy
|
||||
// values read behind `&self`.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let declared =
|
||||
punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8);
|
||||
u8::from(punktfunk_core::config::pad_motion_reaches(
|
||||
declared,
|
||||
h.client.requested_gamepad,
|
||||
h.client.resolved_gamepad,
|
||||
))
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
|
||||
@@ -61,11 +61,6 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
@@ -104,14 +99,6 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -121,8 +108,6 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,12 +177,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 35 doubles
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
@@ -203,10 +203,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure,
|
||||
/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the
|
||||
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
|
||||
/// newest-wins pacing); 33/34 are the AUDIO plane's latency — the playback ring's live depth in ms
|
||||
/// and the A/V sync loop's smoothed offset in ms (positive = audio behind the picture) — both live
|
||||
/// gauges rather than windowed samples, like the cumulative drop total at 9), or `null` when no
|
||||
/// decode thread is running.
|
||||
/// newest-wins pacing)), or `null` when no decode thread is running.
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
@@ -230,7 +227,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 35] = [
|
||||
let buf: [f64; 33] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -284,15 +281,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
snap.feed_p50_ms,
|
||||
snap.codec_p50_ms,
|
||||
snap.skipped_overflow as f64,
|
||||
// The audio plane's own latency (`design/audio-latency-overhaul.md`): how much decoded
|
||||
// audio is queued ahead of the speaker, and where the A/V sync loop measures that
|
||||
// PUTS it relative to the picture (+ = audio behind). Both, because a deep ring on a
|
||||
// jittery link is correct behaviour and only the offset tells that apart from audio
|
||||
// simply held late. Live gauges written by the audio thread — before this the whole
|
||||
// plane published nothing any surface could render, so a "the audio delay is way too
|
||||
// high" report had no instrument behind it at all.
|
||||
h.client.audio_buffer_ms() as f64,
|
||||
h.client.audio_av_offset_ms() as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
@@ -472,111 +460,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -19,14 +19,6 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- NOTE: there is deliberately NO NSAppTransportSecurity dict here. ATS stays fully ON.
|
||||
The host is self-signed at a user-supplied address, which default ATS can never accept
|
||||
(it exempts only .local, unqualified names, and RFC1918/link-local literals — notably NOT
|
||||
Tailscale's 100.64/10 CGNAT range), so the management API talks over MgmtTransport
|
||||
(Network.framework), which is outside the URL loading system and pins the host by
|
||||
SHA-256 fingerprint instead. That leaves cover-art CDN fetches as the app's only
|
||||
URLSession traffic, and they keep the full ATS policy — which is the whole reason not to
|
||||
reach for NSAllowsArbitraryLoads here. See MgmtTransport.swift. -->
|
||||
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
|
||||
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
|
||||
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
|
||||
|
||||
@@ -121,11 +121,7 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
||||
host's virtual pad.
|
||||
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
||||
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
|
||||
every main push. See the script header for details. The script's `SCENES` array is the listing
|
||||
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
|
||||
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
|
||||
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
|
||||
(a stranger's hostname reached the live listing that way once).
|
||||
every main push. See the script header for details.
|
||||
- Deeper design notes live in the internal planning repo (punktfunk-planning:
|
||||
`apple-stage2-presenter.md`).
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user