Compare commits

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

atiadlxx.dll is bound dynamically (absent = clean exit 2), structs mirror
adl_structures.h verbatim, and the probe touches only connectors the
board-layout walk enumerated. Gates: check/clippy -D warnings (msvc
cross-target) + fmt clean; native stub unaffected.
2026-08-04 23:30:51 +02:00
728 changed files with 21032 additions and 174029 deletions
-68
View File
@@ -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"
-16
View File
@@ -160,22 +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
+2 -19
View File
@@ -41,23 +41,9 @@ jobs:
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
# is a raw textual substitution performed BEFORE the shell sees the line, so a
# workflow_dispatch input containing shell syntax executes as this step — and this is the
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="$INPUT_TAG"
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
case "$TAG" in
v[0-9]*) ;;
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
esac
TAG="${{ inputs.tag }}"
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
@@ -81,7 +67,4 @@ jobs:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
# Same reasoning as the publish step above: the input is data in the environment, never
# text spliced into the command line.
INPUT_TAG: ${{ inputs.tag }}
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
+8 -206
View File
@@ -48,26 +48,7 @@ on:
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
tags: ['v*']
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
# whole transaction at once, so it did not merely block our package, it blocked those users'
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
workflow_dispatch:
inputs:
release_tag:
description: 'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
required: false
default: ''
pkgrel:
description: 'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
required: false
default: '2'
env:
REGISTRY: git.unom.io
@@ -113,52 +94,7 @@ jobs:
}
bun --version
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
# on this one axis. So heal it in-job and shout, instead of building a dead package.
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
- name: FFmpeg soname parity with today's Arch (heals a stale builder image)
run: |
export LC_ALL=C # `Provides` is a localized field name
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
sonames() { sed -n 's/^Provides *: *//p' | tr ' ' '\n' | grep -E '^lib(av|sw)[a-z]*\.so=' | sort | tr '\n' ' '; }
# A SEPARATE --dbpath: this refreshes only a throwaway view of the repos, so the
# container's own db never enters the partial-upgrade state a bare `pacman -Sy` leaves.
mkdir -p /tmp/pf-archsync
if ! pacman -Sy --dbpath /tmp/pf-archsync --logfile /dev/null >/dev/null 2>&1; then
echo "::warning::could not refresh the Arch db — skipping the FFmpeg parity check"
exit 0
fi
HAVE="$(pacman -Qi ffmpeg | sonames)"
WANT="$(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sonames)"
echo "builder ffmpeg $(pacman -Q ffmpeg | cut -d' ' -f2): $HAVE"
echo "arch ffmpeg $(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sed -n 's/^Version *: *//p'): $WANT"
if [ "$HAVE" = "$WANT" ]; then
echo "OK: the builder links the FFmpeg every up-to-date Arch box already has"
exit 0
fi
echo "::warning::arch-ci is stale ACROSS AN FFMPEG SONAME BUMP — upgrading it for this run."
echo "::warning::Bump the 'refreshed:' date in ci/arch-ci.Dockerfile so the IMAGE carries it."
pacman -Syu --noconfirm || true
HAVE="$(pacman -Qi ffmpeg | sonames)"
if [ "$HAVE" != "$WANT" ]; then
echo "::error::builder still links $HAVE while Arch ships $WANT."
echo "::error::Building on would publish a package no Arch box can install."
exit 1
fi
echo "healed: builder now links $HAVE"
- uses: actions/checkout@v4
with:
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
# = checkout's own default, i.e. the triggering ref, for every other trigger.
ref: ${{ github.event.inputs.release_tag }}
# Cache cargo's git dir too, not just the registry: the workspace includes
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
@@ -191,30 +127,12 @@ jobs:
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
# it to `1.` would sort canaries ABOVE the release and is not an option.)
env:
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
REBUILD_PKGREL: ${{ github.event.inputs.pkgrel }}
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
if [ -n "${RELEASE_TAG:-}" ]; then
# Dispatched rebuild of a published release (see the workflow_dispatch note at the
# top): same upstream version, higher pkgrel, straight into the stable repo.
# ⚠ Keep that pkgrel SINGLE-DIGIT. Gitea's Arch registry picks the version its .db
# advertises by STRING order (the same trap the canary zero-padding below exists for),
# so "0.25.0-10" sorts BELOW "0.25.0-2" and the rebuild would never be advertised.
V="${RELEASE_TAG#v}"
R="${REBUILD_PKGREL:-2}"
REPO=punktfunk
case "$R" in
''|*[!0-9.]*) echo "::error::pkgrel '$R' is not digits+dots"; exit 1 ;;
1) echo "::error::pkgrel 1 is the published build — a rebuild MUST go up (2, 3, …)"; exit 1 ;;
esac
else
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
esac
fi
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
esac
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
echo "REPO=$REPO" >> "$GITHUB_ENV"
@@ -255,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.
@@ -317,63 +210,6 @@ jobs:
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
# THE GATE THIS PIPELINE WAS MISSING. The soname assert above proves the libav dep is
# VERSIONED; it cannot prove the version is one that EXISTS. v0.25.0 passed it and still
# shipped `libavcodec.so=62-64` to a world that had moved to 63 — every affected user got
# "unable to satisfy dependency … required by punktfunk-host", and because pacman prepares
# one transaction, their whole system upgrade stopped there. So ask the only question that
# matters before publishing: would a real, up-to-date Arch box install this?
#
# An empty --dbpath is what makes the answer honest. It means "nothing is installed", so
# pacman must satisfy every dependency FROM THE REPOS exactly as a user's box does. Checking
# against the builder's own installed set instead would let a stale ffmpeg satisfy the stale
# bound and hide the break completely — the very illusion that shipped v0.25.0. `--print`
# resolves and prints; it downloads nothing and installs nothing. Verified against the real
# broken artifact on an ffmpeg-9 box: it reproduces the user-visible failure verbatim.
- name: Assert every package installs on an up-to-date Arch box
run: |
export LC_ALL=C
mkdir -p /tmp/pf-instcheck
if ! pacman -Sy --dbpath /tmp/pf-instcheck --logfile /dev/null >/dev/null 2>&1; then
echo "::error::could not sync the Arch db — cannot prove these packages install"
exit 1
fi
check() { # check FILE -> 0 installable, 1 not (reason on stdout)
pacman -U --print --noconfirm --dbpath /tmp/pf-instcheck --logfile /dev/null "$1" 2>&1
}
ls dist/*.pkg.tar.zst >/dev/null 2>&1 || { echo "::error::nothing in dist/ to check"; exit 1; }
rc=0
for pkg in dist/*.pkg.tar.zst; do
if out="$(check "$pkg")"; then
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
else
rc=1
echo "::error::$(basename "$pkg") CANNOT be installed on an up-to-date Arch box:"
echo "$out" | sed 's/^/ /'
fi
done
# gamescope stays best-effort, exactly as its build step is: a companion that cannot
# install is dropped from the upload with a warning, never a reason to withhold the
# packages this workflow exists to publish. (It is also the one package that can be
# restored from a cache older than the current Arch snapshot.)
for pkg in dist-gamescope/*.pkg.tar.zst; do
[ -e "$pkg" ] || continue
if out="$(check "$pkg")"; then
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
else
echo "::warning::$(basename "$pkg") is not installable on current Arch — NOT publishing it"
echo "$out" | sed 's/^/ /'
rm -f "$pkg"
fi
done
if [ "$rc" != 0 ]; then
echo "::error::refusing to publish: pacman would reject this on a current box, and a"
echo "::error::rejected dependency blocks the user's ENTIRE upgrade, not just punktfunk."
echo "::error::Usual cause: the arch-ci builder image lags Arch across a soname bump —"
echo "::error::bump 'refreshed:' in ci/arch-ci.Dockerfile, let docker.yml republish it, re-run."
exit 1
fi
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
# per-partition-set), which is the standing packaging verdict behind the on-device
@@ -401,48 +237,14 @@ jobs:
done
echo "published to $OWNER/arch/$REPO"
# On a real release, also attach the packages to the unified Gitea Release. A dispatched
# rebuild attaches to that SAME release object: the release page is a distribution surface
# too, and leaving the superseded .pkg.tar.zst sitting on it is one click away from handing
# someone the exact break the rebuild exists to fix.
- name: Attach packages to the Gitea release (stable tags + release rebuilds)
if: startsWith(gitea.ref, 'refs/tags/v') || github.event.inputs.release_tag != ''
# On a real release, also attach the packages to the unified Gitea Release.
- name: Attach packages to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
run: |
. scripts/ci/gitea-release.sh
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
RID=$(ensure_release "$TAG" "$TAG" auto)
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for pkg in dist/*.pkg.tar.zst; do
upsert_asset "$RID" "$pkg"
done
# A rebuild bumps pkgrel, so its FILENAMES differ from the ones already attached, and
# upsert_asset only replaces by name — the superseded set would survive untouched.
# Drop every pacman asset (and .sha256 sidecar) this upload did not just write.
#
# ⚠⚠ THIS MUST LIVE IN THE WORKFLOW, NOT IN scripts/ci/gitea-release.sh. The sourced
# script comes from the CHECKED-OUT TREE, which on a release rebuild is the OLD TAG —
# so it can only ever offer the helpers that existed when that tag was cut. A helper
# added for this feature is therefore guaranteed ABSENT in the one code path that
# calls it: the first attempt failed with `prune_release_assets: command not found`
# after publishing perfectly. Only the workflow file itself is taken from the ref you
# dispatch. Same reason a packaging fix made after a tag does NOT reach a rebuild of
# that tag — the PKGBUILD is the tag's too.
if [ -n "${RELEASE_TAG:-}" ]; then
KEEP="$(cd dist && printf '%s ' *.pkg.tar.zst)"
# An UNMATCHED glob would come through literally and match nothing in the keep set —
# i.e. "delete every pacman asset on the release". Skip entirely instead.
case "$KEEP" in *'*'*) KEEP="" ;; esac
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
if [ -n "$KEEP" ]; then
curl -fsS "$API/releases/$RID/assets" -H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
| while read -r id name; do
[ -n "$id" ] || continue
echo "dropping superseded release asset: $name"
curl -fsS -o /dev/null -X DELETE "$API/releases/$RID/assets/$id" \
-H "Authorization: token $GITEA_TOKEN" || true
done
fi
fi
+1 -6
View File
@@ -29,9 +29,4 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tier-3 GPU stream benchmark
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
# as this step (2026-08-05 review H-6).
env:
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
-33
View File
@@ -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
+3 -8
View File
@@ -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
+1 -4
View File
@@ -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
View File
@@ -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:
+4 -11
View File
@@ -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.
-167
View File
@@ -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
-7
View File
@@ -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
+3 -6
View File
@@ -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
+3 -11
View File
@@ -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"
+10 -4
View File
@@ -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) {
+12 -13
View File
@@ -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*') {
+31 -32
View File
@@ -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 }}
-717
View File
@@ -1,717 +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 3539 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 (2042 ms/unit at `n_fc=2` vs 2153 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 45 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 821 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.
🛑 **The v0.25.0 Arch packages shipped with that bound pointing at the WRONG FFmpeg — install
`punktfunk-host 0.25.0-2` or newer.** The soname fix and the FFmpeg-9 build landed as one merge;
the release tag was pushed four minutes later, while the CI builder image was still being
rebuilt. arch.yml deliberately runs no `-Syu` ("the image's snapshot IS the build environment"),
so the release was linked against FFmpeg 8 and published `libavcodec.so=62-64` — a bound no
up-to-date Arch box can satisfy. It fails *safely* (pacman refuses; nothing bricks), but it fails
**loudly and broadly**: pacman prepares one transaction, so an unsatisfiable dependency of ours
stopped affected users' entire `pacman -Syu`. `0.25.0-2` is the identical source rebuilt against
FFmpeg 9. Only Arch was exposed — every other format derives its dependency from the ELF at build
time and could not disagree with itself this way.
Two guards now stand where only a convention did. arch.yml compares the builder's libav
`provides` against the live repos before building and `-Syu`s itself if they differ; and no
package is published until a **pristine-`--dbpath`** `pacman -U --print` resolves it, which asks
"would a real, up-to-date Arch box install this?" instead of "does the builder happen to satisfy
it?" — the distinction that let this ship. Keeping `ci/arch-ci.Dockerfile` current is still the
cheap path; the guards are the backstop.
### 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 3539 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
View File
@@ -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
View File
@@ -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.24.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.24.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.24.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.24.0"
dependencies = [
"bindgen",
"cmake",
@@ -2498,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.25.0"
version = "0.24.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.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3017,41 +2871,33 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.25.0"
version = "0.24.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.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3069,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.25.0"
version = "0.24.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.24.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.24.0"
dependencies = [
"ash",
"bindgen",
"pkg-config",
]
[[package]]
name = "pf-frame"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"libc",
@@ -3136,7 +2981,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3150,11 +2995,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.25.0"
version = "0.24.0"
[[package]]
name = "pf-inject"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3183,20 +3028,20 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.25.0"
version = "0.24.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.24.0"
dependencies = [
"serde",
"serde_json",
@@ -3213,7 +3058,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.25.0"
version = "0.24.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.24.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.24.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3290,7 +3115,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.25.0"
version = "0.24.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.24.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3524,7 +3334,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.25.0"
version = "0.24.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.24.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3559,7 +3367,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3574,9 +3382,10 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.25.0"
version = "0.24.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.24.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.24.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3710,7 +3519,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3724,7 +3533,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ksni",
@@ -3747,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.25.0"
version = "0.24.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
View File
@@ -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.24.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -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
View File
@@ -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
+6 -12
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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.
+12 -337
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.25.0"
"version": "0.23.0"
},
"paths": {
"/api/v1/clients": {
@@ -997,7 +997,7 @@
"library"
],
"summary": "List the game library",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
"operationId": "getLibrary",
"parameters": [
{
@@ -1021,13 +1021,13 @@
],
"responses": {
"200": {
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
"description": "Unified library across all stores",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OperatorGameEntry"
"$ref": "#/components/schemas/GameEntry"
}
}
}
@@ -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": [
{
@@ -1301,86 +1301,13 @@
}
}
},
"/api/v1/library/hidden/{id}": {
"put": {
"tags": [
"library"
],
"summary": "Hide or un-hide one library title",
"description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
"operationId": "setLibraryEntryHidden",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The library entry id (e.g. `steam:70`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HiddenToggle"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Stored; the entry's visibility after the call",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HiddenState"
}
}
}
},
"400": {
"description": "Empty entry id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/provider/{provider}": {
"put": {
"tags": [
"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": [
{
@@ -1391,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": {
@@ -1430,7 +1348,7 @@
}
},
"400": {
"description": "Invalid provider id, store id, or payload",
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
@@ -1449,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": {
@@ -4118,51 +4026,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.",
@@ -4296,8 +4159,7 @@
"tier",
"platforms",
"compatible",
"update_available",
"categories"
"update_available"
],
"properties": {
"author": {
@@ -4310,13 +4172,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?"
@@ -4324,13 +4179,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",
@@ -4517,17 +4365,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"
}
@@ -4572,10 +4409,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"
}
@@ -4634,17 +4467,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",
@@ -4665,15 +4487,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
}
}
},
@@ -4902,27 +4715,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": [
{
@@ -5373,10 +5165,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\"`.",
@@ -5508,14 +5296,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).",
@@ -5626,37 +5406,6 @@
}
}
},
"HiddenState": {
"type": "object",
"description": "What `setLibraryEntryHidden` echoes back.",
"required": [
"id",
"hidden"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "Its visibility after the call."
},
"id": {
"type": "string",
"description": "The entry id the call addressed."
}
}
},
"HiddenToggle": {
"type": "object",
"description": "Request body for `setLibraryEntryHidden`.",
"required": [
"hidden"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "Whether this title should be hidden from every play surface."
}
}
},
"HookEntry": {
"type": "object",
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
@@ -6443,23 +6192,6 @@
}
}
},
"OperatorGameEntry": {
"allOf": [
{
"$ref": "#/components/schemas/GameEntry"
},
{
"type": "object",
"properties": {
"hidden": {
"type": "boolean",
"description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
}
}
}
],
"description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
},
"PairedClient": {
"type": "object",
"description": "A paired (certificate-pinned) Moonlight client.",
@@ -6602,13 +6334,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 (164 chars; control chars stripped)."
@@ -6641,13 +6366,6 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "The plugin's kind — see [`PluginRegistration::category`]."
},
"id": {
"type": "string"
},
@@ -6886,10 +6604,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"
}
@@ -6971,17 +6685,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."
@@ -7077,46 +6780,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."
}
}
},
@@ -7279,14 +6962,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 -25
View File
@@ -9,36 +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.
#
# ⚠ AND KNOW WHY THAT WAS NOT ENOUGH: bumping this date only helps once docker.yml has actually
# republished the image, and nothing sequences the two workflows. v0.25.0 was tagged four minutes
# after the ffmpeg-9 merge, so the release build still pulled the FFmpeg-8 `:latest` and published
# a punktfunk-host that no up-to-date Arch box could install — which blocks the user's ENTIRE
# `pacman -Syu`, not just our package. arch.yml therefore no longer trusts this image on that one
# axis: it compares the builder's libav sonames against the repos before building (and `-Syu`s
# itself if they differ), and refuses to publish anything a pristine-db `pacman -U --print` says
# is unsatisfiable. This file staying current is still the CHEAP path — those guards are the
# backstop, not the plan.
# 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 \
+2 -4
View File
@@ -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
+3 -4
View File
@@ -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
-8
View File
@@ -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
+3 -6
View File
@@ -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,
)
}
@@ -85,7 +85,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 +117,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 +139,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 +152,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 +198,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 +214,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 +230,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))
@@ -282,7 +271,6 @@ fun GamepadPinHostsDialog(
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) }
@@ -316,7 +304,7 @@ fun GamepadPinHostsDialog(
.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),
) {
@@ -324,7 +312,7 @@ fun GamepadPinHostsDialog(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -362,7 +350,6 @@ fun GamepadPinHostsDialog(
@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.
@@ -389,7 +376,7 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -463,11 +450,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 +518,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 +525,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 +542,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 +587,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 +615,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,7 +56,6 @@ 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
@@ -65,35 +63,10 @@ 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.
/**
* 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"),
}
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
internal 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,
@@ -127,8 +100,6 @@ 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 }
@@ -162,34 +133,10 @@ fun GamepadSettingsScreen(
// 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) +
val rows = buildSettingsRows(s, hasBodyVibrator, 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 }
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) }
@@ -204,28 +151,20 @@ fun GamepadSettingsScreen(
active = navActive && pinProfile == null,
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++
// 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 -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, 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; liveRow(rows, 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 +183,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 +196,12 @@ 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 = {
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
// its detail explains itself) but never flips it.
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
})
}
}
}
@@ -298,23 +218,8 @@ fun GamepadSettingsScreen(
// 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 },
PadGlyph.hint('B', "Done", onClick = onBack),
) else sections + when {
when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
@@ -349,7 +254,6 @@ fun GamepadSettingsScreen(
@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
@@ -361,7 +265,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
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 +274,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),
)
@@ -396,7 +300,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
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.copy(alpha = if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
@@ -404,7 +308,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 +329,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 +342,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,23 +352,21 @@ 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. */
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
internal 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,
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, 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,
@@ -483,10 +385,10 @@ internal fun buildSettingsRows(
)
}
fun toggle(
id: String, tab: GpTab, header: String?, label: String, detail: String,
id: String, header: String?, label: String, detail: String,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, tab, header, label,
id, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
@@ -495,13 +397,36 @@ internal fun buildSettingsRows(
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,56 +440,55 @@ 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",
"padForward", "Controllers", "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.",
@@ -575,18 +499,18 @@ internal fun buildSettingsRows(
// 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", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
"systemButtons", 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",
"guideGesture", 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,
@@ -594,7 +518,7 @@ internal fun buildSettingsRows(
) + 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,23 +526,11 @@ 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,
@@ -628,53 +540,20 @@ internal fun buildSettingsRows(
// 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)",
"dsCapture", 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
* instead of a dead-looking empty header. 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).
@@ -695,8 +574,7 @@ private fun buildProfileRows(
return listOf(
GpRow(
id = "noProfiles",
tab = GpTab.PROFILES,
header = null,
header = "Profiles",
label = "No profiles yet",
value = "",
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
@@ -708,13 +586,12 @@ private fun buildProfileRows(
),
)
}
return profiles.map { p ->
return profiles.mapIndexed { i, 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,
header = if (i == 0) "Profiles" else null,
label = p.name,
value = when (pins) {
0 -> "Not pinned"
@@ -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),
)
}
@@ -105,16 +105,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 +148,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 +170,6 @@ data class Settings(
*/
val dsCapture: Boolean = true,
/**
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
*
* The pad's own 4-channel audio device carries them, driven directly over usbfs Android's
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
* ordinary rumble (tier C), which on this client already drives the same actuators.
*/
val padHaptics: Boolean = true,
/**
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics]
* the host sends the two as separate streams and either can play alone. Off by default: the
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
* duplicates audio they are already hearing.
*/
val padSpeaker: Boolean = false,
/**
* How a physical mouse drives the host the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -304,17 +264,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
@@ -345,17 +301,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()
@@ -385,7 +337,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 +353,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 +398,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 +432,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 +491,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"),
@@ -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
@@ -850,8 +836,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
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",
@@ -890,18 +875,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.
@@ -923,22 +896,6 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
// Both only ever apply to a captured pad, so they follow that row and gate on it.
ToggleRow(
title = "Controller haptics",
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
"the pad keeps ordinary rumble for games that don't send them",
checked = s.padHaptics,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
)
ToggleRow(
title = "Controller speaker",
subtitle = "Play audio the game sends to the controller's own speaker",
checked = s.padSpeaker,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
)
}
}
}
@@ -18,34 +18,21 @@ import kotlin.math.roundToInt
* The live stats overlay the unified HUD (`design/stats-unification.md`): headline is
* `capturedisplayed` tiled by `host+network` + `decode` + `display` when the platform delivered
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
* `capturedecoded` headline without the `display` term when it didn't. Reads the 35-double
* `capturedecoded` 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 (1821) when nonzero.
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), 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 (1013), 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 capturedisplayed 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 purposewhich 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 lostthe host may be asleep. Wake it to reconnect.",
Toast.LENGTH_LONG,
).show()
onDisconnect()
return@LaunchedEffect
}
}
@@ -366,7 +330,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// 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 +338,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 +434,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
@@ -576,28 +507,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
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 +544,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 +595,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 +603,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 +810,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 +896,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)
}
}
@@ -24,7 +24,6 @@ class GamepadSettingsRowsTest {
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
hasGyroscope = true,
av1Capable = true,
) { sink += it }
@@ -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 = {},
)
}
}
+19 -61
View File
@@ -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,96 +105,12 @@ class DsCapture(
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
ensureSlot(m)
onActiveChanged?.invoke(true)
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
@@ -250,10 +124,6 @@ class DsCapture(
resetRichFeedback(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,126 +135,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()
@@ -416,8 +181,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) {
@@ -473,10 +238,6 @@ class DsCapture(
// write — as this used to — meant a discarded stop left the motors running with
// nothing scheduled to try again; a USB pad holds its last level until told zero.
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
// haptics stream is live the coils it drives were muted by the very write that
// silenced the motors. Give them back.
if (sent && padAudioStarted) restoreAudioHaptics()
}
}
@@ -589,9 +350,5 @@ class 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,21 +276,6 @@ object DsDevice {
* the classic compat-vibration path AND `VIBRATION2` (firmware 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) the host parser accepts either.
*/
/**
* B6: hand the voice coils back to the audio-haptics path.
*
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
* "disable audio haptics" bit the firmware mutes the coils the 0xD1 haptics stream drives.
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
*
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
* client, which is the same packet one transport over.
*/
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
@@ -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/ 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
@@ -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,8 +31,7 @@ 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,
@@ -71,18 +69,7 @@ class GamepadRouter(
) {
/** 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
@@ -98,33 +85,12 @@ class GamepadRouter(
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 +115,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
@@ -365,82 +320,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]
@@ -471,7 +357,7 @@ class GamepadRouter(
/** 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 && forwarding) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
@@ -487,26 +373,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()
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index, motionReaches)
return ExternalPad(syntheticId, index)
}
/**
@@ -562,18 +437,8 @@ class GamepadRouter(
// 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),
)
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
}
@@ -98,40 +98,6 @@ class HidUsbLink(
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
*
* **Not a convenience a correctness requirement.** `UsbDeviceConnection.requestWait()`
* returns *any* completed request on that connection, and the same is true of the usbfs reap
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
* other's completions. This link's reader owns its connection exclusively (see the note on
* [outQueue]), so anything else driving transfers on this device the isochronous audio
* renderer must open its own.
*
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
* interface), so a claim made on this connection does not conflict with one made on that.
*
* The caller owns the returned connection and must close it.
*/
fun openAuxConnection(): UsbDeviceConnection? {
val dev = device ?: return null
return usb.openDevice(dev)
}
/**
* The open connection's usbfs file descriptor, or -1 when the link is not running.
*
* Handed to native code that drives interfaces this link deliberately does NOT claim the
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
* HID claim untouched.
*
* **The borrower must stop using it before [stop] runs**: closing the connection while a
* transfer is in flight pulls the descriptor out from under the kernel.
*/
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
@@ -442,42 +408,6 @@ class HidUsbLink(
return n >= 0
}
/**
* 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].
*
@@ -505,13 +435,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 capturedecoded headline; 1013
* 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` = receivedqueued (hand-off + input-slot wait),
* `codec` = queueddecoded, 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,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 (yawRY, pitchRX, rollRZ) 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/, 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,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 devicecontroller 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,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/: 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)
}
}
-8
View File
@@ -64,14 +64,6 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
# should move when we choose to. Becomes a plain version dependency once the crates are published.
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
[lints]
workspace = true
+12 -106
View File
@@ -20,16 +20,6 @@
//! (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.
use ndk::audio::{
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
@@ -44,14 +34,6 @@ 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;
@@ -112,45 +94,15 @@ 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)
ring_depth: AtomicU64, // ring sample count at the last callback
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"))
}
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
pub struct AudioPlayback {
_stream: AudioStream, // dropping it stops + closes the AAudio stream
@@ -175,15 +127,15 @@ impl AudioPlayback {
// Worst transient the ring can hold before the policy trims it.
let hard_cap_max = tuning.hard_cap_ms as usize * 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 +146,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
@@ -220,13 +171,6 @@ 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
@@ -257,6 +201,9 @@ impl AudioPlayback {
// 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);
cb_counters
.ring_depth
.store(ring.len() as u64, Ordering::Relaxed);
cb_counters
.target_ms
.store(policy.target_ms() as u64, Ordering::Relaxed);
@@ -356,7 +303,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 +334,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 +354,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 +404,12 @@ 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={} buffer_ms={} target_ms={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
(depth / ms.max(1)) as u64,
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
counters.target_ms.load(Ordering::Relaxed),
av.offset_ms(),
);
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
+9 -45
View File
@@ -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);
+2 -15
View File
@@ -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
-13
View File
@@ -77,14 +77,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
// handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble_command(PULL_TIMEOUT) {
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
// write, and that bit disables the audio-haptics path — so one replayed command would
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
@@ -182,11 +174,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return -1;
}
};
n as jint
})
-2
View File
@@ -37,8 +37,6 @@ mod discovery;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
mod pad_audio;
mod session;
mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
+7 -10
View File
@@ -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
+1 -38
View File
@@ -145,7 +145,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
timeout_ms: jint,
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
@@ -269,16 +268,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
// should say what the client does).
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
// so declaring a pad's render caps later would have nothing to gate. Gated on the
// settings so a user with pad audio off does not make the host provision endpoints.
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
| if pad_audio_ok != 0 {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
},
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
@@ -301,8 +291,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
#[cfg(target_os = "android")]
pad_audio: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
@@ -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
-15
View File
@@ -61,11 +61,6 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
/// `Option` because a session may have no wired DualSense at all, which is the common case.
#[cfg(target_os = "android")]
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
@@ -104,14 +99,6 @@ impl SessionHandle {
fn stop_mic(&self) {
let _ = self.mic.lock().unwrap().take();
}
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
/// `UsbDeviceConnection`. Idempotent.
#[cfg(target_os = "android")]
pub(crate) fn stop_pad_audio(&self) {
let _ = self.pad_audio.lock().unwrap().take();
}
}
impl Drop for SessionHandle {
@@ -121,8 +108,6 @@ impl Drop for SessionHandle {
self.stop_audio();
#[cfg(target_os = "android")]
self.stop_mic();
#[cfg(target_os = "android")]
self.stop_pad_audio();
}
}
+4 -121
View File
@@ -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 021 match the previous 22-double layout — 013 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
-8
View File
@@ -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
@@ -206,20 +206,6 @@ struct ContentView: View {
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
}
#if os(iOS) || os(tvOS)
// Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do
// NOT fire across background/foreground, and a browse the system suspended while we were
// away does not resume on its own so the host grid came back empty and stayed empty
// until the app was relaunched. No-op unless the browse is already running (mid-session
// the home has deliberately torn it down).
//
// Mobile only: macOS never suspends the process, and its `scenePhase` flips on every
// window focus change re-arming there would rebuild the browser each time you alt-tab.
// A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead.
.onChange(of: scenePhase) { _, phase in
if phase == .active { discovery.refreshIfRunning() }
}
#endif
#if os(iOS) || os(tvOS)
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
// ignored so neither branch fires for a Control-Center pull.
//
@@ -349,16 +335,6 @@ struct ContentView: View {
active: fullscreenForSession && model.connection != nil,
isFullscreen: $isFullscreen))
#endif
// A game launched from the library just exited, so the session ended on purpose: put the
// player back in that host's library rather than on host selection. Set on the outer Group
// (like the sheets below) so it survives the streaming home transition the disconnect
// drives, and consumed here the model hands the host over once and we clear it, so a
// later manual dismiss of the library can't be undone by a stale value.
.onChange(of: model.returnToLibrary) { _, host in
guard let host else { return }
model.returnToLibrary = nil
libraryTarget = host
}
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
@@ -384,13 +360,7 @@ struct ContentView: View {
.frame(minWidth: 940, minHeight: 620)
}
#else
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
// of GamepadHomeView's in-place layers (the console shell no bottom-up cover), so the
// proxy hides the target from the cover while that mode owns it; every writer (Y on a
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
// controller arriving or leaving mid-browse hands the open library to whichever
// presentation the new mode owns.
.fullScreenCover(item: touchLibraryTarget) { host in
.fullScreenCover(item: $libraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
@@ -407,14 +377,6 @@ struct ContentView: View {
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
}
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
/// the library in place (see the cover's comment).
private var touchLibraryTarget: Binding<StoredHost?> {
Binding(
get: { gamepadUIActive ? nil : libraryTarget },
set: { libraryTarget = $0 })
}
private var approvalChoicePresented: Binding<Bool> {
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
}
@@ -550,9 +512,6 @@ struct ContentView: View {
waker: waker,
gamepadUI: gamepadUIActive,
onCancelConnect: { model.disconnect() })
// The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so
// it publishes the palette's ink itself rather than inheriting it.
.gamepadPaletteInk()
}
}
@@ -572,8 +531,7 @@ struct ContentView: View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
} else {
HomeView(
store: store, model: model, discovery: discovery,
@@ -589,8 +547,7 @@ struct ContentView: View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
// On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its
// own presenters (exactly one of the two homes is mounted at a time, so these can
@@ -780,15 +737,6 @@ struct ContentView: View {
// other in the seconds where they overlap.
.overlay(alignment: .bottom) {
VStack(spacing: 8) {
// A forwarded pad has a gyro this session's virtual controller cannot
// carry. Shown briefly at every stats tier and with the overlay off: the
// failure is otherwise completely silent the gyro just does nothing
// and the fix is a setting, so the hint has to name it. Every platform,
// including tvOS, where a DualSense is an ordinary way to play.
if captureEnabled, model.motionUnreachableKind != nil {
MotionUnreachableBadge()
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
#if !os(tvOS)
// Shown for as long as the mic is muted, at every stats tier and with the
// overlay off see MicMutedBadge. tvOS has no microphone to mute.
@@ -47,16 +47,12 @@ struct ConnectOverlay: View {
return nil
}
@Environment(\.gamepadInk) private var ink
var body: some View {
if let phase {
ZStack {
if gamepadUI {
// Console: an opaque, living aurora over everything, in the chosen palette.
// The takeover's own text rides `ink`, so a pale palette flips it here too
// without that this is the one console screen that stays white-on-white.
ink.isLight ? Color.white.ignoresSafeArea() : Color.black.ignoresSafeArea()
// Console: an opaque, living aurora over everything.
Color.black.ignoresSafeArea()
GamepadScreenBackground().ignoresSafeArea()
Color.clear.contentShape(Rectangle()).onTapGesture {}
content(phase).padding(40).frame(maxWidth: 460)
@@ -74,8 +70,7 @@ struct ConnectOverlay: View {
.padding(40)
}
}
// The console takeover follows the palette; the default UI's modal stays dark.
.environment(\.colorScheme, gamepadUI && ink.isLight ? .light : .dark)
.environment(\.colorScheme, .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
@@ -83,11 +78,6 @@ struct ConnectOverlay: View {
}
}
/// The overlay's text/glyph colour: the palette's ink in the console takeover over a pale
/// aurora, literal white was the one console surface that stayed white-on-white and white
/// in the touch modal, whose branch is deliberately forced dark over a black scrim.
private var overlayFG: Color { gamepadUI ? ink.fg : .white }
@ViewBuilder private func content(_ phase: Phase) -> some View {
// The takeover carries larger type than the compact modal.
let titleSize: CGFloat = gamepadUI ? 24 : 19
@@ -95,24 +85,21 @@ struct ConnectOverlay: View {
VStack(spacing: gamepadUI ? 16 : 14) {
switch phase {
case .connecting(let name):
ProgressView().controlSize(.large).tint(overlayFG)
ProgressView().controlSize(.large).tint(.white)
Text("Connecting to \(name)")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Establishing a secure connection…")
.font(.geist(bodySize, relativeTo: .caption))
.foregroundStyle(overlayFG.opacity(0.6))
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
case .waking(let w) where w.timedOut:
Image(systemName: "moon.zzz.fill")
.font(.system(size: gamepadUI ? 40 : 34))
.foregroundStyle(overlayFG.opacity(0.9))
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
Text("\(w.hostName) didn't wake")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("It may still be booting, or it's powered off / off this network.")
.font(.geist(bodySize, relativeTo: .caption))
.foregroundStyle(overlayFG.opacity(0.6))
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
@@ -120,13 +107,12 @@ struct ConnectOverlay: View {
}
.padding(.top, 6)
case .waking(let w):
ProgressView().controlSize(.large).tint(overlayFG)
ProgressView().controlSize(.large).tint(.white)
Text("Waking \(w.hostName)")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(bodySize)).foregroundStyle(overlayFG.opacity(0.6))
.monospacedDigit()
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
.buttonStyle(.bordered).padding(.top, 6)
@@ -12,17 +12,8 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let onAdd: (StoredHost) -> Void
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover)
/// falls back to the environment dismiss. Declared AFTER `onAdd` so the existing trailing-
/// closure call sites keep binding to it, not to this.
var close: (() -> Void)?
/// Whether this screen owns the controller false while the shell is mid-transition or the
/// connect takeover is up (see GamepadSettingsView's twin).
var controllerActive = true
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so the keyboard tray still fits.
@@ -44,8 +35,8 @@ struct GamepadAddHostView: View {
items: rows,
focusID: $focusID,
onActivate: { activate(id: $0.id) },
onBack: { performClose() },
isActive: controllerActive && editing == nil
onBack: { dismiss() },
isActive: editing == nil
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -53,24 +44,23 @@ struct GamepadAddHostView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like every gamepad heading and no close chrome (B is the exit).
VStack(spacing: 4) {
Text("Add Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
.foregroundStyle(.white.opacity(0.55))
.multilineTextAlignment(.center)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
}
}
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, compact ? 4 : 8)
.frame(maxWidth: .infinity)
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -82,29 +72,11 @@ struct GamepadAddHostView: View {
.background { GamepadTrayScrim(edge: .bottom) }
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
.background {
if !hostedInShell { GamepadFormBackground() }
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.background { GamepadFormBackground() }
// A port can't exceed 5 digits cap while typing so the row can't grow absurd.
.onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) }
}
#if !os(tvOS)
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
.background {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
#endif
#if os(tvOS)
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
// tray the remote and the pad both drive it natively. Same `editing` state as the
@@ -165,10 +137,22 @@ struct GamepadAddHostView: View {
#endif
}
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
/// the environment dismiss under a macOS sheet / tvOS cover.
private func performClose() {
if let close { close() } else { dismiss() }
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
/// rides the cancel action.
private var closeButton: some View {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
#if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Cancel")
}
// MARK: - Rows
@@ -196,22 +180,22 @@ struct GamepadAddHostView: View {
if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill")
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canAdd ? ink.accent : ink.fg(0.35))
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
.frame(maxWidth: .infinity)
} else {
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
.lineLimit(1)
.truncationMode(.head) // keep the end of a long address visible while typing
if editing == row.id {
// The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle()
.fill(ink.accent)
.fill(Color.brand)
.frame(width: 2, height: m.labelFont + 2)
}
}
@@ -222,12 +206,12 @@ struct GamepadAddHostView: View {
// takes the brand wash, and the edited row keeps its brand caret border.
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
@@ -249,7 +233,7 @@ struct GamepadAddHostView: View {
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(port) ?? 9777))
performClose()
dismiss()
default:
openKeyboard(id)
}
@@ -55,14 +55,7 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// otherwise poll the SAME controller at once driving both. The parent sets this false while
/// something is presented on top so only the front-most carousel consumes the gamepad.
var isActive: Bool = true
/// Whether the cards are worth showing off yet the entrance holds until this is true. The
/// library passes "the first covers have their artwork" (see LibraryCoverflowView); anything
/// whose cards are ready the moment they mount leaves it alone.
var contentReady: Bool = true
/// Builds one card. The `CardEntrance` handed along is the card's share of the strip's
/// arrival, and the caller MUST apply it (`.modifier(entrance)`) *underneath* its own
/// `.scrollTransition` see `CardEntrance` for why that placement is load-bearing.
@ViewBuilder let card: (Item, CardEntrance) -> Card
@ViewBuilder let card: (Item) -> Card
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
@@ -90,26 +83,6 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// confirm and end-stop events (moves trigger on `cursor`).
@State private var activateTick = 0
@State private var boundaryTick = 0
/// The strip's entrance, as ONE timeline: 0 = every card still away, 1 = every card landed
/// (see `CardEntrance`, which slices its own window out of this). Animated exactly once per
/// mount a strip that re-played its entrance every time a screen popped off the top of it
/// would be noise, and the shell's push/pop carries that motion already. So it plays when a
/// screen is entered: the launcher when the gamepad UI comes up, the coverflow each time the
/// library opens (its layer mounts fresh).
///
/// One animated Double rather than a Bool behind per-card `.animation(_:value:)` modifiers,
/// because those modifiers wrap the caller's card INCLUDING its `.scrollTransition` and a
/// delayed spring flipping while the scroll view was still settling captured the transition's
/// own per-frame phase updates, stranding the centred card in a half-receded state until the
/// next scroll re-drove it. Nothing here wraps the card in an animation at all.
@State private var entranceProgress: Double = 0
/// Which card the entrance fans out from the cursor as it stood when the strip was armed,
/// so a restored selection assembles around where the eye already is instead of sweeping in
/// from the left.
@State private var entranceAnchor = 0
/// The entrance has been scheduled; it plays exactly once per mount.
@State private var entranceArmed = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
/// (longer than a move animation, so overlapping held-stick moves never let it through).
@@ -121,27 +94,24 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
ScrollViewReader { proxy in
ScrollView(.horizontal) {
HStack(spacing: spacing) {
// Enumerated for the entrance stagger only identity stays `item.id`,
// which is what `.scrollTargetLayout()` and `scrollPosition` key on.
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
ForEach(items) { item in
#if os(tvOS)
// A focusable Button per card: the focus engine does the navigating
// (remote swipes and pad dpad alike), select activates. The bare style
// below keeps the tile's own look the `.scrollTransition` center pop
// is the focus treatment, since focus and center track each other.
Button { activate(item) } label: {
card(item, entrance(idx))
card(item)
.frame(width: itemWidth)
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
.id(item.id)
#else
card(item, entrance(idx))
card(item)
.frame(width: itemWidth)
.contentShape(Rectangle())
.onTapGesture { tap(item) }
.id(item.id) // explicit scroll-target identity for scrollPosition
#endif
}
}
@@ -195,10 +165,7 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
reconcile()
wire()
if isActive { input.start() }
armEntrance()
}
// The cards became worth showing (the library's covers got their art) play now.
.onChange(of: contentReady) { _, _ in armEntrance() }
.onDisappear {
input.stop()
haptics.stop()
@@ -233,55 +200,9 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
.onChange(of: items.map(\.id)) { _, _ in
reconcile()
wire()
// A strip that mounted empty (its content arrived after) still gets its entrance.
armEntrance()
}
}
// MARK: - Entrance
/// Run the entrance, once, as soon as the strip is mounted AND its cards are worth showing.
///
/// Deferred one runloop turn ON PURPOSE: a state change made inside `onAppear` lands in the
/// same transaction as the view's insertion, where SwiftUI runs with animations disabled so
/// the cards would simply BE there. Note the failure mode is benign either way: progress
/// reaching 1 without animating leaves every card at exact identity, never stranded.
private func armEntrance() {
guard !entranceArmed, contentReady, !items.isEmpty else { return }
entranceArmed = true
// After `reconcile`, so the fan-out anchors on the seeded/restored cursor.
entranceAnchor = cursor
// Not just the next runloop turn (a change made inside `onAppear` lands in the
// insertion's transaction, where animations are disabled) but a couple of frames: the
// GeometryReader's first pass can report no width at all, so the strip has to lay out
// for real and the scroll view has to centre itself on the cursor before this starts.
// Cards are invisible until then (progress 0 opacity 0), so the wait never shows.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
// Linear on purpose: the master timeline is a clock, and each card eases its OWN
// slice of it (see `CardEntrance`) a spring here would warp every card's curve.
withAnimation(
reduceMotion ? .easeOut(duration: 0.28) : .linear(duration: CardEntrance.total)
) {
entranceProgress = 1
}
}
}
/// The card's share of the strip's entrance: it swings in on the drum, the anchored card
/// landing first and its neighbours fanning outward to either side.
private func entrance(_ idx: Int) -> CardEntrance {
// Capped so a several-hundred-title library never queues a card behind a visibly long
// wait everything past the cap lands together, well off-screen anyway.
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07)
return CardEntrance(
progress: entranceProgress,
start: delay / CardEntrance.total,
// Never zero: the anchor is the card the eye is ON, so it must swing like the rest
// giving it "no rotation" left the one card you actually watch merely sliding up.
side: idx < entranceAnchor ? -1 : 1,
reduceMotion: reduceMotion)
}
// MARK: - Input wiring
private func wire() {
@@ -425,87 +346,4 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
}
}
/// How a card arrives when its strip does: turned away on the drum, small, low and invisible
/// then it swings flat, grows and rises into place on a spring soft enough to overshoot. Cards to
/// the left of the anchor hinge on their trailing edge and cards to its right on their leading
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
/// itself only grows, since it is already facing you. Each card carries its own delay (see
/// `entrance(_:)`) that stagger is what makes the strip read as one gesture instead of a
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
/// speaks, so the arrival and the scrolling feel like one object.
///
/// APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
/// on the OUTSIDE moves the very thing the transition is measuring: every card read as far from
/// centre for the whole travel, its phase pinned at fully-receded, and the centred card only
/// collapsed into its focused look as the entrance ended arriving as a jump. Underneath, the
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
///
/// Transforms only nothing here touches layout, so the scroll view's snapping and the tvOS
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
/// unstaggered cross-fade.
struct CardEntrance: ViewModifier, Animatable {
/// How long ONE card takes to travel, and the most any card waits before it starts.
static let perCard: Double = 0.6
static let maxDelay: Double = 0.42
/// The master timeline the carousel animates 0 1.
static var total: Double { perCard + maxDelay }
/// The interpolated master progress. `Animatable` is the whole point: SwiftUI hands this
/// modifier a fresh value every frame and re-runs `body`, so the card's transforms are a pure
/// FUNCTION of the clock. No `.animation` modifier wraps the card, so nothing here can catch
/// the caller's `.scrollTransition` mid-scroll and strand it.
var progress: Double
/// Where this card's window opens on that timeline, 01.
let start: Double
/// Which way the card swings in: -1 hinged on its trailing edge (it sits left of the anchor),
/// +1 hinged on its leading edge (right of it). Never 0 every card turns, including the
/// centred one.
let side: Double
let reduceMotion: Bool
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
// This card's own 01, sliced out of the master clock.
let span = Self.perCard / Self.total
let raw = min(max((progress - start) / span, 0), 1)
// The travel eases out with a whisker of overshoot, so a card settles rather than stops.
let travel = Self.easeOutBack(raw)
// The fade is FAR quicker than the travel it finishes in the first third of the window.
// Sharing one curve meant the card spent its whole swing at near-zero opacity and only
// the last few degrees ever showed, which is why this read as a small slide.
let fade = Self.easeOut(min(raw / 0.34, 1))
// Deep turn, well down, well shrunk the card is genuinely edge-on and travelling. The
// sign matches the coverflow's own recede (right of centre turns negative about its
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
// it instead of swinging the opposite way.
let away = reduceMotion ? 0 : 1 - travel
return content
.opacity(reduceMotion ? raw : fade)
.scaleEffect(1 - 0.26 * away)
.rotation3DEffect(
.degrees(side * -64 * away),
axis: (x: 0, y: 1, z: 0),
anchor: .center,
perspective: 0.65)
.offset(y: 34 * away)
}
/// `1 - (1-t)³`, with a small overshoot past 1 before it settles.
private static func easeOutBack(_ t: Double) -> Double {
let c1 = 1.2, c3 = c1 + 1
let u = t - 1
return 1 + c3 * u * u * u + c1 * u * u
}
private static func easeOut(_ t: Double) -> Double {
let u = 1 - t
return 1 - u * u * u
}
}
#endif
@@ -23,47 +23,22 @@ func buttonGlyph(
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance the launcher
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
/// at all. The other values follow the console shell's rhythm (title top = 18 design units,
/// k-floored to 10 for a landscape phone): the title needs air to the screen edge or the whole
/// header reads pressed against the bezel, which the tab strip's extra band made obvious.
/// at all, so the iOS value hugs the top edge there.
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#if os(macOS)
26
#elseif os(tvOS)
24
#else
compact ? 18 : 28
#endif
}
/// Padding under a gamepad screen's pinned header block (title, and the tab strip where there is
/// one) before the content: the console leaves ~14 units of air under its tab pills, and without
/// it the first row sits shoulder-to-shoulder with the header.
func gamepadTitleBottomPadding(compact: Bool) -> CGFloat {
#if os(tvOS)
16
#else
compact ? 8 : 12
#endif
}
/// Spacing between a header's stacked elements (title over tab strip / subtitle).
func gamepadHeaderSpacing(compact: Bool) -> CGFloat {
#if os(tvOS)
13
#else
compact ? 6 : 10
compact ? 4 : 10
#endif
}
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading the field verdict
/// on the smaller first cut was "way too small" once the title moved off-centre.
/// in-hand compact-aware sizes elsewhere.
func gamepadTitleSize(compact: Bool) -> CGFloat {
#if os(tvOS)
44
#else
compact ? 24 : 34
compact ? 20 : 30
#endif
}
@@ -83,7 +58,8 @@ enum GamepadFormMetrics {
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let bandWidth: CGFloat = 380
static let closeFont: CGFloat = 20
static let closeSide: CGFloat = 48
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
@@ -96,8 +72,8 @@ enum GamepadFormMetrics {
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
static let bandWidth: CGFloat = 240
static let closeFont: CGFloat = 14
static let closeSide: CGFloat = 34
#endif
}
@@ -113,7 +89,6 @@ struct GamepadHint: Identifiable {
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
@Environment(\.gamepadInk) private var ink
let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
@@ -133,35 +108,26 @@ struct GamepadHintBar: View {
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: Self.glyphFont))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
}
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(ink.fg(0.85))
.foregroundStyle(.white.opacity(0.85))
.padding(Self.pad)
.consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
}
}
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` a continuous
/// silk of colour whose control points wander on slow, out-of-phase sinusoids finished with an
/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility
/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing
/// regresses.
///
/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools
/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass
/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the
/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose only
/// the contrast differs, so a screen change can't make the field jump.
///
/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the
/// COLOURS, not by stacking a filter see GamepadPalette.swift for why.
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
/// animated `MeshGradient` a continuous silk of colour whose control points wander on slow,
/// out-of-phase sinusoids finished with an elliptical vignette (pools light in the centre, sinks
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
/// radial-blob field, unchanged, so nothing regresses.
///
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
@@ -170,104 +136,77 @@ struct GamepadHintBar: View {
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.gamepadInk) private var ink
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
/// it during a push/pop the console does the same with its `bg_mix` and every
/// calm-dependent factor below rides an `.opacity` modifier, which animates reliably where
/// re-built gradient stops do not.
var calmMix: Double
/// The Bool spelling every non-shell call site uses (see the type comment for `calm`).
init(calm: Bool = false) {
calmMix = calm ? 1 : 0
}
init(calmMix: Double) {
self.calmMix = calmMix
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
var body: some View {
let palette = GamepadPalette.named(paletteID)
Group {
if reduceMotion {
composite(at: 0, palette: palette)
composite(at: 0)
} else {
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
// redraw cost of a battery-fed couch device vs. the display's native rate.
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette)
composite(at: context.date.timeIntervalSinceReferenceDate)
}
}
}
.ignoresSafeArea()
}
/// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical
/// vignette, and the title/hints legibility scrim in that order, matching the console
/// shader's `composite` so the two platforms' backdrops stay the same picture.
private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View {
// 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 `u_scrim.a` the console shader carries.
let scrim: Color = palette.light ? ink.fg : .black
let strength = palette.light ? 0.45 : 1.0
return ZStack {
Self.color(palette.ground)
colorField(at: t, palette: palette)
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
/// title/hints legibility scrim.
private func composite(at t: TimeInterval) -> some View {
ZStack {
Color.black
colorField(at: t)
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply
.opacity(1 - 0.4 * calmMix)
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
// Mounted unconditionally at opacity 0 a plusLighter layer contributes nothing,
// and an always-present layer is what lets the mix animate instead of popping.
Self.color(palette.ground)
.opacity(0.4 * calmMix)
.blendMode(.plusLighter)
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
// pooled light. Soft (extends past the frame) so the corners deepen rather than
// crush. 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: darker toward the edges so the cards sit in the pooled light.
// Soft (extends past the frame) so the corners deepen rather than crush to black.
EllipticalGradient(
colors: [.clear, scrim.opacity(0.42 * strength)],
colors: [.clear, .black.opacity(0.42)],
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
.opacity(1 - 0.5 * calmMix)
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
// works on the field itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one.
// darkens the aurora itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one now.
LinearGradient(
stops: [
.init(color: scrim.opacity(0.38 * strength), location: 0),
.init(color: scrim.opacity(0.06 * strength), location: 0.32),
.init(color: scrim.opacity(0.08 * strength), location: 0.68),
.init(color: scrim.opacity(0.40 * strength), location: 1),
.init(color: .black.opacity(0.38), location: 0),
.init(color: .black.opacity(0.06), location: 0.32),
.init(color: .black.opacity(0.08), location: 0.68),
.init(color: .black.opacity(0.40), location: 1),
],
startPoint: .top, endPoint: .bottom)
}
}
@ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View {
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
if #available(iOS 18, macOS 15, tvOS 18, *) {
MeshGradient(
width: 4, height: 4,
points: Self.meshPoints(at: t),
colors: palette.meshColors.map(Self.color),
colors: Self.meshColors,
smoothsColors: true)
} else {
LegacyBlobField(t: t, palette: palette)
LegacyBlobField(t: t)
}
}
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
static func color(_ c: SIMD3<Double>) -> Color {
Color(red: c.x, green: c.y, blue: c.z)
}
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
/// mid-tone violets, and the four interior points hold the bright brand family a violet and a
/// blue-violet up top, a magenta-violet and a violet below so warm pools on the left, cool on
/// the right, and the silk shifts temperature as those interior points drift.
private static let meshColors: [Color] = {
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
return [
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
]
}()
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
/// always fills edge-to-edge a drifting edge point would shrink the mesh and expose the black
@@ -294,18 +233,15 @@ struct GamepadScreenBackground: View {
}
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field four
/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim
/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS
/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the
/// setting too instead of being stuck on violet.
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
private struct LegacyBlobField: View {
let t: TimeInterval
let palette: GamepadPalette
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
/// (rad/s periods of 3090 s), and a radius that slowly breathes. The COLOUR comes from the
/// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too.
/// (rad/s periods of 3090 s), and a radius that slowly breathes.
private struct Blob {
let color: Color
let center: CGPoint
let drift: CGSize
let speed: (x: Double, y: Double)
@@ -316,16 +252,20 @@ private struct LegacyBlobField: View {
}
private static let blobs: [Blob] = [
Blob(center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
speed: (0.111, 0.083), phase: (0.0, 1.9),
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
Blob(center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
speed: (0.071, 0.096), phase: (2.4, 0.7),
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
Blob(center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
speed: (0.089, 0.067), phase: (4.1, 3.2),
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
Blob(center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
speed: (0.059, 0.104), phase: (1.2, 5.0),
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
]
@@ -335,31 +275,26 @@ private struct LegacyBlobField: View {
let side = max(geo.size.width, geo.size.height)
ZStack {
ForEach(Self.blobs.indices, id: \.self) { i in
blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side)
blobView(Self.blobs[i], in: geo.size, side: side)
}
}
.drawingGroup()
}
}
private func blobView(
_ blob: Blob, tone: SIMD3<Double>, in size: CGSize, side: CGFloat
) -> some View {
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
let r = side * blob.radius
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
let color = GamepadScreenBackground.color(tone)
return Circle()
.fill(RadialGradient(
colors: [color, color.opacity(0)],
colors: [blob.color, blob.color.opacity(0)],
center: .center, startRadius: 0, endRadius: r / 2))
.frame(width: r, height: r)
.position(x: x * size.width, y: y * size.height)
.opacity(blob.opacity)
// 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(palette.light ? .normal : .plusLighter)
.blendMode(.plusLighter)
}
}
@@ -369,63 +304,53 @@ private struct LegacyBlobField: View {
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
@Environment(\.gamepadInk) private var ink
var body: some View {
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
let toContent: UnitPoint = edge == .top ? .bottom : .top
Rectangle()
.fill(.ultraThinMaterial)
// Force the frost to match the PALETTE, not the system appearance: the tray exists
// to keep the pinned title legible, so it has to frost dark under white ink and
// light under dark ink.
.environment(\.colorScheme, ink.isLight ? .light : .dark)
// Sink the material's grey luminance lift toward the palette's shade (black on a
// dark field field ask: the frost read GREY over the aurora). Inside the mask, so
// the tint dissolves with the blur.
.overlay(ink.shade(0.35))
// Fade the whole blur out toward the content so it dissolves rather than ending on a
// line. The strong region sits deep (0.65) because the first stretch of the gradient
// now runs over the fixed 80 pt outer overhang below.
// These trays always sit on the dark console UI; force dark so the material frosts dark
// (white text stays legible) regardless of the system appearance.
.environment(\.colorScheme, .dark)
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black.opacity(0.92), location: 0.65),
.init(color: .black.opacity(0.9), location: 0.5),
.init(color: .clear, location: 1),
],
startPoint: fromEdge, endPoint: toContent)
}
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds the tray's own
// text always sits on the strong part, rows blur out before they reach it. The bottom
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
// the field verdict on the short reach was rows colliding visibly with the legend.
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
// beat after insertion (outside any geometry group and outside this view's own
// transaction), which is exactly the pop the field kept seeing vertically first,
// then, once the vertical runway became padding, on the X axis alone (the landscape
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
// so the overhang simply draws.
.padding(edge == .top ? .top : .bottom, -80)
.padding(.horizontal, -80)
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
// geometry would ride the push's transaction and visibly grow into place. The
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
.transaction { $0.animation = nil }
// text always sits on the strong part, rows blur out before they reach it.
.padding(edge == .top ? .bottom : .top, -32)
.ignoresSafeArea()
}
}
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
/// of glows over a deep indigo base deliberately not near-black, because Liquid Glass refracts
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting
/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI
/// backed by a static image. Kept as its own type because that is what the form screens ask for by
/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`.
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) NOT the launcher's
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
/// so the rows read as glass while the screen stays restful.
struct GamepadFormBackground: View {
var body: some View {
GamepadScreenBackground(calm: true)
ZStack {
Color(red: 0.075, green: 0.062, blue: 0.150)
// Violet lift top-leading, cooler indigo bottom-trailing resolution-independent
// (fraction radii) so the glow scale tracks the window on any screen.
EllipticalGradient(
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
center: UnitPoint(x: 0.26, y: 0.14),
startRadiusFraction: 0, endRadiusFraction: 0.78)
EllipticalGradient(
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
center: UnitPoint(x: 0.82, y: 0.9),
startRadiusFraction: 0, endRadiusFraction: 0.78)
}
.ignoresSafeArea()
}
}
@@ -448,7 +373,6 @@ struct ConsoleBareButtonStyle: ButtonStyle {
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes.
struct ControllerStatusChip: View {
@Environment(\.gamepadInk) private var ink
let controller: GamepadManager.DiscoveredController
// Legible from the couch on tvOS, quiet in hand elsewhere.
@@ -473,15 +397,15 @@ struct ControllerStatusChip: View {
Image(systemName: batterySymbol(level))
.font(.system(size: Self.font))
.foregroundStyle(level <= 0.2 && !controller.isCharging
? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7)))
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
}
}
.font(.geist(Self.font, .medium, relativeTo: .caption))
.foregroundStyle(ink.fg(0.7))
.foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, Self.hPad)
.padding(.vertical, Self.vPad)
.background(Capsule().fill(ink.fg(0.08)))
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
.background(Capsule().fill(.white.opacity(0.08)))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
}
private func batterySymbol(_ level: Float) -> String {
@@ -23,15 +23,14 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing
/// actions. Hashable so it can be the carousel's scroll-position identity.
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
/// action. Hashable so it can be the carousel's scroll-position identity.
private enum GamepadHomeTarget: Hashable {
/// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) which on a
/// controller-first surface are THE profile affordance: focus and press, no menus.
case saved(UUID, profile: String?)
case discovered(String)
case addHost
case rescan
}
/// A fully-resolved launcher tile display fields + the activate action, built fresh each render
@@ -64,7 +63,6 @@ private struct HomeTile: Identifiable {
}
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@@ -74,9 +72,6 @@ struct GamepadHomeView: View {
@ObservedObject var waker: HostWaker
let connect: (StoredHost, ProfileSelection) -> Void
let connectDiscovered: (DiscoveredHost) -> Void
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
/// The profile catalog pinned host+profile combos render as their own tiles here, which is
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
@@ -96,59 +91,29 @@ struct GamepadHomeView: View {
private let compact = false // no size classes on macOS; the window minimum keeps room
#endif
@ObservedObject private var gamepads = GamepadManager.shared
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var selection: GamepadHomeTarget?
@State private var showSettings = false
@State private var showAddHost = false
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
/// the controller a double-tapped A can't push two screens, and the held button that
/// caused the change is long released before the next poller starts (whose own
/// `needsSnapshot` seed swallows it if not).
@State private var transitioning = false
/// Guards the gate's release against an interrupted transition: only the newest hold clears.
@State private var transitionEpoch = 0
var body: some View {
// The in-place shell (see GamepadShell.swift): the launcher is the base layer, the
// current sub-screen a transparent layer over it, both over ONE persistent backdrop
// that never unmounts a push slides the screen up out of a fade while the launcher
// recedes underneath, the console's own choreography. On macOS/tvOS `topScreen` is
// constantly nil and this ZStack degenerates to the plain launcher, presented over by
// the sheets/covers below exactly as before.
ZStack {
homeLayer
.opacity(covered ? 0 : 1)
.scaleEffect(covered ? GamepadShellMotion.underScale : 1)
// The covers used to swallow touch; the recessed layer must too.
.allowsHitTesting(!covered)
#if os(iOS)
if let screen = topScreen {
screenLayer(screen)
// Settle the screen's internal layout before the insertion animates, so
// descendants never lerp from a half-resolved first frame. (Not sufficient
// for the tray blurs on its own safe-area expansion resolves outside a
// geometry group; GamepadTrayScrim pins its own geometry too.)
.geometryGroup()
.zIndex(1)
.id(screen.id)
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
}
#endif
GeometryReader { geo in
hero(for: geo.size)
}
// Value-keyed rather than `withAnimation` at the triggers: pushes originate outside
// this view too (`model.returnToLibrary` writes `libraryTarget`), and keying on the
// derived id catches every writer. Reduce Motion snaps.
.animation(reduceMotion ? nil : GamepadShellMotion.screen, value: topScreenID)
// ONE living field for every layer, still a `.background` (the layout rule in this
// file's header). Its calm is CHASED between the launcher's aurora and the form
// screens' quiet, never crossfaded per screen the console's `bg_mix`.
.background {
GamepadScreenBackground(calmMix: calmTarget)
.animation(reduceMotion ? nil : GamepadShellMotion.calm, value: calmTarget)
// Pinned inside the safe area, out of the carousel's vertical budget never clipped.
.safeAreaInset(edge: .top, spacing: 0) {
titleBar
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
// Equal distance from the left and bottom edges the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
}
.background { GamepadScreenBackground() }
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
@@ -159,17 +124,6 @@ struct GamepadHomeView: View {
try? await Task.sleep(for: .seconds(10))
}
}
#if os(iOS)
.onChange(of: topScreenID) { _, _ in
transitionEpoch += 1
let epoch = transitionEpoch
transitioning = true
let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
if epoch == transitionEpoch { transitioning = false }
}
}
#endif
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
// X, and historically tvOS maps a pad's X to this same press the poll and this command
// double-firing just sets the same Bool twice.
@@ -177,9 +131,8 @@ struct GamepadHomeView: View {
.onPlayPauseCommand { showSettings = true }
#endif
// The settings / add-host screens take over the controller (the carousel's `isActive`
// gate above). macOS has no fullScreenCover they are generously sized sheets over the
// dimmed launcher; tvOS keeps its focus-engine covers. iOS needs nothing here: the
// shell's layers above ARE the presentation.
// gate above). iOS presents them full screen the immersive console feel; macOS has no
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView(store: store)
@@ -190,7 +143,7 @@ struct GamepadHomeView: View {
.frame(width: 660, height: 620)
}
.frame(minWidth: 640, minHeight: 420)
#elseif os(tvOS)
#else
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
@@ -198,110 +151,6 @@ struct GamepadHomeView: View {
#endif
}
// MARK: - The shell's layers (see GamepadShell.swift)
/// The launcher itself everything the pre-shell body was, minus the backdrop (hoisted to
/// the shell) and the presentation modifiers (below).
private var homeLayer: some View {
GeometryReader { geo in
hero(for: geo.size)
}
// Pinned inside the safe area, out of the carousel's vertical budget never clipped.
.safeAreaInset(edge: .top, spacing: 0) {
titleBar
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
// Equal distance from the left and bottom edges the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
}
}
#if os(iOS)
/// The screen the shell shows over the launcher derived from the same triggers every
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
private var topScreen: GamepadScreen? {
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
return nil
}
@ViewBuilder private func screenLayer(_ screen: GamepadScreen) -> some View {
// The layer owns the controller only once the push settles and nothing rides over the
// shell (the connect/wake takeover is an overlay in ContentView, above these layers).
let active = !transitioning && waker.waking == nil && model.phase != .connecting
Group {
switch screen {
case .settings:
GamepadSettingsView(
store: store,
close: { if !transitioning { showSettings = false } },
controllerActive: active)
case .addHost:
GamepadAddHostView(
onAdd: { store.add($0) },
close: { if !transitioning { showAddHost = false } },
controllerActive: active)
case .library(let host):
GamepadLibraryScreen(
store: store, host: host,
onLaunch: { launchTitle(host, $0) },
close: { if !transitioning { libraryTarget = nil } },
controllerActive: active)
}
}
.environment(\.gamepadHostedInShell, true)
}
#endif
private var covered: Bool {
#if os(iOS)
topScreen != nil
#else
false
#endif
}
private var topScreenID: String? {
#if os(iOS)
topScreen?.id
#else
nil
#endif
}
/// The backdrop's calm target: 1 under a form screen, 0 under the launcher/library. The
/// macOS sheets / tvOS covers mount their own calmed field, so the launcher behind them
/// keeps its aurora exactly what shipped.
private var calmTarget: Double {
#if os(iOS)
topScreen?.isForm == true ? 1 : 0
#else
0
#endif
}
/// Stop consuming the controller while another screen (or the connect/wake takeover) is on
/// top otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
/// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
/// takeover's Connecting phase; `waker.waking` its Waking phase. On iOS the shell adds the
/// transition's input drop, during which NOBODY polls.
private var homeOwnsController: Bool {
#if os(iOS)
topScreen == nil && !transitioning
&& waker.waking == nil && model.phase != .connecting
#else
libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
#endif
}
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@ViewBuilder private func hero(for size: CGSize) -> some View {
@@ -327,27 +176,32 @@ struct GamepadHomeView: View {
// MARK: - Chrome
private var titleBar: some View {
// Leading title (a console heading, not a floating label field ask), chip trailing.
// The old hidden-mirror trick existed only to keep a CENTRED title clear of the chip;
// a leading title needs none of it the flexible frame keeps the two apart, and the
// title shrinks a little before it would ever truncate.
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
// optically centred AND clear of the chip at every width; the title shrinks a little
// before it would ever truncate.
HStack(spacing: 12) {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity, alignment: .leading)
statusChip
.frame(maxWidth: .infinity)
statusChip(hidden: false)
}
.padding(.horizontal, 24)
.padding(.horizontal, 20)
}
/// Which pad is driving this UI (name + battery) quiet, and only where there's room; a
/// compact-height phone gives the pixels to the carousel instead.
@ViewBuilder private var statusChip: some View {
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
/// chip purely as a width reserve.
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
}
@@ -370,9 +224,14 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
isActive: homeOwnsController
) { tile, entrance in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
// top otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
isActive: libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
}
.frame(height: cardHeight + 40)
}
@@ -381,12 +240,8 @@ struct GamepadHomeView: View {
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
/// brightness/saturation + a depth blur on the recessed neighbors.
private func hostCard(
_ tile: HomeTile, size: CGSize, entrance: CardEntrance
) -> some View {
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
GamepadHostTile(tile: tile, size: size)
// Beneath the scroll transition, never around it see CardEntrance.
.modifier(entrance)
.scrollTransition { content, phase in
let d = CGFloat(min(abs(phase.value), 1))
let scale = 1 - d * 0.12
@@ -407,14 +262,10 @@ struct GamepadHomeView: View {
private var hints: [GamepadHint] {
let selected = tiles.first { $0.id == selection }
let action: String? = switch selected?.id {
case .addHost: "Add Host"
case .rescan: "Rescan"
default: nil
}
var hints = [GamepadHint(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
text: selected?.id == .addHost ? "Add Host"
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
}
@@ -474,15 +325,7 @@ struct GamepadHomeView: View {
subtitle: "Register a host by address",
icon: "plus",
activate: { showAddHost = true })
// A controller surface has no toolbar and no pull-to-refresh, so the rescan the field
// asked for is a tile like any other one press from wherever the stick already is.
let rescan = HomeTile(
id: .rescan,
title: "Rescan",
subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network",
icon: "arrow.clockwise",
activate: { discovery.refresh() })
return saved + discovered + [add, rescan]
return saved + discovered + [add]
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
@@ -499,7 +342,6 @@ struct GamepadHomeView: View {
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
private struct GamepadHostTile: View {
@Environment(\.gamepadInk) private var ink
let tile: HomeTile
let size: CGSize
@@ -539,25 +381,20 @@ private struct GamepadHostTile: View {
if tile.isPaired {
Image(systemName: "lock.fill")
.font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(ink.fg(0.5))
.foregroundStyle(.white.opacity(0.5))
}
if tile.isOnline {
// Status colours stay palette-independent (a pip must not change meaning
// with the wallpaper) only the glow softens on a pale field, where it
// reads as a smudge at full strength.
Circle()
.fill(GamepadInk.onlineGreen)
.fill(Color.green)
.frame(width: Self.pipSide, height: Self.pipSide)
.shadow(
color: GamepadInk.onlineGreen.opacity(ink.isLight ? 0.45 : 0.7),
radius: 5)
.shadow(color: .green.opacity(0.7), radius: 5)
}
}
}
Spacer(minLength: 0)
Text(tile.title)
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
if let profile = tile.profile {
@@ -567,7 +404,7 @@ private struct GamepadHostTile: View {
}
Text(tile.subtitle)
.font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
.padding(.top, 2)
}
@@ -577,16 +414,16 @@ private struct GamepadHostTile: View {
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? ink.accent(0.20) : nil)
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
.overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [ink.fg(0.22), ink.fg(0.04)],
colors: [.white.opacity(0.22), .white.opacity(0.04)],
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
.shadow(color: ink.shadow(0.45), radius: 20, y: 14)
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
}
private var monogramBadge: some View {
@@ -594,15 +431,15 @@ private struct GamepadHostTile: View {
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
colors: [ink.accent, ink.accent(0.68)],
colors: [Color.brand, Color.brand.opacity(0.68)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(ink.accent(0.16)))
: AnyShapeStyle(Color.brand.opacity(0.16)))
if tile.isConnecting {
ProgressView().tint(ink.fg)
ProgressView().tint(.white)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(ink.accent)
.foregroundStyle(Color.brand)
} else if let mark = osIconImage(for: tile.osChain) {
// The OS mark stands in for the initial (template asset tints like the text it
// replaces), and carries the label, since nothing else on the tile names the OS.
@@ -610,18 +447,18 @@ private struct GamepadHostTile: View {
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(tile.filled ? .white : Color.brand)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(tile.filled ? .white : Color.brand)
}
}
.frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay {
if !tile.filled {
shape.strokeBorder(ink.accent(0.5), lineWidth: 1)
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
}
}
}
@@ -1,101 +0,0 @@
// The ink the 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.
//
// Handed down the view tree as an environment value rather than passed to each screen, so a
// leaf (a row, a hint pill, a card) can ask for the right colour without every caller in between
// knowing about palettes. `pf-console-ui` does the same thing with a thread-local `Ink`.
import PunktfunkShared
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadInk: Equatable, Sendable {
/// Primary text/glyph colour.
let fg: Color
/// Focus wash, selected tab pill, switch track, caret the palette's own accent.
let accent: Color
/// What reads ON the accent (a filled pill's label).
let onAccent: Color
/// The base fill every glass surface starts from.
let glass: Color
/// What a wash laid UNDER text tends toward: black on a dark field, white on a pale one.
let 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.
let shadeScale: Double
/// True when the field is pale, for the few places that need to branch rather than blend
/// (a material's `colorScheme`, a shadow's presence).
let isLight: Bool
/// The foreground at `alpha`.
func fg(_ alpha: Double) -> Color { fg.opacity(alpha) }
/// The accent at `alpha`.
func accent(_ alpha: Double) -> Color { accent.opacity(alpha) }
/// A wash under text: `alpha` is the dark-field strength, scaled for a pale one.
func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) }
/// The glass base at `alpha` what a surface's material is washed with so it carries the
/// palette's hue (the console fills its panels with exactly this colour).
func glass(_ alpha: Double) -> Color { glass.opacity(alpha) }
/// A drop shadow: always black a white shadow is not a shadow but softened on a pale
/// field, where full-strength black under every card reads as a smear rather than depth.
func shadow(_ alpha: Double) -> Color { .black.opacity(alpha * (isLight ? 0.4 : 1)) }
static func of(_ p: GamepadPalette) -> GamepadInk {
let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z)
let accentLuma = 0.2126 * p.accent.x + 0.7152 * p.accent.y + 0.0722 * p.accent.z
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
// GLASS, not against the field.
let onAccent: Color = accentLuma > 0.55 ? .black : .white
guard p.light else {
return GamepadInk(
fg: .white, accent: accent, onAccent: onAccent,
glass: Color(red: 0.086, green: 0.086, blue: 0.125),
shade: .black, shadeScale: 1, isLight: false)
}
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg: Color(red: p.ground.x * 0.16, green: p.ground.y * 0.14, blue: p.ground.z * 0.20),
accent: accent, onAccent: onAccent,
glass: .white,
shade: .white, shadeScale: 0.45, isLight: true)
}
/// The shipped dark look what a preview or a test composition gets.
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
/// The online pip deliberately NOT palette-derived: a status colour must not change
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
}
private struct GamepadInkKey: EnvironmentKey {
static let defaultValue = GamepadInk.dark
}
extension EnvironmentValues {
/// The ink of the palette currently drawing. Set once, high up (see `GamepadInkModifier`).
var gamepadInk: GamepadInk {
get { self[GamepadInkKey.self] }
set { self[GamepadInkKey.self] = newValue }
}
}
extension View {
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
/// gamepad screens' common root so no individual view has to read the setting.
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
}
private struct GamepadInkModifier: ViewModifier {
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
func body(content: Content) -> some View {
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
}
}
#endif
@@ -14,7 +14,6 @@ import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadKeyboard: View {
@Environment(\.gamepadInk) private var ink
@Binding var text: String
/// Restricts typed characters (e.g. digits for a port field); backspace always works.
var allowed: CharacterSet?
@@ -80,7 +79,7 @@ struct GamepadKeyboard: View {
}
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
@@ -111,13 +110,11 @@ struct GamepadKeyboard: View {
.font(.geist(15, .semibold, relativeTo: .callout))
}
}
// The focused keycap sits on `ink.accent`, so `onAccent` is what reads on it a dark
// accent palette got black-on-dark with the old literal black.
.foregroundStyle(focused ? ink.onAccent : ink.fg)
.foregroundStyle(focused ? Color.black : .white)
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(0.08)))
.fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08)))
}
.animation(.smooth(duration: 0.12), value: focused)
.contentShape(Rectangle())

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