Compare commits
@@ -31,6 +31,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Publish the SIGNED stable update manifest — the moment every host's update check learns
|
||||
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
|
||||
# announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the
|
||||
# fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed)
|
||||
# or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are
|
||||
# ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease.
|
||||
- name: Publish the stable update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
VER="${TAG#v}"
|
||||
URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe"
|
||||
# Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth.
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
curl -fsSL "$URL.sha256" -o /tmp/installer.sha256
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
grep -qi "$SHA" /tmp/installer.sha256 || {
|
||||
echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2
|
||||
exit 1
|
||||
}
|
||||
CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
- name: Post release announcement to Discord
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -348,6 +348,21 @@ jobs:
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "web console smoke (bun): /login -> $code"
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
|
||||
# WEB_OUTPUT_DIR has to be exported whether or not the step above ran. It used to be that step's
|
||||
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
|
||||
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
|
||||
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
|
||||
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
|
||||
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
|
||||
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
|
||||
# The throw is the point: never silently ship a console-less installer again.
|
||||
- name: Export the console output dir (cache hit or fresh build)
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path 'web\.output\server\index.mjs')) {
|
||||
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
|
||||
}
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build plugin/script runner bundle (bun)
|
||||
@@ -376,6 +391,33 @@ jobs:
|
||||
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
|
||||
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
|
||||
# workspace-relative target.
|
||||
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
|
||||
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
|
||||
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
|
||||
# all of these, so here a missing input is a build failure rather than a quietly smaller
|
||||
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
|
||||
- name: Verify every installer payload is present
|
||||
shell: pwsh
|
||||
run: |
|
||||
$need = @(
|
||||
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
|
||||
@{ 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) {
|
||||
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
|
||||
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
|
||||
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
|
||||
else { Write-Output "payload OK - $($x.n) -> $full" }
|
||||
}
|
||||
if ($missing.Count) {
|
||||
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
|
||||
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
|
||||
}
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -460,6 +502,36 @@ jobs:
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
|
||||
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
|
||||
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
|
||||
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
|
||||
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
|
||||
canary-manifest:
|
||||
needs: package
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish the canary update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
|
||||
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
|
||||
Generated
+38
-30
@@ -947,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2896,7 +2896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2914,7 +2914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2935,7 +2935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2959,7 +2959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2968,7 +2968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2980,7 +2980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2994,11 +2994,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3027,14 +3027,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3047,9 +3047,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3082,7 +3090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3094,7 +3102,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3302,7 +3310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3313,7 +3321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3329,7 +3337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3346,7 +3354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3361,7 +3369,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3381,7 +3389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3413,7 +3421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3497,7 +3505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3511,7 +3519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3534,7 +3542,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ members = [
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"crates/pf-paths",
|
||||
"crates/pf-update",
|
||||
"crates/pf-host-config",
|
||||
"crates/pf-gpu",
|
||||
"crates/pf-zerocopy",
|
||||
@@ -51,7 +52,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.22.2"
|
||||
version = "0.22.3"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+425
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.21.0"
|
||||
"version": "0.22.3"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -3432,6 +3432,142 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/apply": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Apply the available update",
|
||||
"description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.",
|
||||
"operationId": "applyUpdate",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApplyRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Apply started — poll `GET /update/status`",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/check": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Check for updates now",
|
||||
"description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.",
|
||||
"operationId": "forceUpdateCheck",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Refreshed update-check state (`last_error` carries a failed check)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Update checks are disabled on this host",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "A forced check ran less than 30 s ago",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Update-check status",
|
||||
"description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.",
|
||||
"operationId": "getUpdateStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current update-check state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -3764,6 +3900,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApplyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApprovePending": {
|
||||
"type": "object",
|
||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
||||
@@ -4799,6 +4944,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).",
|
||||
"required": [
|
||||
"version",
|
||||
"channel",
|
||||
"install_kind",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "The channel it was announced on (`stable` | `canary`)."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"update.available"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The newer release's version string."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.",
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"update.applied"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7040,6 +7238,228 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateJobInfo": {
|
||||
"type": "object",
|
||||
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
|
||||
"required": [
|
||||
"target_version",
|
||||
"stage",
|
||||
"received_bytes",
|
||||
"started_unix"
|
||||
],
|
||||
"properties": {
|
||||
"received_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"stage": {
|
||||
"type": "string",
|
||||
"description": "`downloading` | `verifying` | `applying` | `restarting`."
|
||||
},
|
||||
"started_unix": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"target_version": {
|
||||
"type": "string",
|
||||
"description": "The version being installed."
|
||||
},
|
||||
"total_bytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateManifestInfo": {
|
||||
"type": "object",
|
||||
"description": "One channel's manifest facts, as much as the console renders.",
|
||||
"required": [
|
||||
"version",
|
||||
"serial",
|
||||
"published_at",
|
||||
"notes_url",
|
||||
"stale"
|
||||
],
|
||||
"properties": {
|
||||
"notes_url": {
|
||||
"type": "string",
|
||||
"description": "Release-notes link (pinned to our forge by the manifest validator)."
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"description": "RFC-3339 publish time (display only)."
|
||||
},
|
||||
"serial": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Publish serial (unix seconds) — monotonic per channel.",
|
||||
"minimum": 0
|
||||
},
|
||||
"stale": {
|
||||
"type": "boolean",
|
||||
"description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint."
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The released version this manifest announces."
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateResultInfo": {
|
||||
"type": "object",
|
||||
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
|
||||
"required": [
|
||||
"ok",
|
||||
"from",
|
||||
"to",
|
||||
"finished_unix"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"finished_unix": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"log_path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The installer's own log file on this host, for diagnosis."
|
||||
},
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"stage": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The stage that failed; absent on success."
|
||||
},
|
||||
"staged": {
|
||||
"type": "boolean",
|
||||
"description": "Applied but activates on the next reboot (rpm-ostree)."
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateStatus": {
|
||||
"type": "object",
|
||||
"description": "The full update-check state for this host.",
|
||||
"required": [
|
||||
"install_kind",
|
||||
"channel",
|
||||
"current_version",
|
||||
"apply",
|
||||
"channel_hint",
|
||||
"check_disabled",
|
||||
"available"
|
||||
],
|
||||
"properties": {
|
||||
"apply": {
|
||||
"type": "string",
|
||||
"description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)."
|
||||
},
|
||||
"available": {
|
||||
"type": "boolean",
|
||||
"description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)."
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Release channel this install follows: `stable` | `canary`."
|
||||
},
|
||||
"channel_hint": {
|
||||
"type": "string",
|
||||
"description": "The copy-pastable update command for this install kind."
|
||||
},
|
||||
"check_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)."
|
||||
},
|
||||
"current_version": {
|
||||
"type": "string",
|
||||
"description": "The running host version."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
|
||||
},
|
||||
"job": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateJobInfo",
|
||||
"description": "The apply in flight, if any."
|
||||
}
|
||||
]
|
||||
},
|
||||
"last_checked_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When the last successful check happened (unix seconds).",
|
||||
"minimum": 0
|
||||
},
|
||||
"last_error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the last check failed, verbatim, if it did."
|
||||
},
|
||||
"last_result": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateResultInfo",
|
||||
"description": "Outcome of the most recent apply attempt."
|
||||
}
|
||||
]
|
||||
},
|
||||
"manifest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateManifestInfo",
|
||||
"description": "The last verified manifest, if any check has succeeded."
|
||||
}
|
||||
]
|
||||
},
|
||||
"opt_in_hint": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
@@ -7110,6 +7530,10 @@
|
||||
{
|
||||
"name": "store",
|
||||
"description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"
|
||||
},
|
||||
{
|
||||
"name": "update",
|
||||
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -149,13 +150,14 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
|
||||
// capture claims even those away), so it's enumerated on the capture side — USB device
|
||||
// list + bonded BLE — and re-checked on USB hot-plug.
|
||||
var sc2Generation by remember { mutableIntStateOf(0) }
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
|
||||
// row supplements the PadRow below with the capture status + the USB grant.
|
||||
var usbGeneration by remember { mutableIntStateOf(0) }
|
||||
DisposableEffect(Unit) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
|
||||
}
|
||||
val filter = android.content.IntentFilter().apply {
|
||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
val sc2Probe = remember { Sc2Capture(context) }
|
||||
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(sc2Generation) {
|
||||
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(usbGeneration) {
|
||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) sc2Probe.pairedBleAddress() else null
|
||||
}
|
||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||
val dsUsb = remember(usbGeneration) {
|
||||
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
|
||||
.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
}
|
||||
|
||||
Group("Gamepads") {
|
||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||
dsUsb?.let { DsRow(it) }
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
@@ -319,6 +328,104 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast action for the Sony-pad USB grants — fired by both the menu-time auto-ask
|
||||
* ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card
|
||||
* refreshes whichever dialog was answered.
|
||||
*/
|
||||
internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
|
||||
|
||||
/**
|
||||
* The Sony USB pad card — capture status + the USB grant. The grant normally arrives via the
|
||||
* menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the
|
||||
* button here is the recovery path after a deny (the auto-ask fires once per attach). Shown
|
||||
* ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture
|
||||
* itself only runs inside a stream, so at menu time this card is pure status.
|
||||
*/
|
||||
@Composable
|
||||
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
val context = LocalContext.current
|
||||
val settingOn = remember { SettingsStore(context).load().dsCapture }
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
||||
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
|
||||
val model = DsDevice.modelFor(usbDev.productId)
|
||||
val label = when (model) {
|
||||
DsDevice.Model.DUALSENSE -> "DualSense"
|
||||
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
|
||||
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
|
||||
null -> return
|
||||
}
|
||||
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
|
||||
// this receiver only updates the card).
|
||||
val action = DS_USB_PERMISSION_ACTION
|
||||
DisposableEffect(usbDev) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) {
|
||||
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
|
||||
}
|
||||
}
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
android.content.IntentFilter(action),
|
||||
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Wired (USB)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when {
|
||||
!settingOn -> Text(
|
||||
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
|
||||
"passthrough (USB)\" to capture it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
!permitted -> {
|
||||
Text(
|
||||
"Needs USB access — grant it now and streams capture the pad silently.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = {
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
android.app.PendingIntent.getBroadcast(
|
||||
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
|
||||
android.content.Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
android.app.PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
@@ -169,6 +170,10 @@ class MainActivity : ComponentActivity() {
|
||||
private var sc2Receiver: BroadcastReceiver? = null
|
||||
private var sc2PermissionAsked = false
|
||||
|
||||
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
|
||||
* Controllers screen's explicit button). */
|
||||
private var dsPermissionAsked = false
|
||||
|
||||
/**
|
||||
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
|
||||
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
|
||||
@@ -225,6 +230,8 @@ class MainActivity : ComponentActivity() {
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
sc2PermissionAsked = false // a fresh attach may ask once again
|
||||
startSc2MenuNav()
|
||||
dsPermissionAsked = false
|
||||
maybeAskDsPermission()
|
||||
}
|
||||
SC2_MENU_PERMISSION -> {
|
||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
@@ -281,6 +288,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
startSc2MenuNav()
|
||||
maybeAskDsPermission()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -341,6 +349,37 @@ class MainActivity : ComponentActivity() {
|
||||
sc2MenuActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for USB access to an attached Sony pad the moment it appears — a fresh attach while
|
||||
* the app is open, or the app coming to the foreground with one already plugged in — at most
|
||||
* once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages
|
||||
* silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow
|
||||
* there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice
|
||||
* at menu time, so the grant is simply recorded (Android keeps it while the pad stays
|
||||
* attached). The broadcast only refreshes the Controllers screen's card if it happens to be
|
||||
* open; a deny leaves that card's explicit button as the re-ask.
|
||||
*/
|
||||
private fun maybeAskDsPermission() {
|
||||
if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming
|
||||
if (dsPermissionAsked) return
|
||||
if (!SettingsStore(this).load().dsCapture) return
|
||||
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val dev = usbManager.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
} ?: return
|
||||
if (usbManager.hasPermission(dev)) return
|
||||
dsPermissionAsked = true
|
||||
usbManager.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants
|
||||
Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
|
||||
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
|
||||
@@ -405,8 +444,8 @@ class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
||||
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
|
||||
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
|
||||
*/
|
||||
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||
if (highRefreshModeId == 0) return
|
||||
@@ -415,6 +454,64 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration —
|
||||
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
|
||||
* pulldown), else the highest available. Same-resolution modes only.
|
||||
*
|
||||
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
|
||||
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
|
||||
* logic among them) ignore it entirely for third-party apps — leaving a 120 Hz session
|
||||
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
|
||||
* preferredDisplayModeId is the one signal they all honor. [hz] ≤ 0 falls back to releasing
|
||||
* the pin (the pre-pin behaviour).
|
||||
*/
|
||||
fun setStreamDisplayMode(hz: Int) {
|
||||
if (hz <= 0) {
|
||||
setConsoleHighRefreshRate(false)
|
||||
return
|
||||
}
|
||||
val target = streamModeFor(hz) ?: return
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel refresh rate a [hz] stream runs against — [streamModeFor]'s pick, from the mode
|
||||
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
|
||||
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
|
||||
* override, not the panel — observed on-glass as a 120 Hz panel reading back as 60. The
|
||||
* supported-modes list is not override-filtered. `0` when unresolvable.
|
||||
*/
|
||||
fun streamPanelFps(hz: Int): Int =
|
||||
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
|
||||
|
||||
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
|
||||
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
|
||||
if (hz <= 0) return null
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
val current = disp?.mode ?: return null
|
||||
val sameRes = disp.supportedModes.filter {
|
||||
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
|
||||
}
|
||||
fun multiple(rate: Float): Int {
|
||||
val k = (rate / hz).toInt()
|
||||
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
|
||||
}
|
||||
return sameRes.minWithOrNull(
|
||||
compareBy(
|
||||
{
|
||||
when {
|
||||
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
|
||||
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
|
||||
else -> 2 // no relation — prefer highest so at least nothing is halved
|
||||
}
|
||||
},
|
||||
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
|
||||
@@ -48,6 +48,9 @@ data class SettingsOverlay(
|
||||
* else, but here it is the one knob a marginal link wants turned off per host.
|
||||
*/
|
||||
val lowLatencyMode: Boolean? = null,
|
||||
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
|
||||
val presentPriority: String? = null,
|
||||
val smoothBuffer: Int? = null,
|
||||
/**
|
||||
* Overlay keys a newer build wrote and this one doesn't model — carried through a load→save
|
||||
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
|
||||
@@ -73,6 +76,8 @@ data class SettingsOverlay(
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
smoothBuffer = smoothBuffer ?: base.smoothBuffer,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -104,6 +109,8 @@ data class SettingsOverlay(
|
||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
smoothBuffer = if (after.smoothBuffer != before.smoothBuffer) after.smoothBuffer else smoothBuffer,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -127,6 +134,8 @@ data class SettingsOverlay(
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
"smooth_buffer" -> copy(smoothBuffer = null)
|
||||
else -> this
|
||||
}
|
||||
|
||||
@@ -147,6 +156,8 @@ data class SettingsOverlay(
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
if (smoothBuffer != null) add("smooth_buffer")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +186,8 @@ data class SettingsOverlay(
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
smoothBuffer?.let { j.put("smooth_buffer", it) }
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -187,6 +200,7 @@ data class SettingsOverlay(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
|
||||
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
|
||||
"present_priority", "smooth_buffer",
|
||||
)
|
||||
|
||||
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
|
||||
@@ -209,6 +223,8 @@ data class SettingsOverlay(
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
presentPriority = j.optStringOrNull("present_priority"),
|
||||
smoothBuffer = j.optIntOrNull("smooth_buffer"),
|
||||
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,19 @@ data class Settings(
|
||||
* feeds a queue that only grows.
|
||||
*/
|
||||
val lowLatencyMode: Boolean = true,
|
||||
/**
|
||||
* The timeline presenter's intent — the cross-client `present_priority` pair (the Apple
|
||||
* client's "Prioritize" picker, same stored values): `"latency"` (default) = newest-wins,
|
||||
* a frame reaches glass the instant the glass budget opens; `"smooth"` = a small FIFO
|
||||
* drained one frame per vsync, absorbing network/decode jitter at one refresh of added
|
||||
* display latency per buffered frame. Anything unrecognized resolves to latency.
|
||||
*/
|
||||
val presentPriority: String = "latency",
|
||||
/**
|
||||
* The smoothness buffer depth (`smooth_buffer`): 0 = Automatic (2 frames), else 1..3.
|
||||
* Only meaningful when [presentPriority] is `"smooth"`.
|
||||
*/
|
||||
val smoothBuffer: Int = 0,
|
||||
/**
|
||||
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
||||
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
||||
@@ -110,6 +123,18 @@ data class Settings(
|
||||
*/
|
||||
val sc2Capture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
|
||||
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
|
||||
* by writing USB output reports — rumble works on every phone (no kernel force-feedback
|
||||
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
|
||||
* platform API for any of them). ON by default — it engages only when such a pad is attached
|
||||
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
|
||||
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
|
||||
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -201,9 +226,12 @@ class SettingsStore(context: Context) {
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
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),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
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
|
||||
@@ -231,9 +259,12 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.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_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -271,9 +302,12 @@ class SettingsStore(context: Context) {
|
||||
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
|
||||
*/
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
const val K_PRESENT_PRIORITY = "present_priority"
|
||||
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_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
@@ -491,6 +525,29 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
|
||||
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
|
||||
|
||||
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
|
||||
* Unrecognized values resolve to latency — same rule as the Apple client. */
|
||||
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
|
||||
|
||||
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
|
||||
val PRESENT_PRIORITY_OPTIONS = listOf(
|
||||
"latency" to "Lowest latency",
|
||||
"smooth" to "Smoothness",
|
||||
)
|
||||
|
||||
/** (frames, label) for the smoothness-buffer picker; each buffered frame ≈ one refresh interval
|
||||
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
|
||||
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
||||
val periodMs = 1000.0 / maxOf(24, hz)
|
||||
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
|
||||
return listOf(
|
||||
0 to "Automatic",
|
||||
1 to "1 frame (${cost(1)})",
|
||||
2 to "2 frames (${cost(2)})",
|
||||
3 to "3 frames (${cost(3)})",
|
||||
)
|
||||
}
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -711,6 +711,26 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
field = "low_latency_mode",
|
||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||
)
|
||||
// The timeline presenter's intent — the Apple client's "Prioritize" pair, same stored
|
||||
// values, so a profile written on one platform means the same thing here.
|
||||
SettingDropdown(
|
||||
label = "Prioritize",
|
||||
options = PRESENT_PRIORITY_OPTIONS,
|
||||
selected = if (s.presentPriority == "smooth") "smooth" else "latency",
|
||||
field = "present_priority",
|
||||
caption = "Lowest latency shows each frame the moment it can reach the panel; " +
|
||||
"Smoothness buffers a little to absorb network jitter.",
|
||||
) { v -> update(s.copy(presentPriority = v)) }
|
||||
if (s.presentPriority == "smooth") {
|
||||
SettingDropdown(
|
||||
label = "Smoothness buffer",
|
||||
options = smoothBufferOptions(if (s.hz > 0) s.hz else nhz),
|
||||
selected = if (s.smoothBuffer in 1..3) s.smoothBuffer else 0,
|
||||
field = "smooth_buffer",
|
||||
caption = "Each buffered frame absorbs one refresh of jitter and adds one of " +
|
||||
"display latency — the cost shown is at the session's refresh rate.",
|
||||
) { v -> update(s.copy(smoothBuffer = v)) }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
|
||||
@@ -816,6 +836,15 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
checked = s.sc2Capture,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||
// the CONTROLLER's own motors/LEDs, not this device's.
|
||||
ToggleRow(
|
||||
title = "DualSense / DualShock passthrough (USB)",
|
||||
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||
"plus adaptive triggers, lightbar and gyro",
|
||||
checked = s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +46,20 @@ internal fun StatsOverlay(
|
||||
* common case: no profile) the line is exactly what it always was.
|
||||
*/
|
||||
profileName: String? = null,
|
||||
/**
|
||||
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
|
||||
* it sits below the stream rate — the "an OEM governor ignored the mode pin" tell, which
|
||||
* otherwise reads as inexplicable judder and an extra refresh of latency.
|
||||
*/
|
||||
panelHz: Float = 0f,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
val w = s[6].toInt()
|
||||
val h = s[7].toInt()
|
||||
val hz = s[8].toInt()
|
||||
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
|
||||
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val lost = s[9].toLong()
|
||||
@@ -65,12 +73,12 @@ internal fun StatsOverlay(
|
||||
val profileTag = profileName?.let { " · $it" }.orEmpty()
|
||||
// Compact: everything the glance-value needs on one line, nothing else.
|
||||
if (verbosity == StatsVerbosity.COMPACT) {
|
||||
statLine(compactLine(s, latValid) + profileTag, Color.White)
|
||||
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
|
||||
return@Column
|
||||
}
|
||||
|
||||
statLine(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
|
||||
Color.White,
|
||||
)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
@@ -104,8 +112,27 @@ internal fun StatsOverlay(
|
||||
} else {
|
||||
"host+network ${"%.1f".format(s[14])}"
|
||||
}
|
||||
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
|
||||
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
|
||||
// Timeline-presenter split (s[26]/s[27], when s[29] flags it active): the display
|
||||
// term decomposes into pace (store + glass budget) + latch (SurfaceFlinger), and
|
||||
// s[28] is the on-glass confirm count — presents ≪ fps means the presenter is
|
||||
// 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 {
|
||||
dispValid && split ->
|
||||
" + display ${"%.1f".format(s[23])} " +
|
||||
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
|
||||
dispValid -> " + display ${"%.1f".format(s[23])}"
|
||||
else -> ""
|
||||
}
|
||||
val presents = if (s.size >= 30 && s[29] != 0.0) {
|
||||
" · presents ${s[28].toInt()}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
statLine(
|
||||
"= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm$presents",
|
||||
Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
@@ -62,6 +63,7 @@ import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -77,7 +79,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val micEnabled = initialSettings.micEnabled
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
// The View hosting this composition — the one that receives the stream's touch/pointer events
|
||||
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
|
||||
// requested.
|
||||
val composeView = androidx.compose.ui.platform.LocalView.current
|
||||
val window = activity?.window
|
||||
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
|
||||
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
|
||||
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
|
||||
val controller = remember(window) {
|
||||
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
||||
}
|
||||
@@ -99,6 +108,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var codecLabel by remember { mutableStateOf("") }
|
||||
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
|
||||
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
|
||||
var panelHz by remember { mutableStateOf(0f) }
|
||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
@@ -120,6 +132,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
|
||||
// The decoder is fixed for the session; fetch its label once it's resolved.
|
||||
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
|
||||
}
|
||||
@@ -226,13 +239,39 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val priorSoftInput = window?.attributes?.softInputMode
|
||||
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
// Draw under the display cutout, explicitly. Android 15's SDK-35 edge-to-edge enforcement
|
||||
// makes ALWAYS the immersive default, but pre-15 devices letterbox the notch as a dead
|
||||
// black bar unless asked — and the stream's own letterbox is black anyway, so the cutout
|
||||
// region can never show anything wrong. Captured + restored like the rest of the window
|
||||
// state so the menus keep their platform-default behaviour.
|
||||
val priorCutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.attributes?.layoutInDisplayCutoutMode
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.let { w ->
|
||||
w.attributes = w.attributes.apply {
|
||||
layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
|
||||
// The prior request is captured and restored on the way out.
|
||||
//
|
||||
// COMPACT devices only (sw < 600 dp): on tablets/foldables/desktop windows the lock is a
|
||||
// large-display anti-pattern (Play flags it; Android 16+ ignores it there outright), and the
|
||||
// stream doesn't need it — the aspect-ratio letterbox renders correctly in any orientation,
|
||||
// the lock is purely a phone-ergonomics choice.
|
||||
val compactDevice = context.resources.configuration.smallestScreenWidthDp < 600
|
||||
val priorOrientation = activity?.requestedOrientation
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
if (compactDevice) {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
@@ -304,7 +343,30 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
|
||||
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
|
||||
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
|
||||
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
|
||||
if (isTv) {
|
||||
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
|
||||
} else {
|
||||
activity?.setStreamDisplayMode(streamHz)
|
||||
}
|
||||
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
|
||||
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
|
||||
// Undone by passing 0 on the way out (API 30+).
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
|
||||
}
|
||||
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
|
||||
// panel, but the platform separately down-rates a quiet app's choreographer stream
|
||||
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
|
||||
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
|
||||
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
|
||||
// that braces. Reset to no-preference on the way out.
|
||||
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
|
||||
composeView.requestedFrameRate = streamHz.toFloat()
|
||||
}
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||
@@ -367,13 +429,59 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
|
||||
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
|
||||
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
|
||||
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
|
||||
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
|
||||
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||
// hands over deterministically.
|
||||
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
usbDev != null && usbManager.hasPermission(usbDev) -> ds.startUsb(usbDev)
|
||||
usbDev != null -> {
|
||||
// One-time system dialog; capture engages on grant (Android remembers the
|
||||
// grant for as long as the device stays attached).
|
||||
val action = "io.unom.punktfunk.DS_USB_PERMISSION"
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != action) return
|
||||
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
if (ok) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
|
||||
}
|
||||
}
|
||||
dsUsbReceiver = receiver
|
||||
ContextCompat.registerReceiver(
|
||||
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
PendingIntent.getBroadcast(
|
||||
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
|
||||
Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
||||
feedback.onHidRaw = null
|
||||
feedback.sink = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
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.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
@@ -388,8 +496,19 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||
activity?.startSc2MenuNav()
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 35) {
|
||||
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
|
||||
}
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
|
||||
window?.let { w ->
|
||||
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||
}
|
||||
}
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@@ -480,6 +599,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
lowLatencyMode,
|
||||
choice?.lowLatencyFeature ?: false,
|
||||
isTv,
|
||||
initialSettings.presentPriorityWire(),
|
||||
initialSettings.smoothBuffer,
|
||||
// The panel's own refresh — from the mode TABLE (streamPanelFps),
|
||||
// because display.refreshRate reports a per-uid override, not the
|
||||
// panel. Fallback: the (possibly lying) live rate.
|
||||
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
|
||||
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||
.roundToInt(),
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||
@@ -508,6 +635,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
stats?.let {
|
||||
StatsOverlay(
|
||||
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
|
||||
panelHz,
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -366,6 +366,8 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
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,
|
||||
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
|
||||
/**
|
||||
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB — stream mode only.
|
||||
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
|
||||
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
|
||||
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
|
||||
* phone, plus gyro + touchpad the standard path never captured.
|
||||
*
|
||||
* Unlike [Sc2Capture] there is no raw passthrough — the host's DualSense/DS4 backends consume
|
||||
* only typed events — and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
|
||||
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
|
||||
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
|
||||
* engage (toggle off, permission denied, Bluetooth).
|
||||
*
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
*
|
||||
* 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
|
||||
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
|
||||
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
|
||||
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
|
||||
* itself if the poll thread stalls — the engine's explicit zeros remain the real stop mechanism.
|
||||
*/
|
||||
class DsCapture(
|
||||
context: Context,
|
||||
private val router: GamepadRouter,
|
||||
) : GamepadFeedback.PadFeedbackSink {
|
||||
private val usb = HidUsbLink(
|
||||
context,
|
||||
HidUsbLink.Config(
|
||||
tag = TAG,
|
||||
threadName = "pf-ds-usb",
|
||||
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
|
||||
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
|
||||
// class check already leaves them (and the pad's headset routing) to Android; the
|
||||
// single HID interface is the only claim.
|
||||
),
|
||||
::onReport,
|
||||
::onLinkClosed,
|
||||
)
|
||||
|
||||
@Volatile private var model: DsDevice.Model? = null
|
||||
@Volatile private var pad: GamepadRouter.ExternalPad? = null
|
||||
|
||||
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
|
||||
private val state = DsDevice.State()
|
||||
private var wireButtons = 0
|
||||
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||
private val lastTouchActive = BooleanArray(2)
|
||||
private val lastTouchX = IntArray(2) { -1 }
|
||||
private val lastTouchY = IntArray(2) { -1 }
|
||||
|
||||
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
|
||||
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
|
||||
// rumble, before any host Led lands) doesn't black the bar out.
|
||||
@Volatile private var ds4Low = 0
|
||||
@Volatile private var ds4High = 0
|
||||
@Volatile private var ds4Rgb = 0x000040
|
||||
|
||||
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
|
||||
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@Volatile private var backstop: Runnable? = null
|
||||
|
||||
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||
|
||||
/**
|
||||
* Start capturing [dev] (permission already granted). Claims the HID interface — the kernel
|
||||
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
|
||||
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
|
||||
* than waiting for the system's removal callback — so the freed wire index is deterministic
|
||||
* for this capture's ExternalPad instead of racing the first report against the callback. A
|
||||
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
|
||||
* reopens a slot on its next input event, so over-matching self-heals.
|
||||
*/
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (model != null) return false
|
||||
val m = DsDevice.modelFor(dev.productId) ?: return false
|
||||
if (!usb.start(dev)) return false
|
||||
model = m
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
|
||||
}
|
||||
// Release the firmware's lightbar animation once so host lightbar writes take effect
|
||||
// (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))
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
usb.writeControl(stopReport(m))
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
// ---- link callbacks (link thread) ----
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||
var changed = state.buttons xor wireButtons
|
||||
while (changed != 0) {
|
||||
val bit = changed and -changed // lowest changed bit
|
||||
p.button(bit, state.buttons and bit != 0)
|
||||
changed = changed and bit.inv()
|
||||
}
|
||||
wireButtons = state.buttons
|
||||
axis(p, Gamepad.AXIS_LS_X, state.lsX)
|
||||
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
|
||||
axis(p, Gamepad.AXIS_RS_X, state.rsX)
|
||||
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
|
||||
axis(p, Gamepad.AXIS_LT, state.lt)
|
||||
axis(p, Gamepad.AXIS_RT, state.rt)
|
||||
}
|
||||
|
||||
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
|
||||
if (lastAxis[id] == v) return
|
||||
lastAxis[id] = v
|
||||
p.axis(id, v)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
|
||||
* 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) {
|
||||
if (state.touchActive[f]) {
|
||||
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
|
||||
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
|
||||
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
|
||||
p.touch(f, true, x, y)
|
||||
lastTouchActive[f] = true
|
||||
lastTouchX[f] = x
|
||||
lastTouchY[f] = y
|
||||
}
|
||||
} else if (lastTouchActive[f]) {
|
||||
p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||
lastTouchActive[f] = false
|
||||
}
|
||||
}
|
||||
p.motion(state.gyro, state.accel)
|
||||
}
|
||||
|
||||
private fun releaseSlot() {
|
||||
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
|
||||
val p = pad
|
||||
if (p != null) {
|
||||
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||
}
|
||||
p?.close()
|
||||
pad = null
|
||||
wireButtons = 0
|
||||
lastAxis.fill(Int.MIN_VALUE)
|
||||
lastTouchActive.fill(false)
|
||||
lastTouchX.fill(-1)
|
||||
lastTouchY.fill(-1)
|
||||
}
|
||||
|
||||
// ---- PadFeedbackSink (feedback poll threads) ----
|
||||
|
||||
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
}
|
||||
}
|
||||
|
||||
override fun led(pad: Int, r: Int, g: Int, b: Int) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Rgb = (r shl 16) or (g shl 8) or b
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
|
||||
}
|
||||
}
|
||||
|
||||
override fun playerLeds(pad: Int, bits: Int) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
|
||||
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
|
||||
}
|
||||
|
||||
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
ds4Low,
|
||||
ds4High,
|
||||
(ds4Rgb shr 16) and 0xFF,
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
)
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = 0
|
||||
ds4High = 0
|
||||
DsDevice.ds4Report(
|
||||
0,
|
||||
0,
|
||||
(ds4Rgb shr 16) and 0xFF,
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
)
|
||||
} else {
|
||||
DsDevice.ds5RumbleReport(m, 0, 0)
|
||||
}
|
||||
|
||||
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
|
||||
private fun armBackstop(ms: Long) {
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
}
|
||||
|
||||
private fun disarmBackstop() {
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
backstop = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* 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
|
||||
* as-is passthrough, nothing rides the wire raw here — the host's DualSense/DS4 backends consume
|
||||
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
|
||||
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
|
||||
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
|
||||
* USB output reports itself.
|
||||
*
|
||||
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
|
||||
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
|
||||
* `dualsense_proto.rs` / `dualshock4_proto.rs` — this file is the byte-exact inverse of those
|
||||
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
|
||||
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
|
||||
* reaches this code — an uncaptured pad stays on the ordinary InputDevice path.
|
||||
*/
|
||||
object DsDevice {
|
||||
const val VID_SONY = 0x054C
|
||||
const val PID_DUALSENSE = 0x0CE6
|
||||
const val PID_DUALSENSE_EDGE = 0x0DF2
|
||||
const val PID_DUALSHOCK4_V1 = 0x05C4
|
||||
const val PID_DUALSHOCK4_V2 = 0x09CC
|
||||
|
||||
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
|
||||
|
||||
/**
|
||||
* 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), and its touchpad extent
|
||||
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
|
||||
* onto the wire's 0..65535 space.
|
||||
*/
|
||||
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. */
|
||||
fun modelFor(pid: Int): Model? = when (pid) {
|
||||
PID_DUALSENSE -> Model.DUALSENSE
|
||||
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
|
||||
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 {
|
||||
var buttons = 0
|
||||
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) // 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)
|
||||
}
|
||||
|
||||
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
|
||||
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
|
||||
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
|
||||
private const val DS5_INPUT_ID = 0x01
|
||||
// report[8] high nibble (`dualsense_proto::btn0`).
|
||||
private const val DS5_SQUARE = 0x10
|
||||
private const val DS5_CROSS = 0x20
|
||||
private const val DS5_CIRCLE = 0x40
|
||||
private const val DS5_TRIANGLE = 0x80
|
||||
// report[9] (`btn1`).
|
||||
private const val DS5_L1 = 0x01
|
||||
private const val DS5_R1 = 0x02
|
||||
private const val DS5_CREATE = 0x10
|
||||
private const val DS5_OPTIONS = 0x20
|
||||
private const val DS5_L3 = 0x40
|
||||
private const val DS5_R3 = 0x80
|
||||
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
|
||||
private const val DS5_PS = 0x01
|
||||
private const val DS5_TOUCHPAD = 0x02
|
||||
private const val DS5_MUTE = 0x04
|
||||
private const val EDGE_FN_LEFT = 0x10
|
||||
private const val EDGE_FN_RIGHT = 0x20
|
||||
private const val EDGE_BACK_LEFT = 0x40
|
||||
private const val EDGE_BACK_RIGHT = 0x80
|
||||
|
||||
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
|
||||
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
|
||||
// [35..43) two touch points (same 4-byte packing as the DS5).
|
||||
private const val DS4_L1 = 0x01
|
||||
private const val DS4_R1 = 0x02
|
||||
private const val DS4_SHARE = 0x10
|
||||
private const val DS4_OPTIONS = 0x20
|
||||
private const val DS4_L3 = 0x40
|
||||
private const val DS4_R3 = 0x80
|
||||
private const val DS4_PS = 0x01
|
||||
private const val DS4_TOUCHPAD = 0x02
|
||||
|
||||
/**
|
||||
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
|
||||
* 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).
|
||||
*/
|
||||
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
|
||||
if (model == Model.DUALSHOCK4) {
|
||||
parseDs4(report, len, out)
|
||||
} else {
|
||||
parseDs5(model, report, len, out)
|
||||
}
|
||||
|
||||
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))
|
||||
out.rsX = stickX(u8(r, 3))
|
||||
out.rsY = stickY(u8(r, 4))
|
||||
out.lt = u8(r, 5)
|
||||
out.rt = u8(r, 6)
|
||||
val b8 = u8(r, 8)
|
||||
val b9 = u8(r, 9)
|
||||
val b10 = u8(r, 10)
|
||||
var w = hatBits(b8 and 0x0F)
|
||||
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
|
||||
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
|
||||
// L2/R2 digital bits ride the analog axes instead (wire convention).
|
||||
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
|
||||
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
|
||||
if (model == Model.DUALSENSE_EDGE) {
|
||||
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
|
||||
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
|
||||
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
|
||||
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
|
||||
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
|
||||
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
|
||||
}
|
||||
out.buttons = w
|
||||
if (len >= 28) {
|
||||
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)
|
||||
unpackTouch(r, 37, out, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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))
|
||||
out.rsX = stickX(u8(r, 3))
|
||||
out.rsY = stickY(u8(r, 4))
|
||||
val b5 = u8(r, 5)
|
||||
val b6 = u8(r, 6)
|
||||
val b7 = u8(r, 7)
|
||||
out.lt = u8(r, 8)
|
||||
out.rt = u8(r, 9)
|
||||
var w = hatBits(b5 and 0x0F)
|
||||
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
|
||||
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
|
||||
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
|
||||
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||
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] = 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)
|
||||
unpackTouch(r, 39, out, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
|
||||
private fun hatBits(h: Int): Int = when (h) {
|
||||
0 -> Gamepad.BTN_DPAD_UP
|
||||
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
|
||||
2 -> Gamepad.BTN_DPAD_RIGHT
|
||||
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
|
||||
4 -> Gamepad.BTN_DPAD_DOWN
|
||||
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
|
||||
6 -> Gamepad.BTN_DPAD_LEFT
|
||||
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One 4-byte touch point (shared DS5/DS4 packing — `dualsense_proto::pack_touch`): byte0
|
||||
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
|
||||
*/
|
||||
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
|
||||
val b0 = u8(r, o)
|
||||
out.touchActive[slot] = b0 and 0x80 == 0
|
||||
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
|
||||
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
|
||||
}
|
||||
|
||||
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
|
||||
|
||||
private fun i16(r: ByteArray, o: Int): Int =
|
||||
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
|
||||
|
||||
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
|
||||
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
|
||||
private fun stickX(raw: Int): Int = raw * 257 - 32768
|
||||
|
||||
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
|
||||
|
||||
// ---- Output reports ----
|
||||
//
|
||||
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
|
||||
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
|
||||
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
|
||||
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
|
||||
|
||||
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
|
||||
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
|
||||
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
|
||||
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
|
||||
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
|
||||
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
|
||||
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
|
||||
private const val DS5_FLAG0_R2_EFFECT = 0x04
|
||||
private const val DS5_FLAG0_L2_EFFECT = 0x08
|
||||
private const val DS5_FLAG1_LIGHTBAR = 0x04
|
||||
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
|
||||
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
|
||||
private const val DS5_FLAG2_VIBRATION2 = 0x04
|
||||
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
|
||||
|
||||
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
|
||||
const val TRIGGER_EFFECT_LEN = 11
|
||||
|
||||
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
|
||||
|
||||
/**
|
||||
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
|
||||
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect — the same
|
||||
* init both hid-playstation and SDL send on open. No-op fields otherwise.
|
||||
*/
|
||||
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
|
||||
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
|
||||
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
|
||||
* light/right — the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
|
||||
* 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.
|
||||
*/
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
it[3] = amp8(high).toByte()
|
||||
it[4] = amp8(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
|
||||
* trigger block from the wire (`HidOutput::Trigger` — the game's bytes verbatim), copied to
|
||||
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
|
||||
*/
|
||||
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
|
||||
val at = if (which == 1) 11 else 22
|
||||
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
|
||||
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
|
||||
System.arraycopy(effect, 0, it, at, n)
|
||||
}
|
||||
|
||||
/** DS5/Edge lightbar RGB. */
|
||||
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
|
||||
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
|
||||
it[45] = r.toByte()
|
||||
it[46] = g.toByte()
|
||||
it[47] = b.toByte()
|
||||
}
|
||||
|
||||
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
|
||||
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
|
||||
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
|
||||
it[44] = (bits and 0x1F).toByte()
|
||||
}
|
||||
|
||||
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
|
||||
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
|
||||
// [6..9) RGB, [9]/[10] blink on/off.
|
||||
private const val DS4_FLAG0_MOTORS = 0x01
|
||||
private const val DS4_FLAG0_LED = 0x02
|
||||
|
||||
/**
|
||||
* One full-state DS4 write: motors + lightbar together, both flags set — the composed-state
|
||||
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
|
||||
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
|
||||
*/
|
||||
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = amp8(high).toByte()
|
||||
it[5] = amp8(low).toByte()
|
||||
it[6] = r.toByte()
|
||||
it[7] = g.toByte()
|
||||
it[8] = b.toByte()
|
||||
}
|
||||
|
||||
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
|
||||
// vibrator path's toAmplitude).
|
||||
private fun amp8(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,42 @@ class GamepadFeedback(
|
||||
private val router: GamepadRouter?,
|
||||
private val deviceVibrator: Vibrator? = null,
|
||||
) {
|
||||
/**
|
||||
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
|
||||
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
|
||||
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
|
||||
* resolves null and the platform paths no-op) — the link renders instead, by composing USB
|
||||
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
|
||||
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
|
||||
* Invoked on the feedback poll threads; implementations must be thread-safe.
|
||||
*/
|
||||
interface PadFeedbackSink {
|
||||
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
|
||||
* invoked while true. Racing a pad close is fine — a late render is a harmless no-op. */
|
||||
fun ownsPad(pad: Int): Boolean
|
||||
|
||||
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
|
||||
* [backstopMs] as the self-termination net — see [GamepadFeedback.renderRumble]). */
|
||||
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
|
||||
|
||||
/** Lightbar RGB. */
|
||||
fun led(pad: Int, r: Int, g: Int, b: Int)
|
||||
|
||||
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
|
||||
fun playerLeds(pad: Int, bits: Int)
|
||||
|
||||
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
|
||||
* block (mode byte + parameters) exactly as the game wrote it host-side. */
|
||||
fun trigger(pad: Int, which: Int, effect: ByteArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
|
||||
* [onHidRaw]; cleared before the poll threads stop.
|
||||
*/
|
||||
@Volatile
|
||||
var sink: PadFeedbackSink? = null
|
||||
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
@@ -221,6 +257,12 @@ class GamepadFeedback(
|
||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||
// already decided the bind, and the user opted in.
|
||||
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
||||
// A captured pad's link renders on the physical controller itself (its slot has no
|
||||
// InputDevice, so the vibrator bind below would resolve null and drop the command).
|
||||
sink?.takeIf { it.ownsPad(pad) }?.let {
|
||||
it.rumble(pad, low, high, durationMs)
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
@@ -313,23 +355,36 @@ class GamepadFeedback(
|
||||
val g = buf.get().toInt() and 0xFF
|
||||
val b = buf.get().toInt() and 0xFF
|
||||
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null) s.led(pad, r, g, b)
|
||||
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
}
|
||||
TAG_PLAYER_LEDS -> {
|
||||
val bits = buf.get().toInt() and 0x1F
|
||||
val player = playerIndexForBits(bits)
|
||||
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null) s.playerLeds(pad, bits)
|
||||
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
}
|
||||
TAG_TRIGGER -> {
|
||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||
val effLen = n - 3 // [pad][kind][which] header, then the effect block
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null && effLen > 0) {
|
||||
// A captured DualSense: the raw trigger block replays onto the physical pad.
|
||||
val effect = ByteArray(effLen)
|
||||
buf.get(effect)
|
||||
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
|
||||
s.trigger(pad, which, effect)
|
||||
} else {
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No platform adaptive-trigger API — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
TAG_HID_RAW -> {
|
||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||
|
||||
@@ -210,6 +210,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
accel[0], accel[1], accel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||
fun close() = closeSlot(syntheticId)
|
||||
}
|
||||
@@ -228,6 +246,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
|
||||
* detaches the kernel driver, so the system's own removal callback would close it moments
|
||||
* later anyway — doing it at claim time makes the freed wire index deterministic for the
|
||||
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
|
||||
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
|
||||
* slot on its next input event). Main thread, like the hot-plug callbacks.
|
||||
*/
|
||||
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
|
||||
* interface(s) — `force = true` detaches the kernel/OS driver, so a captured pad can't
|
||||
* double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest] read loop, and
|
||||
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
|
||||
* else EP0 `SET_REPORT`).
|
||||
*
|
||||
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
|
||||
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence —
|
||||
* the SC2's lizard-mode refresh; a DualSense needs none).
|
||||
*
|
||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||
* (an SC2 on-glass round tripped exactly this — a 5 s silence heuristic firing on an idle pad).
|
||||
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||
*/
|
||||
class HidUsbLink(
|
||||
private val context: Context,
|
||||
private val config: Config,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
/**
|
||||
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
|
||||
* HID/vendor-class interfaces get claimed (the class check itself is built in) — e.g. the SC2
|
||||
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
|
||||
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
|
||||
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
|
||||
*/
|
||||
class Config(
|
||||
val tag: String,
|
||||
val threadName: String,
|
||||
val deviceMatch: (UsbDevice) -> Boolean,
|
||||
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
|
||||
val keepAliveFeatures: List<ByteArray> = emptyList(),
|
||||
val keepAliveMs: Long = 0,
|
||||
)
|
||||
|
||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||
private class Claim(
|
||||
val iface: UsbInterface,
|
||||
val epIn: UsbEndpoint,
|
||||
val epOut: UsbEndpoint?,
|
||||
) {
|
||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||
var inReq: UsbRequest? = null
|
||||
var outReq: UsbRequest? = null
|
||||
var outBusy = false
|
||||
var reports = 0L
|
||||
}
|
||||
|
||||
private var connection: UsbDeviceConnection? = null
|
||||
private var device: UsbDevice? = null
|
||||
private var claims: List<Claim> = emptyList()
|
||||
|
||||
/** The claim whose IN endpoint last produced data — where output/feature writes go.
|
||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||
@Volatile private var activeClaim: Claim? = null
|
||||
|
||||
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun start(dev: UsbDevice): Boolean {
|
||||
if (!usb.hasPermission(dev)) {
|
||||
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterfaces(dev, conn)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
"USB link up: PID=0x%04x ifaces=%s".format(
|
||||
dev.productId,
|
||||
claimed.joinToString {
|
||||
"%d(in=0x%02x out=%s)".format(
|
||||
it.iface.id, it.epIn.address,
|
||||
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
detachReceiver = receiver
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
if (config.keepAliveFeatures.isNotEmpty()) {
|
||||
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||
}
|
||||
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
|
||||
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional — the fallback is
|
||||
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
|
||||
* from Android's own input stack while captured.
|
||||
*/
|
||||
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||
val out = mutableListOf<Claim>()
|
||||
for (i in 0 until dev.interfaceCount) {
|
||||
val iface = dev.getInterface(i)
|
||||
if (!config.ifaceFilter(dev, iface)) continue
|
||||
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||
iface.interfaceClass == 0xFF
|
||||
if (!hidOrVendor) continue
|
||||
var inEp: UsbEndpoint? = null
|
||||
var outEp: UsbEndpoint? = null
|
||||
for (e in 0 until iface.endpointCount) {
|
||||
val ep = iface.getEndpoint(e)
|
||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||
if (!usable) continue
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||
}
|
||||
if (inEp == null) continue
|
||||
if (conn.claimInterface(iface, true)) {
|
||||
out.add(Claim(iface, inEp, outEp))
|
||||
} else {
|
||||
Log.w(config.tag, "could not claim iface ${iface.id}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||
*/
|
||||
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||
val live = claims.filter { c ->
|
||||
val req = UsbRequest()
|
||||
if (!req.initialize(conn, c.epIn)) {
|
||||
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||
return@filter false
|
||||
}
|
||||
req.clientData = c
|
||||
c.inReq = req
|
||||
c.epOut?.let { ep ->
|
||||
val o = UsbRequest()
|
||||
if (o.initialize(conn, ep)) {
|
||||
o.clientData = c
|
||||
c.outReq = o
|
||||
} else {
|
||||
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||
}
|
||||
}
|
||||
c.inBuf.clear()
|
||||
req.queue(c.inBuf)
|
||||
}
|
||||
if (live.isEmpty()) {
|
||||
Log.e(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
|
||||
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||
try {
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
|
||||
now - lastKeepAlive >= config.keepAliveMs
|
||||
) {
|
||||
// Refresh the firmware settings on the streaming interface (else every live
|
||||
// one, before a streaming interface is known) — replaying also repairs state
|
||||
// some other consumer changed after capture started.
|
||||
val target = activeClaim
|
||||
if (target != null) sendKeepAlive(conn, target.iface.id)
|
||||
else live.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||
lastKeepAlive = now
|
||||
}
|
||||
// Submit the next pending OUT report on the active (else first) interface.
|
||||
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||
if (outTarget != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||
}
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS)
|
||||
} catch (_: TimeoutException) {
|
||||
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||
// detach broadcast is the real signal.
|
||||
errorsSince = 0L
|
||||
continue
|
||||
}
|
||||
if (done == null) {
|
||||
// Hard error. On a real unplug these storm continuously (the detach
|
||||
// broadcast usually beats us to it); tolerate transient ones.
|
||||
if (errorsSince == 0L) errorsSince = now
|
||||
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
errorsSince = 0L
|
||||
val claim = done.clientData as? Claim ?: continue
|
||||
if (done === claim.inReq) {
|
||||
val n = claim.inBuf.position()
|
||||
if (n > 0) {
|
||||
claim.inBuf.flip()
|
||||
claim.inBuf.get(scratch, 0, n)
|
||||
if (claim.reports++ == 0L) {
|
||||
Log.i(
|
||||
config.tag,
|
||||
"first report on iface %d: id=0x%02x len=%d".format(
|
||||
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||
),
|
||||
)
|
||||
}
|
||||
activeClaim = claim
|
||||
onReport(scratch, n)
|
||||
}
|
||||
claim.inBuf.clear()
|
||||
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||
break
|
||||
}
|
||||
} else if (done === claim.outReq) {
|
||||
claim.outBusy = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
for (c in claims) {
|
||||
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||
c.inReq = null
|
||||
c.outReq = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
|
||||
* queue — for a teardown write that must land while the reader thread is stopping and the
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
}
|
||||
|
||||
/**
|
||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
running = false
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
claims = emptyList()
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
device = null
|
||||
}
|
||||
|
||||
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. */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
}
|
||||
@@ -184,10 +184,12 @@ object NativeBridge {
|
||||
external fun nativeVideoMime(handle: Long): String
|
||||
|
||||
/**
|
||||
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
|
||||
* handshake, so it is known before the first frame — the stream view sizes itself to THIS
|
||||
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
|
||||
* Cheap; UI-safe.
|
||||
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
|
||||
* Resolved at the handshake, so it is known before the first frame — the stream view sizes
|
||||
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
|
||||
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
|
||||
* (an older native lib returns only `[width, height]` — index defensively). Fixed for the
|
||||
* session; read once. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSize(handle: Long): IntArray?
|
||||
|
||||
@@ -204,11 +206,13 @@ object NativeBridge {
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
|
||||
* the MIME — what the pre-overhaul client always did); [lowLatencyMode] is the user's
|
||||
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
|
||||
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
|
||||
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
|
||||
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
|
||||
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
|
||||
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
|
||||
* hint otherwise). No-op if already started.
|
||||
* hint otherwise). [presentPriority]/[smoothBuffer] are the timeline presenter's intent
|
||||
* (0 = lowest latency / 1 = smoothness; buffer 0 = automatic, else 1..3 frames) — the Apple
|
||||
* client's `present_priority`/`smooth_buffer` pair. No-op if already started.
|
||||
*/
|
||||
external fun nativeStartVideo(
|
||||
handle: Long,
|
||||
@@ -217,6 +221,11 @@ object NativeBridge {
|
||||
lowLatencyMode: Boolean,
|
||||
lowLatencyFeature: Boolean,
|
||||
isTv: Boolean,
|
||||
presentPriority: Int,
|
||||
smoothBuffer: Int,
|
||||
/** The display mode's own refresh rate (0 = unknown) — the latch grid the presenter
|
||||
* subdivides onto when the platform down-rates the app's choreographer stream. */
|
||||
panelFps: Int,
|
||||
)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
@@ -231,11 +240,11 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* Returns 30 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]`
|
||||
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
|
||||
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
@@ -407,6 +416,30 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||
|
||||
/**
|
||||
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
|
||||
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
|
||||
* are normalized 0..65535 in SCREEN convention (+y down — the wire's fixed meaning); active
|
||||
* false lifts the finger. Send on change only — the host holds per-slot state.
|
||||
*/
|
||||
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
|
||||
|
||||
/**
|
||||
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
|
||||
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units — the host passes
|
||||
* them straight into the virtual DualSense report. Called at the pad's report rate.
|
||||
*/
|
||||
external fun nativeSendPadMotion(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
gyroPitch: Int,
|
||||
gyroYaw: Int,
|
||||
gyroRoll: Int,
|
||||
accelX: Int,
|
||||
accelY: Int,
|
||||
accelZ: Int,
|
||||
)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
|
||||
* dongle (`1304`/`1305`). Claims the controller interface(s) — detaching the OS input stack, so
|
||||
* the pad can't double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest]
|
||||
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
|
||||
* writes (Steam's rumble output reports / settings feature reports) back to the device.
|
||||
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
|
||||
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
|
||||
* SC2-specific:
|
||||
*
|
||||
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
|
||||
* HID interface each, and there is no way to know which slot a controller bonded to — claiming
|
||||
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
|
||||
* on-glass symptom: the pad surfaced as a generic InputDevice → Xbox360). Whichever interface
|
||||
* streams state becomes the write target for rumble/settings.
|
||||
*
|
||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
|
||||
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
|
||||
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
|
||||
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence — the generic link's keep-alive.
|
||||
*/
|
||||
class Sc2UsbLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
context: Context,
|
||||
onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
onClosed: () -> Unit,
|
||||
) {
|
||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||
private class Claim(
|
||||
val iface: UsbInterface,
|
||||
val epIn: UsbEndpoint,
|
||||
val epOut: UsbEndpoint?,
|
||||
) {
|
||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||
var inReq: UsbRequest? = null
|
||||
var outReq: UsbRequest? = null
|
||||
var outBusy = false
|
||||
var reports = 0L
|
||||
}
|
||||
|
||||
private var connection: UsbDeviceConnection? = null
|
||||
private var device: UsbDevice? = null
|
||||
private var claims: List<Claim> = emptyList()
|
||||
|
||||
/** The claim whose IN endpoint last produced data — where rumble/settings writes go.
|
||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||
@Volatile private var activeClaim: Claim? = null
|
||||
|
||||
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread — only
|
||||
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
|
||||
* returns ANY completed request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
private val link = HidUsbLink(
|
||||
context,
|
||||
HidUsbLink.Config(
|
||||
tag = "Sc2UsbLink",
|
||||
threadName = "pf-sc2-usb",
|
||||
deviceMatch = {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
},
|
||||
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
|
||||
ifaceFilter = { dev, iface ->
|
||||
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
|
||||
},
|
||||
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
|
||||
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
|
||||
),
|
||||
onReport,
|
||||
onClosed,
|
||||
)
|
||||
|
||||
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
}
|
||||
fun findDevice(): UsbDevice? = link.findDevice()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun start(dev: UsbDevice): Boolean {
|
||||
if (!usb.hasPermission(dev)) {
|
||||
Log.e(TAG, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterfaces(dev, conn)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
running = true
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
|
||||
dev.productId,
|
||||
claimed.joinToString {
|
||||
"%d(in=0x%02x out=%s)".format(
|
||||
it.iface.id, it.epIn.address,
|
||||
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
detachReceiver = receiver
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
claimed.forEach { configureInputMode(conn, it.iface.id) }
|
||||
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
|
||||
* of a Puck's controller slots (interfaces 2..5 — the controller may be bonded to any of
|
||||
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
|
||||
* Android's own input stack while captured.
|
||||
*/
|
||||
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||
val dongle = dev.productId != Sc2Device.PID_WIRED
|
||||
val out = mutableListOf<Claim>()
|
||||
for (i in 0 until dev.interfaceCount) {
|
||||
val iface = dev.getInterface(i)
|
||||
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
|
||||
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||
iface.interfaceClass == 0xFF
|
||||
if (!hidOrVendor) continue
|
||||
var inEp: UsbEndpoint? = null
|
||||
var outEp: UsbEndpoint? = null
|
||||
for (e in 0 until iface.endpointCount) {
|
||||
val ep = iface.getEndpoint(e)
|
||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||
if (!usable) continue
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||
}
|
||||
if (inEp == null) continue
|
||||
if (conn.claimInterface(iface, true)) {
|
||||
out.add(Claim(iface, inEp, outEp))
|
||||
} else {
|
||||
Log.w(TAG, "could not claim iface ${iface.id}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||
*/
|
||||
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||
val live = claims.filter { c ->
|
||||
val req = UsbRequest()
|
||||
if (!req.initialize(conn, c.epIn)) {
|
||||
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||
return@filter false
|
||||
}
|
||||
req.clientData = c
|
||||
c.inReq = req
|
||||
c.epOut?.let { ep ->
|
||||
val o = UsbRequest()
|
||||
if (o.initialize(conn, ep)) {
|
||||
o.clientData = c
|
||||
c.outReq = o
|
||||
} else {
|
||||
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||
}
|
||||
}
|
||||
c.inBuf.clear()
|
||||
req.queue(c.inBuf)
|
||||
}
|
||||
if (live.isEmpty()) {
|
||||
Log.e(TAG, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
var lastLizard = android.os.SystemClock.elapsedRealtime()
|
||||
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||
try {
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
||||
// Refresh both required firmware modes. The raw-joystick setting is normally
|
||||
// persistent, but replaying it also repairs a host/driver that enabled ADC
|
||||
// coordinates after capture started.
|
||||
val target = activeClaim
|
||||
if (target != null) configureInputMode(conn, target.iface.id)
|
||||
else live.forEach { configureInputMode(conn, it.iface.id) }
|
||||
lastLizard = now
|
||||
}
|
||||
// Submit the next pending OUT report on the active (else first) interface.
|
||||
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||
if (outTarget != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||
}
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS)
|
||||
} catch (_: TimeoutException) {
|
||||
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||
// detach broadcast is the real signal.
|
||||
errorsSince = 0L
|
||||
continue
|
||||
}
|
||||
if (done == null) {
|
||||
// Hard error. On a real unplug these storm continuously (the detach
|
||||
// broadcast usually beats us to it); tolerate transient ones.
|
||||
if (errorsSince == 0L) errorsSince = now
|
||||
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
errorsSince = 0L
|
||||
val claim = done.clientData as? Claim ?: continue
|
||||
if (done === claim.inReq) {
|
||||
val n = claim.inBuf.position()
|
||||
if (n > 0) {
|
||||
claim.inBuf.flip()
|
||||
claim.inBuf.get(scratch, 0, n)
|
||||
if (claim.reports++ == 0L) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 first report on iface %d: id=0x%02x len=%d".format(
|
||||
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||
),
|
||||
)
|
||||
}
|
||||
activeClaim = claim
|
||||
onReport(scratch, n)
|
||||
}
|
||||
claim.inBuf.clear()
|
||||
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||
break
|
||||
}
|
||||
} else if (done === claim.outReq) {
|
||||
claim.outBusy = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
for (c in claims) {
|
||||
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||
c.inReq = null
|
||||
c.outReq = null
|
||||
}
|
||||
}
|
||||
fun start(dev: UsbDevice): Boolean = link.start(dev)
|
||||
|
||||
/**
|
||||
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
|
||||
* rumble & friends — the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
|
||||
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
|
||||
* report, id byte first, exactly as hidapi framed it host-side.
|
||||
* rumble & friends), kind 1 = feature report. [data] is the full report, id byte first,
|
||||
* exactly as hidapi framed it host-side.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the host re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
}
|
||||
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, data)
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
|
||||
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
|
||||
}
|
||||
|
||||
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
|
||||
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
running = false
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
claims = emptyList()
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
device = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2UsbLink"
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
|
||||
fun stop() = link.stop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM tests of the Sony USB report codec ([DsDevice]) — the byte-exact inverse of the
|
||||
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
|
||||
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DsDeviceTest {
|
||||
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
// Sticks centred, hat neutral (8).
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[8] = 0x08
|
||||
// Touch points inactive (bit7 set).
|
||||
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[5] = 0x08
|
||||
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
// ---- input parse ----
|
||||
|
||||
@Test
|
||||
fun ds5ButtonsMapPositionally() {
|
||||
val s = DsDevice.State()
|
||||
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
|
||||
val r = ds5Report {
|
||||
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
|
||||
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
|
||||
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
|
||||
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
|
||||
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
|
||||
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
|
||||
assertEquals(expected, s.buttons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5SticksInvertYAndCoverTheFullRange() {
|
||||
val s = DsDevice.State()
|
||||
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
|
||||
val r = ds5Report {
|
||||
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
|
||||
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
|
||||
it[5] = 0x40; it[6] = 0xFF.toByte()
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
assertEquals(-32768, s.lsX)
|
||||
assertEquals(32767, s.lsY) // device up → wire +32767
|
||||
assertEquals(32767, s.rsX)
|
||||
assertEquals(-32768, s.rsY) // device down → wire −32768
|
||||
assertEquals(0x40, s.lt)
|
||||
assertEquals(0xFF, s.rt)
|
||||
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
|
||||
val c = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
|
||||
assertEquals(128, c.lsX)
|
||||
assertEquals(-129, c.lsY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5MotionAndTouchUnpack() {
|
||||
val s = DsDevice.State()
|
||||
val r = ds5Report {
|
||||
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
|
||||
it[16] = 0x02; it[17] = 0x01
|
||||
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
|
||||
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
|
||||
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
|
||||
it[33] = 0x05
|
||||
it[34] = 0x7F
|
||||
it[35] = (0x07 or (0x07 shl 4)).toByte()
|
||||
it[36] = 0x43
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
assertEquals(0x0102, s.gyro[0])
|
||||
assertEquals(-2, s.accel[2])
|
||||
assertTrue(s.touchActive[0])
|
||||
assertEquals(1919, s.touchX[0])
|
||||
assertEquals(1079, s.touchY[0])
|
||||
assertFalse(s.touchActive[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun edgePaddlesParseOnlyOnTheEdge() {
|
||||
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
|
||||
val edge = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
|
||||
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
|
||||
assertEquals(
|
||||
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
|
||||
edge.buttons,
|
||||
)
|
||||
val plain = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
|
||||
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds4LayoutDiffersWhereItShould() {
|
||||
val s = DsDevice.State()
|
||||
val r = ds4Report {
|
||||
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
|
||||
it[6] = (0x10 or 0x20).toByte() // share | options
|
||||
it[7] = 0x03 // PS | touchpad click
|
||||
it[8] = 0x11 // L2 analog
|
||||
it[9] = 0x99.toByte() // R2 analog
|
||||
// gyro yaw at 15.. (second i16 of 13..19).
|
||||
it[15] = 0x34; it[16] = 0x12
|
||||
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
|
||||
it[35] = 0x03
|
||||
it[36] = 0x64
|
||||
it[37] = 0xD0.toByte()
|
||||
it[38] = 0x3A
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
|
||||
assertEquals(
|
||||
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
|
||||
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
|
||||
s.buttons,
|
||||
)
|
||||
assertEquals(0x11, s.lt)
|
||||
assertEquals(0x99, s.rt)
|
||||
assertEquals(0x1234, s.gyro[1])
|
||||
assertTrue(s.touchActive[0])
|
||||
assertEquals(100, s.touchX[0])
|
||||
assertEquals(941, s.touchY[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsForeignAndShortReports() {
|
||||
val s = DsDevice.State()
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
|
||||
}
|
||||
|
||||
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
|
||||
|
||||
@Test
|
||||
fun ds5RumbleReportFlagsAndMotors() {
|
||||
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
|
||||
assertEquals(48, r.size)
|
||||
assertEquals(0x02, r[0].toInt())
|
||||
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
|
||||
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
|
||||
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
|
||||
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
|
||||
// A nonzero amplitude never collapses to motor 0.
|
||||
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
|
||||
// The Edge's output report is the 64-byte variant.
|
||||
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5TriggerReportPlacesTheBlockPerSide() {
|
||||
val effect = ByteArray(11) { (it + 1).toByte() }
|
||||
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
|
||||
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
|
||||
assertEquals(1, r2[11].toInt()) // block at [11..22)
|
||||
assertEquals(11, r2[21].toInt())
|
||||
assertEquals(0, r2[22].toInt())
|
||||
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
|
||||
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
|
||||
assertEquals(1, l2[22].toInt()) // block at [22..33)
|
||||
assertEquals(11, l2[32].toInt())
|
||||
// Oversized wire effects clamp to the 11-byte hardware block.
|
||||
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
|
||||
assertEquals(0, big[22].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5LightbarPlayerLedsAndInit() {
|
||||
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
|
||||
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
|
||||
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
|
||||
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
|
||||
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
|
||||
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
|
||||
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
|
||||
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
|
||||
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds4ReportIsAFullStateWrite() {
|
||||
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
|
||||
assertEquals(32, r.size)
|
||||
assertEquals(0x05, r[0].toInt())
|
||||
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
|
||||
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
|
||||
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
|
||||
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
|
||||
assertEquals(0, r[9].toInt()) // blink untouched
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelResolution() {
|
||||
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
|
||||
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
|
||||
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
|
||||
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
|
||||
assertEquals(null, DsDevice.modelFor(0x1234))
|
||||
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
|
||||
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
|
||||
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,12 @@ use super::display::{
|
||||
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
||||
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||
use super::setup::{
|
||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||
configure_low_latency, create_codec, try_set_frame_rate,
|
||||
};
|
||||
use super::vsync::{now_monotonic_ns, VsyncClock};
|
||||
use super::{
|
||||
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
|
||||
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
|
||||
@@ -55,6 +57,8 @@ enum DecodeEvent {
|
||||
},
|
||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||
FormatChanged,
|
||||
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
|
||||
Vsync,
|
||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||
Error { fatal: bool },
|
||||
}
|
||||
@@ -78,6 +82,9 @@ pub(super) fn run_async(
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -196,9 +203,33 @@ pub(super) fn run_async(
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let meter = Arc::new(PresentMeter::new());
|
||||
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
|
||||
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
|
||||
// legacy release-immediately path for a rebuild-free on-device A/B.
|
||||
let mut presenter = if presenter_disabled_by_sysprop() {
|
||||
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
|
||||
None
|
||||
} else {
|
||||
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
|
||||
log::info!(
|
||||
"decode: presenter = timeline ({})",
|
||||
match priority {
|
||||
PresentPriority::Latency => "lowest latency".to_string(),
|
||||
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
|
||||
}
|
||||
);
|
||||
Some(Presenter::new(priority))
|
||||
};
|
||||
stats.set_presenter_active(presenter.is_some());
|
||||
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
|
||||
// the same event channel. The Sender parks here until that moment.
|
||||
let mut vsync: Option<VsyncClock> = None;
|
||||
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
|
||||
|
||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||
let feeder = {
|
||||
@@ -277,6 +308,7 @@ pub(super) fn run_async(
|
||||
};
|
||||
let work_t0 = Instant::now();
|
||||
let mut fmt_dirty = false;
|
||||
let mut vsync_tick = false;
|
||||
let mut aus_dropped: u64 = 0;
|
||||
if let Some(ev) = ev0 {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
@@ -285,6 +317,7 @@ pub(super) fn run_async(
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
@@ -299,11 +332,17 @@ pub(super) fn run_async(
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
));
|
||||
}
|
||||
if vsync_tick {
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.on_vsync();
|
||||
}
|
||||
}
|
||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||
if fmt_dirty {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
@@ -317,6 +356,7 @@ pub(super) fn run_async(
|
||||
&mut oversized_dropped,
|
||||
);
|
||||
let had_output = !ready.is_empty();
|
||||
let rendered_before = rendered;
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
@@ -326,14 +366,64 @@ pub(super) fn run_async(
|
||||
&in_flight,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut presenter,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
|
||||
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick: the measured latch
|
||||
// p50 is the arrival-lead error signal the host's capture controller drives toward
|
||||
// its target (design/phase-locked-capture.md §6). Timestamps convert
|
||||
// monotonic→realtime→host here — the skew offset lives only client-side.
|
||||
if let (Some(latch_p50_ns), Some(c)) = (p.flush_log(&meter, clock), clock) {
|
||||
let period = c.panel_period_ns().max(c.period_ns());
|
||||
if period > 0 {
|
||||
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
|
||||
let mono_now = now_monotonic_ns();
|
||||
let real_now = now_realtime_ns();
|
||||
let latch_real_ns = real_now + (t.expected_present_ns - mono_now) as i128;
|
||||
let latch_host_ns = (latch_real_ns
|
||||
+ clock_offset.load(Ordering::Relaxed) as i128)
|
||||
.max(0) as u64;
|
||||
client.report_phase(
|
||||
latch_host_ns,
|
||||
period.clamp(0, u32::MAX as i64) as u32,
|
||||
1_000_000, // skew residual + latch jitter — conservative 1 ms
|
||||
latch_p50_ns.min(u32::MAX as u64) as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let presented_now = rendered > rendered_before;
|
||||
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
|
||||
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
|
||||
// lesson). A `None` from start (no choreographer surface) simply leaves ASAP targets.
|
||||
if had_output && vsync.is_none() {
|
||||
if let Some(tx) = vsync_tx.take() {
|
||||
vsync = VsyncClock::start(
|
||||
panel_hz,
|
||||
Box::new(move || {
|
||||
let _ = tx.send(DecodeEvent::Vsync);
|
||||
}),
|
||||
);
|
||||
if vsync.is_none() {
|
||||
log::info!("decode: no choreographer clock — presenter uses ASAP targets");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
if had_output {
|
||||
if presented_now {
|
||||
if !hint_tried {
|
||||
hint_tried = true;
|
||||
let tids = client.hot_thread_ids();
|
||||
@@ -420,6 +510,10 @@ pub(super) fn run_async(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.release_all(&codec); // hand every held output buffer back before the codec stops
|
||||
}
|
||||
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
|
||||
let _ = codec.stop();
|
||||
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||
if let Some(j) = feeder {
|
||||
@@ -520,6 +614,7 @@ fn dispatch_event(
|
||||
free_inputs: &mut VecDeque<usize>,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
fmt_dirty: &mut bool,
|
||||
vsync_tick: &mut bool,
|
||||
fatal: &mut bool,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
@@ -552,6 +647,7 @@ fn dispatch_event(
|
||||
decoded_ns,
|
||||
}),
|
||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||
DecodeEvent::Vsync => *vsync_tick = true,
|
||||
DecodeEvent::Error { fatal: f } => {
|
||||
if f {
|
||||
*fatal = true;
|
||||
@@ -613,13 +709,14 @@ fn feed_ready(
|
||||
}
|
||||
}
|
||||
|
||||
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
|
||||
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
||||
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
||||
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
||||
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
|
||||
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
|
||||
/// drained.
|
||||
/// Route the ready outputs toward glass. With the timeline presenter (default): fold each output
|
||||
/// through the re-anchor gate in pts order, hand the approved ones to the presenter's store
|
||||
/// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and
|
||||
/// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop):
|
||||
/// present only the NEWEST ready output immediately and release the rest unrendered — the
|
||||
/// original policy. Every dequeued buffer, rendered or not, is the HUD's `decoded` measurement
|
||||
/// point (it finished decoding either way); samples are recorded in pts order so the receipt-map
|
||||
/// eviction stays monotonic. `ready` is drained.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||
fn present_ready(
|
||||
codec: &MediaCodec,
|
||||
@@ -630,6 +727,7 @@ fn present_ready(
|
||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||
clock_offset: i64,
|
||||
tracker: &DisplayTracker,
|
||||
presenter: &mut Option<Presenter>,
|
||||
rendered: &mut u64,
|
||||
discarded: &mut u64,
|
||||
gate: &mut ReanchorGate,
|
||||
@@ -657,31 +755,46 @@ fn present_ready(
|
||||
}
|
||||
}
|
||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||
// so the two-mark re-anchor count stays correct; a `false` verdict is withheld concealment (the
|
||||
// SurfaceView keeps the last rendered frame frozen on).
|
||||
let now = Instant::now();
|
||||
let last = ready.len() - 1;
|
||||
let mut skipped: u64 = 0;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
if stats.enabled() {
|
||||
tracker.note_rendered(o.pts_us, o.decoded_ns);
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
for o in ready.drain(..) {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
|
||||
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
|
||||
skipped += dropped;
|
||||
*discarded += dropped;
|
||||
} else {
|
||||
if let Err(e) = codec.release_output_buffer_by_index(o.index, false) {
|
||||
log::warn!("decode: release_output_buffer_by_index({}): {e}", o.index);
|
||||
}
|
||||
}
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
o.index
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let last = ready.len() - 1;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
tracker.note_rendered(o.pts_us, o.decoded_ns, now_realtime_ns());
|
||||
}
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
o.index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,32 +35,37 @@ 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>,
|
||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||
/// callback early-outs) while the overlay is hidden.
|
||||
rendered: Mutex<VecDeque<(u64, i128)>>,
|
||||
/// 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>,
|
||||
/// `(pts_us, decoded_real_ns, released_real_ns)` of frames released with `render = true`, in
|
||||
/// release order, awaiting their callback. Pushed on EVERY render (no HUD gate — the ring is
|
||||
/// a 64-tuple bound and the latch metric wants to exist when nobody is watching).
|
||||
rendered: Mutex<VecDeque<(u64, i128, i128)>>,
|
||||
}
|
||||
|
||||
impl DisplayTracker {
|
||||
pub(super) fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
meter,
|
||||
rendered: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
|
||||
/// Caller gates on the HUD being visible.
|
||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
|
||||
/// Park one just-rendered frame's `(pts, decoded stamp, release stamp)` for the render
|
||||
/// callback to pair — the release stamp is the latch metric's start (release→displayed).
|
||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128, released_ns: i128) {
|
||||
let mut g = self
|
||||
.rendered
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.push_back((pts_us, decoded_ns));
|
||||
g.push_back((pts_us, decoded_ns, released_ns));
|
||||
if g.len() > RENDERED_CAP {
|
||||
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
||||
}
|
||||
@@ -152,36 +157,42 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
// `Arc::into_raw` pointer from `install_render_callback`, whose refcount is held for as long as
|
||||
// the codec exists, and the codec is what delivers this call.
|
||||
let t = unsafe { &*(userdata as *const DisplayTracker) };
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
||||
}
|
||||
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
||||
let pts_us = media_time_us.max(0) as u64;
|
||||
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
||||
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
|
||||
// discipline as `note_decoded_pts`.
|
||||
let mut decoded_ns = None;
|
||||
// dropped by the platform) — same monotonic-eviction discipline as `note_decoded_pts`.
|
||||
let mut paired = None;
|
||||
{
|
||||
let mut g = t
|
||||
.rendered
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while let Some(&(p, d)) = g.front() {
|
||||
while let Some(&(p, d, r)) = g.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own callback
|
||||
}
|
||||
g.pop_front();
|
||||
if p == pts_us {
|
||||
decoded_ns = Some(d);
|
||||
paired = Some((d, r));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clamped to (0, 10 s) like the e2e sample: a vendor's first render callbacks can carry a
|
||||
// garbage `system_nano` (observed on-glass: an epoch-sized latch max on the session's first
|
||||
// window), and one such sample would poison every max/percentile it lands in.
|
||||
let clamp = |v: i128| (v > 0 && v < 10_000_000_000).then_some((v / 1000) as u64);
|
||||
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
|
||||
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
|
||||
// Always-on half: the presenter's pf-present line reads these with the HUD off.
|
||||
t.meter.note_latch(latch_us);
|
||||
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;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us);
|
||||
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
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
mod async_loop;
|
||||
mod display;
|
||||
mod latency;
|
||||
mod presenter;
|
||||
mod setup;
|
||||
mod sync_loop;
|
||||
mod vsync;
|
||||
|
||||
use async_loop::run_async;
|
||||
pub(crate) use setup::{codec_label, codec_mime};
|
||||
@@ -106,6 +108,17 @@ pub(crate) struct DecodeOptions {
|
||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||
pub is_tv: bool,
|
||||
/// The user's presentation intent (`present_priority` setting): 0 = lowest latency
|
||||
/// (newest-wins), 1 = smoothness (a small FIFO). Resolved by
|
||||
/// [`presenter::PresentPriority::resolve`]; anything else = latency.
|
||||
pub present_priority: i32,
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
//! The timeline presenter — Android's port of the Apple client's stage-4 deadline discipline
|
||||
//! (`clients/apple/.../Stage2Pipeline.swift`, the `.deadline` pacing):
|
||||
//!
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
//! identical to the legacy path — and only the budget prediction uses the measured period.
|
||||
//!
|
||||
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
|
||||
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
|
||||
//! A/B needs no rebuild. The user-facing escape hatch stays the "Low-latency mode" master toggle
|
||||
//! (off = the synchronous pre-overhaul loop, no presenter at all).
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::display::DisplayTracker;
|
||||
use super::latency::now_realtime_ns;
|
||||
use super::vsync::VsyncShared;
|
||||
|
||||
/// Submit-margin ahead of a timeline's EXPECTED PRESENT — SurfaceFlinger's own latch lead: the
|
||||
/// released buffer must be in the BufferQueue by SF's wakeup for that vsync (a few ms before
|
||||
/// present). A present closer than this is treated as missed and the next one is targeted. This
|
||||
/// is deliberately NOT the timeline's `deadline` (which budgets for GPU rendering a video
|
||||
/// buffer doesn't do — see `VsyncShared::next_target`); a too-tight gamble here presents one
|
||||
/// vsync later, the exact cost the deadline gate paid on every frame.
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||
/// signal to widen, not stutter.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// The budget's liveness backstop: a release whose predicted latch never seems to arrive
|
||||
/// (clock glitch, mode switch) force-reopens the budget this long after the release, counted in
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
/// The user's presentation intent — the Apple client's `PresentPriority`, same resolution rules:
|
||||
/// anything but an explicit "smooth" is latency; a smooth buffer outside 1..=3 becomes 2.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) enum PresentPriority {
|
||||
/// Newest-wins, release the instant the budget opens. The default.
|
||||
Latency,
|
||||
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
|
||||
/// added display latency per slot, which the metrics show rather than hide.
|
||||
Smooth { buffer: usize },
|
||||
}
|
||||
|
||||
impl PresentPriority {
|
||||
/// From the JNI ints (`presentPriority` 0 = latency / 1 = smooth; `smoothBuffer` 0 = auto).
|
||||
pub(crate) fn resolve(priority: i32, buffer: i32) -> PresentPriority {
|
||||
if priority != 1 {
|
||||
return PresentPriority::Latency;
|
||||
}
|
||||
let b = if (1..=3).contains(&buffer) {
|
||||
buffer as usize
|
||||
} else {
|
||||
2
|
||||
};
|
||||
PresentPriority::Smooth { buffer: b }
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded output buffer held for presentation.
|
||||
struct HeldFrame {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
|
||||
decoded_ns: i128,
|
||||
}
|
||||
|
||||
/// The one-in-flight glass budget.
|
||||
struct InFlight {
|
||||
/// Monotonic instant the budget reopens: the release target's expected present (clock), or
|
||||
/// `release + period` on the fallback path.
|
||||
reopen_at_ns: i64,
|
||||
released_at_ns: i64,
|
||||
}
|
||||
|
||||
/// Latch samples + display confirms recorded by the `OnFrameRendered` callback thread, drained by
|
||||
/// the presenter's 1 Hz `pf-present` line. Always on (independent of the HUD) — this is what makes
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
latch_us: Vec<u64>,
|
||||
displays: u64,
|
||||
}
|
||||
|
||||
impl PresentMeter {
|
||||
pub(super) fn new() -> PresentMeter {
|
||||
PresentMeter {
|
||||
inner: Mutex::new(PresentMeterInner {
|
||||
latch_us: Vec::with_capacity(256),
|
||||
displays: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.displays += 1;
|
||||
if let Some(l) = latch_us {
|
||||
if g.latch_us.len() < 4096 {
|
||||
g.latch_us.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(&self) -> (Vec<u64>, u64) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let displays = g.displays;
|
||||
g.displays = 0;
|
||||
(std::mem::take(&mut g.latch_us), displays)
|
||||
}
|
||||
}
|
||||
|
||||
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
|
||||
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
|
||||
if v.is_empty() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
v.sort_unstable();
|
||||
let p50 = v[v.len() / 2] as f64 / 1000.0;
|
||||
let max = *v.last().unwrap() as f64 / 1000.0;
|
||||
(p50, max)
|
||||
}
|
||||
|
||||
pub(super) struct Presenter {
|
||||
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
|
||||
fifo_capacity: usize,
|
||||
frames: VecDeque<HeldFrame>,
|
||||
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
|
||||
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
|
||||
prerolled: bool,
|
||||
inflight: Option<InFlight>,
|
||||
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
|
||||
vsync_tick: bool,
|
||||
// -- 1 Hz pf-present window, always on --
|
||||
released: u64,
|
||||
paced_drops: u64,
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
pub(super) fn new(priority: PresentPriority) -> Presenter {
|
||||
Presenter {
|
||||
fifo_capacity: match priority {
|
||||
PresentPriority::Latency => 0,
|
||||
PresentPriority::Smooth { buffer } => buffer,
|
||||
},
|
||||
frames: VecDeque::new(),
|
||||
prerolled: false,
|
||||
inflight: None,
|
||||
vsync_tick: false,
|
||||
released: 0,
|
||||
paced_drops: 0,
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
|
||||
/// FIFO's drain pace.
|
||||
pub(super) fn on_vsync(&mut self) {
|
||||
self.vsync_tick = true;
|
||||
}
|
||||
|
||||
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
|
||||
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
|
||||
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
|
||||
pub(super) fn submit(
|
||||
&mut self,
|
||||
codec: &MediaCodec,
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
) -> u64 {
|
||||
let mut dropped = 0u64;
|
||||
if self.fifo_capacity == 0 {
|
||||
while let Some(stale) = self.frames.pop_front() {
|
||||
release_unrendered(codec, stale.index);
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
self.frames.push_back(HeldFrame {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
});
|
||||
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
|
||||
if let Some(stale) = self.frames.pop_front() {
|
||||
release_unrendered(codec, stale.index);
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
self.paced_drops += dropped;
|
||||
dropped
|
||||
}
|
||||
|
||||
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
|
||||
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
|
||||
/// `true` when a frame was released to glass this call.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; the seams are the point
|
||||
pub(super) fn pump(
|
||||
&mut self,
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
|
||||
if let Some(f) = &self.inflight {
|
||||
if now_mono_ns >= f.reopen_at_ns {
|
||||
self.inflight = None;
|
||||
} else if now_mono_ns - f.released_at_ns > STALE_REOPEN_NS {
|
||||
self.forced += 1;
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
} else {
|
||||
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
|
||||
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
|
||||
// the previous frame persists on glass, a repeat by omission, while headroom
|
||||
// rebuilds). Everything is gated on the tick so an idle stream neither counts
|
||||
// underflows nor churns the preroll flag 200×/s.
|
||||
if !self.vsync_tick {
|
||||
return false;
|
||||
}
|
||||
if !self.prerolled {
|
||||
if self.frames.len() < self.fifo_capacity {
|
||||
return false;
|
||||
}
|
||||
self.prerolled = true;
|
||||
}
|
||||
if self.frames.is_empty() {
|
||||
self.prerolled = false;
|
||||
self.dry += 1;
|
||||
self.vsync_tick = false; // this tick's drain ran (and found nothing)
|
||||
return false;
|
||||
}
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
_ => self.frames.push_front(frame),
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Release: timeline-timed when the clock has one, ASAP otherwise.
|
||||
let target = clock.and_then(|c| c.next_target(now_mono_ns, LATCH_MARGIN_NS));
|
||||
let released = match target {
|
||||
Some(t) => codec
|
||||
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
|
||||
.map_err(|e| log::warn!("presenter: release_at_time({}): {e}", frame.index)),
|
||||
None => codec
|
||||
.release_output_buffer_by_index(frame.index, true)
|
||||
.map_err(|e| log::warn!("presenter: release({}): {e}", frame.index)),
|
||||
};
|
||||
self.vsync_tick = false;
|
||||
if released.is_err() {
|
||||
return false; // the buffer is gone either way; nothing to book-keep
|
||||
}
|
||||
let period = clock.map(|c| c.period_ns()).filter(|&p| p > 0);
|
||||
// Reopen at SurfaceFlinger's LATCH for the targeted vsync (expected present minus the
|
||||
// latch lead) — the instant SF consumes the queued buffer and the slot frees, so the
|
||||
// next release can target the NEXT refresh. Not the platform `deadline` (with the
|
||||
// aggressive present gate it can already be in the past — an instant reopen would let
|
||||
// two releases pile onto the same vsync) and not the present time itself (a period too
|
||||
// late — it would cap the sustainable rate at roughly half the panel).
|
||||
let reopen_at_ns = target
|
||||
.map(|t| t.expected_present_ns - LATCH_MARGIN_NS)
|
||||
.unwrap_or(now_mono_ns + period.unwrap_or(FALLBACK_PERIOD_NS));
|
||||
self.inflight = Some(InFlight {
|
||||
reopen_at_ns,
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
self.pace_us.push(pace_us);
|
||||
}
|
||||
stats.note_release(pace_us);
|
||||
tracker.note_rendered(frame.pts_us, frame.decoded_ns, release_real_ns);
|
||||
true
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
release_unrendered(codec, f.index);
|
||||
}
|
||||
self.inflight = None;
|
||||
}
|
||||
|
||||
/// The 1 Hz `pf-present` logcat mirror (target `pf.present`) — the Apple client's Console
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `vsync` (the measured panel period).
|
||||
///
|
||||
/// Returns this window's measured latch p50 (ns) when a window actually flushed — the
|
||||
/// phase-lock reporter's `arrival_lead` error signal (design/phase-locked-capture.md §6).
|
||||
pub(super) fn flush_log(
|
||||
&mut self,
|
||||
meter: &PresentMeter,
|
||||
clock: Option<&VsyncShared>,
|
||||
) -> Option<u64> {
|
||||
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
|
||||
return None;
|
||||
}
|
||||
self.last_flush = Instant::now();
|
||||
let (latch, displays) = meter.drain();
|
||||
if self.released == 0 && displays == 0 {
|
||||
return None; // idle stream — nothing worth a line
|
||||
}
|
||||
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} vsyncMs={:.2} panelMs={:.2}",
|
||||
self.released,
|
||||
displays,
|
||||
self.paced_drops,
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
latch_max,
|
||||
period_ms,
|
||||
panel_ms,
|
||||
);
|
||||
self.released = 0;
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
(latch_p50 > 0.0).then(|| (latch_p50 * 1e6) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
fn release_unrendered(codec: &MediaCodec, index: usize) {
|
||||
if let Err(e) = codec.release_output_buffer_by_index(index, false) {
|
||||
log::warn!("presenter: release_output_buffer({index}, false): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `debug.punktfunk.presenter` sysprop: `arrival` = the legacy release-immediately path,
|
||||
/// anything else / unset = the timeline presenter. The rebuild-free on-device A/B lever.
|
||||
pub(super) fn presenter_disabled_by_sysprop() -> bool {
|
||||
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.presenter".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
n > 0 && &buf[..n as usize] == b"arrival"
|
||||
}
|
||||
@@ -180,8 +180,14 @@ pub(super) fn boost_thread_priority() {
|
||||
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
||||
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
||||
/// phone. Falls through to the 2-arg hint on API 30.
|
||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
|
||||
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
|
||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) — the seamless-preferred hint for
|
||||
/// phones/tablets and the universal fallback.
|
||||
///
|
||||
/// Both paths pass `compatibility = FIXED_SOURCE` (1): the stream is fixed-rate video content the
|
||||
/// client cannot re-pace, which is exactly what that value declares — `DEFAULT` (0) told the
|
||||
/// platform the app could adapt to whatever rate it picked, an invitation some OEM refresh
|
||||
/// governors accepted by simply not switching. (The window-level `preferredDisplayModeId` pin in
|
||||
/// `MainActivity.setStreamDisplayMode` is the phone-side belt to this braces.)
|
||||
///
|
||||
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
||||
/// decline.
|
||||
@@ -200,8 +206,10 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
if lib.is_null() {
|
||||
return false;
|
||||
}
|
||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
|
||||
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||
// ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE — fixed-rate video content.
|
||||
const FIXED_SOURCE: i8 = 1;
|
||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 =
|
||||
// ALWAYS). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||
if is_tv {
|
||||
let sym = libc::dlsym(
|
||||
lib,
|
||||
@@ -209,7 +217,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
);
|
||||
if !sym.is_null() {
|
||||
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
||||
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
|
||||
return set(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE, 1) == 0;
|
||||
}
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||
@@ -217,7 +225,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
return false; // device API < 30 — no per-surface frame-rate hint
|
||||
}
|
||||
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE) == 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ pub(super) fn run_sync(
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
// The timeline presenter lives in the async loop only; this loop IS the escape hatch.
|
||||
present_priority: _,
|
||||
smooth_buffer: _,
|
||||
panel_hz: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -181,7 +185,11 @@ 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.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
std::sync::Arc::new(super::presenter::PresentMeter::new()),
|
||||
);
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||
@@ -598,7 +606,7 @@ fn drain(
|
||||
Ok(()) if held_present => {
|
||||
rendered = 1;
|
||||
if let Some((pts_us, decoded_ns)) = meta {
|
||||
tracker.note_rendered(pts_us, decoded_ns);
|
||||
tracker.note_rendered(pts_us, decoded_ns, super::latency::now_realtime_ns());
|
||||
}
|
||||
}
|
||||
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//! The vsync clock behind the timeline presenter: an `AChoreographer` thread publishing the
|
||||
//! panel's vsync grid + upcoming frame timelines, and pulsing the decode loop's event channel so
|
||||
//! a frame parked on a closed glass budget gets its retry tick.
|
||||
//!
|
||||
//! On API 33+ the thread rides `AChoreographer_postVsyncCallback`, whose callback payload carries
|
||||
//! the platform's FRAME TIMELINES — for each upcoming refresh, when SurfaceFlinger expects to
|
||||
//! present and the deadline by which a frame must be submitted to make it. That pair is exactly
|
||||
//! what `AMediaCodec_releaseOutputBufferAtTime` wants as its target. On 31/32 the older
|
||||
//! `postFrameCallback64` supplies only the vsync instant; the presenter then releases ASAP
|
||||
//! (identical to the legacy path) and uses the measured period purely to predict the latch for
|
||||
//! its glass budget.
|
||||
//!
|
||||
//! Every `AChoreographer_*` symbol is dlsym-resolved from `libandroid.so` (mirrors
|
||||
//! [`super::setup::try_set_frame_rate`]): several sit above the crate's API floor, and one hard
|
||||
//! import of a too-new symbol fails `System.loadLibrary` on every older device.
|
||||
//!
|
||||
//! Started LAZILY on the first decoded frame (the Apple deadline presenter's bootstrap lesson:
|
||||
//! an eagerly started clock ticks uselessly for the whole connect window), stopped + joined via
|
||||
//! [`VsyncClock`]'s `Drop`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// `CLOCK_MONOTONIC` now in nanoseconds — the clock AChoreographer stamps its timelines on and
|
||||
/// the one `AMediaCodec_releaseOutputBufferAtTime` compares against (`System.nanoTime` basis).
|
||||
/// Distinct from the stats path's `CLOCK_REALTIME`: presenter scheduling stays monotonic.
|
||||
pub(super) fn now_monotonic_ns() -> i64 {
|
||||
let mut ts = libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
// 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).
|
||||
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
|
||||
/// frame, and the last instant it can be submitted to make that present. Monotonic ns.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct FrameTimeline {
|
||||
pub expected_present_ns: i64,
|
||||
pub deadline_ns: i64,
|
||||
}
|
||||
|
||||
/// State the choreographer thread publishes and the decode loop reads. All monotonic ns.
|
||||
pub(super) struct VsyncShared {
|
||||
stop: AtomicBool,
|
||||
/// The latest vsync callback's frame time (0 = no callback yet).
|
||||
last_vsync_ns: AtomicI64,
|
||||
/// Estimated vsync period (EMA over callback deltas / timeline spacing; 0 = unmeasured).
|
||||
///
|
||||
/// ⚠ This is the APP's render rate, not necessarily the panel's: Android down-rates a
|
||||
/// process's vsync stream (frame-rate categories / per-uid overrides), so a quiet UI can be
|
||||
/// served 60 Hz callbacks while the panel scans at 120 (observed on-glass, A024). Pacing
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
/// The latest callback's upcoming timelines, soonest first. Empty on the 31/32 fallback.
|
||||
timelines: Mutex<Vec<FrameTimeline>>,
|
||||
}
|
||||
|
||||
impl VsyncShared {
|
||||
/// The measured vsync period, or 0 while unmeasured.
|
||||
pub(super) fn period_ns(&self) -> i64 {
|
||||
self.period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The panel's own refresh period (0 = unknown) — for the pf-present line's decomposition.
|
||||
pub(super) fn panel_period_ns(&self) -> i64 {
|
||||
self.panel_period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
|
||||
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
|
||||
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
|
||||
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
|
||||
///
|
||||
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
|
||||
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
|
||||
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
|
||||
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
|
||||
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
|
||||
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
|
||||
/// frame presents one vsync later — exactly what the conservative gate always paid.
|
||||
///
|
||||
/// The picked target is then SUBDIVIDED onto the panel grid: the platform reports timelines
|
||||
/// at the app's assigned render rate, but the panel latches at its own — when the app is
|
||||
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
|
||||
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
|
||||
/// by whole panel periods (while its present still clears the margin) restores the true
|
||||
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
|
||||
/// a no-op.
|
||||
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
|
||||
let mut t = {
|
||||
let g = self
|
||||
.timelines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let found = g
|
||||
.iter()
|
||||
.find(|t| t.expected_present_ns > now_ns + margin_ns)
|
||||
.copied();
|
||||
match found {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let last = g.last().copied()?;
|
||||
let period = self.period_ns();
|
||||
if period <= 0 {
|
||||
return None;
|
||||
}
|
||||
// All stored timelines have passed — step the last one forward whole
|
||||
// periods until its present clears `now + margin` again.
|
||||
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
|
||||
let k = behind / period + 1;
|
||||
FrameTimeline {
|
||||
expected_present_ns: last.expected_present_ns + k * period,
|
||||
deadline_ns: last.deadline_ns + k * period,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let panel = self.panel_period_ns.load(Ordering::Relaxed);
|
||||
if panel > 0 {
|
||||
while t.expected_present_ns - panel > now_ns + margin_ns {
|
||||
t.deadline_ns -= panel;
|
||||
t.expected_present_ns -= panel;
|
||||
}
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dlsym'd AChoreographer surface ----
|
||||
|
||||
type PostFrameCallback64 =
|
||||
unsafe extern "C" fn(*mut c_void, unsafe extern "C" fn(i64, *mut c_void), *mut c_void);
|
||||
type PostVsyncCallback = unsafe extern "C" fn(
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, *mut c_void),
|
||||
*mut c_void,
|
||||
);
|
||||
|
||||
struct ChoreoApi {
|
||||
get_instance: unsafe extern "C" fn() -> *mut c_void,
|
||||
/// API 33: vsync callback with frame-timeline payload. Preferred.
|
||||
post_vsync: Option<PostVsyncCallback>,
|
||||
/// API 29 fallback: frame callback with only the vsync instant.
|
||||
post_frame64: Option<PostFrameCallback64>,
|
||||
// AChoreographerFrameCallbackData accessors (API 33; present iff `post_vsync` is).
|
||||
fcd_frame_time: Option<unsafe extern "C" fn(*const c_void) -> i64>,
|
||||
fcd_timelines_len: Option<unsafe extern "C" fn(*const c_void) -> usize>,
|
||||
fcd_preferred_index: Option<unsafe extern "C" fn(*const c_void) -> usize>,
|
||||
fcd_expected_present: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
|
||||
fcd_deadline: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
|
||||
}
|
||||
|
||||
impl ChoreoApi {
|
||||
/// Resolve from `libandroid.so`. `None` when even the baseline symbols are missing.
|
||||
fn resolve() -> Option<ChoreoApi> {
|
||||
// SAFETY: dlopen of the always-mapped libandroid.so (refcount bump, never closed); each
|
||||
// dlsym is null-checked before the transmute to its fn-pointer type.
|
||||
unsafe {
|
||||
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
let sym = |name: &std::ffi::CStr| {
|
||||
let p = libc::dlsym(lib, name.as_ptr());
|
||||
(!p.is_null()).then_some(p)
|
||||
};
|
||||
let get_instance = sym(c"AChoreographer_getInstance")?;
|
||||
let post_vsync = sym(c"AChoreographer_postVsyncCallback");
|
||||
let post_frame64 = sym(c"AChoreographer_postFrameCallback64");
|
||||
post_vsync.or(post_frame64)?; // neither post entry point — no clock on this device
|
||||
Some(ChoreoApi {
|
||||
get_instance: std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn() -> *mut c_void,
|
||||
>(get_instance),
|
||||
post_vsync: post_vsync.map(|p| std::mem::transmute::<*mut c_void, PostVsyncCallback>(p)),
|
||||
post_frame64: post_frame64
|
||||
.map(|p| std::mem::transmute::<*mut c_void, PostFrameCallback64>(p)),
|
||||
fcd_frame_time: sym(c"AChoreographerFrameCallbackData_getFrameTimeNanos").map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> i64>(p)
|
||||
}),
|
||||
fcd_timelines_len: sym(c"AChoreographerFrameCallbackData_getFrameTimelinesLength")
|
||||
.map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(
|
||||
p,
|
||||
)
|
||||
}),
|
||||
fcd_preferred_index: sym(
|
||||
c"AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex",
|
||||
)
|
||||
.map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(p)
|
||||
}),
|
||||
fcd_expected_present: sym(
|
||||
c"AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos",
|
||||
)
|
||||
.map(|p| {
|
||||
std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, usize) -> i64,
|
||||
>(p)
|
||||
}),
|
||||
fcd_deadline: sym(c"AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos")
|
||||
.map(|p| {
|
||||
std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, usize) -> i64,
|
||||
>(p)
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a callback invocation needs. Owned by the choreographer thread's stack; callbacks
|
||||
/// only ever fire inside that thread's looper poll, so the borrow can't outlive the thread.
|
||||
struct CallbackCtx {
|
||||
api: ChoreoApi,
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
/// Common tail of both callback flavours: update the grid estimate, publish, pulse, re-arm.
|
||||
fn tick(&self, frame_time_ns: i64, timelines: Vec<FrameTimeline>) {
|
||||
let prev = self
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
if self.shared.ticks.fetch_add(1, Ordering::Relaxed) == 2 {
|
||||
let spacing = if timelines.len() >= 2 {
|
||||
timelines[1].expected_present_ns - timelines[0].expected_present_ns
|
||||
} else {
|
||||
0
|
||||
};
|
||||
log::info!(
|
||||
"vsync: cadence Δ={:.2}ms timelines={} spacing={:.2}ms panel={:.2}ms",
|
||||
if prev > 0 {
|
||||
(frame_time_ns - prev) as f64 / 1e6
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
timelines.len(),
|
||||
spacing as f64 / 1e6,
|
||||
self.shared.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
|
||||
);
|
||||
}
|
||||
// Period: prefer timeline spacing (exact, straight from the platform), else the delta of
|
||||
// successive callbacks (jittery — EMA'd), clamped to sane panel rates (24..500 Hz).
|
||||
let mut period = 0i64;
|
||||
if timelines.len() >= 2 {
|
||||
period = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
} else if prev > 0 {
|
||||
period = frame_time_ns - prev;
|
||||
}
|
||||
if (2_000_000..=42_000_000).contains(&period) {
|
||||
let old = self.shared.period_ns.load(Ordering::Relaxed);
|
||||
let smoothed = if old > 0 {
|
||||
(old * 7 + period) / 8
|
||||
} else {
|
||||
period
|
||||
};
|
||||
self.shared.period_ns.store(smoothed, Ordering::Relaxed);
|
||||
}
|
||||
if !timelines.is_empty() {
|
||||
let mut g = self
|
||||
.shared
|
||||
.timelines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*g = timelines;
|
||||
}
|
||||
(self.on_tick)();
|
||||
if !self.shared.stop.load(Ordering::Relaxed) {
|
||||
self.repost();
|
||||
}
|
||||
}
|
||||
|
||||
fn repost(&self) {
|
||||
// SAFETY: `choreographer` is this thread's instance; the ctx pointer stays valid for the
|
||||
// thread's life and callbacks only fire on this thread (see the struct doc).
|
||||
unsafe {
|
||||
let ud = self as *const CallbackCtx as *mut c_void;
|
||||
if let Some(post) = self.api.post_vsync {
|
||||
post(self.choreographer, on_vsync, ud);
|
||||
} else if let Some(post) = self.api.post_frame64 {
|
||||
post(self.choreographer, on_frame64, ud);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API 33+ trampoline: harvest the frame timelines, then the common tick. Panic-free (an unwind
|
||||
/// out of an `extern "C"` fn aborts).
|
||||
unsafe extern "C" fn on_vsync(data: *const c_void, ud: *mut c_void) {
|
||||
// SAFETY: `ud` is the thread's `CallbackCtx`, alive for the whole poll loop (see struct doc).
|
||||
let ctx = unsafe { &*(ud as *const CallbackCtx) };
|
||||
let api = &ctx.api;
|
||||
let (mut frame_time, mut timelines) = (now_monotonic_ns(), Vec::new());
|
||||
// SAFETY: `data` is the platform's callback payload, valid for this invocation; the accessors
|
||||
// were resolved together with `post_vsync` (same API level) and are only called when present.
|
||||
unsafe {
|
||||
if let Some(f) = api.fcd_frame_time {
|
||||
frame_time = f(data);
|
||||
}
|
||||
if let (Some(len_f), Some(pref_f), Some(exp_f), Some(dl_f)) = (
|
||||
api.fcd_timelines_len,
|
||||
api.fcd_preferred_index,
|
||||
api.fcd_expected_present,
|
||||
api.fcd_deadline,
|
||||
) {
|
||||
let len = len_f(data).min(8);
|
||||
// From the PREFERRED index on: earlier timelines are ones the platform already
|
||||
// considers missed for a frame starting now.
|
||||
let start = pref_f(data).min(len);
|
||||
timelines = (start..len)
|
||||
.map(|i| FrameTimeline {
|
||||
expected_present_ns: exp_f(data, i),
|
||||
deadline_ns: dl_f(data, i),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
ctx.tick(frame_time, timelines);
|
||||
}
|
||||
|
||||
/// API 29 fallback trampoline: vsync instant only.
|
||||
unsafe extern "C" fn on_frame64(frame_time_ns: i64, ud: *mut c_void) {
|
||||
// SAFETY: `ud` is the thread's `CallbackCtx` (see `on_vsync`).
|
||||
let ctx = unsafe { &*(ud as *const CallbackCtx) };
|
||||
ctx.tick(frame_time_ns, Vec::new());
|
||||
}
|
||||
|
||||
/// The clock: a dedicated looper thread the choreographer calls back on. Dropping stops + joins.
|
||||
pub(super) struct VsyncClock {
|
||||
shared: Arc<VsyncShared>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
let api = ChoreoApi::resolve()?;
|
||||
let timelines_live = api.post_vsync.is_some();
|
||||
let shared = Arc::new(VsyncShared {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
let thread_shared = shared.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-vsync".into())
|
||||
.spawn(move || {
|
||||
let looper = ndk::looper::ThreadLooper::prepare();
|
||||
// SAFETY: getInstance on a thread with a prepared looper returns this thread's
|
||||
// choreographer (never null once a looper exists).
|
||||
let choreographer = unsafe { (api.get_instance)() };
|
||||
if choreographer.is_null() {
|
||||
log::warn!("vsync: AChoreographer_getInstance returned null — no clock");
|
||||
return;
|
||||
}
|
||||
let ctx = CallbackCtx {
|
||||
api,
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
// case teardown waits one timeout out. Callbacks fire inside poll_once_timeout.
|
||||
while !ctx.shared.stop.load(Ordering::Relaxed) {
|
||||
let _ = looper.poll_once_timeout(Duration::from_millis(250));
|
||||
}
|
||||
// `ctx` drops here — after the loop, so no queued callback can outlive it (they
|
||||
// only ever fire inside this thread's poll).
|
||||
})
|
||||
.ok()?;
|
||||
log::info!(
|
||||
"vsync: choreographer clock started ({})",
|
||||
if timelines_live {
|
||||
"frame timelines"
|
||||
} else {
|
||||
"frame callback fallback"
|
||||
}
|
||||
);
|
||||
Some(VsyncClock {
|
||||
shared,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn shared(&self) -> &Arc<VsyncShared> {
|
||||
&self.shared
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VsyncClock {
|
||||
fn drop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,3 +406,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
|
||||
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
|
||||
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
|
||||
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||
/// the capture diffs, the host holds per-slot state.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
finger: jint,
|
||||
active: jboolean,
|
||||
x: jint,
|
||||
y: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::Touchpad {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
finger: (finger as u32 & 0x1) as u8,
|
||||
active: active != 0,
|
||||
x: (x as i64).clamp(0, 65535) as u16,
|
||||
y: (y as i64).clamp(0, 65535) as u16,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
|
||||
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
|
||||
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||
/// controller's report rate.
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
gyro_pitch: jint,
|
||||
gyro_yaw: jint,
|
||||
gyro_roll: jint,
|
||||
accel_x: jint,
|
||||
accel_y: jint,
|
||||
accel_z: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::Motion {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
|
||||
accel: [c(accel_x), c(accel_y), c(accel_z)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ use jni::JNIEnv;
|
||||
|
||||
use super::{jni_guard, SessionHandle};
|
||||
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
|
||||
/// — wrap the SurfaceView's `Surface` as an `ANativeWindow` and start the decode thread rendering
|
||||
/// onto it. `decoderName` is the codec Kotlin ranked from `MediaCodecList` (`""` = let the platform
|
||||
/// resolve the default for the MIME); `lowLatencyMode` is the user's master toggle;
|
||||
/// `lowLatencyFeature` is whether that decoder advertised `FEATURE_LowLatency` (HUD label only).
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||||
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||||
/// and start the decode thread rendering onto it. `decoderName` is the codec Kotlin ranked from
|
||||
/// `MediaCodecList` (`""` = let the platform resolve the default for the MIME); `lowLatencyMode`
|
||||
/// is the user's master toggle; `lowLatencyFeature` is whether that decoder advertised
|
||||
/// `FEATURE_LowLatency` (HUD label only); `presentPriority`/`smoothBuffer` are the timeline
|
||||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||||
/// No-op if already started.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
@@ -27,6 +29,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
low_latency_mode: jboolean,
|
||||
ll_feature: jboolean,
|
||||
is_tv: jboolean,
|
||||
present_priority: jni::sys::jint,
|
||||
smooth_buffer: jni::sys::jint,
|
||||
panel_fps: jni::sys::jint,
|
||||
) {
|
||||
use super::VideoThread;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -70,6 +75,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
ll_feature: ll_feature != 0,
|
||||
low_latency_mode: low_latency_mode != 0,
|
||||
is_tv: is_tv != 0,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -173,7 +181,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// `[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]`
|
||||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
|
||||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
@@ -186,7 +194,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
|
||||
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
|
||||
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
|
||||
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
|
||||
/// the capture→decoded headline at 2/3; 26–29 are the timeline presenter's split of the `display`
|
||||
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
|
||||
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
|
||||
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
|
||||
/// is active this session), 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).
|
||||
@@ -210,7 +222,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; 26] = [
|
||||
let buf: [f64; 30] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -251,6 +263,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
snap.display_p50_ms,
|
||||
snap.e2e_disp_p50_ms,
|
||||
snap.e2e_disp_p95_ms,
|
||||
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
|
||||
// release→displayed), the window's on-glass confirm count, and whether the presenter
|
||||
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
|
||||
snap.pace_p50_ms,
|
||||
snap.latch_p50_ms,
|
||||
snap.presents as f64,
|
||||
if h.stats.presenter_active() { 1.0 } else { 0.0 },
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
@@ -264,10 +283,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
||||
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
|
||||
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
|
||||
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
|
||||
/// on the host build too. Cheap; safe on the UI thread.
|
||||
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
|
||||
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
|
||||
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
|
||||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||||
/// build too. Cheap; safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
env: JNIEnv,
|
||||
@@ -281,7 +302,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mode = h.client.mode();
|
||||
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
|
||||
let buf: [i32; 3] = [
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
mode.refresh_hz as i32,
|
||||
];
|
||||
let arr = match env.new_int_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
|
||||
@@ -28,6 +28,9 @@ pub struct VideoStats {
|
||||
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
||||
/// Off until Kotlin shows the HUD.
|
||||
enabled: AtomicBool,
|
||||
/// Whether the timeline presenter is active this session (async loop, not sysprop-disabled) —
|
||||
/// stats index 29, so the HUD can label the display split it is (or isn't) getting.
|
||||
presenter_active: AtomicBool,
|
||||
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
|
||||
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
|
||||
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
|
||||
@@ -67,6 +70,16 @@ struct Inner {
|
||||
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
|
||||
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
|
||||
e2e_disp_us: Vec<u64>,
|
||||
/// The `display` stage's presenter split, decoded→release (pace wait: store + glass budget),
|
||||
/// µs. Empty on the legacy release-immediately path.
|
||||
pace_us: Vec<u64>,
|
||||
/// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs —
|
||||
/// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame.
|
||||
latch_us: Vec<u64>,
|
||||
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs-
|
||||
/// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps
|
||||
/// deficit is upstream.
|
||||
presents: u64,
|
||||
/// Client-side newest-wins/pacing drops this window (decoded frames released without
|
||||
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
|
||||
skipped: u64,
|
||||
@@ -101,6 +114,13 @@ pub struct Snapshot {
|
||||
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
|
||||
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
|
||||
pub disp_valid: bool,
|
||||
/// The `display` stage's presenter split p50s (ms): `pace` = decoded→release (store + glass
|
||||
/// budget), `latch` = release→displayed (SurfaceFlinger). 0.0 when no sample landed (legacy
|
||||
/// path / no render callbacks).
|
||||
pub pace_p50_ms: f64,
|
||||
pub latch_p50_ms: f64,
|
||||
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks).
|
||||
pub presents: u64,
|
||||
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
||||
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
||||
pub host_p50_ms: f64,
|
||||
@@ -132,6 +152,7 @@ impl VideoStats {
|
||||
pub fn new() -> VideoStats {
|
||||
VideoStats {
|
||||
enabled: AtomicBool::new(false),
|
||||
presenter_active: AtomicBool::new(false),
|
||||
decoder: Mutex::new(None),
|
||||
inner: Mutex::new(Inner {
|
||||
window_start: Instant::now(),
|
||||
@@ -144,6 +165,9 @@ impl VideoStats {
|
||||
decode_us: Vec::with_capacity(256),
|
||||
display_us: Vec::with_capacity(256),
|
||||
e2e_disp_us: Vec::with_capacity(256),
|
||||
pace_us: Vec::with_capacity(256),
|
||||
latch_us: Vec::with_capacity(256),
|
||||
presents: 0,
|
||||
skipped: 0,
|
||||
last_dropped_total: 0,
|
||||
last_fec_total: 0,
|
||||
@@ -160,6 +184,18 @@ impl VideoStats {
|
||||
self.enabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Record whether the timeline presenter runs this session (decode thread, once at start).
|
||||
// Set only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn set_presenter_active(&self, on: bool) {
|
||||
self.presenter_active.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the timeline presenter is active (stats index 29).
|
||||
pub fn presenter_active(&self) -> bool {
|
||||
self.presenter_active.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
||||
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
|
||||
@@ -181,6 +217,9 @@ impl VideoStats {
|
||||
g.decode_us.clear();
|
||||
g.display_us.clear();
|
||||
g.e2e_disp_us.clear();
|
||||
g.pace_us.clear();
|
||||
g.latch_us.clear();
|
||||
g.presents = 0;
|
||||
g.skipped = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
@@ -298,13 +337,19 @@ impl VideoStats {
|
||||
}
|
||||
|
||||
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
|
||||
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
|
||||
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
|
||||
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
|
||||
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
|
||||
/// realtime clock): its capture→displayed `end-to-end` sample, its decoded→displayed
|
||||
/// `display` stage sample, and the presenter split's `latch` half (release→displayed) — any
|
||||
/// may be absent (the e2e clamp rejected an out-of-range value, or the release record for
|
||||
/// this pts was already evicted). Fired from the codec's render-callback thread, not the
|
||||
/// decode thread — the lock makes that safe.
|
||||
// Driven only by the android-only decode path; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
|
||||
pub fn note_displayed(
|
||||
&self,
|
||||
e2e_us: Option<u64>,
|
||||
display_us: Option<u64>,
|
||||
latch_us: Option<u64>,
|
||||
) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
|
||||
}
|
||||
@@ -313,12 +358,32 @@ impl VideoStats {
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.presents += 1;
|
||||
if let Some(l) = e2e_us {
|
||||
g.e2e_disp_us.push(l);
|
||||
}
|
||||
if let Some(l) = display_us {
|
||||
g.display_us.push(l);
|
||||
}
|
||||
if let Some(l) = latch_us {
|
||||
g.latch_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one released frame's pace-wait (decoded→release: the presenter's store + glass
|
||||
/// budget), µs — the `display` stage's other half. Decode-thread only.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_release(&self, pace_us: u64) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.pace_us.push(pace_us);
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
@@ -341,6 +406,8 @@ impl VideoStats {
|
||||
g.decode_us.sort_unstable();
|
||||
g.display_us.sort_unstable();
|
||||
g.e2e_disp_us.sort_unstable();
|
||||
g.pace_us.sort_unstable();
|
||||
g.latch_us.sort_unstable();
|
||||
let snap = Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
@@ -352,6 +419,9 @@ impl VideoStats {
|
||||
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
||||
display_p50_ms: pctl_ms(&g.display_us, 0.50),
|
||||
disp_valid: !g.e2e_disp_us.is_empty(),
|
||||
pace_p50_ms: pctl_ms(&g.pace_us, 0.50),
|
||||
latch_p50_ms: pctl_ms(&g.latch_us, 0.50),
|
||||
presents: g.presents,
|
||||
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||
lat_valid: !g.e2e_us.is_empty(),
|
||||
@@ -371,6 +441,9 @@ impl VideoStats {
|
||||
g.decode_us.clear();
|
||||
g.display_us.clear();
|
||||
g.e2e_disp_us.clear();
|
||||
g.pace_us.clear();
|
||||
g.latch_us.clear();
|
||||
g.presents = 0;
|
||||
g.skipped = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
|
||||
@@ -240,11 +240,17 @@ mod session_main {
|
||||
audio_channels: settings.audio_channels,
|
||||
preferred_codec: settings.preferred_codec(),
|
||||
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
|
||||
video_caps: if settings.hdr_enabled {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// MULTI_SLICE is decoder truth for THIS embedder: every desktop decode stack
|
||||
// (FFmpeg software, VAAPI, D3D11VA, Vulkan Video) handles AUs carrying several
|
||||
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
|
||||
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
|
||||
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
|
||||
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||
| if settings.hdr_enabled {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
|
||||
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||
// pump) pins one manually.
|
||||
|
||||
@@ -429,6 +429,11 @@ pub struct IddPushCapturer {
|
||||
/// One-shot latch for [`Self::poll_display_hdr`]'s "could not pin the negotiated depth" error —
|
||||
/// the poller samples every ~250 ms, and a display that refuses the flip refuses it every time.
|
||||
hdr_pin_warned: bool,
|
||||
/// Consecutive failed depth-pin attempts ([`Self::poll_display_hdr`]) — past
|
||||
/// [`Self::HDR_PIN_EAGER`] the re-pin backs off to every [`Self::HDR_PIN_RETRY_EVERY`]th
|
||||
/// descriptor sample: a display that refuses the flip was costing 4 CCD writes + 8 display-
|
||||
/// config queries per second, forever, all on the session-global display-config lock.
|
||||
hdr_pin_failures: u32,
|
||||
/// The session negotiated full-chroma 4:4:4: while the display is SDR the BGRA slot passes
|
||||
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
|
||||
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
|
||||
@@ -496,8 +501,11 @@ pub struct IddPushCapturer {
|
||||
offered_at_fresh: u64,
|
||||
max_hb_age_us: u64,
|
||||
/// The Phase A.2 micro-probe engine (refcounted process singleton) — its window read rides
|
||||
/// every stall report so the verdict matrix can name the disturbance class.
|
||||
probes: Arc<probes::ProbeEngine>,
|
||||
/// every stall report so the verdict matrix can name the disturbance class. `None` when
|
||||
/// `PUNKTFUNK_STALL_PROBES=0` opted the box out (the engine costs standing threads); the
|
||||
/// verdict matrix already treats an absent probe window as never-stalled, so reports just
|
||||
/// lose the corroborating legs, never invent them.
|
||||
probes: Option<Arc<probes::ProbeEngine>>,
|
||||
/// The Phase A.3 DxgKrnl ETW watch; `None` when the session can't start (non-admin dev run)
|
||||
/// — reports then say `etw=unavailable`.
|
||||
etw: Option<Arc<dxgkrnl_etw::EtwWatch>>,
|
||||
@@ -539,6 +547,13 @@ pub struct IddPushCapturer {
|
||||
unsafe impl Send for IddPushCapturer {}
|
||||
|
||||
impl IddPushCapturer {
|
||||
/// Failed depth-pin attempts before [`Self::poll_display_hdr`] backs its re-pin off
|
||||
/// (≈2 s of eager retries at the descriptor poller's 4 Hz).
|
||||
const HDR_PIN_EAGER: u32 = 8;
|
||||
/// While backed off, re-attempt the depth pin on every this-many-th descriptor sample
|
||||
/// (~4 s at 4 Hz) — self-healing without the 4 Hz CCD-write drumbeat.
|
||||
const HDR_PIN_RETRY_EVERY: u64 = 16;
|
||||
|
||||
#[inline]
|
||||
fn latest(&self) -> u64 {
|
||||
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
|
||||
@@ -832,40 +847,63 @@ impl IddPushCapturer {
|
||||
// either direction (its encoder re-inits on the depth change).
|
||||
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
|
||||
let want = self.client_10bit;
|
||||
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s `bool`
|
||||
// and then write `now.hdr = self.client_10bit` — substituting the DESIRED state for the
|
||||
// observed one, which broke in both directions on a display that cannot be flipped
|
||||
// (the state this file already logs as "Downgrade point D" at open):
|
||||
// - want HDR, display stays SDR: the fabricated `true` differed from `current`, so two
|
||||
// samples drove `recreate_ring(true, …)` and rebuilt the ring FP16 while the driver
|
||||
// composed 8-bit BGRA. Every publish was then dropped by the driver's format guard,
|
||||
// `recovering_since` expired, and `try_consume` bailed — a permanent 3-second
|
||||
// reconnect loop.
|
||||
// - want SDR, display stays HDR: the fabricated `false` MATCHED `current`, so no
|
||||
// recreate ever fired and the ring stayed BGRA against an FP16 composition. Same
|
||||
// dropped-publish outcome, silently.
|
||||
// Reading back immediately can catch a flip that has not settled yet; that costs one
|
||||
// debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring, which is why
|
||||
// this does not block the frame path on a settle poll the way `open_on` does.
|
||||
let requested = pf_win_display::win_display::set_advanced_color(self.target_id, want);
|
||||
let observed = pf_win_display::win_display::advanced_color_enabled(self.target_id);
|
||||
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
|
||||
now.hdr = observed.unwrap_or(now.hdr);
|
||||
if now.hdr != want && !self.hdr_pin_warned {
|
||||
self.hdr_pin_warned = true;
|
||||
tracing::error!(
|
||||
target_id = self.target_id,
|
||||
want_hdr = want,
|
||||
observed_hdr = ?observed,
|
||||
set_advanced_color_returned = requested,
|
||||
pyrowave = self.pyrowave,
|
||||
client_10bit = self.client_10bit,
|
||||
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
|
||||
it actually composes instead (a physical display forcing HDR, or a driver that \
|
||||
refuses the flip). The stream's depth will not match the negotiation; the \
|
||||
encoder's caps cross-check reports the truth to the client"
|
||||
);
|
||||
// A display that refuses the pin refuses it on every 250 ms sample — past
|
||||
// [`Self::HDR_PIN_EAGER`] consecutive failures the write+read-back re-fires only on
|
||||
// every [`Self::HDR_PIN_RETRY_EVERY`]th sample (~4 s), instead of 4 CCD writes +
|
||||
// 8 display-config queries per second forever, all on the session-global
|
||||
// display-config lock. Skipped samples keep the poller's own advanced-color read,
|
||||
// so nothing is fabricated and a display that becomes flippable is re-pinned on
|
||||
// the next retry sample.
|
||||
if self.hdr_pin_failures < Self::HDR_PIN_EAGER
|
||||
|| self.desc_seq % Self::HDR_PIN_RETRY_EVERY == 0
|
||||
{
|
||||
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s
|
||||
// `bool` and then write `now.hdr = self.client_10bit` — substituting the DESIRED
|
||||
// state for the observed one, which broke in both directions on a display that
|
||||
// cannot be flipped (the state this file already logs as "Downgrade point D" at
|
||||
// open):
|
||||
// - want HDR, display stays SDR: the fabricated `true` differed from `current`,
|
||||
// so two samples drove `recreate_ring(true, …)` and rebuilt the ring FP16
|
||||
// while the driver composed 8-bit BGRA. Every publish was then dropped by the
|
||||
// driver's format guard, `recovering_since` expired, and `try_consume`
|
||||
// bailed — a permanent 3-second reconnect loop.
|
||||
// - want SDR, display stays HDR: the fabricated `false` MATCHED `current`, so no
|
||||
// recreate ever fired and the ring stayed BGRA against an FP16 composition.
|
||||
// Same dropped-publish outcome, silently.
|
||||
// Reading back immediately can catch a flip that has not settled yet; that costs
|
||||
// one debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring,
|
||||
// which is why this does not block the frame path on a settle poll the way
|
||||
// `open_on` does.
|
||||
let requested =
|
||||
pf_win_display::win_display::set_advanced_color(self.target_id, want);
|
||||
let observed = pf_win_display::win_display::advanced_color_enabled(self.target_id);
|
||||
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
|
||||
now.hdr = observed.unwrap_or(now.hdr);
|
||||
if now.hdr != want {
|
||||
self.hdr_pin_failures = self.hdr_pin_failures.saturating_add(1);
|
||||
if !self.hdr_pin_warned {
|
||||
self.hdr_pin_warned = true;
|
||||
tracing::error!(
|
||||
target_id = self.target_id,
|
||||
want_hdr = want,
|
||||
observed_hdr = ?observed,
|
||||
set_advanced_color_returned = requested,
|
||||
pyrowave = self.pyrowave,
|
||||
client_10bit = self.client_10bit,
|
||||
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
|
||||
it actually composes instead (a physical display forcing HDR, or a driver that \
|
||||
refuses the flip). The stream's depth will not match the negotiation; the \
|
||||
encoder's caps cross-check reports the truth to the client"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.hdr_pin_failures = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No mismatch (or the session follows flips): the pin is healthy — a later refusal
|
||||
// starts its eager attempts fresh.
|
||||
self.hdr_pin_failures = 0;
|
||||
}
|
||||
let current = DisplayDescriptor {
|
||||
hdr: self.display_hdr,
|
||||
@@ -1613,15 +1651,38 @@ impl IddPushCapturer {
|
||||
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
probes: now
|
||||
.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.map(|from| self.probes.window(from, now)),
|
||||
.zip(self.probes.as_deref())
|
||||
.map(|(from, p)| p.window(from, now)),
|
||||
etw: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.map(|from| w.summary(from, now))
|
||||
}),
|
||||
// The discriminator counts span the GAP ONLY — no lead-in: presents from the
|
||||
// healthy flow right before the hole would falsely acquit the content. The
|
||||
// stall-ending frame's own present lands at the window edge and stays well
|
||||
// under the acquit bar.
|
||||
etw_counts: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_counts(from, now))
|
||||
}),
|
||||
};
|
||||
self.stall_watch.report(&stall, now, &evidence);
|
||||
}
|
||||
if !regen {
|
||||
// A degraded stretch just ended (or was cut by a ring recreate): the one line that
|
||||
// makes a sustained ~2 fps phase log-visible — per-hole stall lines gate on prior
|
||||
// ACTIVE flow and go quiet exactly while the stretch runs.
|
||||
if let Some(r) = self.stall_watch.take_recovery() {
|
||||
tracing::info!(
|
||||
degraded_ms = r.degraded.as_millis() as u64,
|
||||
holes = r.holes,
|
||||
hole_time_ms = r.hole_time.as_millis() as u64,
|
||||
worst_hole_ms = r.worst.as_millis() as u64,
|
||||
"IDD-push capture recovered from a degraded stretch — fresh frames arrived \
|
||||
only between stall-sized holes for its whole span; the per-stall lines \
|
||||
above cover at most its first hole"
|
||||
);
|
||||
}
|
||||
// A fresh driver frame: feed the driver-death watch and roll the stall-evidence
|
||||
// trackers (a regen re-encodes OLD content — it is not evidence of driver progress).
|
||||
self.last_fresh = now;
|
||||
@@ -2090,6 +2151,84 @@ mod tests {
|
||||
assert!(out[10].is_some(), "30 fps flow must pass the activity gate");
|
||||
}
|
||||
|
||||
/// Feed a [`StallWatch`] frames at the given offsets and return the first degraded-stretch
|
||||
/// summary it parks (checking after every frame, the way the capture loop does).
|
||||
fn watch_recovery(offsets_ms: &[u64]) -> (StallWatch, Option<super::stall::Recovery>) {
|
||||
let base = Instant::now();
|
||||
let mut w = StallWatch::new();
|
||||
let mut recovery = None;
|
||||
for ms in offsets_ms {
|
||||
w.note_fresh(base + Duration::from_millis(*ms));
|
||||
if let Some(r) = w.take_recovery() {
|
||||
recovery.get_or_insert(r);
|
||||
}
|
||||
}
|
||||
(w, recovery)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_degraded_stretch_summarizes_on_recovery() {
|
||||
// Active flow, then a ~2 fps phase (10 holes of 500 ms — the sustained-degradation
|
||||
// shape whose holes the activity gate keeps out of the per-stall log), then recovery
|
||||
// flow. One summary, carrying the whole stretch.
|
||||
let mut t = Vec::new();
|
||||
flow(&mut t, 0, 20); // last frame at 304 ms
|
||||
t.extend((1..=10).map(|i| 304 + i * 500)); // 804..5304: ten 500 ms holes
|
||||
t.extend((1..=12).map(|i| 5304 + i * 16)); // sustained flow is back
|
||||
let (_, r) = watch_recovery(&t);
|
||||
let r = r.expect("a multi-hole degraded stretch summarizes at recovery");
|
||||
assert_eq!(r.holes, 10);
|
||||
assert_eq!(r.hole_time.as_millis(), 5000);
|
||||
assert_eq!(r.worst.as_millis(), 500);
|
||||
assert_eq!(r.degraded.as_millis(), 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_stall_never_summarizes() {
|
||||
// One hole inside otherwise-healthy flow: its own stall line covers it — a
|
||||
// one-hole "stretch" dissolving silently is what keeps the summary meaningful.
|
||||
let mut t = Vec::new();
|
||||
flow(&mut t, 0, 20);
|
||||
t.push(604); // the lone 300 ms hole
|
||||
t.extend((1..=12).map(|i| 604 + i * 16));
|
||||
let (_, r) = watch_recovery(&t);
|
||||
assert!(
|
||||
r.is_none(),
|
||||
"single stall must not produce a stretch summary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recreate_cut_stretch_still_summarizes() {
|
||||
// A ring recreate resets the flow history mid-stretch; the holes accumulated BEFORE it
|
||||
// are real evidence and must still surface.
|
||||
let mut t = Vec::new();
|
||||
flow(&mut t, 0, 20);
|
||||
t.extend((1..=3).map(|i| 304 + i * 500));
|
||||
let (mut w, r) = watch_recovery(&t);
|
||||
assert!(r.is_none(), "stretch still open — no summary yet");
|
||||
w.reset();
|
||||
let r = w
|
||||
.take_recovery()
|
||||
.expect("reset closes and summarizes the open stretch");
|
||||
assert_eq!(r.holes, 3);
|
||||
assert_eq!(r.hole_time.as_millis(), 1500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_content_stop_closes_the_stretch_without_folding_the_pause_in() {
|
||||
// Two degraded holes, then the content plainly STOPS (a 20 s pause — quit to desktop).
|
||||
// The summary covers the stretch only; the legitimate pause never joins the tally.
|
||||
let mut t = Vec::new();
|
||||
flow(&mut t, 0, 20);
|
||||
t.extend([804, 1304, 21_304]);
|
||||
let (_, r) = watch_recovery(&t);
|
||||
let r = r.expect("the content stop closes the stretch");
|
||||
assert_eq!(r.holes, 2);
|
||||
assert_eq!(r.hole_time.as_millis(), 1000);
|
||||
assert_eq!(r.degraded.as_millis(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metronomic_stalls_self_diagnose() {
|
||||
// The field signature: ~300 ms DWM holes every 4 s inside 60 fps flow. Stalls land at the
|
||||
@@ -2146,6 +2285,7 @@ mod tests {
|
||||
max_heartbeat_age_ms: hb_age_ms,
|
||||
probes: None,
|
||||
etw: None,
|
||||
etw_counts: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
@@ -2167,10 +2307,12 @@ mod tests {
|
||||
assert_eq!(verdict(3_000, Some(2), 1_600), StallVerdict::WorkerStalled);
|
||||
}
|
||||
|
||||
/// [`stall::classify`]'s verdict matrix — how the micro-probe window refines (or declines to
|
||||
/// refine) the driver-telemetry verdict into a named disturbance class.
|
||||
/// [`stall::classify`]'s verdict matrix — how the micro-probe window and the ETW
|
||||
/// present-vs-queue counts refine (or decline to refine) the driver-telemetry verdict into
|
||||
/// a named disturbance class.
|
||||
#[test]
|
||||
fn stall_classification_matrix() {
|
||||
use super::dxgkrnl_etw::EtwWindowCounts;
|
||||
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
|
||||
let gap = Duration::from_millis(600);
|
||||
let probes = |fence: Option<u64>, dwm: Option<u64>, flush: Option<u64>| ProbeWindow {
|
||||
@@ -2179,22 +2321,29 @@ mod tests {
|
||||
dwm_flush_max_us: flush,
|
||||
..ProbeWindow::default()
|
||||
};
|
||||
let counts = |presents: u32, queue_adds: u32| EtwWindowCounts {
|
||||
presents,
|
||||
queue_adds,
|
||||
present_history: true,
|
||||
queue_history: true,
|
||||
};
|
||||
// The driver's own verdicts win outright — probes can't overrule "we lost the frames".
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::WorkerStalled,
|
||||
Some(&probes(Some(500_000), None, None))
|
||||
Some(&probes(Some(500_000), None, None)),
|
||||
None
|
||||
),
|
||||
StallClass::OursWorker
|
||||
);
|
||||
assert_eq!(
|
||||
classify(gap, &StallVerdict::DeliveryLeg, None),
|
||||
classify(gap, &StallVerdict::DeliveryLeg, None, None),
|
||||
StallClass::OursDelivery
|
||||
);
|
||||
// No probes: compose-silence alone can't name a class.
|
||||
assert_eq!(
|
||||
classify(gap, &StallVerdict::ComposeSilence, None),
|
||||
classify(gap, &StallVerdict::ComposeSilence, None, None),
|
||||
StallClass::Unattributed
|
||||
);
|
||||
// Fences stalled ≥ gap/2 → the adapter froze — Class 1 (even without driver telemetry).
|
||||
@@ -2202,7 +2351,8 @@ mod tests {
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(400_000), Some(400_000), None))
|
||||
Some(&probes(Some(400_000), Some(400_000), None)),
|
||||
None
|
||||
),
|
||||
StallClass::AdapterFreeze
|
||||
);
|
||||
@@ -2210,7 +2360,8 @@ mod tests {
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::NoTelemetry,
|
||||
Some(&probes(Some(400_000), None, None))
|
||||
Some(&probes(Some(400_000), None, None)),
|
||||
None
|
||||
),
|
||||
StallClass::AdapterFreeze
|
||||
);
|
||||
@@ -2219,7 +2370,8 @@ mod tests {
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(500_000), None))
|
||||
Some(&probes(Some(16_000), Some(500_000), None)),
|
||||
None
|
||||
),
|
||||
StallClass::CompositorBlocked
|
||||
);
|
||||
@@ -2227,36 +2379,91 @@ mod tests {
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(450_000)))
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(450_000))),
|
||||
None
|
||||
),
|
||||
StallClass::CompositorBlocked
|
||||
);
|
||||
// Everything alive + the driver swears E_PENDING → the frame-generation path.
|
||||
// Everything alive + the driver swears E_PENDING, but NO working present witness:
|
||||
// the silence cannot be pinned on either side — UNATTRIBUTED, never a guess. (The
|
||||
// pre-2026-07-30 default of FRAME-GENERATION here mislabeled benign content pauses.)
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000)))
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||
None
|
||||
),
|
||||
StallClass::Unattributed
|
||||
);
|
||||
// A witness that exists but has never produced an event is NOT a working witness.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||
Some(&EtwWindowCounts {
|
||||
present_history: false,
|
||||
..EtwWindowCounts::default()
|
||||
})
|
||||
),
|
||||
StallClass::Unattributed
|
||||
);
|
||||
// The present witness splits the silence. Presents flowing through the hole while the
|
||||
// virtual display's queue starved → the OS display path dropped composed frames: the
|
||||
// frame-generation leg, POSITIVELY convicted.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||
Some(&counts(54, 0))
|
||||
),
|
||||
StallClass::FrameGeneration
|
||||
);
|
||||
// (Essentially) no presents anywhere across the hole — the stall-ending frame and a
|
||||
// caret blink stay under the bar → the CONTENT stopped presenting: benign for the
|
||||
// display path.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||
Some(&counts(2, 1))
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// The present witness does NOT overrule the driver's own verdicts or the harder
|
||||
// classes — it only refines compose-silence.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(400_000), Some(400_000), None)),
|
||||
Some(&counts(54, 0))
|
||||
),
|
||||
StallClass::AdapterFreeze
|
||||
);
|
||||
// Healthy probes but a pre-telemetry driver: delivery-leg is equally possible — honest.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::NoTelemetry,
|
||||
Some(&probes(Some(16_000), Some(20_000), None))
|
||||
Some(&probes(Some(16_000), Some(20_000), None)),
|
||||
Some(&counts(54, 0))
|
||||
),
|
||||
StallClass::Unattributed
|
||||
);
|
||||
// An absent probe (None) never reads as "stalled" — absence is stated, not guessed.
|
||||
// An absent probe (None) never reads as "stalled" — absence is stated, not guessed;
|
||||
// with the present witness working, the silence still splits correctly.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(None, Some(20_000), Some(30_000)))
|
||||
Some(&probes(None, Some(20_000), Some(30_000))),
|
||||
Some(&counts(1, 0))
|
||||
),
|
||||
StallClass::FrameGeneration
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,30 @@
|
||||
//! - 154/155 `SetPowerState` start/stop — monitor/link power transitions (Class-1 servicing),
|
||||
//! - 430 `SetTimingsFromVidPn` — modeset-class commits (Level-Two "hardware is idle" freezes),
|
||||
//! - 1096/1097 `DisplayDetectControl` start/stop — present on newer builds; filtered in
|
||||
//! unconditionally, harmless where absent.
|
||||
//! unconditionally, harmless where absent,
|
||||
//! - 1071 `BltQueueAddEntry` — one event per frame ENTERING the virtual display's kernel present
|
||||
//! queue (the modern IDD path: DWM present → PresentRedirected → BltQueue → soft-vsync →
|
||||
//! `BltQueueCompleteIndirectPresent` → IddCx → our driver; anatomy proven on-glass via xperf,
|
||||
//! 2026-07-30 — its gaps matched our capture holes to the millisecond),
|
||||
//! - 1068 `BltQueueCompleteIndirectPresent` — one event per frame COMPLETED to IddCx.
|
||||
//!
|
||||
//! A second provider rides the same session: `Microsoft-Windows-DXGI` (user-mode), filtered to
|
||||
//! `Present`/`PresentMultiplaneOverlay` starts (ids 42/55) — one event per swapchain present,
|
||||
//! stamped with the PRESENTING process id. Together they are the compose-silence discriminator
|
||||
//! ([`EtwWatch::window_counts`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! display path dropped composed frames (the real display-path bug); BOTH silent = the content
|
||||
//! stopped presenting (benign pause — menus/loading/game hitch). The predecessor witnesses are
|
||||
//! retired for cause: DxgKrnl id 184 `Present` never fires on the modern redirected path, and
|
||||
//! `DWM_TIMING_INFO.cFrame` is refresh-synthesized on Win11 (advances without composes) — both
|
||||
//! proven on-glass 2026-07-30.
|
||||
//!
|
||||
//! Kernel-side event-id filtering (`EVENT_FILTER_TYPE_EVENT_ID`) keeps the per-vblank firehose
|
||||
//! off; the session costs a few events per minute. Starting a real-time session needs admin /
|
||||
//! Performance Log Users — the packaged host (service, SYSTEM) has it; a plain dev run degrades
|
||||
//! to `None` and every report says `etw=unavailable` instead of guessing. The session's
|
||||
//! `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land AFTER that
|
||||
//! stall's report line — the next report (and the metronomic tally) still carries it.
|
||||
//! off; the DDI families cost a few events per minute, and the present/queue streams a few
|
||||
//! hundred per second (bounded by the ring + the 64 KB session buffers). Starting a real-time
|
||||
//! session needs admin / Performance Log Users — the packaged host (service, SYSTEM) has it; a
|
||||
//! plain dev run degrades to `None` and every report says `etw=unavailable` instead of guessing.
|
||||
//! The session's `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land
|
||||
//! AFTER that stall's report line — the next report (and the metronomic tally) still carries it.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
@@ -25,7 +41,7 @@ use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use windows::core::{GUID, PWSTR};
|
||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows::Win32::Foundation::{CloseHandle, ERROR_SUCCESS};
|
||||
use windows::Win32::System::Diagnostics::Etw::{
|
||||
CloseTrace, ControlTraceW, EnableTraceEx2, OpenTraceW, ProcessTrace, StartTraceW,
|
||||
CONTROLTRACE_HANDLE, ENABLE_TRACE_PARAMETERS, ENABLE_TRACE_PARAMETERS_VERSION_2,
|
||||
@@ -35,21 +51,59 @@ use windows::Win32::System::Diagnostics::Etw::{
|
||||
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
|
||||
};
|
||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
/// `Microsoft-Windows-DxgKrnl` (`{802EC45A-1E99-4B83-9920-87C98277BA9D}`).
|
||||
const DXGKRNL: GUID = GUID::from_u128(0x802EC45A_1E99_4B83_9920_87C98277BA9D);
|
||||
|
||||
/// The event ids the session filters IN (see the module docs).
|
||||
const FILTER_IDS: [u16; 8] = [150, 151, 272, 154, 155, 430, 1096, 1097];
|
||||
/// `Microsoft-Windows-DXGI` (`{CA11C036-0102-4A2D-A6AD-F03CFED5D3C9}`) — the user-mode swapchain
|
||||
/// runtime; its `Present` events fire in the presenting process's context.
|
||||
const DXGI: GUID = GUID::from_u128(0xCA11C036_0102_4A2D_A6AD_F03CFED5D3C9);
|
||||
|
||||
/// The DxgKrnl event ids the session filters IN (see the module docs).
|
||||
const FILTER_IDS: [u16; 10] = [
|
||||
150,
|
||||
151,
|
||||
272,
|
||||
154,
|
||||
155,
|
||||
430,
|
||||
1096,
|
||||
1097,
|
||||
BLT_ADD_ID,
|
||||
BLT_COMPLETE_ID,
|
||||
];
|
||||
|
||||
/// `BltQueueAddEntry` — a frame ENTERED the virtual display's kernel present queue. Event id
|
||||
/// resolved from the 26200 manifest (task 1105) and count-verified against an on-glass capture.
|
||||
const BLT_ADD_ID: u16 = 1071;
|
||||
|
||||
/// `BltQueueCompleteIndirectPresent` — a frame COMPLETED from the queue to IddCx (task 1059).
|
||||
const BLT_COMPLETE_ID: u16 = 1068;
|
||||
|
||||
/// The DXGI event ids the session filters IN: `Present` start (42) and
|
||||
/// `PresentMultiplaneOverlay` start (55) — one per app/DWM present call.
|
||||
const DXGI_FILTER_IDS: [u16; 2] = [DXGI_PRESENT_ID, DXGI_PRESENT_MPO_ID];
|
||||
const DXGI_PRESENT_ID: u16 = 42;
|
||||
const DXGI_PRESENT_MPO_ID: u16 = 55;
|
||||
|
||||
/// Session name — ours, stopped-if-stale at start (a crashed host leaves the session behind;
|
||||
/// real-time sessions are machine-global named objects).
|
||||
const SESSION: &str = "punktfunk-stallwatch-dxgkrnl";
|
||||
|
||||
/// The consumer callback's destination: `(event QPC, event id)`, capped. A few events per minute
|
||||
/// in the field; the cap only matters under a detection storm — exactly when the tail is the
|
||||
/// least interesting part.
|
||||
static RING: Mutex<VecDeque<(i64, u16)>> = Mutex::new(VecDeque::new());
|
||||
/// The consumer callback's destination: `(event QPC, event id, process id)`, capped. The DDI
|
||||
/// families are a few events per minute; the DXGI present stream and the two BltQueue streams
|
||||
/// each run at compose rate, so the cap sizes the history: 16384 entries ≈ 15–60 s at a
|
||||
/// 90–240 Hz desktop under a game + DWM — comfortably more than any single stall window the
|
||||
/// reporter asks about (a deeper hole's counts read as floors, which is still evidence).
|
||||
/// Event-id spaces of the two providers are disjoint (42/55 vs 15x/2xx/4xx/10xx/1071/1068), so
|
||||
/// one ring holds both without a provider tag.
|
||||
static RING: Mutex<VecDeque<(i64, u16, u32)>> = Mutex::new(VecDeque::new());
|
||||
|
||||
/// [`RING`]'s cap (see its doc for the sizing math).
|
||||
const RING_CAP: usize = 16384;
|
||||
|
||||
fn qpc_now() -> i64 {
|
||||
let mut v = 0i64;
|
||||
@@ -75,17 +129,18 @@ unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `record` is the live event the consumer is delivering for the duration of this call.
|
||||
let (id, ts) = unsafe {
|
||||
let (id, ts, pid) = unsafe {
|
||||
(
|
||||
(*record).EventHeader.EventDescriptor.Id,
|
||||
(*record).EventHeader.TimeStamp,
|
||||
(*record).EventHeader.ProcessId,
|
||||
)
|
||||
};
|
||||
let mut ring = RING.lock().unwrap();
|
||||
if ring.len() == 2048 {
|
||||
if ring.len() == RING_CAP {
|
||||
ring.pop_front();
|
||||
}
|
||||
ring.push_back((ts, id));
|
||||
ring.push_back((ts, id, pid));
|
||||
}
|
||||
|
||||
/// A live DxgKrnl watch: the controller handle (stops the session on drop) + the consumer handle.
|
||||
@@ -171,45 +226,10 @@ impl EtwWatch {
|
||||
}
|
||||
|
||||
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
|
||||
// vblank/DPC keywords never reach us.
|
||||
let mut filter = Vec::with_capacity(4 + FILTER_IDS.len() * 2);
|
||||
filter.extend_from_slice(&[1u8, 0u8]); // FilterIn = TRUE, Reserved
|
||||
filter.extend_from_slice(&(FILTER_IDS.len() as u16).to_le_bytes());
|
||||
for id in FILTER_IDS {
|
||||
filter.extend_from_slice(&id.to_le_bytes());
|
||||
}
|
||||
let mut desc = EVENT_FILTER_DESCRIPTOR {
|
||||
Ptr: filter.as_ptr() as u64,
|
||||
Size: filter.len() as u32,
|
||||
Type: EVENT_FILTER_TYPE_EVENT_ID,
|
||||
};
|
||||
let params = ENABLE_TRACE_PARAMETERS {
|
||||
Version: ENABLE_TRACE_PARAMETERS_VERSION_2,
|
||||
EnableProperty: 0,
|
||||
ControlFlags: 0,
|
||||
SourceId: GUID::zeroed(),
|
||||
EnableFilterDesc: &mut desc,
|
||||
FilterDescCount: 1,
|
||||
};
|
||||
// SAFETY: `session` is the live handle just started; `params`/`desc`/`filter` are live
|
||||
// locals for this synchronous call (the kernel copies the filter).
|
||||
let rc = unsafe {
|
||||
EnableTraceEx2(
|
||||
session,
|
||||
&DXGKRNL,
|
||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
|
||||
TRACE_LEVEL_INFORMATION as u8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(¶ms),
|
||||
)
|
||||
};
|
||||
if rc != ERROR_SUCCESS {
|
||||
tracing::debug!(
|
||||
rc = rc.0,
|
||||
"DxgKrnl ETW enable failed — stopping the session"
|
||||
);
|
||||
// vblank/DPC keywords never reach us. Fatal on failure (the DDI families + queue
|
||||
// witnesses are this watch's reason to exist).
|
||||
if !enable_provider(session, &DXGKRNL, &FILTER_IDS) {
|
||||
tracing::debug!("DxgKrnl ETW enable failed — stopping the session");
|
||||
let (mut buf, _) = properties_buffer();
|
||||
// SAFETY: live handle + valid properties allocation, stopped exactly once on this path.
|
||||
unsafe {
|
||||
@@ -219,6 +239,14 @@ impl EtwWatch {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// The DXGI (user-mode) present witness rides the same session. Degraded-not-fatal: a
|
||||
// refusal only costs the per-process present counts — `window_counts` then reports
|
||||
// no present history and classification stays honest (Unattributed, never a guess).
|
||||
if !enable_provider(session, &DXGI, &DXGI_FILTER_IDS) {
|
||||
tracing::debug!(
|
||||
"DXGI ETW enable failed — present-vs-queue discrimination unavailable this session"
|
||||
);
|
||||
}
|
||||
|
||||
let mut log = EVENT_TRACE_LOGFILEW {
|
||||
LoggerName: PWSTR(name.as_ptr() as *mut _),
|
||||
@@ -283,9 +311,12 @@ impl EtwWatch {
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let events: Vec<(i64, u16)> = {
|
||||
let events: Vec<(i64, u16, u32)> = {
|
||||
let ring = RING.lock().unwrap();
|
||||
ring.iter().filter(|(ts, _)| *ts <= to_q).copied().collect()
|
||||
ring.iter()
|
||||
.filter(|(ts, _, _)| *ts <= to_q)
|
||||
.copied()
|
||||
.collect()
|
||||
};
|
||||
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
||||
let mut parts = Vec::new();
|
||||
@@ -298,7 +329,7 @@ impl EtwWatch {
|
||||
let mut count = 0u32;
|
||||
let mut max_ms = 0i64;
|
||||
let mut still_open = false;
|
||||
for &(ts, id) in &events {
|
||||
for &(ts, id, _) in &events {
|
||||
if id == start_id {
|
||||
open = Some(ts);
|
||||
} else if id == stop_id {
|
||||
@@ -331,18 +362,184 @@ impl EtwWatch {
|
||||
] {
|
||||
let count = events
|
||||
.iter()
|
||||
.filter(|(ts, i)| *i == id && *ts >= from_q && *ts <= to_q)
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= from_q && *ts <= to_q)
|
||||
.count();
|
||||
if count > 0 {
|
||||
parts.push(format!("{label}×{count}"));
|
||||
}
|
||||
}
|
||||
// Present + queue accounting (DXGI 42/55 + BltQueueAddEntry/Complete): total presents
|
||||
// inside the window plus the top presenters, NAMED — the line that splits a
|
||||
// compose-silence hole into "the content stopped presenting" (no presents anywhere)
|
||||
// versus "presents flowed and the display path dropped them" (presents at rate while
|
||||
// the queue starves). "Present×0" is printed explicitly when the stream has history
|
||||
// but the window is empty — silence is a finding, not an absence.
|
||||
let mut per_pid: Vec<(u32, u32)> = Vec::new();
|
||||
let mut have_present_history = false;
|
||||
let (mut adds, mut completes) = (0u32, 0u32);
|
||||
let mut have_queue_history = false;
|
||||
for &(ts, id, pid) in &events {
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
have_present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID | BLT_COMPLETE_ID => {
|
||||
have_queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
if id == BLT_ADD_ID {
|
||||
adds += 1;
|
||||
} else {
|
||||
completes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !per_pid.is_empty() {
|
||||
per_pid.sort_by_key(|&(_, c)| std::cmp::Reverse(c));
|
||||
let total: u32 = per_pid.iter().map(|(_, c)| c).sum();
|
||||
let top = per_pid
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|(pid, c)| {
|
||||
let name = process_name(*pid).unwrap_or_else(|| format!("pid{pid}"));
|
||||
format!("{name}:{c}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
parts.push(format!("Present×{total}({top})"));
|
||||
} else if have_present_history {
|
||||
parts.push("Present×0".to_string());
|
||||
}
|
||||
if have_queue_history {
|
||||
parts.push(format!("blt-queue add×{adds} complete×{completes}"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// The structured discriminator read for `[from, to]` (the stall classifier's evidence):
|
||||
/// how many swapchain presents (DXGI 42/55, any process) and how many virtual-display
|
||||
/// queue entries (`BltQueueAddEntry`) landed in the window, plus whether each stream has
|
||||
/// EVER produced an event (distinguishing a true zero from a witness that is not working —
|
||||
/// e.g. the DXGI enable was refused, or an OS build renumbered the BltQueue events).
|
||||
pub(super) fn window_counts(&self, from: Instant, to: Instant) -> EtwWindowCounts {
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let ring = RING.lock().unwrap();
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in ring.iter() {
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_counts`]'s read: the compose-silence discriminator's structured evidence.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct EtwWindowCounts {
|
||||
/// Swapchain presents (any process — the game AND dwm both count) inside the window.
|
||||
pub(super) presents: u32,
|
||||
/// `BltQueueAddEntry` events (frames entering the virtual display's kernel queue) inside it.
|
||||
pub(super) queue_adds: u32,
|
||||
/// The present stream has produced at least one event EVER (witness known-working).
|
||||
pub(super) present_history: bool,
|
||||
/// The queue stream has produced at least one event EVER (witness known-working).
|
||||
pub(super) queue_history: bool,
|
||||
}
|
||||
|
||||
/// Enable `guid` on `session` with a kernel-side event-id allowlist. `true` on success.
|
||||
fn enable_provider(session: CONTROLTRACE_HANDLE, guid: &GUID, ids: &[u16]) -> bool {
|
||||
let mut filter = Vec::with_capacity(4 + ids.len() * 2);
|
||||
filter.extend_from_slice(&[1u8, 0u8]); // FilterIn = TRUE, Reserved
|
||||
filter.extend_from_slice(&(ids.len() as u16).to_le_bytes());
|
||||
for id in ids {
|
||||
filter.extend_from_slice(&id.to_le_bytes());
|
||||
}
|
||||
let mut desc = EVENT_FILTER_DESCRIPTOR {
|
||||
Ptr: filter.as_ptr() as u64,
|
||||
Size: filter.len() as u32,
|
||||
Type: EVENT_FILTER_TYPE_EVENT_ID,
|
||||
};
|
||||
let params = ENABLE_TRACE_PARAMETERS {
|
||||
Version: ENABLE_TRACE_PARAMETERS_VERSION_2,
|
||||
EnableProperty: 0,
|
||||
ControlFlags: 0,
|
||||
SourceId: GUID::zeroed(),
|
||||
EnableFilterDesc: &mut desc,
|
||||
FilterDescCount: 1,
|
||||
};
|
||||
// SAFETY: `session` is a live handle owned by the caller; `params`/`desc`/`filter` are live
|
||||
// locals for this synchronous call (the kernel copies the filter before returning).
|
||||
let rc = unsafe {
|
||||
EnableTraceEx2(
|
||||
session,
|
||||
guid,
|
||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
|
||||
TRACE_LEVEL_INFORMATION as u8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(¶ms),
|
||||
)
|
||||
};
|
||||
rc == ERROR_SUCCESS
|
||||
}
|
||||
|
||||
/// Best-effort image base name for `pid` (Present attribution); `None` when the process is gone
|
||||
/// or protected. Runs only at stall-report time — a rare, already-degraded moment.
|
||||
fn process_name(pid: u32) -> Option<String> {
|
||||
// SAFETY: plain FFI; a refused open returns Err (checked via `ok()?`), and the returned
|
||||
// handle is closed exactly once below.
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?;
|
||||
let mut buf = [0u16; 512];
|
||||
let mut len = buf.len() as u32;
|
||||
// SAFETY: `process` is the live handle just opened with QUERY_LIMITED (what this API needs);
|
||||
// `buf`/`len` are a valid out-buffer and its capacity, `len` updated to the written UTF-16
|
||||
// length (no NUL) on success.
|
||||
let ok = unsafe {
|
||||
QueryFullProcessImageNameW(
|
||||
process,
|
||||
PROCESS_NAME_WIN32,
|
||||
PWSTR(buf.as_mut_ptr()),
|
||||
&mut len,
|
||||
)
|
||||
}
|
||||
.is_ok();
|
||||
// SAFETY: `process` is the handle opened above, closed exactly once here.
|
||||
unsafe {
|
||||
let _ = CloseHandle(process);
|
||||
}
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf16_lossy(&buf[..len as usize]);
|
||||
Some(path.rsplit(['\\', '/']).next().unwrap_or(&path).to_string())
|
||||
}
|
||||
|
||||
/// A `Duration` in QPC ticks (saturating; diagnostic precision).
|
||||
|
||||
@@ -610,6 +610,7 @@ impl IddPushCapturer {
|
||||
client_10bit,
|
||||
display_hdr,
|
||||
hdr_pin_warned: false,
|
||||
hdr_pin_failures: 0,
|
||||
want_444,
|
||||
pyrowave,
|
||||
pyro_fence: None,
|
||||
@@ -635,7 +636,9 @@ impl IddPushCapturer {
|
||||
stall_watch: StallWatch::new(),
|
||||
offered_at_fresh: 0,
|
||||
max_hb_age_us: 0,
|
||||
probes: super::probes::acquire(),
|
||||
probes: pf_host_config::config()
|
||||
.stall_probes
|
||||
.then(super::probes::acquire),
|
||||
etw: super::dxgkrnl_etw::acquire(),
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
//! for the freeze's duration on EVERY engine of that adapter.
|
||||
//! - **dwm-tick**: `DwmGetCompositionTimingInfo(NULL)` `cRefresh` advance — the compositor's own
|
||||
//! clock. Frozen tick with live fences = DWM (or something it waits on) is blocked, not the GPU.
|
||||
//! The same sample also tracks `cFrame` (the COMPOSED-frame counter) — the compose-silence
|
||||
//! discriminator: a clock that ticks while `cFrame` freezes means DWM had NOTHING to compose
|
||||
//! (the foreground content stopped presenting); a `cFrame` that keeps advancing through a hole
|
||||
//! means DWM built frames that never reached OUR swap-chain (the OS frame-generation leg).
|
||||
//! - **dwm-flush**: a watchdogged `DwmFlush` — its latency IS the composition-wait measurement.
|
||||
//! - **scanline**: `D3DKMTGetScanLine` on an active output (Level-Zero reentrant — documented safe
|
||||
//! against every miniport lock, so a BLOCKED call here convicts the KMD itself). Prefers a
|
||||
@@ -155,6 +159,8 @@ struct Inner {
|
||||
/// One per hardware adapter, labelled by LUID (hybrids run two).
|
||||
fences: Vec<(String, BlockingProbe)>,
|
||||
dwm_tick: Ring,
|
||||
/// `cFrame` (composed-frame counter) frozen spans, sampled by the same dwm-tick thread.
|
||||
dwm_frame: Ring,
|
||||
dwm_flush: BlockingProbe,
|
||||
scanline: BlockingProbe,
|
||||
/// Whether the scanline probe currently targets a PHYSICAL head (see the module docs).
|
||||
@@ -201,6 +207,7 @@ impl ProbeEngine {
|
||||
.filter_map(|(_, p)| p.window_max(from, to))
|
||||
.max(),
|
||||
dwm_tick_frozen_us: i.dwm_tick.window_max(from, to),
|
||||
dwm_frame_frozen_us: i.dwm_frame.window_max(from, to),
|
||||
dwm_flush_max_us: i.dwm_flush.window_max(from, to),
|
||||
scanline_max_us: if i.scanline_running.load(Ordering::Relaxed) {
|
||||
i.scanline.window_max(from, to)
|
||||
@@ -221,6 +228,7 @@ impl ProbeEngine {
|
||||
stop: AtomicBool::new(false),
|
||||
fences,
|
||||
dwm_tick: Ring::new(),
|
||||
dwm_frame: Ring::new(),
|
||||
dwm_flush: BlockingProbe::new(),
|
||||
scanline: BlockingProbe::new(),
|
||||
scanline_physical: AtomicBool::new(false),
|
||||
@@ -397,11 +405,17 @@ fn fence_probe_setup(
|
||||
Some((context, ctx4, fence, event, (a, b)))
|
||||
}
|
||||
|
||||
/// `cRefresh` advance sampling: each tick records how long the compositor's frame counter has been
|
||||
/// unchanged. A frozen span covering a stall (with live fences) = DWM blocked, not the GPU.
|
||||
/// `cRefresh` + `cFrame` advance sampling: each tick records how long the compositor's refresh
|
||||
/// counter and its COMPOSED-frame counter have been unchanged. A frozen refresh span covering a
|
||||
/// stall (with live fences) = DWM blocked, not the GPU. A LIVE refresh with a frozen `cFrame` =
|
||||
/// DWM ticked but composed nothing — no damage anywhere, i.e. the foreground content itself
|
||||
/// stopped presenting; a `cFrame` that advances through a capture hole = DWM composed frames the
|
||||
/// IDD swap-chain never received (the OS frame-generation leg dropped them).
|
||||
fn dwm_tick_loop(inner: &Inner) {
|
||||
let mut last_refresh = 0u64;
|
||||
let mut last_change = Instant::now();
|
||||
let mut last_frame = 0u64;
|
||||
let mut last_frame_change = Instant::now();
|
||||
while !inner.stop.load(Ordering::Relaxed) {
|
||||
let mut info = DWM_TIMING_INFO {
|
||||
cbSize: std::mem::size_of::<DWM_TIMING_INFO>() as u32,
|
||||
@@ -417,6 +431,14 @@ fn dwm_tick_loop(inner: &Inner) {
|
||||
}
|
||||
let frozen = now.duration_since(last_change);
|
||||
inner.dwm_tick.push(now, frozen, frozen.as_micros() as u64);
|
||||
if info.cFrame != last_frame {
|
||||
last_frame = info.cFrame;
|
||||
last_frame_change = now;
|
||||
}
|
||||
let frame_frozen = now.duration_since(last_frame_change);
|
||||
inner
|
||||
.dwm_frame
|
||||
.push(now, frame_frozen, frame_frozen.as_micros() as u64);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
@@ -16,6 +16,31 @@ pub(super) struct Stall {
|
||||
pub(super) metronomic: Option<Duration>,
|
||||
}
|
||||
|
||||
/// One degraded stretch, summarized at recovery ([`StallWatch::take_recovery`]). Per-hole stall
|
||||
/// lines gate on prior ACTIVE flow, so inside a sustained ~2 fps phase only the first hole is
|
||||
/// reported and the log goes quiet exactly while the user suffers — this summary is the stretch's
|
||||
/// one visible line.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct Recovery {
|
||||
/// First hole's start → last hole's end.
|
||||
pub(super) degraded: Duration,
|
||||
/// Stall-sized (≥ [`StallWatch::STALL_MIN`]) holes inside the stretch.
|
||||
pub(super) holes: u32,
|
||||
/// Their summed length.
|
||||
pub(super) hole_time: Duration,
|
||||
/// The longest single hole.
|
||||
pub(super) worst: Duration,
|
||||
}
|
||||
|
||||
/// [`StallWatch`]'s in-flight degraded stretch (see [`Recovery`]).
|
||||
struct Episode {
|
||||
started: Instant,
|
||||
last_hole_end: Instant,
|
||||
holes: u32,
|
||||
hole_time: Duration,
|
||||
worst: Duration,
|
||||
}
|
||||
|
||||
/// Driver-telemetry evidence for one stall window (the v2 header tail — see
|
||||
/// `pf_driver_proto::frame::SharedHeader`), sampled by the capturer between the last pre-gap
|
||||
/// frame and the frame that ended the stall.
|
||||
@@ -32,6 +57,11 @@ pub(super) struct StallEvidence {
|
||||
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
|
||||
/// session is unavailable (non-admin dev run).
|
||||
pub(super) etw: Option<String>,
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_counts`]) —
|
||||
/// the compose-silence discriminator: presents flowing while the queue starves = the OS
|
||||
/// display path dropped composed frames; both silent = the content stopped presenting.
|
||||
/// `None` when the ETW session is unavailable.
|
||||
pub(super) etw_counts: Option<super::dxgkrnl_etw::EtwWindowCounts>,
|
||||
}
|
||||
|
||||
/// The micro-probes' window read (Phase A.2, built by `probes::ProbeEngine::window`): per-leg
|
||||
@@ -43,6 +73,12 @@ pub(super) struct ProbeWindow {
|
||||
pub(super) fence_max_us: Option<u64>,
|
||||
/// Longest span (µs) with no `DwmGetCompositionTimingInfo` `cRefresh` advance.
|
||||
pub(super) dwm_tick_frozen_us: Option<u64>,
|
||||
/// Longest span (µs) with no `cFrame` (composed-frame counter) advance. ADVISORY ONLY —
|
||||
/// never classification evidence: on Win11 `DWM_TIMING_INFO.cFrame` is refresh-synthesized
|
||||
/// and advances without real composes (proven on-glass 2026-07-30 against a kernel trace
|
||||
/// where DWM verifiably presented nothing for 1.6 s while cFrame ticked). The line keeps
|
||||
/// reporting it for older builds' sake; [`classify`] ignores it.
|
||||
pub(super) dwm_frame_frozen_us: Option<u64>,
|
||||
/// Worst watchdogged `DwmFlush` latency (µs).
|
||||
pub(super) dwm_flush_max_us: Option<u64>,
|
||||
/// Worst `D3DKMTGetScanLine` CALL latency (µs) — Level-Zero, so blocking convicts the KMD.
|
||||
@@ -62,9 +98,11 @@ impl std::fmt::Display for ProbeWindow {
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"fence={} dwm_tick_frozen={} dwm_flush={} scanline={}({}) cpu_overshoot={}",
|
||||
"fence={} dwm_tick_frozen={} dwm_frames_frozen={} dwm_flush={} scanline={}({}) \
|
||||
cpu_overshoot={}",
|
||||
ms(self.fence_max_us),
|
||||
ms(self.dwm_tick_frozen_us),
|
||||
ms(self.dwm_frame_frozen_us),
|
||||
ms(self.dwm_flush_max_us),
|
||||
ms(self.scanline_max_us),
|
||||
if self.scanline_physical {
|
||||
@@ -92,9 +130,17 @@ pub(super) enum StallClass {
|
||||
/// Engines alive but DWM's own tick froze: the compositor is blocked on something (DDC/child
|
||||
/// I/O vendor lock, win32k display-config queue). Class 2.
|
||||
CompositorBlocked,
|
||||
/// Engines alive, DWM ticking, driver drained E_PENDING — composition happened for OTHER
|
||||
/// surfaces but produced no frame for OUR display: the frame-generation path
|
||||
/// (IddCx/dirty-tracking/divider). Ours to chase with IddCx WPP.
|
||||
/// Engines alive, DWM's clock ticking, driver drained E_PENDING, and the ETW present witness
|
||||
/// saw (essentially) NO swapchain presents from ANY process across the hole: the content
|
||||
/// stopped presenting — no damage, DWM correctly composed nothing (a game hitch, a loading
|
||||
/// screen, a menu). Benign for the display path; the content side is where to look if the
|
||||
/// user FELT it.
|
||||
ContentSilence,
|
||||
/// Engines alive, DWM ticking, driver drained E_PENDING — and the ETW present witness saw
|
||||
/// presents FLOWING through the hole while the virtual display's kernel queue
|
||||
/// (`BltQueueAddEntry`) starved: composed frames existed and the OS display path dropped
|
||||
/// them before our swap-chain. The real display-path bug class — never yet observed in the
|
||||
/// field; a report with this label (counts attached) is the specimen we want.
|
||||
FrameGeneration,
|
||||
/// Not enough evidence to name a class (pre-telemetry driver and/or probes absent).
|
||||
Unattributed,
|
||||
@@ -111,21 +157,39 @@ impl std::fmt::Display for StallClass {
|
||||
Self::CompositorBlocked => {
|
||||
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
|
||||
}
|
||||
Self::ContentSilence => {
|
||||
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; not the display path)"
|
||||
}
|
||||
Self::FrameGeneration => {
|
||||
"FRAME-GENERATION (DWM ticked, engines alive, no frame for THIS display — IddCx/dirty/divider)"
|
||||
"FRAME-GENERATION (presents FLOWED while the virtual display's kernel queue starved — the OS display path dropped composed frames)"
|
||||
}
|
||||
Self::Unattributed => "UNATTRIBUTED (insufficient telemetry)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The verdict matrix: fold the driver-telemetry verdict and the probe window into a class.
|
||||
/// Pure — unit-tested beside the [`StallWatch`] tests. A leg is "stalled for the hole" when its
|
||||
/// worst reading covers at least half the gap (the same proportional bar as [`attribute`]).
|
||||
/// How many window presents acquit the content: ≥8 presents across the hole mirrors
|
||||
/// [`attribute`]'s offered-frames bar and [`StallWatch::RECENT`]'s sustained-flow definition —
|
||||
/// a caret blink or a stall-ending frame stays under it, a game presenting through the hole
|
||||
/// clears it by an order of magnitude.
|
||||
const PRESENTS_ACQUIT_CONTENT: u32 = 8;
|
||||
|
||||
/// The verdict matrix: fold the driver-telemetry verdict, the probe window and the ETW
|
||||
/// present-vs-queue counts into a class. Pure — unit-tested beside the [`StallWatch`] tests.
|
||||
/// A leg is "stalled for the hole" when its worst reading covers at least half the gap (the
|
||||
/// same proportional bar as [`attribute`]).
|
||||
///
|
||||
/// Compose-silence is split by the ETW witnesses ONLY (`DWM_TIMING_INFO.cFrame` is
|
||||
/// refresh-synthesized on Win11 and convicts nothing — see [`ProbeWindow::dwm_frame_frozen_us`]):
|
||||
/// presents flowing while the hole ran = the OS display path dropped them (FRAME-GENERATION,
|
||||
/// positively convicted); no presents anywhere = the content stopped (CONTENT-SILENCE). With no
|
||||
/// working witness the class stays UNATTRIBUTED — the pre-2026-07-30 default of blaming the
|
||||
/// frame-generation path mislabeled benign content pauses and is retired.
|
||||
pub(super) fn classify(
|
||||
gap: Duration,
|
||||
verdict: &StallVerdict,
|
||||
probes: Option<&ProbeWindow>,
|
||||
etw_counts: Option<&super::dxgkrnl_etw::EtwWindowCounts>,
|
||||
) -> StallClass {
|
||||
match verdict {
|
||||
StallVerdict::WorkerStalled => return StallClass::OursWorker,
|
||||
@@ -144,10 +208,21 @@ pub(super) fn classify(
|
||||
return StallClass::CompositorBlocked;
|
||||
}
|
||||
// Engines alive and DWM ticking: only the driver's own E_PENDING testimony can pin the
|
||||
// frame-generation path — without it (pre-telemetry driver) the delivery leg is equally
|
||||
// possible, so stay honest.
|
||||
// silence on the present path — without it (pre-telemetry driver) the delivery leg is
|
||||
// equally possible, so stay honest.
|
||||
if matches!(verdict, StallVerdict::ComposeSilence) {
|
||||
StallClass::FrameGeneration
|
||||
match etw_counts {
|
||||
Some(c) if c.present_history => {
|
||||
if c.presents >= PRESENTS_ACQUIT_CONTENT {
|
||||
StallClass::FrameGeneration
|
||||
} else {
|
||||
StallClass::ContentSilence
|
||||
}
|
||||
}
|
||||
// No working present witness (session refused / DXGI enable failed / renumbered
|
||||
// events): the silence cannot be attributed to either side.
|
||||
_ => StallClass::Unattributed,
|
||||
}
|
||||
} else {
|
||||
StallClass::Unattributed
|
||||
}
|
||||
@@ -228,8 +303,14 @@ pub(super) struct StallWatch {
|
||||
/// whole session's beat, not just the stall that tripped the metronome.
|
||||
verdicts: [u32; 4],
|
||||
/// Running per-class tally ([`StallClass`] order: ours-worker, ours-delivery, adapter-freeze,
|
||||
/// compositor-blocked, frame-generation, unattributed) — the verdict matrix's session summary.
|
||||
classes: [u32; 6],
|
||||
/// compositor-blocked, content-silence, frame-generation, unattributed) — the verdict
|
||||
/// matrix's session summary.
|
||||
classes: [u32; 7],
|
||||
/// The degraded stretch currently being accumulated, opened by a reported stall and fed by
|
||||
/// every stall-sized hole until sustained flow returns.
|
||||
episode: Option<Episode>,
|
||||
/// A closed episode's summary, parked for the caller ([`Self::take_recovery`]).
|
||||
pending_recovery: Option<Recovery>,
|
||||
}
|
||||
|
||||
impl StallWatch {
|
||||
@@ -242,6 +323,12 @@ impl StallWatch {
|
||||
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
|
||||
/// reported 300–700 ms freezes, above encode/present jitter.
|
||||
const STALL_MIN: Duration = Duration::from_millis(150);
|
||||
/// A hole this long is a content STOP, not a degraded stretch — an open episode is closed
|
||||
/// (and summarized) before it, so a quit-to-idle pause never folds into the tally.
|
||||
const EPISODE_BREAK: Duration = Duration::from_secs(10);
|
||||
/// Episodes with fewer holes than this dissolve silently — the single stall's own report
|
||||
/// line already covers them.
|
||||
const EPISODE_MIN_HOLES: u32 = 2;
|
||||
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
@@ -250,14 +337,39 @@ impl StallWatch {
|
||||
seen: 0,
|
||||
with_os_events: 0,
|
||||
verdicts: [0; 4],
|
||||
classes: [0; 6],
|
||||
classes: [0; 7],
|
||||
episode: None,
|
||||
pending_recovery: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
||||
/// the reset the first post-recreate frame would read as one).
|
||||
/// the reset the first post-recreate frame would read as one). An open episode is closed and
|
||||
/// summarized: its holes predate the recreate and are real evidence.
|
||||
pub(super) fn reset(&mut self) {
|
||||
self.recent.clear();
|
||||
self.close_episode();
|
||||
}
|
||||
|
||||
/// Close an open episode into [`Self::pending_recovery`] (kept only past the noise bar).
|
||||
fn close_episode(&mut self) {
|
||||
if let Some(ep) = self.episode.take() {
|
||||
if ep.holes >= Self::EPISODE_MIN_HOLES {
|
||||
self.pending_recovery = Some(Recovery {
|
||||
degraded: ep.last_hole_end.duration_since(ep.started),
|
||||
holes: ep.holes,
|
||||
hole_time: ep.hole_time,
|
||||
worst: ep.worst,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed degraded stretch's summary, if one is waiting — the caller logs it. Check after
|
||||
/// every [`Self::note_fresh`]/[`Self::reset`]: closure rides frames that are NOT stalls (the
|
||||
/// first sustained-flow frame after recovery).
|
||||
pub(super) fn take_recovery(&mut self) -> Option<Recovery> {
|
||||
self.pending_recovery.take()
|
||||
}
|
||||
|
||||
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
|
||||
@@ -274,6 +386,37 @@ impl StallWatch {
|
||||
self.recent.pop_front();
|
||||
}
|
||||
let gap = gap?;
|
||||
if gap >= Self::EPISODE_BREAK {
|
||||
// The content plainly STOPPED (quit to desktop, long idle) — summarize what came
|
||||
// before rather than folding a legitimate pause into the degraded tally.
|
||||
self.close_episode();
|
||||
}
|
||||
if gap >= Self::STALL_MIN {
|
||||
match &mut self.episode {
|
||||
// Inside a degraded stretch every stall-sized hole accumulates — the activity
|
||||
// gate below keeps per-hole reports quiet here (the pre-gap window spans the
|
||||
// slow frames), which is exactly why the episode summary exists.
|
||||
Some(ep) => {
|
||||
ep.holes += 1;
|
||||
ep.hole_time += gap;
|
||||
ep.worst = ep.worst.max(gap);
|
||||
ep.last_hole_end = now;
|
||||
}
|
||||
None if was_active => {
|
||||
self.episode = Some(Episode {
|
||||
started: now - gap,
|
||||
last_hole_end: now,
|
||||
holes: 1,
|
||||
hole_time: gap,
|
||||
worst: gap,
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
} else if was_active {
|
||||
// Sustained flow is back ([`Self::RECENT`] tight frames) — the stretch is over.
|
||||
self.close_episode();
|
||||
}
|
||||
if !was_active || gap < Self::STALL_MIN {
|
||||
return None;
|
||||
}
|
||||
@@ -312,14 +455,20 @@ impl StallWatch {
|
||||
StallVerdict::ComposeSilence => 2,
|
||||
StallVerdict::DeliveryLeg => 3,
|
||||
}] += 1;
|
||||
let class = classify(stall.gap, &verdict, evidence.probes.as_ref());
|
||||
let class = classify(
|
||||
stall.gap,
|
||||
&verdict,
|
||||
evidence.probes.as_ref(),
|
||||
evidence.etw_counts.as_ref(),
|
||||
);
|
||||
self.classes[match class {
|
||||
StallClass::OursWorker => 0,
|
||||
StallClass::OursDelivery => 1,
|
||||
StallClass::AdapterFreeze => 2,
|
||||
StallClass::CompositorBlocked => 3,
|
||||
StallClass::FrameGeneration => 4,
|
||||
StallClass::Unattributed => 5,
|
||||
StallClass::ContentSilence => 4,
|
||||
StallClass::FrameGeneration => 5,
|
||||
StallClass::Unattributed => 6,
|
||||
}] += 1;
|
||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||
@@ -331,6 +480,11 @@ impl StallWatch {
|
||||
class = %class,
|
||||
probes = evidence.probes.as_ref().map(tracing::field::display),
|
||||
etw = evidence.etw.as_deref().unwrap_or("unavailable"),
|
||||
// The discriminator's numeric read (also embedded in `etw` as prose): swapchain
|
||||
// presents from ANY process vs frames entering the virtual display's kernel queue,
|
||||
// inside the gap window. presents≥bar with adds≈0 = FRAME-GENERATION conviction.
|
||||
etw_presents = evidence.etw_counts.map(|c| c.presents),
|
||||
etw_queue_adds = evidence.etw_counts.map(|c| c.queue_adds),
|
||||
offered_during_gap = evidence.offered_delta,
|
||||
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
|
||||
"IDD-push capture stall — the desktop was composing at speed, then the ring \
|
||||
@@ -351,13 +505,14 @@ impl StallWatch {
|
||||
);
|
||||
let class_tally = format!(
|
||||
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
|
||||
frame-generation {}, unattributed {}",
|
||||
content-silence {}, frame-generation {}, unattributed {}",
|
||||
self.classes[0],
|
||||
self.classes[1],
|
||||
self.classes[2],
|
||||
self.classes[3],
|
||||
self.classes[4],
|
||||
self.classes[5]
|
||||
self.classes[5],
|
||||
self.classes[6]
|
||||
);
|
||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||
|
||||
@@ -477,6 +477,52 @@ pub mod edid {
|
||||
};
|
||||
cv.min(255) as u8
|
||||
}
|
||||
|
||||
/// Fixed reduced-blanking geometry for [`dtd`] (CVT-RBv2-shaped): 80 px of horizontal and 45
|
||||
/// lines of vertical blanking, front-porch/sync splits within them. A virtual display has no
|
||||
/// real scan-out, so the blanking only has to be self-consistent — the pixel clock is derived
|
||||
/// from these same totals.
|
||||
const DTD_H_BLANK: u32 = 80;
|
||||
const DTD_V_BLANK: u32 = 45;
|
||||
const DTD_H_SYNC_OFFSET: u32 = 8;
|
||||
const DTD_H_SYNC_WIDTH: u32 = 32;
|
||||
const DTD_V_SYNC_OFFSET: u32 = 3;
|
||||
const DTD_V_SYNC_WIDTH: u32 = 5;
|
||||
|
||||
/// An 18-byte EDID detailed timing descriptor for `width`×`height`@`refresh_hz` with the fixed
|
||||
/// reduced blanking above — the pf-vdisplay EDID's PREFERRED timing, built from the mode the
|
||||
/// session actually asked for instead of a hard-coded 1080p60. `None` when the mode does not
|
||||
/// FIT the DTD encoding — pixel clock above 655.35 MHz (the u16 10 kHz field: 4K120-class) or
|
||||
/// active dimensions above the 12-bit fields — in which case the caller keeps its
|
||||
/// representable fallback descriptor. Flags byte: digital separate sync, +H/+V (0x1E, matching
|
||||
/// the previous hard-coded DTD).
|
||||
pub fn dtd(width: u32, height: u32, refresh_hz: u32) -> Option<[u8; 18]> {
|
||||
if width == 0 || height == 0 || refresh_hz == 0 || width > 4095 || height > 4095 {
|
||||
return None;
|
||||
}
|
||||
let h_total = u64::from(width + DTD_H_BLANK);
|
||||
let v_total = u64::from(height + DTD_V_BLANK);
|
||||
let clock_10khz = h_total * v_total * u64::from(refresh_hz) / 10_000;
|
||||
let clock_10khz = u16::try_from(clock_10khz).ok()?;
|
||||
let mut d = [0u8; 18];
|
||||
d[0..2].copy_from_slice(&clock_10khz.to_le_bytes());
|
||||
d[2] = (width & 0xFF) as u8;
|
||||
d[3] = (DTD_H_BLANK & 0xFF) as u8;
|
||||
d[4] = (((width >> 8) & 0x0F) << 4) as u8 | ((DTD_H_BLANK >> 8) & 0x0F) as u8;
|
||||
d[5] = (height & 0xFF) as u8;
|
||||
d[6] = (DTD_V_BLANK & 0xFF) as u8;
|
||||
d[7] = (((height >> 8) & 0x0F) << 4) as u8 | ((DTD_V_BLANK >> 8) & 0x0F) as u8;
|
||||
d[8] = (DTD_H_SYNC_OFFSET & 0xFF) as u8;
|
||||
d[9] = (DTD_H_SYNC_WIDTH & 0xFF) as u8;
|
||||
d[10] = (((DTD_V_SYNC_OFFSET & 0x0F) << 4) | (DTD_V_SYNC_WIDTH & 0x0F)) as u8;
|
||||
d[11] = ((((DTD_H_SYNC_OFFSET >> 8) & 0x03) << 6)
|
||||
| (((DTD_H_SYNC_WIDTH >> 8) & 0x03) << 4)
|
||||
| (((DTD_V_SYNC_OFFSET >> 4) & 0x03) << 2)
|
||||
| ((DTD_V_SYNC_WIDTH >> 4) & 0x03)) as u8;
|
||||
// Bytes 12..17 (image size mm, borders) stay 0 = undefined, matching the previous DTD.
|
||||
d[17] = 0x1E;
|
||||
Some(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// The IDD-push frame transport: the host-created shared ring header, the publish token, and the
|
||||
@@ -1094,11 +1140,35 @@ pub mod gamepad {
|
||||
/// (see `pf_umdf_util::ChannelConfig::min_data_size`).
|
||||
pub const PAD_SHM_LEGACY_SIZE: usize = 256;
|
||||
|
||||
/// Output-report ring depth. 8 slots at the host's ~4 ms poll tolerates a sustained 2 kHz
|
||||
/// writer — double any real HID output rate.
|
||||
/// The v2.1 output-report ring depth — the length every pre-v2.2 driver hardcodes in its
|
||||
/// `% OUT_RING_LEN` slot math, kept as the drain length whenever [`PadShm::out_ring_len`]
|
||||
/// reads 0. Its sizing assumption ("8 slots at a ~4 ms poll tolerates a sustained 2 kHz
|
||||
/// writer — double any real HID output rate") was falsified in the field: DS5 compat-vibration
|
||||
/// writers that re-send per audio quantum sustain >2 kHz for tens of seconds (field log
|
||||
/// 2026-07-30), overflowing every poll.
|
||||
pub const OUT_RING_LEN: u32 = 8;
|
||||
pub const OUT_RING_LEN_USIZE: usize = OUT_RING_LEN as usize;
|
||||
|
||||
/// The v2.2 output-report ring depth — the same ring grown in place to every slot that fits
|
||||
/// the one-page section ([`PAD_SHM_SIZE`] = 4096): 56 slots at the host's ~4 ms poll
|
||||
/// tolerates a sustained 14 kHz writer, ~7× the worst rate observed in the field. Used only
|
||||
/// when BOTH sides negotiated it (host stamped `out_ring_ver >= 2`, driver echoed the length
|
||||
/// in [`PadShm::out_ring_len`]).
|
||||
pub const OUT_RING_LEN_V22: u32 = 56;
|
||||
pub const OUT_RING_LEN_V22_USIZE: usize = OUT_RING_LEN_V22 as usize;
|
||||
|
||||
/// The v2.1 [`PadShm`] size. A v2.1 driver maps this much and gates its 8-slot ring writes on
|
||||
/// `mapped_len() >= 1024`; the v2.2 growth keeps bytes `0..1024` layout-identical (the ring
|
||||
/// slots 8.. extend over what was `_reserved2`).
|
||||
pub const PAD_SHM_V21_SIZE: usize = 1024;
|
||||
|
||||
/// The full v2.2 [`PadShm`] size — exactly one page. This is a hard ceiling, not a choice:
|
||||
/// pagefile-backed sections round up to page granularity, which is what lets every binary
|
||||
/// generation map its own idea of the size against any other generation's section. A layout
|
||||
/// that grows past 4096 breaks that (an old section refuses the larger view) and must ride a
|
||||
/// new negotiation, not this one.
|
||||
pub const PAD_SHM_SIZE: usize = 4096;
|
||||
|
||||
/// One slot of the lossless output-report ring: the report bytes as the game wrote them
|
||||
/// (report id first), with the exact length — unlike the legacy latest-report slot, whose
|
||||
/// fixed 64-byte copy can carry a stale tail from a previous longer report.
|
||||
@@ -1110,8 +1180,9 @@ pub mod gamepad {
|
||||
pub data: [u8; 64],
|
||||
}
|
||||
|
||||
/// Virtual DualSense / DualShock 4 shared section (1024 B; bytes `0..256` are the v2 legacy
|
||||
/// layout verbatim — [`PAD_SHM_LEGACY_SIZE`]). The host writes the `0x01`-style HID input
|
||||
/// Virtual DualSense / DualShock 4 shared section (4096 B — [`PAD_SHM_SIZE`]; bytes `0..256`
|
||||
/// are the v2 legacy layout verbatim — [`PAD_SHM_LEGACY_SIZE`]; bytes `0..1024` are the v2.1
|
||||
/// layout verbatim — [`PAD_SHM_V21_SIZE`]). The host writes the `0x01`-style HID input
|
||||
/// report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's
|
||||
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) twice: into the legacy
|
||||
/// latest-report `output` slot (bumping `out_seq` — every host generation reads this), and,
|
||||
@@ -1126,6 +1197,16 @@ pub mod gamepad {
|
||||
/// mismatch (no pad at all), which is the wrong failure mode for a feedback-quality fix. An
|
||||
/// old driver never reads the new fields; an old host never stamps `out_ring_ver`, so a new
|
||||
/// driver stays on the legacy slot against it.
|
||||
///
|
||||
/// v2.2 ring-length negotiation (the ring grown 8 → [`OUT_RING_LEN_V22`] in place): the two
|
||||
/// sides must agree on the `% len` slot math, and neither the host binary nor the driver
|
||||
/// binary can assume the other's generation, so each declares and the SHORTER understanding
|
||||
/// wins. The host stamps `out_ring_ver = 2` at section creation ("I can drain the long
|
||||
/// ring"); the driver picks its length from that stamp (`>= 2` and a full-size map → 56,
|
||||
/// `1` → 8, `0` → no ring) and ECHOES the picked length into `out_ring_len` before every
|
||||
/// `ring_head` bump. The host keys its drain off the echo alone (`0` = pre-v2.2 driver = 8),
|
||||
/// and the slot-bytes → echo → head-bump store order means a drain that Acquire-observed a
|
||||
/// head bump always reads the length that wrote those slots.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||
pub struct PadShm {
|
||||
@@ -1154,16 +1235,23 @@ pub mod gamepad {
|
||||
/// the host drains it". The section starts zeroed and an old host never writes it, so `0`
|
||||
/// tells a new driver to stay legacy-only. Carved from v2 reserved space (v2.1).
|
||||
pub out_ring_ver: u32,
|
||||
/// Driver-bumped (AFTER writing `out_ring[ring_head % OUT_RING_LEN]`) count of reports
|
||||
/// ever published to the ring — the host's drain cursor compares against its own tail and
|
||||
/// detects overflow by `head - tail > OUT_RING_LEN`. Same publish-then-bump store order as
|
||||
/// Driver-bumped (AFTER writing `out_ring[ring_head % len]`) count of reports ever
|
||||
/// published to the ring — the host's drain cursor compares against its own tail and
|
||||
/// detects overflow by `head - tail > len`. Same publish-then-bump store order as
|
||||
/// `out_seq` (the host's Acquire load orders the reads). Carved from v2 reserved space
|
||||
/// (v2.1).
|
||||
pub ring_head: u32,
|
||||
pub _reserved1: [u8; 92],
|
||||
/// The lossless output-report ring (v2.1) — see the struct docs and [`OutSlot`].
|
||||
pub out_ring: [OutSlot; OUT_RING_LEN_USIZE],
|
||||
pub _reserved2: [u8; 224],
|
||||
/// The ring length the driver's slot math is USING — the driver's side of the v2.2
|
||||
/// negotiation (see the struct docs), (re-)stamped before every `ring_head` bump. `0` =
|
||||
/// a pre-v2.2 driver that never writes it = [`OUT_RING_LEN`]. Carved from v2.1 reserved
|
||||
/// space (v2.2).
|
||||
pub out_ring_len: u32,
|
||||
pub _reserved1: [u8; 88],
|
||||
/// The lossless output-report ring — [`OUT_RING_LEN`] slots under a v2.1 negotiation,
|
||||
/// [`OUT_RING_LEN_V22`] under v2.2 (slots 8.. overlay what v2.1 called `_reserved2`,
|
||||
/// which no shipped binary ever read or wrote). See the struct docs and [`OutSlot`].
|
||||
pub out_ring: [OutSlot; OUT_RING_LEN_V22_USIZE],
|
||||
pub _reserved2: [u8; 32],
|
||||
}
|
||||
|
||||
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
|
||||
@@ -1189,7 +1277,7 @@ pub mod gamepad {
|
||||
assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
|
||||
assert!(offset_of!(XusbShm, pad_index) == 40);
|
||||
|
||||
assert!(size_of::<PadShm>() == 1024);
|
||||
assert!(size_of::<PadShm>() == PAD_SHM_SIZE);
|
||||
assert!(offset_of!(PadShm, magic) == 0);
|
||||
assert!(offset_of!(PadShm, input) == 8);
|
||||
assert!(offset_of!(PadShm, out_seq) == 72);
|
||||
@@ -1203,6 +1291,14 @@ pub mod gamepad {
|
||||
assert!(offset_of!(PadShm, ring_head) == 160);
|
||||
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
|
||||
assert!(size_of::<OutSlot>() == 68);
|
||||
// v2.2 in-place ring growth — the echo field sits in v2.1 reserved space, the grown ring
|
||||
// stays within the v2.1 slots' historical offsets (slot k at 256 + k*68), and the whole
|
||||
// struct is exactly the one page that keeps cross-generation views mappable.
|
||||
assert!(offset_of!(PadShm, out_ring_len) == 164);
|
||||
assert!(
|
||||
PAD_SHM_LEGACY_SIZE + OUT_RING_LEN_USIZE * size_of::<OutSlot>() <= PAD_SHM_V21_SIZE
|
||||
);
|
||||
assert!(PAD_SHM_SIZE == 4096);
|
||||
|
||||
assert!(size_of::<ChannelProof>() == 16);
|
||||
assert!(offset_of!(ChannelProof, magic) == 0);
|
||||
@@ -1404,6 +1500,31 @@ mod tests {
|
||||
use super::*;
|
||||
use bytemuck::Zeroable;
|
||||
|
||||
#[test]
|
||||
fn dtd_encodes_the_session_mode() {
|
||||
// 1920×1080@60 with the fixed RB blanking: totals 2000×1125 → 135.00 MHz = 13500 ×10 kHz.
|
||||
let d = edid::dtd(1920, 1080, 60).expect("1080p60 fits the DTD encoding");
|
||||
assert_eq!(u16::from_le_bytes([d[0], d[1]]), 13_500);
|
||||
// Active dimensions round-trip through the split 8+4-bit fields.
|
||||
assert_eq!(u32::from(d[2]) | (u32::from(d[4] >> 4) << 8), 1920);
|
||||
assert_eq!(u32::from(d[5]) | (u32::from(d[7] >> 4) << 8), 1080);
|
||||
// Blanking fields carry the fixed geometry; flags match the legacy descriptor.
|
||||
assert_eq!(u32::from(d[3]) | (u32::from(d[4] & 0x0F) << 8), 80);
|
||||
assert_eq!(u32::from(d[6]) | (u32::from(d[7] & 0x0F) << 8), 45);
|
||||
assert_eq!(d[17], 0x1E);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtd_rejects_what_the_encoding_cannot_carry() {
|
||||
// 4K120: (3840+80)·(2160+45)·120 ≈ 1.037 GHz — past the u16 10 kHz pixel-clock field.
|
||||
assert_eq!(edid::dtd(3840, 2160, 120), None);
|
||||
// 4K60 fits (≈518 MHz).
|
||||
assert!(edid::dtd(3840, 2160, 60).is_some());
|
||||
// Degenerate and over-wide modes are refused, not mis-encoded.
|
||||
assert_eq!(edid::dtd(0, 1080, 60), None);
|
||||
assert_eq!(edid::dtd(5000, 1080, 10), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_token_roundtrips() {
|
||||
for (g, s, slot) in [
|
||||
|
||||
@@ -795,10 +795,17 @@ pub struct NvencCudaEncoder {
|
||||
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
|
||||
stream_ordered: bool,
|
||||
/// Slice count the live session was configured with ([`resolve_slices`] — env override,
|
||||
/// else the Linux direct-NVENC default of 4 since Phase 3; 1 = the preset's single slice).
|
||||
/// else the Linux direct-NVENC default of 4 since Phase 3 clamped to
|
||||
/// [`max_slices`](Self::max_slices); 1 = the preset's single slice).
|
||||
/// Chunked poll needs ≥ 2 to have boundaries to cut at. Latched at init, consumed by
|
||||
/// `build_config` (so an in-place reconfigure presents the same slicing).
|
||||
slices: u32,
|
||||
/// Ceiling on the per-frame slice count the session's CLIENT decoder accepts (from
|
||||
/// negotiation: `VIDEO_CAP_MULTI_SLICE`, or GameStream's `videoEncoderSlicesPerFrame`).
|
||||
/// 1 = single-slice only — the safe shape toward decoders that never asked (Amlogic TV
|
||||
/// SoCs wedge on multi-slice AUs). Clamps the Phase-3 default; the explicit
|
||||
/// `PUNKTFUNK_NVENC_SLICES` env override still wins in both directions.
|
||||
max_slices: u32,
|
||||
/// `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` from the caps probe — gates the DEFAULT-on
|
||||
/// sub-frame arming (an unsupported GPU must not have `enableSubFrameWrite` forced into its
|
||||
/// init params, which could fail the session open). `PUNKTFUNK_NVENC_SUBFRAME=1` overrides.
|
||||
@@ -847,6 +854,7 @@ impl NvencCudaEncoder {
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
cursor_blend: bool,
|
||||
max_slices: u32,
|
||||
) -> Result<Self> {
|
||||
// The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a
|
||||
// clear reason instead of an opaque session error on the first frame.
|
||||
@@ -895,6 +903,8 @@ impl NvencCudaEncoder {
|
||||
io_stream: ptr::null_mut(),
|
||||
stream_ordered: false,
|
||||
slices: 1,
|
||||
// A zero from a misbehaving caller must not zero the resolver's default arithmetic.
|
||||
max_slices: max_slices.max(1),
|
||||
subframe_cap: false,
|
||||
subframe_on: false,
|
||||
subframe_forced: false,
|
||||
@@ -1094,9 +1104,14 @@ impl NvencCudaEncoder {
|
||||
// every Linux direct-NVENC session, resolved HERE (before the session opens) so the
|
||||
// config author, the init params and the chunked-poll latch all agree; the caps probe
|
||||
// gates the sub-frame default so a GPU without SUBFRAME_READBACK never has it forced
|
||||
// into its init params. PUNKTFUNK_NVENC_SLICES=1 / PUNKTFUNK_NVENC_SUBFRAME=0 are the
|
||||
// escapes.
|
||||
self.slices = resolve_slices(self.codec, 4);
|
||||
// into its init params. The Phase-3 default is CLAMPED to the session's negotiated
|
||||
// `max_slices` — the client-decoder ceiling (`VIDEO_CAP_MULTI_SLICE`, or GameStream's
|
||||
// `videoEncoderSlicesPerFrame`): a client that never asked for multi-slice AUs gets
|
||||
// single-slice frames, because TV-SoC decoders (Amlogic — Chromecast with Google TV)
|
||||
// wedge the whole device on frames carrying several slice NALs (the 0.17.0 field
|
||||
// regression). PUNKTFUNK_NVENC_SLICES / PUNKTFUNK_NVENC_SUBFRAME stay the explicit
|
||||
// operator overrides in both directions.
|
||||
self.slices = resolve_slices(self.codec, 4.min(self.max_slices));
|
||||
self.subframe_on = resolve_subframe(self.subframe_cap);
|
||||
self.subframe_forced = subframe_env_forced();
|
||||
tracing::info!(
|
||||
@@ -1105,6 +1120,8 @@ impl NvencCudaEncoder {
|
||||
yuv444 = self.yuv444_supported,
|
||||
subframe_readback = subframe != 0,
|
||||
dynamic_slice = dyn_slice != 0,
|
||||
slices = self.slices,
|
||||
max_slices = self.max_slices,
|
||||
max = %format!("{wmax}x{hmax}"),
|
||||
"NVENC (Linux direct) capabilities probed"
|
||||
);
|
||||
@@ -2559,6 +2576,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2657,6 +2675,7 @@ mod tests {
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2737,6 +2756,7 @@ mod tests {
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
true, // cursor_blend: bring up the Vulkan slot ring + the 10-bit blend
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
|
||||
@@ -2802,6 +2822,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA 4:4:4 session");
|
||||
|
||||
@@ -2848,6 +2869,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2906,6 +2928,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
) else {
|
||||
eprintln!(
|
||||
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
|
||||
@@ -2933,6 +2956,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA encoder")
|
||||
}
|
||||
@@ -2968,6 +2992,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open");
|
||||
for f in 0..4u32 {
|
||||
@@ -3106,6 +3131,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
@@ -3149,6 +3175,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
true, // cursor_blend: bring up the Vulkan slot ring + blend
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
|
||||
@@ -3227,6 +3254,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
// Steady sync frames first (stream-ordered mode).
|
||||
@@ -3307,6 +3335,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -3413,6 +3442,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -3483,6 +3513,49 @@ mod tests {
|
||||
assert!(!au.data.is_empty());
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): a session whose CLIENT ceiling is 1 slice (`max_slices` from
|
||||
/// negotiation — no cap bit / Moonlight `videoEncoderSlicesPerFrame:1`, the Chromecast field
|
||||
/// regression's fix) must encode single-slice frames with chunked poll disarmed, with NO env
|
||||
/// knobs involved. Run with `--test-threads=1` (env vars are process-global).
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_single_slice_client_ceiling() {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
// The ceiling under test is the negotiated one, not the operator override.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
20_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
1, // the client never advertised multi-slice tolerance
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
for i in 0..4u32 {
|
||||
let frame = nv12_frame(W, H, i);
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
assert_eq!(
|
||||
enc.slices, 1,
|
||||
"a 1-slice client ceiling must clamp the Phase-3 default"
|
||||
);
|
||||
assert!(
|
||||
!enc.supports_chunked_poll(),
|
||||
"single-slice sessions have no boundaries — chunked poll must stay disarmed"
|
||||
);
|
||||
let au = enc.poll().expect("poll").expect("one AU per sync frame");
|
||||
assert!(!au.data.is_empty(), "frame {i} produced an empty AU");
|
||||
}
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): the Phase-3 default-on ESCAPES — `PUNKTFUNK_NVENC_SLICES=1`
|
||||
/// must fully disarm chunked poll (and `poll_chunk` degrades
|
||||
/// to exactly one self-closing whole-AU chunk (the default-path contract every non-chunked
|
||||
|
||||
@@ -45,11 +45,14 @@ pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
|
||||
/// Resolved per-frame slice count for a session (latency plan §7 LN1, Phase 3): the
|
||||
/// `PUNKTFUNK_NVENC_SLICES` env override wins (1..=32; **1 = the explicit single-slice
|
||||
/// escape**, needed now that a backend can default higher), else the backend's
|
||||
/// `default_slices` — 4 on Linux direct-NVENC since the Phase-3 default-on, 1 everywhere else
|
||||
/// (the Windows async path is deliberately untouched). H.264/HEVC only (AV1 partitions via
|
||||
/// tiles). ONE parse shared by the config author ([`apply_low_latency_config`] via
|
||||
/// [`LowLatencyConfig::slices`]) and the Linux backend's chunked-poll arming, so the two can
|
||||
/// never disagree about whether a session is multi-slice.
|
||||
/// `default_slices` — on Linux direct-NVENC the Phase-3 default of 4 CLAMPED to the session's
|
||||
/// negotiated client-decoder ceiling (`VIDEO_CAP_MULTI_SLICE` / GameStream's
|
||||
/// `videoEncoderSlicesPerFrame` — a client that never asked stays single-slice: Amlogic TV
|
||||
/// SoCs wedge on multi-slice AUs), 1 everywhere else (the Windows async path is deliberately
|
||||
/// untouched). H.264/HEVC only (AV1 partitions via tiles). ONE parse shared by the config
|
||||
/// author ([`apply_low_latency_config`] via [`LowLatencyConfig::slices`]) and the Linux
|
||||
/// backend's chunked-poll arming, so the two can never disagree about whether a session is
|
||||
/// multi-slice.
|
||||
pub(super) fn resolve_slices(codec: Codec, default_slices: u32) -> u32 {
|
||||
if !matches!(codec, Codec::H264 | Codec::H265) {
|
||||
return 1;
|
||||
|
||||
@@ -174,6 +174,13 @@ pub fn open_video(
|
||||
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
|
||||
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
|
||||
cursor_blend: bool,
|
||||
// Ceiling on the per-frame slice count this session's CLIENT decoder accepts: 1 =
|
||||
// single-slice only (the safe default toward decoders that never asked — Amlogic TV SoCs
|
||||
// wedge on multi-slice AUs), a Moonlight `videoEncoderSlicesPerFrame` request, or 32 (= no
|
||||
// client-side limit) for a `VIDEO_CAP_MULTI_SLICE` punktfunk/1 client. Backends that split
|
||||
// frames (Linux direct-NVENC, §7 LN1) clamp their default against it;
|
||||
// `PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions.
|
||||
max_slices: u32,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
let (inner, backend) = open_video_backend(
|
||||
codec,
|
||||
@@ -186,6 +193,7 @@ pub fn open_video(
|
||||
bit_depth,
|
||||
chroma,
|
||||
cursor_blend,
|
||||
max_slices,
|
||||
)?;
|
||||
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
|
||||
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
|
||||
@@ -337,6 +345,7 @@ fn open_video_backend_linux(
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
cursor_blend: bool,
|
||||
max_slices: u32,
|
||||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
// A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes
|
||||
// straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override.
|
||||
@@ -446,6 +455,7 @@ fn open_video_backend_linux(
|
||||
bit_depth,
|
||||
chroma,
|
||||
cursor_blend,
|
||||
max_slices,
|
||||
)
|
||||
.map(|e| (e, "nvenc"))
|
||||
};
|
||||
@@ -562,8 +572,11 @@ fn open_video_backend(
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
cursor_blend: bool,
|
||||
max_slices: u32,
|
||||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
let _ = cursor_blend; // consumed only by the Linux vulkan-encode + direct-NVENC arms below
|
||||
// Consumed only by the Linux vulkan-encode + direct-NVENC arms below (max_slices: the
|
||||
// direct-NVENC arm — the one backend that splits frames).
|
||||
let _ = (cursor_blend, max_slices);
|
||||
validate_dimensions(codec, width, height)?;
|
||||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||
@@ -600,6 +613,7 @@ fn open_video_backend(
|
||||
bit_depth,
|
||||
chroma,
|
||||
cursor_blend,
|
||||
max_slices,
|
||||
)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -816,6 +830,7 @@ fn open_video_backend(
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
max_slices,
|
||||
);
|
||||
anyhow::bail!("video encode requires Linux or Windows")
|
||||
}
|
||||
@@ -840,14 +855,16 @@ fn open_nvenc_probed(
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
cursor_blend: bool,
|
||||
max_slices: u32,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
// Consumed by the direct-SDK arm below.
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
let _ = cursor_blend; // consumed by the direct-SDK arm below
|
||||
// Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA
|
||||
// capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those
|
||||
// keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set
|
||||
// PUNKTFUNK_NVENC_DIRECT=0 to fall back to libav. It self-clamps the bitrate internally (its own
|
||||
// level-ceiling binary search at session open), so it skips the probe-loop stepping below.
|
||||
let _ = (cursor_blend, max_slices);
|
||||
// Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA
|
||||
// capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those
|
||||
// keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set
|
||||
// PUNKTFUNK_NVENC_DIRECT=0 to fall back to libav. It self-clamps the bitrate internally (its own
|
||||
// level-ceiling binary search at session open), so it skips the probe-loop stepping below.
|
||||
#[cfg(feature = "nvenc")]
|
||||
if cuda && nvenc_direct_enabled() {
|
||||
tracing::info!(
|
||||
@@ -865,6 +882,7 @@ fn open_nvenc_probed(
|
||||
bit_depth,
|
||||
chroma,
|
||||
cursor_blend,
|
||||
max_slices,
|
||||
)?) as Box<dyn Encoder>);
|
||||
}
|
||||
// The silent-degrade trap: a build without `--features nvenc` compiles the direct-SDK
|
||||
@@ -2317,6 +2335,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("software arm must open GPU-free");
|
||||
assert_eq!(label, "software");
|
||||
@@ -2335,6 +2354,7 @@ mod tests {
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
) {
|
||||
// `expect_err` needs `Ok: Debug` and `Box<dyn Encoder>` isn't — match instead.
|
||||
Ok(_) => panic!("software emits H.264 only; an H.265 session must be refused"),
|
||||
|
||||
@@ -120,6 +120,14 @@ pub struct HostConfig {
|
||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||
pub vdisplay: Option<String>,
|
||||
/// `PUNKTFUNK_STALL_PROBES` — run the Windows IDD-push capture's micro-probe engine (per-GPU
|
||||
/// fence probes, DWM tick/flush watchdogs, scanline + CPU sentinels — `idd_push/probes.rs`),
|
||||
/// the corroborating evidence legs on every stall report. Default ON while the
|
||||
/// interval-stutter field program runs; explicit-off grammar for perf-sensitive boxes — the
|
||||
/// engine costs standing threads (a blocking `DwmFlush` waiter, ~10 Hz fence copies per GPU,
|
||||
/// a 5 ms-cadence CPU sentinel). Off, stall lines still carry the driver telemetry + the ETW
|
||||
/// present/queue discriminator (cheap, session-filtered); only the probe legs read absent.
|
||||
pub stall_probes: bool,
|
||||
/// `PUNKTFUNK_GAMESCOPE_STEAM` — force the bare headless gamescope spawn into its Steam
|
||||
/// integration mode (`--steam`) for EVERY launch. A Steam title auto-enables `--steam` on its
|
||||
/// own regardless of this knob; it exists to force it on for non-Steam launches too. Managed
|
||||
@@ -235,6 +243,8 @@ impl HostConfig {
|
||||
// per-session switch; see the field doc).
|
||||
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
// Default ON while the interval-stutter field program runs (see the field doc).
|
||||
stall_probes: env_on("PUNKTFUNK_STALL_PROBES").unwrap_or(true),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
// Trimmed + emptied-to-None: `PUNKTFUNK_CAPTURE_MONITOR=` in a host.env means "not
|
||||
// set", not "match the monitor named empty string".
|
||||
|
||||
@@ -102,6 +102,45 @@ pub struct UhidManager<B: PadProto> {
|
||||
/// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see
|
||||
/// [`pump`](Self::pump).
|
||||
last_active: Vec<Instant>,
|
||||
/// Per-pad rate limiter for the ring-overflow WARN — see [`OverflowWarn`].
|
||||
overflow_warn: Vec<OverflowWarn>,
|
||||
}
|
||||
|
||||
/// Rate limiter for the per-poll ring-overflow WARN. A sustained >2 kHz output-report writer
|
||||
/// against a pre-v2.2 8-slot driver ring overflows EVERY ~4 ms poll; unlimited, that is ~230
|
||||
/// WARN lines/s — a field log's 5000-line web-console ring was 96 % this one line, which evicted
|
||||
/// the whole session history it was needed to diagnose (2026-07-30). One line per
|
||||
/// [`Self::PERIOD`] with a `suppressed` count keeps the signal and the log.
|
||||
#[derive(Default, Clone)]
|
||||
struct OverflowWarn {
|
||||
/// When the last line was emitted (`None` = never — the next overflow logs immediately).
|
||||
last: Option<Instant>,
|
||||
/// Overflow polls swallowed since `last` — carried on the next emitted line.
|
||||
suppressed: u32,
|
||||
}
|
||||
|
||||
impl OverflowWarn {
|
||||
const PERIOD: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Record one overflow poll; emit (rate-limited) the WARN for it.
|
||||
fn note(&mut self, now: Instant, backend: &'static str, index: usize) {
|
||||
if self
|
||||
.last
|
||||
.is_none_or(|t| now.duration_since(t) >= Self::PERIOD)
|
||||
{
|
||||
tracing::warn!(
|
||||
backend,
|
||||
index,
|
||||
suppressed = self.suppressed,
|
||||
"output-report ring overflow — resyncing feedback state (repeats coalesced, 1 \
|
||||
line/s; `suppressed` = swallowed since the previous line)"
|
||||
);
|
||||
self.last = Some(now);
|
||||
self.suppressed = 0;
|
||||
} else {
|
||||
self.suppressed = self.suppressed.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a latched, non-zero rumble may sit without the game driving the RUMBLE plane before it
|
||||
@@ -162,6 +201,7 @@ impl<B: PadProto> UhidManager<B> {
|
||||
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
last_active: vec![Instant::now(); MAX_PADS],
|
||||
overflow_warn: vec![OverflowWarn::default(); MAX_PADS],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,14 +289,13 @@ impl<B: PadProto> UhidManager<B> {
|
||||
let fb = self.backend.service(pad, i as u8);
|
||||
if fb.resync {
|
||||
// The driver's output-report ring overflowed — reports were dropped and the
|
||||
// feedback state is unknown. Conservatively silence the pad (a game still rumbling
|
||||
// re-asserts the level within a poll or two) and re-arm the rich-plane dedup so
|
||||
// the next LED/trigger state re-forwards.
|
||||
tracing::warn!(
|
||||
backend = B::LABEL,
|
||||
index = i,
|
||||
"output-report ring overflow — resyncing feedback state"
|
||||
);
|
||||
// feedback state is unknown beyond what the drain salvaged from the legacy
|
||||
// latest-report slot (delivered through `fb` like any report). Conservatively
|
||||
// silence the pad FIRST (a plane the salvage didn't carry must not stay latched;
|
||||
// the salvaged state re-applies right below) and re-arm the rich-plane dedup so
|
||||
// the next LED/trigger state re-forwards. WARN through the per-pad rate limiter —
|
||||
// a storm overflows every poll and the raw line once flooded a whole log export.
|
||||
self.overflow_warn[i].note(now, B::LABEL, i);
|
||||
if self.last_rumble[i] != (0, 0) {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
rumble(i as u16, 0, 0);
|
||||
@@ -646,6 +685,24 @@ mod tests {
|
||||
m.heartbeat(Duration::from_secs(3600));
|
||||
assert_eq!(writes(&m), after_frame + 2);
|
||||
}
|
||||
/// The overflow WARN limiter: a storm overflowing every ~4 ms poll must emit one line per
|
||||
/// [`OverflowWarn::PERIOD`] and carry the swallowed count — the raw per-poll line once made
|
||||
/// up 96 % of a field log export and evicted the session history around it.
|
||||
#[test]
|
||||
fn overflow_warn_coalesces_within_the_period() {
|
||||
let mut w = OverflowWarn::default();
|
||||
let t0 = Instant::now();
|
||||
w.note(t0, "Mock", 0); // first overflow logs immediately
|
||||
assert_eq!((w.last, w.suppressed), (Some(t0), 0));
|
||||
for _ in 0..250 {
|
||||
w.note(t0 + Duration::from_millis(4), "Mock", 0);
|
||||
}
|
||||
assert_eq!((w.last, w.suppressed), (Some(t0), 250), "storm coalesced");
|
||||
let t1 = t0 + OverflowWarn::PERIOD;
|
||||
w.note(t1, "Mock", 0); // period elapsed — logs (with suppressed=250) and re-arms
|
||||
assert_eq!((w.last, w.suppressed), (Some(t1), 0));
|
||||
}
|
||||
|
||||
/// A ring-overflow resync must silence a latched rumble once and re-arm the rich-plane dedup,
|
||||
/// so the game's next asserted state re-forwards even when it equals the pre-overflow state.
|
||||
#[test]
|
||||
|
||||
@@ -33,8 +33,9 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_LOCATE_DEVNODE_NORMAL, CR_SUCCESS,
|
||||
};
|
||||
use windows::Win32::Devices::HumanInterfaceDevice::{
|
||||
HidD_GetFeature, HidD_GetIndexedString, HidD_GetProductString, HidD_GetSerialNumberString,
|
||||
HidD_SetFeature, GUID_DEVINTERFACE_HID,
|
||||
HidD_FreePreparsedData, HidD_GetFeature, HidD_GetIndexedString, HidD_GetPreparsedData,
|
||||
HidD_GetProductString, HidD_GetSerialNumberString, HidD_SetFeature, HidP_GetCaps,
|
||||
GUID_DEVINTERFACE_HID, HIDP_CAPS, PHIDP_PREPARSED_DATA,
|
||||
};
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
@@ -130,47 +131,86 @@ fn ask_ioctl(path: &str, expect_pad_index: u32) -> Result<u32> {
|
||||
/// Open a HID collection and read the proof out of a FEATURE report — the pad transport.
|
||||
fn ask_feature_path(path: &str, expect_pad_index: u32) -> Result<u32> {
|
||||
let handle = open_device(path)?;
|
||||
let proof = ask_feature(HANDLE(handle.as_raw_handle()))?;
|
||||
proof
|
||||
.check(expect_pad_index)
|
||||
.map_err(|why| anyhow!("{why}"))
|
||||
ask_feature(HANDLE(handle.as_raw_handle()), expect_pad_index)
|
||||
}
|
||||
|
||||
/// Take a parsed answer only if it VALIDATES; otherwise remember why and let the caller keep
|
||||
/// looking. Parsing is not evidence: [`ChannelProof::from_feature_report`] happily reinterprets any
|
||||
/// 17 bytes, so a transport that answers with something else entirely yields a proof-shaped struct
|
||||
/// full of another report's bytes.
|
||||
fn accept(
|
||||
p: Option<ChannelProof>,
|
||||
expect: u32,
|
||||
rejected: &mut Option<&'static str>,
|
||||
) -> Option<u32> {
|
||||
match p?.check(expect) {
|
||||
Ok(pid) => Some(pid),
|
||||
Err(why) => {
|
||||
*rejected = Some(why);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The PS identities answer on the declared-but-unserved report `0x85`; the Deck answers its
|
||||
/// unnumbered report after a private SET_FEATURE command. Both are tried — one driver binary serves
|
||||
/// four identities and the host does not know which one this devnode became until the DATA section
|
||||
/// is attached, which is precisely what we are trying to earn the right to do.
|
||||
fn ask_feature(h: HANDLE) -> Result<ChannelProof> {
|
||||
// Feature buffers are sized by the descriptor; the largest of these reports is 64 bytes, and
|
||||
// Windows accepts a buffer at least that big.
|
||||
const BUF: usize = 64;
|
||||
///
|
||||
/// So neither answer can be trusted on shape alone: a Deck serves its ONE unnumbered feature report
|
||||
/// for *any* requested id, which means it answers the `0x85` probe with Steam attribute bytes that
|
||||
/// parse into a proof and fail on magic. Returning that first answer stopped the search and refused
|
||||
/// the delivery while the real proof sat one transport away.
|
||||
fn ask_feature(h: HANDLE, expect_pad_index: u32) -> Result<u32> {
|
||||
// `HidD_GetFeature`/`HidD_SetFeature` REJECT any buffer shorter than the collection's
|
||||
// `FeatureReportByteLength` — so it must come from the descriptor, not from a constant. The PS
|
||||
// identities land on 64 (63-byte reports + a report id); the Deck's ONE feature report is
|
||||
// unnumbered and 64 bytes wide, which Windows reports as 65 (payload + the report-id slot it
|
||||
// always reserves). Hardcoding 64 silently failed every call on a correctly-enumerated Deck.
|
||||
let buf_len = feature_report_len(h).unwrap_or(64).max(64);
|
||||
let mut rejected = None;
|
||||
|
||||
// PS identities: GET_FEATURE 0x85.
|
||||
let mut buf = [0u8; BUF];
|
||||
let mut buf = vec![0u8; buf_len];
|
||||
buf[0] = HID_FEATURE_REPORT_CHANNEL_PROOF;
|
||||
// SAFETY: `h` is the live HID interface handle; `buf` is a valid BUF-sized in/out buffer.
|
||||
if unsafe { HidD_GetFeature(h, buf.as_mut_ptr().cast(), BUF as u32) } {
|
||||
if let Some(p) = ChannelProof::from_feature_report(&buf) {
|
||||
return Ok(p);
|
||||
// SAFETY: `h` is the live HID interface handle; `buf` is a valid `buf_len`-sized in/out buffer.
|
||||
if unsafe { HidD_GetFeature(h, buf.as_mut_ptr().cast(), buf_len as u32) } {
|
||||
let p = ChannelProof::from_feature_report(&buf);
|
||||
if let Some(pid) = accept(p, expect_pad_index, &mut rejected) {
|
||||
return Ok(pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Deck: SET the private command, then GET the reply. Byte 0 is the (unnumbered) report id 0.
|
||||
let mut cmd = [0u8; BUF];
|
||||
let mut cmd = vec![0u8; buf_len];
|
||||
cmd[1..1 + DECK_PROOF_CMD.len()].copy_from_slice(&DECK_PROOF_CMD);
|
||||
// SAFETY: `h` is live; `cmd` is a valid BUF-sized buffer.
|
||||
let set_ok = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), BUF as u32) };
|
||||
// SAFETY: `h` is live; `cmd` is a valid `buf_len`-sized buffer.
|
||||
let set_ok = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), buf_len as u32) };
|
||||
if set_ok {
|
||||
let mut reply = [0u8; BUF];
|
||||
let mut reply = vec![0u8; buf_len];
|
||||
// SAFETY: as above.
|
||||
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), BUF as u32) }
|
||||
&& reply.starts_with(&DECK_PROOF_CMD)
|
||||
{
|
||||
if let Some(p) = ChannelProof::from_bytes(&reply[DECK_PROOF_CMD.len()..]) {
|
||||
return Ok(p);
|
||||
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), buf_len as u32) } {
|
||||
// The driver answers with the payload; on an unnumbered report Windows hands it back
|
||||
// one byte in, behind the report-id slot. Accept either placement rather than pinning a
|
||||
// marshalling detail that differs between the two descriptors this one driver serves.
|
||||
for off in [0usize, 1] {
|
||||
let Some(body) = reply.get(off..) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(tail) = body.strip_prefix(&DECK_PROOF_CMD[..]) {
|
||||
let p = ChannelProof::from_bytes(tail);
|
||||
if let Some(pid) = accept(p, expect_pad_index, &mut rejected) {
|
||||
return Ok(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A well-formed answer that failed validation is a different fault from no answer at all, and
|
||||
// says which check refused — do not bury it under the generic "no proof here".
|
||||
if let Some(why) = rejected {
|
||||
bail!("{why}");
|
||||
}
|
||||
bail!(
|
||||
"this HID collection carries no channel proof (feature 0x{:02x}: no; Deck command: {}) — \
|
||||
the driver predates the proof (reinstall: punktfunk-host.exe driver install --gamepad)",
|
||||
@@ -183,6 +223,22 @@ fn ask_feature(h: HANDLE) -> Result<ChannelProof> {
|
||||
)
|
||||
}
|
||||
|
||||
/// This collection's `FeatureReportByteLength` — the exact buffer size `HidD_GetFeature` /
|
||||
/// `HidD_SetFeature` demand. `None` when the preparsed data can't be read (caller falls back).
|
||||
fn feature_report_len(h: HANDLE) -> Option<usize> {
|
||||
let mut pp = PHIDP_PREPARSED_DATA::default();
|
||||
// SAFETY: `h` is the live HID interface handle; `pp` receives an owned preparsed-data handle.
|
||||
if !unsafe { HidD_GetPreparsedData(h, &mut pp) } {
|
||||
return None;
|
||||
}
|
||||
let mut caps = HIDP_CAPS::default();
|
||||
// SAFETY: `pp` is the handle just obtained (freed below); `caps` is a valid out-param.
|
||||
let st = unsafe { HidP_GetCaps(pp, &mut caps) };
|
||||
// SAFETY: `pp` came from `HidD_GetPreparsedData` and is not used after this.
|
||||
let _ = unsafe { HidD_FreePreparsedData(pp) };
|
||||
(st.0 >= 0 && caps.FeatureReportByteLength > 0).then_some(caps.FeatureReportByteLength as usize)
|
||||
}
|
||||
|
||||
/// Open a HID collection and read the proof out of a string.
|
||||
fn ask_hid_path(path: &str, expect_pad_index: u32) -> Result<u32> {
|
||||
let handle = open_device(path)?;
|
||||
@@ -266,15 +322,21 @@ pub fn diagnose(instance_id: &str, transport: ProofTransport, expect_pad_index:
|
||||
let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF FAILED: {e:#}");
|
||||
}
|
||||
},
|
||||
ProofTransport::HidFeatureReport => match ask_feature(h) {
|
||||
Ok(p) => {
|
||||
let _ = writeln!(out, " feature proof -> {p:?}");
|
||||
let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index));
|
||||
ProofTransport::HidFeatureReport => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" feature report length: {:?}",
|
||||
feature_report_len(h)
|
||||
);
|
||||
match ask_feature(h, expect_pad_index) {
|
||||
Ok(pid) => {
|
||||
let _ = writeln!(out, " feature proof -> wudf pid {pid}");
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, " feature proof FAILED: {e:#}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, " feature proof FAILED: {e:#}");
|
||||
}
|
||||
},
|
||||
}
|
||||
ProofTransport::HidSerialString => {
|
||||
// Report each path separately — this is the whole point of the probe.
|
||||
let (indexed, serial, control) = ask_hid_both(h);
|
||||
|
||||
@@ -57,22 +57,27 @@ pub(super) const OFF_PAD_INDEX: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
|
||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE;
|
||||
// v2.1 output-report ring (see `PadShm` in pf-driver-proto for the layout + version posture).
|
||||
// v2.1/v2.2 output-report ring (see `PadShm` in pf-driver-proto for the layout + version posture
|
||||
// + the ring-length negotiation).
|
||||
pub(super) const OFF_OUT_RING_VER: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring_ver);
|
||||
pub(super) const OFF_RING_HEAD: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, ring_head);
|
||||
pub(super) const OFF_OUT_RING_LEN: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring_len);
|
||||
pub(super) const OFF_OUT_RING: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring);
|
||||
pub(super) const OUT_SLOT_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::OutSlot>();
|
||||
pub(super) const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN;
|
||||
pub(super) const OUT_RING_LEN_V22: u32 = pf_driver_proto::gamepad::OUT_RING_LEN_V22;
|
||||
|
||||
/// Shared drain over a pad section's output plane — the lossless v2.1 report ring when the driver
|
||||
/// publishes one, the legacy latest-report slot otherwise (an old driver package). One per pad;
|
||||
/// owns the cursors that used to live as bare `last_out_seq` fields on each backend. The ring is
|
||||
/// what guarantees a rumble-STOP report can never be coalesced away by a following LED/trigger
|
||||
/// report inside one ~4 ms poll window — the confirmed unbounded stuck-rumble path
|
||||
/// (`design/rumble-root-fix.md` §A).
|
||||
/// Shared drain over a pad section's output plane — the lossless report ring when the driver
|
||||
/// publishes one (8 slots from a v2.1 driver, [`OUT_RING_LEN_V22`] once both sides negotiated the
|
||||
/// v2.2 growth — the driver's `out_ring_len` echo decides, see the PadShm docs), the legacy
|
||||
/// latest-report slot otherwise (an old driver package). One per pad; owns the cursors that used
|
||||
/// to live as bare `last_out_seq` fields on each backend. The ring is what guarantees a
|
||||
/// rumble-STOP report can never be coalesced away by a following LED/trigger report inside one
|
||||
/// ~4 ms poll window — the confirmed unbounded stuck-rumble path (`design/rumble-root-fix.md` §A).
|
||||
pub(super) struct OutputDrain {
|
||||
/// Ring cursor: the driver's `ring_head` value up to which we have drained.
|
||||
tail: u32,
|
||||
@@ -94,9 +99,11 @@ impl OutputDrain {
|
||||
|
||||
/// Drain every output report published since the last call, oldest → newest, invoking
|
||||
/// `per_report` with each report's exact bytes. Returns `true` on ring OVERFLOW — more than
|
||||
/// [`OUT_RING_LEN`] reports landed since the last poll (or the driver lapped us mid-copy): the
|
||||
/// pending reports were DISCARDED as possibly torn and the caller must treat its downstream
|
||||
/// feedback state as unknown (`PadFeedback::resync`).
|
||||
/// the negotiated ring length landed since the last poll (or the driver lapped us mid-copy):
|
||||
/// the pending window was DISCARDED as possibly torn, the legacy latest-report slot was
|
||||
/// salvaged into ONE `per_report` call (the freshest coalesced state — the driver
|
||||
/// dual-publishes every report there), and the caller must still treat its downstream
|
||||
/// feedback state as unknown (`PadFeedback::resync`) for the planes that report didn't carry.
|
||||
pub(super) fn drain(&mut self, base: *mut u8, mut per_report: impl FnMut(&[u8])) -> bool {
|
||||
// SAFETY: base points at SHM_SIZE bytes; `OFF_RING_HEAD` (== 160) is 4-aligned off the
|
||||
// page-aligned base. The driver bumps `ring_head` AFTER writing the slot, so an Acquire
|
||||
@@ -108,18 +115,35 @@ impl OutputDrain {
|
||||
if head == self.tail {
|
||||
return false;
|
||||
}
|
||||
// The v2.2 length echo — the modulo the DRIVER's slot math used (0 = a pre-v2.2
|
||||
// driver that never stamps it and hardcodes 8). Loaded after the Acquire on
|
||||
// `ring_head` and re-stamped by the driver before every bump, so any observed head
|
||||
// comes with the length that indexed its slots. Out-of-range values (a torn or
|
||||
// hostile section) clamp to the v2.1 length: the drain then at worst mis-slots and
|
||||
// discards, never reads out of bounds (slot offsets stay ≤ the v2.2 ring's end).
|
||||
// SAFETY: `OFF_OUT_RING_LEN` (== 164) is 4-aligned off the page-aligned base.
|
||||
let echo = unsafe {
|
||||
(*(base.add(OFF_OUT_RING_LEN) as *const AtomicU32)).load(Ordering::Relaxed)
|
||||
};
|
||||
let ring_len = if (1..=OUT_RING_LEN_V22).contains(&echo) {
|
||||
echo
|
||||
} else {
|
||||
OUT_RING_LEN
|
||||
};
|
||||
let pending = head.wrapping_sub(self.tail);
|
||||
if pending <= OUT_RING_LEN {
|
||||
if pending <= ring_len {
|
||||
// Copy the pending slots out FIRST, then re-check the head: a writer that lapped
|
||||
// past our window during the copy may have overwritten what we read, so parse only
|
||||
// when the window provably stayed inside the ring.
|
||||
let n = pending as usize;
|
||||
let mut bufs = [([0u8; 64], 0usize); pf_driver_proto::gamepad::OUT_RING_LEN_USIZE];
|
||||
let mut bufs =
|
||||
[([0u8; 64], 0usize); pf_driver_proto::gamepad::OUT_RING_LEN_V22_USIZE];
|
||||
for (k, buf) in bufs.iter_mut().enumerate().take(n) {
|
||||
let idx = (self.tail.wrapping_add(k as u32) % OUT_RING_LEN) as usize;
|
||||
let idx = (self.tail.wrapping_add(k as u32) % ring_len) as usize;
|
||||
let slot = OFF_OUT_RING + idx * OUT_SLOT_SIZE;
|
||||
// SAFETY: slot .. slot+OUT_SLOT_SIZE is inside the SHM_SIZE section; the len
|
||||
// field is 4-aligned (`OFF_OUT_RING` == 256, `OUT_SLOT_SIZE` == 68).
|
||||
// SAFETY: slot .. slot+OUT_SLOT_SIZE is inside the SHM_SIZE section (idx <
|
||||
// `ring_len` ≤ OUT_RING_LEN_V22, whose last slot ends at 4064 ≤ SHM_SIZE);
|
||||
// the len field is 4-aligned (`OFF_OUT_RING` == 256, `OUT_SLOT_SIZE` == 68).
|
||||
let len = unsafe { std::ptr::read_unaligned(base.add(slot) as *const u32) };
|
||||
buf.1 = (len as usize).min(64);
|
||||
// SAFETY: the slot's data region is slot+4 .. slot+4+64, inside the section;
|
||||
@@ -132,7 +156,7 @@ impl OutputDrain {
|
||||
let head2 = unsafe {
|
||||
(*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire)
|
||||
};
|
||||
if head2.wrapping_sub(self.tail) <= OUT_RING_LEN {
|
||||
if head2.wrapping_sub(self.tail) <= ring_len {
|
||||
for (data, len) in bufs.iter().take(n) {
|
||||
if *len > 0 {
|
||||
per_report(&data[..*len]);
|
||||
@@ -142,11 +166,23 @@ impl OutputDrain {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Overflow (or lapped mid-copy): skip to the freshest head, deliver nothing, and
|
||||
// report the resync — parsing possibly-torn reports is worse than a bounded silence.
|
||||
// Overflow (or lapped mid-copy): skip to the freshest head and salvage the legacy
|
||||
// latest-report slot — the driver dual-publishes every report there, so it holds the
|
||||
// newest state the window carried. One salvaged report beats the old total silence: a
|
||||
// rumble-heavy >2 kHz writer used to be force-muted for the storm's whole duration
|
||||
// (field log 2026-07-30). The slot has no seqlock, so the copy can tear against a
|
||||
// mid-write driver — the parser's id/flag gates drop most tears, a plausible-but-torn
|
||||
// value lasts one poll at storm rates, and the caller's resync still silences every
|
||||
// plane the salvaged report doesn't assert. A report that lands between the reload
|
||||
// and the copy is salvaged now AND drained next poll — harmless, reports are
|
||||
// valid-flag-gated state and the caller's dedup drops the repeat.
|
||||
// SAFETY: as the first `ring_head` load above.
|
||||
self.tail =
|
||||
unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) };
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: the legacy output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe { std::ptr::copy_nonoverlapping(base.add(OFF_OUTPUT), out.as_mut_ptr(), 64) };
|
||||
per_report(&out);
|
||||
return true;
|
||||
}
|
||||
// Legacy driver (never wrote the ring): the latest-report slot + seq — exactly the old
|
||||
@@ -422,8 +458,11 @@ impl DsWinPad {
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = id.devtype;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
// Ring capability (v2.1), stamped before the magic so the driver sees it on attach.
|
||||
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1);
|
||||
// Ring capability, stamped before the magic so the driver sees it on attach: `2` =
|
||||
// "this host drains the v2.2 long ring" (a v2.1 driver reads it as a boolean and
|
||||
// stays on its 8-slot math — the drain follows the driver's `out_ring_len` echo, so
|
||||
// both generations pair correctly; see the PadShm negotiation docs).
|
||||
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
|
||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||
@@ -709,14 +748,28 @@ mod drain_tests {
|
||||
buf.as_mut_ptr() as *mut u8
|
||||
}
|
||||
|
||||
/// Mimic the v2.1 driver's dual write: legacy slot + seq, then ring slot, then head.
|
||||
/// Mimic the v2.1 driver's dual write: legacy slot + seq, then ring slot (8-slot math, no
|
||||
/// length echo), then head.
|
||||
fn publish(buf: &mut [u32], bytes: &[u8]) {
|
||||
ring_publish(buf, bytes, OUT_RING_LEN, false);
|
||||
}
|
||||
|
||||
/// Mimic the v2.2 driver's dual write: same, with the long-ring slot math and the
|
||||
/// `out_ring_len` echo stamped before the head bump.
|
||||
fn v22_publish(buf: &mut [u32], bytes: &[u8]) {
|
||||
ring_publish(buf, bytes, OUT_RING_LEN_V22, true);
|
||||
}
|
||||
|
||||
fn ring_publish(buf: &mut [u32], bytes: &[u8], len: u32, echo: bool) {
|
||||
legacy_publish(buf, bytes);
|
||||
let head = read32(buf, OFF_RING_HEAD);
|
||||
let slot = OFF_OUT_RING + (head % OUT_RING_LEN) as usize * OUT_SLOT_SIZE;
|
||||
let slot = OFF_OUT_RING + (head % len) as usize * OUT_SLOT_SIZE;
|
||||
write32(buf, slot, bytes.len() as u32);
|
||||
let b = bytes_mut(buf);
|
||||
b[slot + 4..slot + 4 + bytes.len()].copy_from_slice(bytes);
|
||||
if echo {
|
||||
write32(buf, OFF_OUT_RING_LEN, len);
|
||||
}
|
||||
write32(buf, OFF_RING_HEAD, head.wrapping_add(1));
|
||||
}
|
||||
|
||||
@@ -792,7 +845,7 @@ mod drain_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_discards_and_flags_resync_then_recovers() {
|
||||
fn overflow_salvages_the_latest_slot_and_flags_resync_then_recovers() {
|
||||
let mut buf = section();
|
||||
let mut d = OutputDrain::new();
|
||||
for i in 0..12u8 {
|
||||
@@ -801,13 +854,91 @@ mod drain_tests {
|
||||
}
|
||||
let (got, resync) = collect(&mut d, &mut buf);
|
||||
assert!(resync, "an overflowed window must be reported");
|
||||
assert!(got.is_empty(), "possibly-torn reports must not be parsed");
|
||||
assert_eq!(
|
||||
got.len(),
|
||||
1,
|
||||
"the possibly-torn ring window must not be parsed — only the legacy latest slot"
|
||||
);
|
||||
assert_eq!(
|
||||
&got[0][..2],
|
||||
&[0x02, 11],
|
||||
"the salvage must be the freshest coalesced state, not silence"
|
||||
);
|
||||
publish(&mut buf, &[0x02, 99]);
|
||||
let (got, resync) = collect(&mut d, &mut buf);
|
||||
assert!(!resync);
|
||||
assert_eq!(got, vec![vec![0x02, 99]]);
|
||||
}
|
||||
|
||||
/// The 2026-07-30 field storm: a >2 kHz writer lands more than 8 reports per poll window. A
|
||||
/// v2.2 driver's echoed long ring must absorb it losslessly — this exact burst overflowed
|
||||
/// EVERY poll against the 8-slot ring.
|
||||
#[test]
|
||||
fn v22_ring_absorbs_a_burst_the_v21_ring_could_not() {
|
||||
let mut buf = section();
|
||||
let mut d = OutputDrain::new();
|
||||
for i in 0..40u8 {
|
||||
v22_publish(&mut buf, &[0x02, i]);
|
||||
}
|
||||
let (got, resync) = collect(&mut d, &mut buf);
|
||||
assert!(!resync, "40 pending ≤ 56 slots — no overflow");
|
||||
assert_eq!(
|
||||
got.iter().map(|r| r[1]).collect::<Vec<_>>(),
|
||||
(0..40).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v22_ring_wraps_across_polls() {
|
||||
let mut buf = section();
|
||||
let mut d = OutputDrain::new();
|
||||
for i in 0..50u8 {
|
||||
v22_publish(&mut buf, &[0x02, i]);
|
||||
}
|
||||
assert_eq!(collect(&mut d, &mut buf).0.len(), 50);
|
||||
for i in 50..100u8 {
|
||||
// wraps past slot 56
|
||||
v22_publish(&mut buf, &[0x02, i]);
|
||||
}
|
||||
let (got, resync) = collect(&mut d, &mut buf);
|
||||
assert!(!resync);
|
||||
assert_eq!(
|
||||
got.iter().map(|r| r[1]).collect::<Vec<_>>(),
|
||||
(50..100).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v22_overflow_still_salvages_and_recovers() {
|
||||
let mut buf = section();
|
||||
let mut d = OutputDrain::new();
|
||||
for i in 0..60u8 {
|
||||
// 60 > OUT_RING_LEN_V22 pending
|
||||
v22_publish(&mut buf, &[0x02, i]);
|
||||
}
|
||||
let (got, resync) = collect(&mut d, &mut buf);
|
||||
assert!(resync);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(&got[0][..2], &[0x02, 59]);
|
||||
v22_publish(&mut buf, &[0x02, 99]);
|
||||
let (got, resync) = collect(&mut d, &mut buf);
|
||||
assert!(!resync);
|
||||
assert_eq!(got, vec![vec![0x02, 99]]);
|
||||
}
|
||||
|
||||
/// An out-of-range `out_ring_len` (torn write / hostile section) must clamp to the v2.1
|
||||
/// length, not drive the slot math out of the ring.
|
||||
#[test]
|
||||
fn garbage_length_echo_clamps_to_the_v21_length() {
|
||||
let mut buf = section();
|
||||
let mut d = OutputDrain::new();
|
||||
publish(&mut buf, &[0x02, 1]); // 8-slot math, like the driver the clamp falls back to
|
||||
write32(&mut buf, OFF_OUT_RING_LEN, 9999);
|
||||
let (got, resync) = collect(&mut d, &mut buf);
|
||||
assert!(!resync);
|
||||
assert_eq!(got, vec![vec![0x02, 1]]);
|
||||
}
|
||||
|
||||
/// Every hardware id the host puts on a pad devnode must be one the shipped INF actually
|
||||
/// declares — otherwise PnP matches none of our models, falls through to the synthesized USB
|
||||
/// ids on the same devnode, and binds Microsoft's inbox `input.inf`/`HidUsb`, which cannot
|
||||
@@ -860,6 +991,85 @@ mod drain_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver reads its HID identity back off the same hardware id — that mapping is what
|
||||
/// decides which report descriptor and which VID/PID a pad enumerates with, and it is settled
|
||||
/// at `EvtDeviceAdd`, before the sealed channel can possibly say anything (its delivery goes
|
||||
/// through the HID interface that does not exist yet). So every hwid the host uses must appear
|
||||
/// in `devtype_from_hwids`' table against this side's `devtype`, and the table must be ordered
|
||||
/// so no token shadows a longer one — `pf_dualsense` is a prefix of `pf_dualsenseedge`, and an
|
||||
/// Edge listed after it would resolve to a plain DualSense.
|
||||
///
|
||||
/// Until the driver read the ids, EVERY non-DualSense pad enumerated with the DualSense
|
||||
/// descriptor: a Steam Deck's 64-byte frame parsed as DualSense report `0x01` pins the left
|
||||
/// stick to a corner and holds d-pad UP forever.
|
||||
#[test]
|
||||
fn hwid_devtype_table_matches_the_driver() {
|
||||
let src = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../packaging/windows/drivers/pf-gamepad/src/lib.rs"
|
||||
);
|
||||
let driver = std::fs::read_to_string(src).expect("read pf-gamepad lib.rs");
|
||||
let table = driver
|
||||
.split_once("fn devtype_from_hwids")
|
||||
.expect("devtype_from_hwids not found — did the driver's identity resolution move?")
|
||||
.1;
|
||||
let table = table.split_once("] {").expect("table literal").0;
|
||||
// `("pf_steamdeck", 3u8),` → ("pf_steamdeck", 3), in source order.
|
||||
let entries: Vec<(String, u8)> = table
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().strip_prefix('('))
|
||||
.filter_map(|l| l.split_once(','))
|
||||
.filter_map(|(id, dt)| {
|
||||
let id = id.trim().trim_matches('"').to_ascii_lowercase();
|
||||
let dt = dt
|
||||
.trim()
|
||||
.trim_end_matches([')', ','])
|
||||
.trim_end_matches("u8");
|
||||
dt.parse().ok().map(|dt| (id, dt))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
4,
|
||||
"parsed {entries:?} out of the driver's table — the shape changed and this test went \
|
||||
vacuous; fix the parse rather than deleting the assert"
|
||||
);
|
||||
for (i, (id, _)) in entries.iter().enumerate() {
|
||||
for (later, _) in &entries[i + 1..] {
|
||||
assert!(
|
||||
!later.starts_with(id.as_str()),
|
||||
"the driver tests {id:?} before {later:?}, so a {later:?} devnode would \
|
||||
resolve to {id:?}'s identity — put the longer id first"
|
||||
);
|
||||
}
|
||||
}
|
||||
for (hwid, devtype) in [
|
||||
(WinDsIdentity::dualsense().hwid, 0),
|
||||
(
|
||||
WinDsIdentity::dualsense_edge().hwid,
|
||||
pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE,
|
||||
),
|
||||
(
|
||||
super::super::dualshock4_windows::DS4_HWID,
|
||||
pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4,
|
||||
),
|
||||
(
|
||||
super::super::steam_deck_windows::DECK_HWID,
|
||||
pf_driver_proto::gamepad::DEVTYPE_STEAMDECK,
|
||||
),
|
||||
] {
|
||||
let want = hwid.to_ascii_lowercase();
|
||||
let got = entries.iter().find(|(id, _)| *id == want);
|
||||
assert_eq!(
|
||||
got.map(|(_, dt)| *dt),
|
||||
Some(devtype),
|
||||
"the host stamps device_type={devtype} for hardware id {hwid:?}, but the driver's \
|
||||
table says {got:?} — the pad would enumerate with another controller's report \
|
||||
descriptor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_driver_still_drains_the_latest_slot() {
|
||||
let mut buf = section();
|
||||
|
||||
@@ -56,8 +56,9 @@ impl Ds4WinPad {
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
// Ring capability (v2.1), stamped before the magic so the driver sees it on attach.
|
||||
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1);
|
||||
// Ring capability `2` = "this host drains the v2.2 long ring", stamped before the
|
||||
// magic so the driver sees it on attach (see the DualSense open path + PadShm docs).
|
||||
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
|
||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||
|
||||
@@ -825,7 +825,7 @@ impl DriverAttach {
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed)",
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
@@ -850,28 +850,52 @@ impl DriverAttach {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
|
||||
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
|
||||
/// draining pad slots even when the driver store is wedged.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the one-off subprocess cost never hits a healthy session.
|
||||
fn driver_store_inventory() -> &'static str {
|
||||
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
|
||||
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
|
||||
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
|
||||
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
|
||||
fn driver_store_inventory() -> Option<&'static str> {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
INV.get_or_init(|| {
|
||||
// Resolve pnputil by full System32 path — the host runs as SYSTEM and must not trust PATH /
|
||||
// the CreateProcess search (which checks the launching EXE's own dir first), or a planted
|
||||
// `pnputil.exe` beside the host binary would run elevated (security-review 2026-07-17).
|
||||
let pnputil = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\pnputil.exe"))
|
||||
.unwrap_or_else(|_| "pnputil.exe".to_string());
|
||||
std::process::Command::new(&pnputil)
|
||||
.arg("/enum-drivers")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_ascii_lowercase())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
static SPAWN: std::sync::Once = std::sync::Once::new();
|
||||
SPAWN.call_once(|| {
|
||||
std::thread::spawn(|| {
|
||||
// Resolve pnputil by full System32 path — the host runs as SYSTEM and must not trust
|
||||
// PATH / the CreateProcess search (which checks the launching EXE's own dir first), or
|
||||
// a planted `pnputil.exe` beside the host binary would run elevated (security-review
|
||||
// 2026-07-17).
|
||||
let pnputil = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\pnputil.exe"))
|
||||
.unwrap_or_else(|_| "pnputil.exe".to_string());
|
||||
let inv = std::process::Command::new(&pnputil)
|
||||
.arg("/enum-drivers")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let _ = INV.set(inv);
|
||||
});
|
||||
});
|
||||
let deadline = Instant::now() + INVENTORY_WAIT;
|
||||
loop {
|
||||
if let Some(inv) = INV.get() {
|
||||
return Some(inv);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the driver store holds `inf` (e.g. `pf_xusb.inf`). `None` = pnputil unavailable/failed.
|
||||
/// Whether the driver store holds `inf` (e.g. `pf_xusb.inf`). `None` = pnputil unavailable,
|
||||
/// failed, or still enumerating.
|
||||
fn driver_store_has(inf: &str) -> Option<bool> {
|
||||
let inv = driver_store_inventory();
|
||||
let inv = driver_store_inventory()?;
|
||||
if inv.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,9 @@ impl DeckWinPad {
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
// Ring capability (v2.1), stamped before the magic so the driver sees it on attach.
|
||||
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1);
|
||||
// Ring capability `2` = "this host drains the v2.2 long ring", stamped before the
|
||||
// magic so the driver sees it on attach (see the DualSense open path + PadShm docs).
|
||||
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
|
||||
std::ptr::write_unaligned(
|
||||
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
|
||||
neutral_deck_report(),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "pf-update"
|
||||
description = "Root helper for web-console-triggered host updates: runs the distro package manager against the punktfunk packages, gates on the new binary actually running, and reports a result record. Deliberately tiny — root runs these few hundred lines, never the host's dependency tree."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "pf-update"
|
||||
path = "src/main.rs"
|
||||
|
||||
# The zero-parameter invariant lives in the DEPENDENCY LIST too: no HTTP client, no TLS, no
|
||||
# argument parsing beyond one subcommand — the helper fetches nothing and parses no remote
|
||||
# data; the package manager's own signature chain gates every payload.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,327 @@
|
||||
//! `pf-update` — the root helper behind web-console-triggered Linux host updates
|
||||
//! (planning: `host-update-from-web-console.md` §7, plan U2.1).
|
||||
//!
|
||||
//! Invoked as `pf-update apply`, normally via the `punktfunk-update.service` oneshot that a
|
||||
//! `punktfunk-update`-group member may start through polkit. **It takes zero
|
||||
//! attacker-influenceable parameters**: no versions, no URLs, no package names from the
|
||||
//! caller — the install kind comes from root-owned markers, the package list from the local
|
||||
//! package database, and every payload from the distro package manager's own signed
|
||||
//! repositories. Compromising the trigger yields "run the system's normal update for the
|
||||
//! punktfunk packages", nothing more.
|
||||
//!
|
||||
//! After a successful package-manager run, the **run-the-binary gate** executes the newly
|
||||
//! installed `/usr/bin/punktfunk-host --version` and requires it to exit cleanly — the
|
||||
//! CI-green-on-the-wrong-program class (the 0.22.0 clobber) dies here for one binary run's
|
||||
//! worth of cost. The outcome is written to `/var/lib/punktfunk/update-result.json`
|
||||
//! (root-written, world-readable) for the unprivileged host to read; stdout/stderr land in
|
||||
//! the unit's journal.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_main {
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const MARKER: &str = "/usr/share/punktfunk/install-kind";
|
||||
const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk";
|
||||
const OSTREE_BOOTED: &str = "/run/ostree-booted";
|
||||
const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf";
|
||||
const RESULT_PATH: &str = "/var/lib/punktfunk/update-result.json";
|
||||
const HOST_BIN: &str = "/usr/bin/punktfunk-host";
|
||||
|
||||
/// What the host reads back. Field meanings mirror the mgmt API's `UpdateResultInfo`
|
||||
/// where they overlap; `changed=false` is the "your package source has nothing newer
|
||||
/// yet" case (not an error), `staged=true` means a reboot finishes the update
|
||||
/// (rpm-ostree).
|
||||
#[derive(Serialize)]
|
||||
struct HelperResult {
|
||||
ok: bool,
|
||||
kind: String,
|
||||
before_version: String,
|
||||
after_version: String,
|
||||
changed: bool,
|
||||
staged: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
finished_unix: u64,
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Root-owned facts → the apply strategy. Mirrors the host's ladder for the kinds a
|
||||
/// root helper serves (the helper decides for ITSELF — never trusts its caller).
|
||||
fn detect_kind() -> Result<&'static str, String> {
|
||||
if Path::new(SYSEXT_MARKER).exists() {
|
||||
return Ok("sysext");
|
||||
}
|
||||
let marker = std::fs::read_to_string(MARKER)
|
||||
.map_err(|e| format!("no install-kind marker at {MARKER}: {e}"))?;
|
||||
match marker.split_whitespace().next() {
|
||||
Some("apt") => Ok("apt"),
|
||||
Some("dnf") if Path::new(OSTREE_BOOTED).exists() => Ok("rpm-ostree"),
|
||||
Some("dnf") => Ok("dnf"),
|
||||
Some("pacman") => Ok("pacman"),
|
||||
other => Err(format!(
|
||||
"install-kind marker says {other:?} — no root apply leg for it"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cmd: &mut Command, what: &str) -> Result<(), String> {
|
||||
println!("pf-update: running {cmd:?}");
|
||||
let status = cmd
|
||||
.status()
|
||||
.map_err(|e| format!("{what}: failed to launch: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("{what}: exited {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_capture(cmd: &mut Command, what: &str) -> Result<String, String> {
|
||||
let out = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("{what}: failed to launch: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("{what}: exited {}", out.status));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// The installed punktfunk packages, from the LOCAL package database — upgrade exactly
|
||||
/// what this box has (host-only installs don't grow a web console out of nowhere).
|
||||
fn installed_packages(query: &mut Command, what: &str) -> Result<Vec<String>, String> {
|
||||
let out = run_capture(query, what)?;
|
||||
let pkgs: Vec<String> = out
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| l.starts_with("punktfunk"))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
if pkgs.is_empty() {
|
||||
return Err(format!("{what}: no installed punktfunk packages found"));
|
||||
}
|
||||
Ok(pkgs)
|
||||
}
|
||||
|
||||
fn host_version() -> Result<String, String> {
|
||||
run_capture(
|
||||
Command::new(HOST_BIN).arg("--version"),
|
||||
"punktfunk-host --version",
|
||||
)
|
||||
}
|
||||
|
||||
/// The per-kind command tables (design §5). Returns `staged` (activation needs a reboot).
|
||||
fn apply_for_kind(kind: &str) -> Result<bool, String> {
|
||||
match kind {
|
||||
"apt" => {
|
||||
// Refresh only OUR index when the documented list file exists (S5);
|
||||
// otherwise a full refresh — normal admin behavior, just slower.
|
||||
let ours = "/etc/apt/sources.list.d/punktfunk.list";
|
||||
let mut update = Command::new("apt-get");
|
||||
update.env("DEBIAN_FRONTEND", "noninteractive");
|
||||
if Path::new(ours).exists() {
|
||||
update.args([
|
||||
"update",
|
||||
"-o",
|
||||
&format!("Dir::Etc::sourcelist={ours}"),
|
||||
"-o",
|
||||
"Dir::Etc::sourceparts=-",
|
||||
]);
|
||||
} else {
|
||||
update.arg("update");
|
||||
}
|
||||
run(&mut update, "apt-get update")?;
|
||||
let pkgs = installed_packages(
|
||||
Command::new("dpkg-query").args(["-W", "-f", "${Package}\n", "punktfunk*"]),
|
||||
"dpkg-query",
|
||||
)?;
|
||||
let mut install = Command::new("apt-get");
|
||||
install
|
||||
.env("DEBIAN_FRONTEND", "noninteractive")
|
||||
.args(["install", "--only-upgrade", "-y"])
|
||||
.args(&pkgs);
|
||||
run(&mut install, "apt-get install --only-upgrade")?;
|
||||
Ok(false)
|
||||
}
|
||||
"dnf" => {
|
||||
let pkgs = installed_packages(
|
||||
Command::new("rpm").args(["-qa", "--qf", "%{NAME}\n", "punktfunk*"]),
|
||||
"rpm -qa",
|
||||
)?;
|
||||
let mut upgrade = Command::new("dnf");
|
||||
upgrade.args(["-y", "upgrade"]).args(&pkgs);
|
||||
run(&mut upgrade, "dnf upgrade")?;
|
||||
Ok(false)
|
||||
}
|
||||
"rpm-ostree" => {
|
||||
// A layered package only re-resolves when forced — the single-transaction
|
||||
// uninstall+install dance (packaging/bazzite/update-punktfunk.sh). Staged;
|
||||
// a reboot activates it.
|
||||
let pkgs = installed_packages(
|
||||
Command::new("rpm").args(["-qa", "--qf", "%{NAME}\n", "punktfunk*"]),
|
||||
"rpm -qa",
|
||||
)?;
|
||||
run(
|
||||
Command::new("rpm-ostree").args(["refresh-md", "--force"]),
|
||||
"rpm-ostree refresh-md",
|
||||
)?;
|
||||
let mut update = Command::new("rpm-ostree");
|
||||
update.arg("update");
|
||||
for p in &pkgs {
|
||||
update.args(["--uninstall", p, "--install", p]);
|
||||
}
|
||||
run(&mut update, "rpm-ostree update (re-resolve)")?;
|
||||
Ok(true)
|
||||
}
|
||||
"sysext" => {
|
||||
// The proven signed-feed updater; it refreshes the merged /usr in place.
|
||||
run(
|
||||
Command::new("punktfunk-sysext").arg("update"),
|
||||
"punktfunk-sysext update",
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
"pacman" => {
|
||||
// Arch doctrine: partial upgrades break boxes, so the ONLY thing this
|
||||
// helper will run is a full -Syu — and only when the operator opted into
|
||||
// that explicitly (root-owned config, not the API).
|
||||
let optin = std::fs::read_to_string(PACMAN_OPTIN_CONF)
|
||||
.ok()
|
||||
.map(|c| c.lines().any(|l| l.trim() == "PACMAN_FULL_SYSUPGRADE=1"))
|
||||
.unwrap_or(false);
|
||||
if !optin {
|
||||
return Err(format!(
|
||||
"pacman full-sysupgrade is not opted in — set PACMAN_FULL_SYSUPGRADE=1 \
|
||||
in {PACMAN_OPTIN_CONF} (this runs `pacman -Syu` for the WHOLE system)"
|
||||
));
|
||||
}
|
||||
run(
|
||||
Command::new("pacman").args(["-Syu", "--noconfirm"]),
|
||||
"pacman -Syu",
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
other => Err(format!("no apply leg for install kind {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_result(result: &HelperResult) {
|
||||
let path = Path::new(RESULT_PATH);
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(result) {
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
let arg = std::env::args().nth(1).unwrap_or_default();
|
||||
if arg != "apply" {
|
||||
eprintln!("usage: pf-update apply (normally via punktfunk-update.service)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// Effective root is required for every leg; refuse early with a clear message
|
||||
// rather than half-running.
|
||||
// SAFETY: geteuid has no preconditions.
|
||||
if unsafe { libc_geteuid() } != 0 {
|
||||
eprintln!("pf-update: must run as root (start punktfunk-update.service)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let kind = match detect_kind() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
eprintln!("pf-update: {e}");
|
||||
write_result(&HelperResult {
|
||||
ok: false,
|
||||
kind: "unknown".into(),
|
||||
before_version: String::new(),
|
||||
after_version: String::new(),
|
||||
changed: false,
|
||||
staged: false,
|
||||
error: Some(e),
|
||||
finished_unix: now_unix(),
|
||||
});
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
println!("pf-update: install kind {kind}");
|
||||
let before = host_version().unwrap_or_default();
|
||||
|
||||
let outcome = apply_for_kind(kind).and_then(|staged| {
|
||||
// The run-the-binary gate: the freshly installed binary must actually run.
|
||||
// Skipped for staged (rpm-ostree) — the new binary isn't in /usr until reboot.
|
||||
let after = if staged {
|
||||
before.clone()
|
||||
} else {
|
||||
host_version()
|
||||
.map_err(|e| format!("run-the-binary gate: {e} — the update did NOT stick"))?
|
||||
};
|
||||
Ok((staged, after))
|
||||
});
|
||||
|
||||
let result = match outcome {
|
||||
Ok((staged, after)) => HelperResult {
|
||||
ok: true,
|
||||
kind: kind.into(),
|
||||
changed: staged || after != before,
|
||||
staged,
|
||||
before_version: before,
|
||||
after_version: after,
|
||||
error: None,
|
||||
finished_unix: now_unix(),
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("pf-update: {e}");
|
||||
HelperResult {
|
||||
ok: false,
|
||||
kind: kind.into(),
|
||||
before_version: before.clone(),
|
||||
after_version: before,
|
||||
changed: false,
|
||||
staged: false,
|
||||
error: Some(e),
|
||||
finished_unix: now_unix(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let ok = result.ok;
|
||||
write_result(&result);
|
||||
println!(
|
||||
"pf-update: {} ({} -> {}, changed: {}, staged: {})",
|
||||
if ok { "ok" } else { "FAILED" },
|
||||
result.before_version,
|
||||
result.after_version,
|
||||
result.changed,
|
||||
result.staged,
|
||||
);
|
||||
std::process::exit(if ok { 0 } else { 1 });
|
||||
}
|
||||
|
||||
// One libc symbol, declared directly — not worth a libc dependency in a root helper.
|
||||
extern "C" {
|
||||
#[link_name = "geteuid"]
|
||||
fn libc_geteuid() -> u32;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
linux_main::main();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
eprintln!("pf-update is a Linux-only root helper");
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -1757,6 +1757,14 @@ pub fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
|
||||
Some(saved)
|
||||
}
|
||||
|
||||
/// The dark-sink futility latch: `(target_id, monitor_device_path)` keys of connected external
|
||||
/// displays that stayed dark THROUGH a force-EXTEND. A sink the EXTEND preset demonstrably
|
||||
/// cannot light (an off/standby TV, a KVM switched away) will not light on the next teardown
|
||||
/// either — without the latch [`restore_displays_ccd`]'s backstop re-warns and re-forces on
|
||||
/// EVERY teardown, forever (field: the off-TV box). Process-lifetime on purpose: a host restart
|
||||
/// re-probes once, in case the sink meanwhile became lightable.
|
||||
static DARK_SINKS_FUTILE: std::sync::Mutex<Vec<(u32, String)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// Restore the topology saved by [`isolate_displays_ccd`] (teardown, before the virtual output is
|
||||
/// removed), re-activating the displays we deactivated.
|
||||
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
|
||||
@@ -1792,15 +1800,41 @@ pub fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
// connected, fall back to the OS database EXTEND preset, which re-activates every connected
|
||||
// display. Internal panels deliberately don't count as lit-able here — a closed clamshell
|
||||
// lid must not be forced back on.
|
||||
let (connected, lit) = target_inventory()
|
||||
let inventory = target_inventory();
|
||||
let (connected, lit) = inventory
|
||||
.iter()
|
||||
.filter(|t| t.external_physical)
|
||||
.fold((0u32, 0u32), |(c, a), t| (c + 1, a + u32::from(t.active)));
|
||||
if connected > 0 && lit == 0 {
|
||||
let dark: Vec<(u32, String)> = inventory
|
||||
.iter()
|
||||
.filter(|t| t.external_physical && !t.active)
|
||||
.map(|t| (t.target_id, t.monitor_device_path.clone()))
|
||||
.collect();
|
||||
// The futility latch: if this exact dark set already survived a force-EXTEND, the sink
|
||||
// cannot light (off/standby TV) — re-warning and re-forcing every teardown is noise
|
||||
// that reads like a new failure in every field log. The poisoned-snapshot chain the
|
||||
// backstop exists for is NOT latched away: there the panel CAN light, so the first
|
||||
// force succeeds and the latch stays clear.
|
||||
if *DARK_SINKS_FUTILE.lock().unwrap() == dark {
|
||||
tracing::debug!(
|
||||
connected,
|
||||
"display isolate (CCD): the connected external display(s) stayed dark through \
|
||||
an earlier force-EXTEND — an unlightable sink (off/standby TV), not a failed \
|
||||
restore; leaving the topology be"
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||
);
|
||||
force_extend_topology();
|
||||
// Measure what the force achieved: a sink still dark AFTER the EXTEND preset can never
|
||||
// be lit by it, so remember the set and stop re-forcing. Anything lit clears the latch.
|
||||
let lit_after = target_inventory()
|
||||
.iter()
|
||||
.any(|t| t.external_physical && t.active);
|
||||
*DARK_SINKS_FUTILE.lock().unwrap() = if lit_after { Vec::new() } else { dark };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4013,6 +4013,39 @@ pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
|
||||
})
|
||||
}
|
||||
|
||||
/// Report this client's display-latch grid so the host can phase-lock its capture tick
|
||||
/// (design/phase-locked-capture.md). `next_latch_host_ns` must already be host clock — convert
|
||||
/// with the connection's clock offset (`T_host = T_client + offset`). Fire-and-forget; call ~1 Hz
|
||||
/// from a vsync-aware presenter. No-op toward a host that never negotiated the capability.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is an opaque handle from a `*_new`/`*_pair` the caller has not yet freed, or null (an
|
||||
/// error, not UB).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_report_phase(
|
||||
c: *const PunktfunkConnection,
|
||||
next_latch_host_ns: u64,
|
||||
latch_period_ns: u32,
|
||||
uncertainty_ns: u32,
|
||||
arrival_lead_ns: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
c.inner.report_phase(
|
||||
next_latch_host_ns,
|
||||
latch_period_ns,
|
||||
uncertainty_ns,
|
||||
arrival_lead_ns,
|
||||
);
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether [`punktfunk_connection_report_decode_us`] is worth calling this session: writes 1 to
|
||||
/// `out` only when the adaptive-bitrate controller is armed (Automatic bitrate, non-PyroWave), so a
|
||||
/// client can skip the per-frame decode-latency measurement entirely for explicit-bitrate and
|
||||
|
||||
@@ -70,6 +70,17 @@ const CHANGE_COOLDOWN: Duration = Duration::from_millis(1500);
|
||||
/// Window shard loss beyond which the window counts bad even without an unrecoverable frame:
|
||||
/// 2 % sustained is congestion territory, not the random tail FEC exists for.
|
||||
const HEAVY_LOSS_PPM: u32 = 20_000;
|
||||
/// Decode-recovery KEYFRAME asks in one window at/above which the window is bad: the decoder
|
||||
/// asked for a fresh picture twice inside 750 ms — it is being overdriven (or repeatedly
|
||||
/// wedged), whatever loss_ppm says. This is the signal the RX-9070 field trace exposed: 14
|
||||
/// requests in 2 s at ~300 Mbps with ZERO loss, and the controller kept the rate because no
|
||||
/// loss/OWD/latency signal moved. RFI asks are deliberately NOT counted — they are the routine
|
||||
/// loss-recovery mechanism and loss_ppm already prices them in.
|
||||
const RECOVERY_KF_BAD: u32 = 2;
|
||||
/// One window at/above this many keyframe asks is SEVERE (skips the two-window confirmation):
|
||||
/// the emitters throttle at 100 ms, so 4+ inside a window means the decoder spent most of it
|
||||
/// unable to produce pictures — the user is already watching the damage.
|
||||
const RECOVERY_KF_SEVERE: u32 = 4;
|
||||
/// How far the window's mean one-way delay may sit above the rolling baseline before it counts
|
||||
/// as queue growth. 25 ms is far beyond jitter at any streamable frame rate.
|
||||
const OWD_RISE_US: i64 = 25_000;
|
||||
@@ -281,7 +292,8 @@ impl BitrateController {
|
||||
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
|
||||
/// carried, as opposed to the target it was allowed; feeds the utilization climb gate and
|
||||
/// the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in the
|
||||
/// window.
|
||||
/// window, `recovery_kf` = decode-recovery keyframe asks the client sent in the window (see
|
||||
/// [`RECOVERY_KF_BAD`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn on_window(
|
||||
&mut self,
|
||||
@@ -293,6 +305,7 @@ impl BitrateController {
|
||||
encode_mean_us: Option<i64>,
|
||||
actual_kbps: u32,
|
||||
flushed: bool,
|
||||
recovery_kf: u32,
|
||||
) -> Option<u32> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
@@ -366,12 +379,22 @@ impl BitrateController {
|
||||
self.proven_kbps = actual_kbps;
|
||||
}
|
||||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
|
||||
// deep decode-latency excursion) or loss far past any blip — one window is enough.
|
||||
// Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise)
|
||||
// still needs two consecutive windows.
|
||||
let severe =
|
||||
dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe || encode_severe;
|
||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad || encode_bad;
|
||||
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
|
||||
// any blip — one window is enough. Ordinary congestion (heavy-but-recoverable loss, an
|
||||
// OWD rise, a decode-latency rise, repeated keyframe asks) still needs two consecutive
|
||||
// windows.
|
||||
let severe = dropped > 0
|
||||
|| flushed
|
||||
|| loss_ppm >= SEVERE_LOSS_PPM
|
||||
|| decode_severe
|
||||
|| encode_severe
|
||||
|| recovery_kf >= RECOVERY_KF_SEVERE;
|
||||
let bad = severe
|
||||
|| loss_ppm >= HEAVY_LOSS_PPM
|
||||
|| owd_bad
|
||||
|| decode_bad
|
||||
|| encode_bad
|
||||
|| recovery_kf >= RECOVERY_KF_BAD;
|
||||
if bad {
|
||||
self.bad_windows += 1;
|
||||
self.clean_windows = 0;
|
||||
@@ -485,6 +508,7 @@ mod tests {
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
if out.is_some() {
|
||||
return out;
|
||||
@@ -499,7 +523,17 @@ mod tests {
|
||||
let mut c = BitrateController::new(0);
|
||||
let now = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(now, 5, 900_000, Some(500_000), None, None, 1_000_000, true),
|
||||
c.on_window(
|
||||
now,
|
||||
5,
|
||||
900_000,
|
||||
Some(500_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
true,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -518,7 +552,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -532,7 +567,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
@@ -547,7 +583,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
); // bad #1 again
|
||||
@@ -560,7 +597,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(9_800)
|
||||
);
|
||||
@@ -572,13 +610,13 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
|
||||
Some(14_000)
|
||||
);
|
||||
// …and so does a jump-to-live flush.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true),
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true, 0),
|
||||
Some(14_000)
|
||||
);
|
||||
// …and ≥6 % window loss.
|
||||
@@ -592,7 +630,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
@@ -603,18 +642,18 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
||||
// boundary (tick 2 = 1.5 s) it fires.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false, 0),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false, 0),
|
||||
Some(9_800)
|
||||
);
|
||||
}
|
||||
@@ -625,17 +664,17 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
|
||||
Some(5_000)
|
||||
);
|
||||
c.on_ack(5_000);
|
||||
// At the floor, further bad windows request nothing.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false, 0),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false, 0),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -645,7 +684,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
@@ -677,6 +716,7 @@ mod tests {
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
got.push(k);
|
||||
@@ -699,7 +739,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(40_000)
|
||||
);
|
||||
@@ -714,7 +755,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(28_000)
|
||||
);
|
||||
@@ -731,6 +773,7 @@ mod tests {
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
if next.is_some() {
|
||||
assert!(i >= 8, "additive climb must wait for the clean run");
|
||||
@@ -745,7 +788,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(0);
|
||||
c.set_ceiling(1_000_000);
|
||||
assert_eq!(
|
||||
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false),
|
||||
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false, 0),
|
||||
None
|
||||
);
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -768,7 +811,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -783,7 +827,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -796,7 +841,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
@@ -819,7 +865,8 @@ mod tests {
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -835,7 +882,8 @@ mod tests {
|
||||
Some(38_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -848,12 +896,96 @@ mod tests {
|
||||
Some(40_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_ask_storm_alone_is_a_congestion_signal() {
|
||||
// The RX-9070 field shape: pristine link (zero loss, flat OWD), no latency signal — but
|
||||
// the decoder keeps begging for keyframes. Two asks per window is ordinary-bad: two
|
||||
// consecutive windows back off ×0.7.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
2
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 1),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
2
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_ask_saturation_is_severe() {
|
||||
// The emitters throttle at 100 ms, so 4+ asks in one 750 ms window means the decoder
|
||||
// spent most of it unable to produce pictures — one window is enough.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
4
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_keyframe_ask_is_not_congestion() {
|
||||
// A lone hiccup's recovery ask must not read as congestion — windows carrying one ask
|
||||
// stay clean (no backoff however many in a row).
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
1
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_latency_caps_the_slow_start_climb() {
|
||||
// A fat link (probe measured ~300 Mbps) but a decoder that saturates around the start rate.
|
||||
@@ -870,7 +1002,8 @@ mod tests {
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(40_000)
|
||||
);
|
||||
@@ -886,7 +1019,8 @@ mod tests {
|
||||
Some(38_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -901,7 +1035,8 @@ mod tests {
|
||||
Some(40_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(28_000)
|
||||
);
|
||||
@@ -925,7 +1060,8 @@ mod tests {
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -942,7 +1078,8 @@ mod tests {
|
||||
Some(8_000),
|
||||
None,
|
||||
18_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(27_000)
|
||||
);
|
||||
@@ -966,7 +1103,8 @@ mod tests {
|
||||
Some(8_000),
|
||||
None,
|
||||
20_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(30_000)
|
||||
);
|
||||
@@ -981,7 +1119,8 @@ mod tests {
|
||||
Some(8_000),
|
||||
None,
|
||||
30_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(45_000)
|
||||
);
|
||||
@@ -1004,7 +1143,8 @@ mod tests {
|
||||
Some(8_000),
|
||||
None,
|
||||
20_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(30_000)
|
||||
);
|
||||
@@ -1020,7 +1160,8 @@ mod tests {
|
||||
Some(4_000),
|
||||
None,
|
||||
600,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -1043,7 +1184,8 @@ mod tests {
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -1059,7 +1201,8 @@ mod tests {
|
||||
Some(60_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
@@ -1141,6 +1284,7 @@ mod tests {
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
|
||||
@@ -1163,7 +1307,8 @@ mod tests {
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -1177,7 +1322,8 @@ mod tests {
|
||||
None,
|
||||
Some(11_500),
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -1190,7 +1336,8 @@ mod tests {
|
||||
None,
|
||||
Some(12_000),
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
@@ -1212,7 +1359,8 @@ mod tests {
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -1226,7 +1374,8 @@ mod tests {
|
||||
None,
|
||||
Some(20_000),
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
@@ -1249,6 +1398,7 @@ mod tests {
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
let _ = c.on_window(
|
||||
@@ -1260,6 +1410,7 @@ mod tests {
|
||||
Some(12_000),
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
@@ -1270,7 +1421,8 @@ mod tests {
|
||||
None,
|
||||
Some(12_500),
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
@@ -1287,7 +1439,8 @@ mod tests {
|
||||
None,
|
||||
Some(15_000),
|
||||
1_000_000,
|
||||
false
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -1302,7 +1455,7 @@ mod tests {
|
||||
let mut i = 0;
|
||||
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
|
||||
while i < 60 {
|
||||
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false)
|
||||
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false, 0)
|
||||
.is_some()
|
||||
{
|
||||
sent += 1;
|
||||
|
||||
@@ -32,6 +32,10 @@ pub(crate) enum CtrlRequest {
|
||||
/// desktop mouse model — host excludes + forwards), `false` = host composites into the
|
||||
/// video (the capture model). Sent on every mouse-model flip; idempotent, latest-wins.
|
||||
CursorRender(crate::quic::CursorRenderMode),
|
||||
/// The client's display-latch grid, ~1 Hz, host-clock timestamps — feeds the host's
|
||||
/// phase-locked capture (design/phase-locked-capture.md). Latest-wins; an old host
|
||||
/// ignores the message.
|
||||
Phase(crate::quic::PhaseReport),
|
||||
}
|
||||
|
||||
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
|
||||
|
||||
@@ -774,6 +774,30 @@ impl NativeClient {
|
||||
self.wants_decode
|
||||
}
|
||||
|
||||
/// Report this client's display-latch grid so the host can phase-lock its capture tick
|
||||
/// (design/phase-locked-capture.md; the vsync-aware presenters call this ~1 Hz).
|
||||
/// `next_latch_host_ns` must already be HOST clock — convert with
|
||||
/// [`clock_offset_now_ns`](Self::clock_offset_now_ns) (`T_host = T_client + offset`) before
|
||||
/// calling; the offset lives only on this side. Fire-and-forget: dropped silently if the
|
||||
/// control task's queue is momentarily full (the next report supersedes) or toward a host
|
||||
/// that never negotiated the capability.
|
||||
pub fn report_phase(
|
||||
&self,
|
||||
next_latch_host_ns: u64,
|
||||
latch_period_ns: u32,
|
||||
uncertainty_ns: u32,
|
||||
arrival_lead_ns: u32,
|
||||
) {
|
||||
let _ = self
|
||||
.ctrl_tx
|
||||
.try_send(CtrlRequest::Phase(crate::quic::PhaseReport {
|
||||
next_latch_host_ns,
|
||||
latch_period_ns,
|
||||
uncertainty_ns,
|
||||
arrival_lead_ns,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
||||
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
||||
|
||||
@@ -112,6 +112,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
|
||||
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
|
||||
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
|
||||
// Decode-recovery keyframe asks (the ABR recovery signal): the control task counts every
|
||||
// outbound `CtrlRequest::Keyframe` — the one choke point all emitters funnel through — and
|
||||
// the pump drains the count per report window.
|
||||
let recovery_kf = Arc::new(AtomicU32::new(0));
|
||||
// Host-encode-latency accumulator (the ABR encode signal, see [`EncodeLatAcc`]): the
|
||||
// datagram task adds one sample per 0xCF; the pump drains a window mean per report tick.
|
||||
let encode_lat = Arc::new(Mutex::new(super::frame_channel::EncodeLatAcc::default()));
|
||||
@@ -130,6 +134,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
mode_slot,
|
||||
probe: probe.clone(),
|
||||
bitrate_ack: bitrate_ack.clone(),
|
||||
recovery_kf: recovery_kf.clone(),
|
||||
clock_offset: clock_offset.clone(),
|
||||
clock_gen: clock_gen.clone(),
|
||||
clip_event_tx: clip_event_tx.clone(),
|
||||
@@ -189,6 +194,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
recovery_kf,
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
|
||||
@@ -16,6 +16,10 @@ pub(super) struct ControlTask {
|
||||
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
/// Outbound decode-recovery KEYFRAME asks, counted here because this is the one choke point
|
||||
/// every emitter funnels through (embedder, `note_frame_index`, the pump's own asks) — the
|
||||
/// pump drains the count per report window as the ABR's recovery signal.
|
||||
pub(super) recovery_kf: Arc<AtomicU32>,
|
||||
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
||||
pub(super) clock_gen: Arc<AtomicU32>,
|
||||
/// Clipboard metadata events (ClipState/ClipOffer) feed the same event plane the
|
||||
@@ -40,6 +44,7 @@ impl ControlTask {
|
||||
mode_slot,
|
||||
probe,
|
||||
bitrate_ack,
|
||||
recovery_kf,
|
||||
clock_offset,
|
||||
clock_gen,
|
||||
clip_event_tx,
|
||||
@@ -72,7 +77,10 @@ impl ControlTask {
|
||||
let bytes = match req {
|
||||
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
|
||||
CtrlRequest::Probe(p) => p.encode(),
|
||||
CtrlRequest::Keyframe => RequestKeyframe.encode(),
|
||||
CtrlRequest::Keyframe => {
|
||||
recovery_kf.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
RequestKeyframe.encode()
|
||||
}
|
||||
CtrlRequest::Rfi(r) => r.encode(),
|
||||
CtrlRequest::Loss(r) => r.encode(),
|
||||
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||
@@ -86,6 +94,7 @@ impl ControlTask {
|
||||
CtrlRequest::ClipControl(c) => c.encode(),
|
||||
CtrlRequest::ClipOffer(o) => o.encode(),
|
||||
CtrlRequest::CursorRender(m) => m.encode(),
|
||||
CtrlRequest::Phase(p) => p.encode(),
|
||||
};
|
||||
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||
break;
|
||||
|
||||
@@ -28,6 +28,9 @@ pub(super) struct DataPump {
|
||||
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
/// Outbound decode-recovery keyframe asks, counted by the control task at its send choke
|
||||
/// point; drained per report window as the ABR's recovery signal.
|
||||
pub(super) recovery_kf: Arc<AtomicU32>,
|
||||
/// The embedder's REQUESTED rate (0 = Automatic — the only case the ABR arms).
|
||||
pub(super) bitrate_kbps: u32,
|
||||
/// The rate the host actually configured (echoed in Welcome).
|
||||
@@ -52,6 +55,7 @@ impl DataPump {
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
recovery_kf: pump_recovery_kf,
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
@@ -103,7 +107,18 @@ impl DataPump {
|
||||
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
|
||||
// default): the target is deliberately far above any plausible link so the burst measures
|
||||
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
|
||||
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
|
||||
// timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
|
||||
// embedder that caps its own speed test wants this capped to match.
|
||||
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(2_000_000);
|
||||
const CAPACITY_PROBE_MS: u32 = 800;
|
||||
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
@@ -245,14 +260,14 @@ impl DataPump {
|
||||
};
|
||||
if ctrl_tx
|
||||
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||
target_kbps: CAPACITY_PROBE_KBPS,
|
||||
target_kbps: capacity_probe_kbps,
|
||||
duration_ms: CAPACITY_PROBE_MS,
|
||||
}))
|
||||
.is_ok()
|
||||
{
|
||||
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
|
||||
tracing::info!(
|
||||
target_kbps = CAPACITY_PROBE_KBPS,
|
||||
target_kbps = capacity_probe_kbps,
|
||||
duration_ms = CAPACITY_PROBE_MS,
|
||||
"adaptive bitrate: startup link-capacity probe"
|
||||
);
|
||||
@@ -414,6 +429,10 @@ impl DataPump {
|
||||
*acc = Default::default();
|
||||
(count > 0).then(|| (sum / count as u64) as i64)
|
||||
};
|
||||
// Decode-recovery keyframe asks this window (counted at the control task's send
|
||||
// choke point). Always drained so a discard window can't leak its count into
|
||||
// the next one.
|
||||
let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed);
|
||||
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||
@@ -435,6 +454,7 @@ impl DataPump {
|
||||
encode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
recovery_kf_reqs,
|
||||
)
|
||||
};
|
||||
if let Some(kbps) = verdict {
|
||||
@@ -449,6 +469,7 @@ impl DataPump {
|
||||
encode_mean_us = encode_mean_us.unwrap_or(-1),
|
||||
actual_kbps,
|
||||
flushed = flush_in_window,
|
||||
recovery_kf = recovery_kf_reqs,
|
||||
"adaptive bitrate: requesting encoder re-target"
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
|
||||
|
||||
@@ -136,6 +136,9 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
// video frame indexes. STREAMED_AU the same way: the shared reassembler accepts
|
||||
// sentinel-headed streamed blocks (retro-validated at the final block), so the host
|
||||
// may overlap a multi-slice encode's tail with packetize/FEC/pacing.
|
||||
// VIDEO_CAP_MULTI_SLICE must NOT join this unconditional set: it is DECODER
|
||||
// truth (Amlogic MediaCodec wedges on multi-slice AUs — the 0.17.0 Chromecast
|
||||
// regression), so only the embedder can set it, per its decode stack.
|
||||
video_caps: video_caps
|
||||
| crate::quic::VIDEO_CAP_HOST_TIMING
|
||||
| crate::quic::VIDEO_CAP_PROBE_SEQ
|
||||
|
||||
@@ -55,6 +55,19 @@ pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20;
|
||||
/// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
|
||||
/// control channel, so there is no downgrade surface.
|
||||
pub const VIDEO_CAP_CHACHA20: u8 = 0x40;
|
||||
/// [`Hello::video_caps`] bit: the client's decoder accepts **multi-slice access units** — H.264/
|
||||
/// HEVC frames carrying several slice NALs (latency plan §7 LN1: the encoder splits frames so
|
||||
/// sub-frame readback can ship early slices while the tail encodes). Decoder-level, so the
|
||||
/// EMBEDDER sets it from what its decode stack actually handles: the desktop clients' FFmpeg/
|
||||
/// D3D11VA/Vulkan-video decoders are fine, but mobile/TV MediaCodec is per-SoC — Amlogic HEVC
|
||||
/// decoders (Chromecast with Google TV, Fire TV) wedge the whole DEVICE on multi-slice frames
|
||||
/// (the 0.17.0 field regression: the 4-slice Linux default froze streams on first frame and
|
||||
/// watchdog-rebooted the CCwGTV), which is exactly why Moonlight requests 1 slice per frame for
|
||||
/// every hardware decoder. The host defaults to >1 slice ONLY toward a client that sets this
|
||||
/// bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions);
|
||||
/// every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the
|
||||
/// video_caps byte's last free bit — the next video cap needs a second byte (ABI bump).
|
||||
pub const VIDEO_CAP_MULTI_SLICE: u8 = 0x80;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||
@@ -91,6 +104,13 @@ pub const HOST_CAP_TEXT_INPUT: u8 = 0x04;
|
||||
/// an older or incapable host nothing changes.
|
||||
pub const CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
|
||||
/// `Hello.client_caps` bit: this client runs a vsync-aware presenter and will send
|
||||
/// [`PhaseReport`](super::control::PhaseReport)s (~1 Hz) so the host can phase-lock its
|
||||
/// capture/send tick to the client's display latch (design/phase-locked-capture.md). Without
|
||||
/// the bit the host never arms the phase controller; toward an older host the reports are
|
||||
/// simply ignored — no behavior change in either direction.
|
||||
pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
|
||||
|
||||
@@ -156,6 +156,30 @@ pub struct ClockEcho {
|
||||
pub t3_ns: u64,
|
||||
}
|
||||
|
||||
/// `client → host`, ~1 Hz: the client's display-latch grid, so the host can PHASE-LOCK its
|
||||
/// capture/send tick and land frames a constant, small margin before the client's vsync latch
|
||||
/// (design/phase-locked-capture.md). Sent only by clients with a vsync-aware presenter, gated on
|
||||
/// [`CLIENT_CAP_PHASE_LOCK`](crate::quic::CLIENT_CAP_PHASE_LOCK); an old host ignores it.
|
||||
///
|
||||
/// Timestamps are HOST clock (`CLOCK_REALTIME`): the client converts before sending
|
||||
/// (`T_host = T_client + offset` — the skew offset from the clock handshake lives only
|
||||
/// client-side, the host deliberately stores none).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PhaseReport {
|
||||
/// The client's next display latch, host clock. The host extrapolates forward by
|
||||
/// `latch_period_ns` — the report describes a grid, not one instant.
|
||||
pub next_latch_host_ns: u64,
|
||||
/// The client panel's refresh period (its true latch grid — not the app's possibly
|
||||
/// down-rated callback rate).
|
||||
pub latch_period_ns: u32,
|
||||
/// The client's own error bound on this grid (skew residual + latch jitter p95) — the host
|
||||
/// widens its arrival margin by this, never narrows below its floor.
|
||||
pub uncertainty_ns: u32,
|
||||
/// Measured median lead of frame ARRIVAL before latch over the last window (ns; clamped
|
||||
/// ≥ 0). The controller drives this toward its target lead — the error signal.
|
||||
pub arrival_lead_ns: u32,
|
||||
}
|
||||
|
||||
/// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||
pub const MSG_RECONFIGURE: u8 = 0x01;
|
||||
/// Type byte of [`Reconfigured`].
|
||||
@@ -178,6 +202,8 @@ pub const MSG_PROBE_RESULT: u8 = 0x21;
|
||||
pub const MSG_CLOCK_PROBE: u8 = 0x30;
|
||||
/// Type byte of [`ClockEcho`].
|
||||
pub const MSG_CLOCK_ECHO: u8 = 0x31;
|
||||
/// Type byte of [`PhaseReport`].
|
||||
pub const MSG_PHASE_REPORT: u8 = 0x32;
|
||||
|
||||
impl Reconfigure {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
@@ -461,6 +487,33 @@ impl ClockEcho {
|
||||
}
|
||||
}
|
||||
|
||||
impl PhaseReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] latch[5..13] period[13..17] uncertainty[17..21] lead[21..25]
|
||||
let mut b = Vec::with_capacity(25);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_PHASE_REPORT);
|
||||
b.extend_from_slice(&self.next_latch_host_ns.to_le_bytes());
|
||||
b.extend_from_slice(&self.latch_period_ns.to_le_bytes());
|
||||
b.extend_from_slice(&self.uncertainty_ns.to_le_bytes());
|
||||
b.extend_from_slice(&self.arrival_lead_ns.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<PhaseReport> {
|
||||
if b.len() != 25 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PHASE_REPORT {
|
||||
return Err(PunktfunkError::InvalidArg("bad PhaseReport"));
|
||||
}
|
||||
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||
Ok(PhaseReport {
|
||||
next_latch_host_ns: u64::from_le_bytes(b[5..13].try_into().unwrap()),
|
||||
latch_period_ns: u32at(13),
|
||||
uncertainty_ns: u32at(17),
|
||||
arrival_lead_ns: u32at(21),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame a message for the control stream: `u16 LE length || payload`.
|
||||
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(2 + payload.len());
|
||||
@@ -923,6 +976,21 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_report_roundtrip() {
|
||||
let pr = PhaseReport {
|
||||
next_latch_host_ns: 1_753_900_000_123_456_789,
|
||||
latch_period_ns: 8_333_333,
|
||||
uncertainty_ns: 900_000,
|
||||
arrival_lead_ns: 4_100_000,
|
||||
};
|
||||
assert_eq!(PhaseReport::decode(&pr.encode()).unwrap(), pr);
|
||||
// Wrong type byte (a ClockProbe) must not decode as a PhaseReport.
|
||||
assert!(PhaseReport::decode(&ClockProbe { t1_ns: 7 }.encode()).is_err());
|
||||
// Truncation must not decode.
|
||||
assert!(PhaseReport::decode(&pr.encode()[..24]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconfigure_roundtrip() {
|
||||
let rq = Reconfigure {
|
||||
|
||||
@@ -557,6 +557,11 @@ pub struct HostTiming {
|
||||
/// and takes the stage tail only when present), so no capability bit is needed in either
|
||||
/// direction: old client + new host reads the prefix, new client + old host gets `None`.
|
||||
pub stages: Option<HostStages>,
|
||||
/// Phase-lock ACK (design/phase-locked-capture.md): the capture-tick hold the host is
|
||||
/// currently applying, ns. Rides the same append-extensible tail (after the stages block):
|
||||
/// `None` from a pre-phase-lock host or one sending the shorter forms. The client's
|
||||
/// presenter compares it against its own requested correction to see the loop close.
|
||||
pub applied_phase_ns: Option<i32>,
|
||||
}
|
||||
|
||||
/// The extended 0xCF's per-stage split of [`HostTiming::host_us`], all µs against the same
|
||||
@@ -578,11 +583,15 @@ pub struct HostStages {
|
||||
const HOST_TIMING_LEN: usize = 1 + 8 + 4;
|
||||
/// Wire length with the [`HostStages`] tail appended: + 3 × u32 = 25 bytes.
|
||||
const HOST_TIMING_STAGES_LEN: usize = HOST_TIMING_LEN + 12;
|
||||
/// Wire length with the phase-lock ACK appended after the stages: + i32 = 29 bytes. The tail
|
||||
/// discipline holds: each form is a strict prefix of the next, so every reader takes what it
|
||||
/// knows and ignores the rest.
|
||||
const HOST_TIMING_PHASE_LEN: usize = HOST_TIMING_STAGES_LEN + 4;
|
||||
|
||||
/// Encode a [`HostTiming`] into a [`HOST_TIMING_MAGIC`] datagram (extended form when `stages`
|
||||
/// is set — an older client parses the prefix and ignores the tail).
|
||||
pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(HOST_TIMING_STAGES_LEN);
|
||||
let mut b = Vec::with_capacity(HOST_TIMING_PHASE_LEN);
|
||||
b.push(HOST_TIMING_MAGIC);
|
||||
b.extend_from_slice(&t.pts_ns.to_le_bytes());
|
||||
b.extend_from_slice(&t.host_us.to_le_bytes());
|
||||
@@ -590,6 +599,11 @@ pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
|
||||
b.extend_from_slice(&s.queue_us.to_le_bytes());
|
||||
b.extend_from_slice(&s.encode_us.to_le_bytes());
|
||||
b.extend_from_slice(&s.pace_us.to_le_bytes());
|
||||
// The phase ACK only ever rides AFTER a stages tail — a prefix-discipline wire can't
|
||||
// express "phase but no stages", and every host new enough to phase-lock sends stages.
|
||||
if let Some(p) = t.applied_phase_ns {
|
||||
b.extend_from_slice(&p.to_le_bytes());
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -606,10 +620,13 @@ pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
|
||||
encode_us: u32::from_le_bytes(b[17..21].try_into().unwrap()),
|
||||
pace_us: u32::from_le_bytes(b[21..25].try_into().unwrap()),
|
||||
});
|
||||
let applied_phase_ns = (b.len() >= HOST_TIMING_PHASE_LEN)
|
||||
.then(|| i32::from_le_bytes(b[25..29].try_into().unwrap()));
|
||||
Some(HostTiming {
|
||||
pts_ns: u64::from_le_bytes(b[1..9].try_into().unwrap()),
|
||||
host_us: u32::from_le_bytes(b[9..13].try_into().unwrap()),
|
||||
stages,
|
||||
applied_phase_ns,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -715,6 +732,7 @@ mod tests {
|
||||
pts_ns: 1_751_500_000_123_456_789, // a realistic 2026 CLOCK_REALTIME capture stamp
|
||||
host_us: 4_321,
|
||||
stages: None,
|
||||
applied_phase_ns: None,
|
||||
};
|
||||
let d = encode_host_timing_datagram(&t);
|
||||
assert_eq!(d[0], HOST_TIMING_MAGIC);
|
||||
@@ -754,6 +772,35 @@ mod tests {
|
||||
"partial stage tail ({n} B) must degrade to the legacy decode"
|
||||
);
|
||||
}
|
||||
|
||||
// Phase-ACK form (design/phase-locked-capture.md): strict-prefix discipline holds a
|
||||
// third time — 29 B roundtrips, 25..28 degrade to the stages form, the prefix is
|
||||
// byte-identical, and a phase without stages is unencodable by construction.
|
||||
let tp = HostTiming {
|
||||
applied_phase_ns: Some(-2_750_000),
|
||||
..ts
|
||||
};
|
||||
let dp = encode_host_timing_datagram(&tp);
|
||||
assert_eq!(dp.len(), 29);
|
||||
assert_eq!(&dp[..25], &ds[..25], "stages form is a strict prefix");
|
||||
assert_eq!(decode_host_timing_datagram(&dp), Some(tp));
|
||||
for n in 25..dp.len() {
|
||||
assert_eq!(
|
||||
decode_host_timing_datagram(&dp[..n]),
|
||||
Some(ts),
|
||||
"partial phase tail ({n} B) must degrade to the stages decode"
|
||||
);
|
||||
}
|
||||
let no_stages = HostTiming {
|
||||
stages: None,
|
||||
applied_phase_ns: Some(1),
|
||||
..t
|
||||
};
|
||||
assert_eq!(
|
||||
encode_host_timing_datagram(&no_stages).len(),
|
||||
13,
|
||||
"phase without stages must encode as the legacy form (prefix discipline)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -198,6 +198,14 @@ usbip-sim = { path = "vendor/usbip-sim" }
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
# WinVerifyTrust + WTHelper* — Authenticode verification of a downloaded host installer
|
||||
# before the update apply spawns it (update/windows.rs). The WTHelper accessors are gated
|
||||
# behind the Catalog+Sip features in windows-rs, hence the extra two.
|
||||
"Win32_Security_WinTrust",
|
||||
"Win32_Security_Cryptography_Catalog",
|
||||
"Win32_Security_Cryptography_Sip",
|
||||
# CERT_CONTEXT for the signing-leaf fingerprint pin (same file).
|
||||
"Win32_Security_Cryptography",
|
||||
# ConvertStringSecurityDescriptorToSecurityDescriptorW — the SDDL on the virtual-DualSense
|
||||
# shared-memory section (inject/dualsense_windows.rs) so the UMDF host can open it.
|
||||
"Win32_Security_Authorization",
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Lowercased executable basenames (no `.exe`) of every running process — the same snapshot the
|
||||
/// conflicting-host scan uses, exposed for the few callers that need to ask "is X running?"
|
||||
/// without duplicating a Toolhelp walk. Best-effort: an empty vec means "could not tell", never
|
||||
/// "nothing is running", so callers must not read absence as proof.
|
||||
pub(crate) fn running_process_names() -> Vec<String> {
|
||||
platform::running_processes()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "detect/windows.rs"]
|
||||
mod platform;
|
||||
|
||||
@@ -202,6 +202,23 @@ pub enum EventKind {
|
||||
/// API (RFC §8) lands.
|
||||
source: String,
|
||||
},
|
||||
/// A verified update manifest announced a release newer than the running host. Emitted
|
||||
/// once per discovered version (a steady-state "newer exists" doesn't re-fire on every
|
||||
/// refresh).
|
||||
#[serde(rename = "update.available")]
|
||||
UpdateAvailable {
|
||||
/// The newer release's version string.
|
||||
version: String,
|
||||
/// The channel it was announced on (`stable` | `canary`).
|
||||
channel: String,
|
||||
/// This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the
|
||||
/// tray render the right "how to update" hint without a second call.
|
||||
install_kind: String,
|
||||
},
|
||||
/// A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's
|
||||
/// first start after a successful apply.
|
||||
#[serde(rename = "update.applied")]
|
||||
UpdateApplied { from: String, to: String },
|
||||
#[serde(rename = "plugins.changed")]
|
||||
PluginsChanged {
|
||||
/// The plugin whose registration changed (registered, restarted, deregistered, or
|
||||
@@ -242,6 +259,8 @@ impl EventKind {
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::UpdateAvailable { .. } => "update.available",
|
||||
EventKind::UpdateApplied { .. } => "update.applied",
|
||||
EventKind::PluginsChanged { .. } => "plugins.changed",
|
||||
EventKind::StoreChanged => "store.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
|
||||
@@ -627,6 +627,7 @@ mod session_tests {
|
||||
codec: crate::encode::Codec::H265,
|
||||
min_fec: 0,
|
||||
hdr: false,
|
||||
slices: 1, // the no-request default — hardware decoders get single-slice AUs
|
||||
});
|
||||
|
||||
assert!(state.end_session("test"), "video was live");
|
||||
|
||||
@@ -556,6 +556,17 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
let min_fec = parse_u("x-nv-vqos[0].fec.minRequiredFecPackets")
|
||||
.unwrap_or(2)
|
||||
.min(16) as u8;
|
||||
// The client's requested per-frame slice count (moonlight-common-c SdpGenerator.c:
|
||||
// `videoEncoderSlicesPerFrame`) — 1 for every HARDWARE decoder, 4 only for software
|
||||
// decoders (slice-threading). Honor it as the encoder's slicing ceiling: GFE/Sunshine
|
||||
// encode what was asked, and hardware TV decoders (Amlogic — Chromecast with Google TV)
|
||||
// wedge the whole DEVICE on multi-slice AUs they never requested — the 0.17.0 field
|
||||
// regression, where the Linux direct-NVENC 4-slice default (§7 LN1) ignored this key and
|
||||
// froze + watchdog-rebooted CCwGTV clients on the first frame. Absent or out-of-range
|
||||
// (attacker-controlled pre-auth input) ⇒ 1, the universally-safe single-slice shape.
|
||||
let slices = parse_u("x-nv-video[0].videoEncoderSlicesPerFrame")
|
||||
.filter(|n| (1..=32).contains(n))
|
||||
.unwrap_or(1);
|
||||
Some(StreamConfig {
|
||||
width,
|
||||
height,
|
||||
@@ -565,6 +576,7 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
codec,
|
||||
min_fec,
|
||||
hdr,
|
||||
slices,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -727,6 +739,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `videoEncoderSlicesPerFrame` is honored as the encoder's slicing ceiling: Moonlight sends
|
||||
/// 1 for every hardware decoder (Amlogic TV SoCs wedge on multi-slice AUs — the 0.17.0
|
||||
/// Chromecast regression) and 4 for software decoders. Absent or out-of-range (pre-auth,
|
||||
/// attacker-controlled) must fall back to the universally-safe 1, never reject the session.
|
||||
#[test]
|
||||
fn announce_slices_per_frame() {
|
||||
// Absent (very old client) → single-slice.
|
||||
assert_eq!(stream_config(&announce(&[])).unwrap().slices, 1);
|
||||
// The two real Moonlight values pass through verbatim.
|
||||
for want in ["1", "4"] {
|
||||
let map = announce(&[("x-nv-video[0].videoEncoderSlicesPerFrame", want)]);
|
||||
assert_eq!(
|
||||
stream_config(&map).unwrap().slices,
|
||||
want.parse::<u32>().unwrap()
|
||||
);
|
||||
}
|
||||
// Garbage / out-of-range degrades to 1 (still streams — this key must never kill a session).
|
||||
for bad in ["0", "33", "999999", "-1", "x"] {
|
||||
let map = announce(&[("x-nv-video[0].videoEncoderSlicesPerFrame", bad)]);
|
||||
let cfg = stream_config(&map).expect("session must still negotiate");
|
||||
assert_eq!(cfg.slices, 1, "slicesPerFrame {bad} must degrade to 1");
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio negotiation: numChannels/AudioQuality/packetDuration, with Moonlight defaults.
|
||||
#[test]
|
||||
fn announce_audio_params() {
|
||||
|
||||
@@ -31,6 +31,12 @@ pub struct StreamConfig {
|
||||
/// Drives the capturer's proactive advanced-color enable; the encoder picks Main10 from the captured
|
||||
/// (P010) frame format. Always `false` on a non-HDR host, so the SDR path is unchanged.
|
||||
pub hdr: bool,
|
||||
/// Client's `x-nv-video[0].videoEncoderSlicesPerFrame` — the per-frame slice count its
|
||||
/// decoder wants (moonlight-common-c requests 1 for every HARDWARE decoder and 4 only for
|
||||
/// software slice-threading). Honored as the encoder's slicing ceiling: hardware TV decoders
|
||||
/// (Amlogic — Chromecast with Google TV) wedge the whole device on multi-slice AUs they
|
||||
/// never asked for (the 0.17.0 4-slice-default field regression). Absent ⇒ 1.
|
||||
pub slices: u32,
|
||||
}
|
||||
|
||||
/// A pooled capturer plus the three properties reuse must match on — its HDR-ness, its
|
||||
@@ -1040,6 +1046,8 @@ fn stream_body(
|
||||
// True only when THIS session's capture negotiated cursor-as-metadata — which the
|
||||
// callers grant only where the resolved backend composites (`cursor_blend_capable`).
|
||||
cursor_blend,
|
||||
// The client's requested slices-per-frame (its DECODER's ceiling) — see `StreamConfig`.
|
||||
cfg.slices,
|
||||
)
|
||||
.context("open video encoder for stream")?;
|
||||
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
|
||||
@@ -1212,6 +1220,7 @@ fn stream_body(
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||
cursor_blend, // same capture cursor mode — see the first open
|
||||
cfg.slices, // client slicing ceiling — see the first open
|
||||
)
|
||||
.context("reopen encoder after rebuild")?;
|
||||
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||
|
||||
@@ -95,10 +95,15 @@ mod session_status;
|
||||
mod sleep_inhibit;
|
||||
mod spike;
|
||||
mod stats_recorder;
|
||||
// Start/stop/status for the per-user tray — the recovery path it has never had of its own.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/tray.rs"]
|
||||
mod tray;
|
||||
// The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the
|
||||
// same runner CLI the `plugins` subcommand uses (design/plugin-store.md).
|
||||
mod store;
|
||||
mod stream_marker;
|
||||
mod update;
|
||||
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
|
||||
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -274,6 +279,7 @@ fn is_management_cli(args: &[String]) -> bool {
|
||||
Some("plugins")
|
||||
| Some("driver")
|
||||
| Some("web")
|
||||
| Some("tray")
|
||||
| Some("openapi")
|
||||
| Some("library")
|
||||
| Some("detect-conflicts")
|
||||
@@ -665,6 +671,11 @@ fn real_main() -> Result<()> {
|
||||
Some("driver") => install::driver_main(&args[1..]),
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("web") => install::web_main(&args[1..]),
|
||||
// The tray's only recovery path: it is a per-user GUI process whose HKLM Run value fires
|
||||
// solely at sign-in, so anything that kills one (an upgrade, a crash) otherwise left the
|
||||
// operator iconless until the next logon.
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("tray") => tray::main(&args[1..]),
|
||||
Some("-h") | Some("--help") | Some("help") | None => {
|
||||
print_usage();
|
||||
Ok(())
|
||||
@@ -898,6 +909,8 @@ USAGE:
|
||||
(secure default; add --gamestream for Moonlight compat)
|
||||
punktfunk-host plugins <CMD> install/run host plugins (add, remove, list, enable,
|
||||
disable, status) — `plugins --help` for details
|
||||
punktfunk-host tray <CMD> status-tray lifecycle (start, stop, status) — Windows;
|
||||
`start` is how you get the icon back without a re-logon
|
||||
punktfunk-host openapi print the management API's OpenAPI document (codegen)
|
||||
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
|
||||
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
|
||||
|
||||
@@ -45,6 +45,7 @@ mod stats;
|
||||
mod store;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod update;
|
||||
|
||||
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
||||
/// number Sunshine users already associate with "the config UI".
|
||||
@@ -103,6 +104,10 @@ pub async fn run(
|
||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||
gamestream_enabled: bool,
|
||||
) -> Result<()> {
|
||||
// Close out any update-intent record a previous apply left behind (the update reports its
|
||||
// own outcome across its own restart — update/jobs.rs). Once per boot, before serving.
|
||||
crate::update::reconcile_at_boot();
|
||||
|
||||
// The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve`
|
||||
// guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated).
|
||||
// A blank token is treated as none — fail loudly rather than ever serve unauthenticated.
|
||||
@@ -257,7 +262,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(store::get_job))
|
||||
.routes(routes!(store::list_sources))
|
||||
.routes(routes!(store::put_source, store::delete_source))
|
||||
.routes(routes!(store::get_runtime, store::set_runtime)),
|
||||
.routes(routes!(store::get_runtime, store::set_runtime))
|
||||
.routes(routes!(update::get_update_status))
|
||||
.routes(routes!(update::force_update_check))
|
||||
.routes(routes!(update::apply_update)),
|
||||
)
|
||||
.split_for_parts()
|
||||
}
|
||||
@@ -297,6 +305,7 @@ pub fn openapi_json() -> String {
|
||||
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
|
||||
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
|
||||
(name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"),
|
||||
(name = "update", description = "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
@@ -149,7 +149,12 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
|| (method == Method::DELETE
|
||||
&& (path.starts_with("/api/v1/clients/")
|
||||
|| path.starts_with("/api/v1/native/clients/")))
|
||||
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"));
|
||||
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"))
|
||||
// The update surface is operator business end to end: today it is only a check, but
|
||||
// the same prefix will carry `apply` (running an installer / the root helper), and a
|
||||
// whole-prefix deny can't be defeated by a route added later.
|
||||
|| path == "/api/v1/update"
|
||||
|| path.starts_with("/api/v1/update/");
|
||||
!denied
|
||||
}
|
||||
|
||||
|
||||
@@ -544,6 +544,31 @@ fn plugin_allowlist_excludes_escalation_routes() {
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
|
||||
// The update surface, wholesale: today a check, tomorrow `apply` (an installer / the root
|
||||
// helper) — operator business end to end, denied by whole-prefix so the apply route added in
|
||||
// U1/U2 is denied by default rather than by remembering to list it. And it is deliberately
|
||||
// NOT on the paired-cert allowlist either: a streaming client has no business knowing or
|
||||
// steering the host's update state.
|
||||
for path in [
|
||||
"/api/v1/update",
|
||||
"/api/v1/update/status",
|
||||
"/api/v1/update/check",
|
||||
"/api/v1/update/apply-does-not-exist-yet",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::GET, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::POST, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
assert!(
|
||||
!auth::cert_may_access(&Method::GET, path),
|
||||
"a paired streaming cert must not reach {path}"
|
||||
);
|
||||
}
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::PUT,
|
||||
"/api/v1/store/sources/evil"
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! `/api/v1/update/*` — the host update-check surface (design
|
||||
//! `host-update-from-web-console.md` §4.2, phase U0).
|
||||
//!
|
||||
//! Admin lane ONLY: denied to the plugin token (`auth::plugin_may_access`) and absent from
|
||||
//! the paired-cert allowlist — an update trigger is operator business. U0 exposes `status` +
|
||||
//! `check`; the `apply` route arrives with the first apply leg (U1) so the API never
|
||||
//! advertises a capability no code backs.
|
||||
|
||||
use super::shared::*;
|
||||
use crate::update::{self, detect};
|
||||
|
||||
/// One channel's manifest facts, as much as the console renders.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateManifestInfo {
|
||||
/// The released version this manifest announces.
|
||||
pub version: String,
|
||||
/// Publish serial (unix seconds) — monotonic per channel.
|
||||
pub serial: u64,
|
||||
/// RFC-3339 publish time (display only).
|
||||
pub published_at: String,
|
||||
/// Release-notes link (pinned to our forge by the manifest validator).
|
||||
pub notes_url: String,
|
||||
/// The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint.
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// A running apply job (or a spawned installer that hasn't resolved yet).
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateJobInfo {
|
||||
/// The version being installed.
|
||||
pub target_version: String,
|
||||
/// `downloading` | `verifying` | `applying` | `restarting`.
|
||||
pub stage: String,
|
||||
pub received_bytes: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_bytes: Option<u64>,
|
||||
pub started_unix: u64,
|
||||
}
|
||||
|
||||
/// Durable outcome of the most recent apply attempt (survives the host's own restart).
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateResultInfo {
|
||||
pub ok: bool,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub finished_unix: u64,
|
||||
/// The stage that failed; absent on success.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stage: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
/// The installer's own log file on this host, for diagnosis.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_path: Option<String>,
|
||||
/// Applied but activates on the next reboot (rpm-ostree).
|
||||
#[serde(default)]
|
||||
pub staged: bool,
|
||||
}
|
||||
|
||||
/// The full update-check state for this host.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateStatus {
|
||||
/// How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |
|
||||
/// `dnf` | `pacman` | `steamos-source` | `nix` | `source`.
|
||||
pub install_kind: String,
|
||||
/// Release channel this install follows: `stable` | `canary`.
|
||||
pub channel: String,
|
||||
/// The running host version.
|
||||
pub current_version: String,
|
||||
/// What the console may offer for this install: `notify` (show the command) — later
|
||||
/// phases add `full` (one-click apply) and `staged` (apply + reboot to finish).
|
||||
pub apply: String,
|
||||
/// The copy-pastable update command for this install kind.
|
||||
pub channel_hint: String,
|
||||
/// Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`).
|
||||
pub check_disabled: bool,
|
||||
/// A newer release than `current_version` exists for this channel (definitive
|
||||
/// comparisons only — an unparseable version pair never flags).
|
||||
pub available: bool,
|
||||
/// The last verified manifest, if any check has succeeded.
|
||||
pub manifest: Option<UpdateManifestInfo>,
|
||||
/// When the last successful check happened (unix seconds).
|
||||
pub last_checked_unix: Option<u64>,
|
||||
/// Why the last check failed, verbatim, if it did.
|
||||
pub last_error: Option<String>,
|
||||
/// This install could one-click apply, but the operator hasn't opted in yet — the
|
||||
/// command to run (Linux: join the `punktfunk-update` group).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opt_in_hint: Option<String>,
|
||||
/// The apply in flight, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub job: Option<UpdateJobInfo>,
|
||||
/// Outcome of the most recent apply attempt.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_result: Option<UpdateResultInfo>,
|
||||
}
|
||||
|
||||
fn status_from(snap: update::Snapshot) -> UpdateStatus {
|
||||
let (kind, channel) = detect::detect();
|
||||
let current = env!("PUNKTFUNK_VERSION");
|
||||
let stale = snap.stale();
|
||||
let available = snap
|
||||
.checked
|
||||
.as_ref()
|
||||
.map(|c| detect::is_newer(&c.manifest.version, c.manifest.ci_run, current, channel))
|
||||
.unwrap_or(false);
|
||||
// A spawned installer that hasn't resolved shows as a `restarting` job even though the
|
||||
// in-process job died with the previous host (the console poller needs continuity here).
|
||||
let job = snap
|
||||
.job
|
||||
.as_ref()
|
||||
.map(|j| UpdateJobInfo {
|
||||
target_version: j.target_version.clone(),
|
||||
stage: j.stage.into(),
|
||||
received_bytes: j.received_bytes,
|
||||
total_bytes: j.total_bytes,
|
||||
started_unix: j.started_unix,
|
||||
})
|
||||
.or_else(|| {
|
||||
snap.applying_from_intent().map(|i| UpdateJobInfo {
|
||||
target_version: i.to,
|
||||
stage: "restarting".into(),
|
||||
received_bytes: 0,
|
||||
total_bytes: None,
|
||||
started_unix: i.started_unix,
|
||||
})
|
||||
});
|
||||
UpdateStatus {
|
||||
install_kind: kind.as_str().into(),
|
||||
channel: channel.as_str().into(),
|
||||
current_version: current.into(),
|
||||
apply: update::apply_support().into(),
|
||||
channel_hint: detect::channel_hint(kind).into(),
|
||||
check_disabled: update::check_disabled(),
|
||||
available,
|
||||
manifest: snap.checked.as_ref().map(|c| UpdateManifestInfo {
|
||||
version: c.manifest.version.clone(),
|
||||
serial: c.manifest.serial,
|
||||
published_at: c.manifest.published_at.clone(),
|
||||
notes_url: c.manifest.notes_url.clone(),
|
||||
stale,
|
||||
}),
|
||||
last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix),
|
||||
last_error: snap.last_error,
|
||||
opt_in_hint: update::opt_in_hint(),
|
||||
job,
|
||||
last_result: snap.last_result.as_ref().map(|r| UpdateResultInfo {
|
||||
ok: r.ok,
|
||||
from: r.from.clone(),
|
||||
to: r.to.clone(),
|
||||
finished_unix: r.finished_unix,
|
||||
stage: r.stage.clone(),
|
||||
error: r.error.clone(),
|
||||
log_path: r.log_path.clone(),
|
||||
staged: r.staged,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update-check status
|
||||
///
|
||||
/// How this host was installed, which channel it follows, whether a newer release is known,
|
||||
/// and how to update. Reading this may kick a background refresh when the cached check is
|
||||
/// older than 6 h; the response never blocks on the network.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/update/status",
|
||||
tag = "update",
|
||||
operation_id = "getUpdateStatus",
|
||||
responses(
|
||||
(status = OK, description = "Current update-check state", body = UpdateStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_update_status() -> Json<UpdateStatus> {
|
||||
Json(status_from(update::snapshot_and_maybe_refresh()))
|
||||
}
|
||||
|
||||
/// Check for updates now
|
||||
///
|
||||
/// Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to
|
||||
/// one forced check per 30 s.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/update/check",
|
||||
tag = "update",
|
||||
operation_id = "forceUpdateCheck",
|
||||
responses(
|
||||
(status = OK, description = "Refreshed update-check state (`last_error` carries a failed check)", body = UpdateStatus),
|
||||
(status = CONFLICT, description = "Update checks are disabled on this host", body = ApiError),
|
||||
(status = TOO_MANY_REQUESTS, description = "A forced check ran less than 30 s ago", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn force_update_check() -> Response {
|
||||
match update::force_check().await {
|
||||
Ok(snap) => Json(status_from(snap)).into_response(),
|
||||
Err(update::ForceError::Disabled) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0)",
|
||||
),
|
||||
Err(update::ForceError::TooSoon) => api_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"a forced update check ran less than 30 s ago — try again shortly",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, ToSchema)]
|
||||
pub(crate) struct ApplyRequest {
|
||||
/// Proceed even while a streaming session is live (the stream will drop when the host
|
||||
/// restarts — the console warns before sending this).
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
/// Apply the available update
|
||||
///
|
||||
/// Starts the one-click apply for install kinds that support it (Windows installer). The
|
||||
/// request carries no version or URL — the host installs exactly what its verified manifest
|
||||
/// announced. Progress is polled via `GET /update/status` (`job`); the host restarts as part
|
||||
/// of the apply, and the outcome lands in `last_result` after it comes back.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/update/apply",
|
||||
tag = "update",
|
||||
operation_id = "applyUpdate",
|
||||
request_body = ApplyRequest,
|
||||
responses(
|
||||
(status = ACCEPTED, description = "Apply started — poll `GET /update/status`", body = UpdateStatus),
|
||||
(status = CONFLICT, description = "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn apply_update(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
// The body is required (send `{}` for defaults) — axum has no optional-body extractor and
|
||||
// the console always posts JSON here anyway.
|
||||
ApiJson(req): ApiJson<ApplyRequest>,
|
||||
) -> Response {
|
||||
// Same session-liveness composition as `get_status`: either plane counts.
|
||||
let session_active = st.app.streaming.load(std::sync::atomic::Ordering::SeqCst)
|
||||
|| !crate::session_status::snapshot().is_empty();
|
||||
|
||||
match update::start_apply(req.force, session_active) {
|
||||
Ok(()) => {
|
||||
let snap = update::snapshot_and_maybe_refresh();
|
||||
(StatusCode::ACCEPTED, Json(status_from(snap))).into_response()
|
||||
}
|
||||
Err(update::ApplyError::Unsupported) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"this install kind has no one-click apply — use the update command shown in status",
|
||||
),
|
||||
Err(update::ApplyError::Disabled) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"one-click apply is disabled on this host (PUNKTFUNK_UPDATE_APPLY=0)",
|
||||
),
|
||||
Err(update::ApplyError::JobRunning) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"an update is already being applied — poll GET /update/status",
|
||||
),
|
||||
Err(update::ApplyError::SessionActive) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"a streaming session is active — pass {\"force\": true} to update anyway (the stream will drop)",
|
||||
),
|
||||
Err(update::ApplyError::NothingToApply) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"no newer release is known for this channel — run a check first",
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1095,6 +1095,11 @@ async fn serve_session(
|
||||
let adaptive_fec = fec_static_override().is_none();
|
||||
let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent));
|
||||
let fec_target_ctl = fec_target.clone();
|
||||
// Phase-locked capture bridge (design/phase-locked-capture.md): the control task stores the
|
||||
// client's PhaseReports here; the encode loop's controller drains them. Inert until a
|
||||
// vsync-aware client actually reports.
|
||||
let phase_ctl = Arc::new(stream::PhaseCtl::new());
|
||||
let phase_ctl_control = phase_ctl.clone();
|
||||
// The session's negotiated rate — the pin PyroWave retarget-refusals ack (§4.6).
|
||||
let session_bitrate_kbps = welcome.bitrate_kbps;
|
||||
// Shared-clipboard enable state (client `ClipControl` → host). The coordinator reads it to
|
||||
@@ -1120,6 +1125,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
fec_target_ctl,
|
||||
phase_ctl_control,
|
||||
reconfig_tx,
|
||||
keyframe_tx,
|
||||
rfi_tx,
|
||||
@@ -1454,6 +1460,10 @@ async fn serve_session(
|
||||
// Streamed-AU capability: the client's reassembler accepts sentinel-headed streamed blocks,
|
||||
// so a chunked encoder session may ship an AU's early FEC blocks while its tail encodes.
|
||||
let streamed_au = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_STREAMED_AU != 0;
|
||||
// Multi-slice capability: the client's DECODER accepts AUs carrying several slice NALs, so
|
||||
// the encoder may keep its multi-slice default (§7 LN1). Absent ⇒ single-slice frames —
|
||||
// TV-SoC decoders (Amlogic: Chromecast with Google TV) wedge the device on multi-slice AUs.
|
||||
let multi_slice = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE != 0;
|
||||
let stats_dp = stats; // data-plane handle to the shared stats recorder
|
||||
// Short label for web-console stats captures: the client's cert-fingerprint prefix, else its
|
||||
// peer IP (no fingerprint = anonymous TOFU/--open client).
|
||||
@@ -1564,6 +1574,7 @@ async fn serve_session(
|
||||
probe_result_tx,
|
||||
reconfig_result_tx,
|
||||
fec_target: fec_target_dp,
|
||||
phase: phase_ctl,
|
||||
conn: conn_stream,
|
||||
timing_conn,
|
||||
cursor_forward,
|
||||
@@ -1571,6 +1582,7 @@ async fn serve_session(
|
||||
cursor_client_draws: cursor_client_draws_dp,
|
||||
probe_seq,
|
||||
streamed_au,
|
||||
multi_slice,
|
||||
stats: stats_dp,
|
||||
client_label,
|
||||
client_name,
|
||||
|
||||
@@ -30,6 +30,9 @@ pub(super) async fn run(
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
|
||||
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
|
||||
phase_ctl: Arc<super::stream::PhaseCtl>,
|
||||
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||
keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||
rfi_tx: std::sync::mpsc::Sender<(u32, u32)>,
|
||||
@@ -230,6 +233,12 @@ pub(super) async fn run(
|
||||
if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
} else if let Ok(pr) = punktfunk_core::quic::PhaseReport::decode(&msg) {
|
||||
// Phase-locked capture: latest-wins into the bridge — no data-plane hop, the
|
||||
// encode loop polls on its own cadence. Only vsync-aware presenters send
|
||||
// these (CLIENT_CAP_PHASE_LOCK), and PUNKTFUNK_PHASE_LOCK=0 host-side leaves
|
||||
// the stored report undrained/inert.
|
||||
phase_ctl.store(pr);
|
||||
} else if let Ok(m) = punktfunk_core::quic::CursorRenderMode::decode(&msg) {
|
||||
// Who renders the pointer (design/remote-desktop-sweep.md §8): the client's
|
||||
// mouse-model flip. Latest-wins into the shared flag; the data-plane loop
|
||||
|
||||
@@ -579,6 +579,10 @@ pub(super) async fn negotiate(
|
||||
// The bit the Welcome just advertised — read back rather than recomputed, so the
|
||||
// prepared display and the session wiring cannot disagree with it.
|
||||
let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
|
||||
// Same bit the data plane's SessionContext reads — the prepared plan and the
|
||||
// session wiring must agree on the slicing ceiling (an encoder rebuilt from the
|
||||
// prepared plan with a DIFFERENT max_slices would change the wire shape mid-flow).
|
||||
let multi_slice = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE != 0;
|
||||
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
|
||||
// "Automatic" — `bitrate_kbps` above is the host's own answer for `mode`, so the build
|
||||
// may re-resolve it if the source turns out to deliver a different size. Sampled here
|
||||
@@ -594,6 +598,7 @@ pub(super) async fn negotiate(
|
||||
client_identity,
|
||||
client_hdr,
|
||||
cursor_fw,
|
||||
multi_slice,
|
||||
bitrate_kbps,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(super) fn synthetic_stream(
|
||||
pts_ns,
|
||||
host_us: (now_ns().saturating_sub(pts_ns) / 1000).min(u32::MAX as u64) as u32,
|
||||
stages: None, // synthetic loop: no capture/encode stages to split
|
||||
applied_phase_ns: None,
|
||||
};
|
||||
let _ = tc.send_datagram(punktfunk_core::quic::encode_host_timing_datagram(&t).into());
|
||||
}
|
||||
@@ -201,6 +202,112 @@ fn frame_driven_enabled() -> bool {
|
||||
*ON.get_or_init(|| std::env::var("PUNKTFUNK_FRAME_DRIVEN").as_deref() != Ok("0"))
|
||||
}
|
||||
|
||||
/// Phase-locked capture (design/phase-locked-capture.md): `PUNKTFUNK_PHASE_LOCK=0` disarms the
|
||||
/// controller — the rebuild-free A/B lever. Armed alone it does nothing until a client actually
|
||||
/// sends [`PhaseReport`](punktfunk_core::quic::PhaseReport)s.
|
||||
fn phase_lock_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("PUNKTFUNK_PHASE_LOCK").as_deref() != Ok("0"))
|
||||
}
|
||||
|
||||
/// Control-task → encode-loop bridge for phase-locked capture (the multi-field sibling of the
|
||||
/// `fec_target` atomic): the control task stores the client's latest
|
||||
/// [`PhaseReport`](punktfunk_core::quic::PhaseReport) (latest-wins), the encode loop drains it on
|
||||
/// its ~1 Hz adjust tick, and publishes the hold it is applying for the 0xCF ACK + diagnostics.
|
||||
pub(crate) struct PhaseCtl {
|
||||
report: std::sync::Mutex<Option<punktfunk_core::quic::PhaseReport>>,
|
||||
applied_ns: std::sync::atomic::AtomicI64,
|
||||
}
|
||||
|
||||
impl PhaseCtl {
|
||||
pub(crate) fn new() -> PhaseCtl {
|
||||
PhaseCtl {
|
||||
report: std::sync::Mutex::new(None),
|
||||
applied_ns: std::sync::atomic::AtomicI64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Latest-wins store (control task).
|
||||
pub(crate) fn store(&self, r: punktfunk_core::quic::PhaseReport) {
|
||||
*self
|
||||
.report
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(r);
|
||||
}
|
||||
|
||||
/// Drain the pending report, if any (encode loop, ~1 Hz).
|
||||
fn take(&self) -> Option<punktfunk_core::quic::PhaseReport> {
|
||||
self.report
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take()
|
||||
}
|
||||
|
||||
fn set_applied(&self, ns: i64) {
|
||||
self.applied_ns.store(ns, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The hold currently applied (0 = idle) — the send thread's 0xCF ACK readout.
|
||||
pub(crate) fn applied_ns(&self) -> i64 {
|
||||
self.applied_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The encode loop's phase controller state (design/phase-locked-capture.md §3): a per-frame
|
||||
/// HOLD before submit, walked toward the client's reported arrival lead hitting the target lead.
|
||||
/// Plain data — lives as a loop local so it survives every in-loop rebuild path; a new session
|
||||
/// (new loop call) starts unlocked, which is correct (new client, new grid).
|
||||
struct PhaseController {
|
||||
/// Applied per-frame hold before submit, ns ∈ [0, period).
|
||||
hold_ns: i64,
|
||||
/// Last adjust instant (~1 Hz cadence).
|
||||
last_adjust: std::time::Instant,
|
||||
}
|
||||
|
||||
impl PhaseController {
|
||||
/// Per-adjustment walk bound: 2 ms per second of reports keeps the wire cadence visually
|
||||
/// undisturbed while converging a worst-case half-period error in ~2-3 s.
|
||||
const MAX_STEP_NS: i64 = 2_000_000;
|
||||
/// Ignore errors under this — a locked loop does nothing (jitter would otherwise dither the
|
||||
/// hold every second).
|
||||
const DEADBAND_NS: i64 = 300_000;
|
||||
/// The lead floor the controller drives toward: SurfaceFlinger-class compositors need the
|
||||
/// frame in the queue ~2.5 ms before latch; the client's own `uncertainty_ns` widens this.
|
||||
const TARGET_LEAD_FLOOR_NS: i64 = 2_500_000;
|
||||
|
||||
fn new() -> PhaseController {
|
||||
PhaseController {
|
||||
hold_ns: 0,
|
||||
last_adjust: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the client's latest report into the hold. `period_ns` is the wire interval (the
|
||||
/// session's frame period). Sign convention: `arrival_lead` is how long before its latch the
|
||||
/// median frame arrives — a LARGE lead means frames sit waiting at the client, so the host
|
||||
/// should send LATER (grow the hold); a small/zero lead risks missing the latch, so send
|
||||
/// EARLIER (shrink the hold, wrapping through the period when it hits zero — the newest-wins
|
||||
/// capture slot makes a wrapped hold sample a fresher frame, not a staler one).
|
||||
fn adjust(&mut self, r: &punktfunk_core::quic::PhaseReport, period_ns: i64) {
|
||||
if period_ns <= 0 {
|
||||
return;
|
||||
}
|
||||
self.last_adjust = std::time::Instant::now();
|
||||
let target = Self::TARGET_LEAD_FLOOR_NS.max(r.uncertainty_ns as i64 + 1_000_000);
|
||||
let error = r.arrival_lead_ns as i64 - target;
|
||||
if error.abs() < Self::DEADBAND_NS {
|
||||
return;
|
||||
}
|
||||
let step = error.clamp(-Self::MAX_STEP_NS, Self::MAX_STEP_NS);
|
||||
self.hold_ns = (self.hold_ns + step).rem_euclid(period_ns);
|
||||
}
|
||||
|
||||
/// Whether the ~1 Hz adjust window has elapsed.
|
||||
fn due(&self) -> bool {
|
||||
self.last_adjust.elapsed() >= std::time::Duration::from_secs(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adaptive pipeline depth (latency plan, from the 2026-07-17 on-glass finding on a `.173` RTX
|
||||
/// 4090): the capturer's pipeline depth of 2 measured **~13 ms of glass-to-glass latency** over
|
||||
/// depth 1 at 60 fps (17 ms → 4 ms) — the AU is ready in µs but depth-2 holds it a whole frame
|
||||
@@ -526,6 +633,8 @@ fn send_loop(
|
||||
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
|
||||
// after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing).
|
||||
timing_conn: Option<quinn::Connection>,
|
||||
// Phase-lock ACK source: the hold the encode loop currently applies rides the 0xCF tail.
|
||||
phase: Arc<PhaseCtl>,
|
||||
// The client advertised VIDEO_CAP_PROBE_SEQ — mid-session speed-test bursts may run in the
|
||||
// probe index space (else they're declined; see `run_probe_burst`).
|
||||
probe_seq: bool,
|
||||
@@ -631,6 +740,13 @@ fn send_loop(
|
||||
encode_us: msg.encode_us,
|
||||
pace_us: stat.spread_us,
|
||||
}),
|
||||
// Phase-lock ACK: the hold the capture tick is applying
|
||||
// right now (0 = controller idle/unarmed) — the client's
|
||||
// closed-loop readout.
|
||||
applied_phase_ns: Some(
|
||||
phase.applied_ns().clamp(i32::MIN as i64, i32::MAX as i64)
|
||||
as i32,
|
||||
),
|
||||
};
|
||||
let _ = tc.send_datagram(
|
||||
punktfunk_core::quic::encode_host_timing_datagram(&t).into(),
|
||||
@@ -968,6 +1084,9 @@ pub(super) struct SessionContext {
|
||||
/// thread emits one 0xCF datagram per AU (capture→sent µs) on it, so the client can split its
|
||||
/// `host+network` latency stage. `None` = older client, no emission.
|
||||
pub(super) timing_conn: Option<quinn::Connection>,
|
||||
/// Phase-locked capture bridge (design/phase-locked-capture.md): the control task stores
|
||||
/// client [`PhaseReport`]s here; the encode loop's controller drains them.
|
||||
pub(super) phase: Arc<PhaseCtl>,
|
||||
/// The session negotiated the cursor channel (design/remote-desktop-sweep.md M2 —
|
||||
/// `handshake::cursor_forward`): the encode loop forwards shape (via `cursor_shape_tx`)
|
||||
/// + per-tick `0xD0` state while the client draws the pointer locally.
|
||||
@@ -992,6 +1111,12 @@ pub(super) struct SessionContext {
|
||||
/// AU's FEC blocks under sentinel headers as the slices complete instead of waiting for the
|
||||
/// whole AU. `false` = older client — chunks (if any) are drained whole-AU, zero wire change.
|
||||
pub(super) streamed_au: bool,
|
||||
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE`]: its decoder
|
||||
/// accepts multi-slice AUs, so the session's encoder may keep its multi-slice default
|
||||
/// (§7 LN1 — becomes [`SessionPlan::max_slices`](crate::session_plan::SessionPlan)).
|
||||
/// `false` = single-slice frames, the pre-0.17 wire shape TV-SoC decoders (Amlogic —
|
||||
/// Chromecast with Google TV) require to not wedge.
|
||||
pub(super) multi_slice: bool,
|
||||
/// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide
|
||||
/// whether to measure the per-stage split; the send thread builds + pushes the aggregated
|
||||
/// `StatsSample` at its 2 s boundary.
|
||||
@@ -1092,6 +1217,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
ctx.bit_depth,
|
||||
),
|
||||
ctx.cursor_forward,
|
||||
ctx.multi_slice,
|
||||
);
|
||||
// gamescope: the XFixes cursor source feeds the host-side composite (Phase C) — unless the
|
||||
// spawned gamescope paints the pointer into its node itself, in which case the reader would
|
||||
@@ -1136,11 +1262,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
fec_target,
|
||||
conn,
|
||||
timing_conn,
|
||||
phase,
|
||||
cursor_forward,
|
||||
cursor_shape_tx,
|
||||
cursor_client_draws,
|
||||
probe_seq,
|
||||
streamed_au,
|
||||
// Already folded into `plan.max_slices` by the resolve above — nothing below reads it.
|
||||
multi_slice: _,
|
||||
stats,
|
||||
client_label,
|
||||
client_name,
|
||||
@@ -1475,6 +1604,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
.name("punktfunk-send".into())
|
||||
.spawn({
|
||||
let stop = stop.clone();
|
||||
let phase_send = phase.clone();
|
||||
move || {
|
||||
send_loop(
|
||||
session,
|
||||
@@ -1487,6 +1617,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
fec_target,
|
||||
send_stats,
|
||||
timing_conn,
|
||||
phase_send,
|
||||
probe_seq,
|
||||
)
|
||||
}
|
||||
@@ -1532,6 +1663,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
|
||||
let mut next = std::time::Instant::now();
|
||||
let mut sent: u64 = 0;
|
||||
// Phase-locked capture (design/phase-locked-capture.md): the per-frame hold this loop applies
|
||||
// after a fresh capture, walked ~1 Hz toward the client's reported arrival lead. A loop local
|
||||
// on purpose — it survives every in-loop rebuild path (session switch, mode/stall rebuilds,
|
||||
// encoder backoff), so a mid-stream rebuild keeps the acquired lock.
|
||||
let mut phase_ctl = PhaseController::new();
|
||||
// The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this
|
||||
// loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`).
|
||||
// A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs
|
||||
@@ -2044,6 +2180,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
plan.cursor_blend,
|
||||
plan.max_slices,
|
||||
) {
|
||||
Ok(mut new_enc) => {
|
||||
// The fresh encoder may have clamped to its codec-level ceiling —
|
||||
@@ -2246,6 +2383,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
Ok(Some(f)) => {
|
||||
frame = f;
|
||||
diag_new += 1;
|
||||
// Phase-locked capture: hold the fresh frame so its ARRIVAL at the client lands a
|
||||
// constant small lead before the client's display latch (§3 hold-then-submit; the
|
||||
// capture slot is newest-wins, so a long hold samples fresher content next tick,
|
||||
// never staler). Adjusted ~1 Hz from the client's PhaseReports; 0 until a report
|
||||
// arrives or when PUNKTFUNK_PHASE_LOCK=0.
|
||||
if phase_lock_enabled() {
|
||||
if phase_ctl.due() {
|
||||
if let Some(r) = phase.take() {
|
||||
phase_ctl.adjust(&r, interval.as_nanos() as i64);
|
||||
} else {
|
||||
phase_ctl.last_adjust = std::time::Instant::now();
|
||||
}
|
||||
phase.set_applied(phase_ctl.hold_ns);
|
||||
}
|
||||
if phase_ctl.hold_ns > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_nanos(
|
||||
phase_ctl.hold_ns as u64,
|
||||
));
|
||||
}
|
||||
}
|
||||
capture_rebuilds = 0; // a delivered frame clears the consecutive-loss counter
|
||||
// Re-arm the park schedule for a (re)built display: pin the seat pointer to
|
||||
// the streamed surface (see `park_pointer` and the schedule state above).
|
||||
@@ -3393,6 +3550,7 @@ fn try_inplace_resize(
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
plan.cursor_blend,
|
||||
plan.max_slices,
|
||||
) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
@@ -3450,6 +3608,7 @@ pub(super) fn prepare_display(
|
||||
client_identity: Option<[u8; 32]>,
|
||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
cursor_forward: bool,
|
||||
multi_slice: bool,
|
||||
bitrate_kbps: u32,
|
||||
// Passed through to [`build_pipeline`] — see its parameter of the same name.
|
||||
bitrate_auto: bool,
|
||||
@@ -3478,6 +3637,7 @@ pub(super) fn prepare_display(
|
||||
bit_depth,
|
||||
),
|
||||
cursor_forward,
|
||||
multi_slice,
|
||||
);
|
||||
plan.gamescope_cursor =
|
||||
crate::session_plan::gamescope_cursor_for(compositor == pf_vdisplay::Compositor::Gamescope);
|
||||
@@ -3899,6 +4059,7 @@ fn build_pipeline(
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
plan.cursor_blend,
|
||||
plan.max_slices,
|
||||
)
|
||||
.context("open video encoder")?;
|
||||
if let Some(t) = trace {
|
||||
|
||||
@@ -569,6 +569,43 @@ fn grant_runner_secret_reads() {
|
||||
);
|
||||
}
|
||||
}
|
||||
// The runner's OWN bundle, in the install dir rather than under the config dir. Same reason as
|
||||
// RUNNER_UNIT_DIRS: bun opens the file it is asked to run requesting FILE_WRITE_ATTRIBUTES on
|
||||
// top of read, and {app}\scripting only carries Users:(RX), which LocalService reaches through
|
||||
// Authenticated Users. So `bun runner-cli.js` died with
|
||||
// error: EPERM reading "C:\Program Files\punktfunk\scripting\runner-cli.js"
|
||||
// the task exited 1 within a second of every start, and the console showed the runner as
|
||||
// enabled-but-not-running with nothing to explain it. The unit dirs were given (RX,WA) when
|
||||
// that behaviour was first found on-glass; the entry script itself was missed, so the runner
|
||||
// could never start at all. Verified on glass: without this the task is Ready/lastResult=1,
|
||||
// with it the task is Running and `GET /store/runtime` reports running:true.
|
||||
// WA touches timestamps and the read-only bit, never content, so "code is read-only — a plugin
|
||||
// cannot rewrite itself" still holds for the runner's own bundle.
|
||||
if let Some(dir) = runner_bundle_dir() {
|
||||
let ok = Command::new(icacls_path())
|
||||
.arg(&dir)
|
||||
.args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(RX,WA)")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if !ok {
|
||||
eprintln!(
|
||||
"warning: could not grant LocalService read on {} - the plugin runner will not \
|
||||
start (bun exits EPERM on its own entry script)",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `{app}\scripting` — where the installer lays down `runner-cli.js` + `scripting-run.cmd`
|
||||
/// (packaging/windows/punktfunk-host.iss), resolved from the running exe like
|
||||
/// [`runner_command`] does. `None` when the exe path cannot be resolved; callers treat that as
|
||||
/// "nothing to grant" rather than failing the whole enable.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn runner_bundle_dir() -> Option<std::path::PathBuf> {
|
||||
Some(std::env::current_exe().ok()?.parent()?.join("scripting"))
|
||||
}
|
||||
|
||||
/// Best-effort removal of the LocalService read grants when the runner is switched off — the
|
||||
@@ -606,6 +643,17 @@ fn revoke_runner_secret_reads() {
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
// …and the bundle dir in the install tree, which is not under `cfg` so the loop above misses
|
||||
// it. Removing the explicit ACE leaves the inherited Users:(RX) from Program Files, so it
|
||||
// reverts to plain read-only rather than losing access altogether.
|
||||
if let Some(dir) = runner_bundle_dir().filter(|d| d.exists()) {
|
||||
let _ = Command::new(icacls_path())
|
||||
.arg(&dir)
|
||||
.args(["/remove:g", LOCAL_SERVICE_SID])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve icacls by full System32 path rather than PATH — same planted-binary reasoning as
|
||||
|
||||
@@ -132,6 +132,13 @@ pub struct SessionPlan {
|
||||
/// redundant work producing a SECOND pointer. Resolved by [`cursor_blend_for`]'s sibling so
|
||||
/// the two answers cannot disagree.
|
||||
pub gamescope_cursor: bool,
|
||||
/// Ceiling on the encoder's per-frame slice count, from the client's
|
||||
/// [`VIDEO_CAP_MULTI_SLICE`](punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE): 32 (= no
|
||||
/// client-side limit, the backend picks its own multi-slice default, §7 LN1) when the bit is
|
||||
/// set, 1 (single-slice frames — the pre-0.17 wire shape TV-SoC decoders like Amlogic
|
||||
/// require) when it isn't. Applied to EVERY encoder this plan opens (initial + all rebuilds)
|
||||
/// so the slicing can never change shape across a mode/bitrate/stall rebuild.
|
||||
pub max_slices: u32,
|
||||
}
|
||||
|
||||
impl SessionPlan {
|
||||
@@ -143,6 +150,7 @@ impl SessionPlan {
|
||||
codec: crate::encode::Codec,
|
||||
cursor_blend: bool,
|
||||
cursor_forward: bool,
|
||||
multi_slice: bool,
|
||||
) -> Self {
|
||||
SessionPlan {
|
||||
capture: CaptureBackend::resolve(),
|
||||
@@ -158,6 +166,7 @@ impl SessionPlan {
|
||||
// Set by the resolve callers (they know the compositor); default off keeps every
|
||||
// non-gamescope plan unchanged.
|
||||
gamescope_cursor: false,
|
||||
max_slices: if multi_slice { 32 } else { 1 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
8, // spike synthetic harness: 8-bit
|
||||
encode::ChromaFormat::Yuv420, // ...and 4:2:0
|
||||
false, // synthetic frames carry no cursor
|
||||
4, // no client decoder — keep the backend's multi-slice default
|
||||
)
|
||||
.context("open encoder")?;
|
||||
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
//! Host **update check** (design `host-update-from-web-console.md`, phase U0).
|
||||
//!
|
||||
//! This module answers one question for the console: *does a newer host release exist for
|
||||
//! this box's channel* — by fetching the per-channel signed manifest and verifying it against
|
||||
//! the Ed25519 keys pinned below. It deliberately contains **no apply code**: U0 ships check
|
||||
//! everywhere; apply legs land per-channel (U1 Windows, U2 Linux helper) behind the same
|
||||
//! status surface.
|
||||
//!
|
||||
//! Shape: a process-wide cache + a lazy refresh. `GET /update/status` returns the cache and,
|
||||
//! when it is older than [`AUTO_REFRESH_AFTER`], kicks a background refresh — the console
|
||||
//! polls status anyway, so freshness needs no timer of its own. `POST /update/check` forces a
|
||||
//! refresh, rate-limited to one per [`FORCE_MIN_INTERVAL`].
|
||||
//!
|
||||
//! Trust and failure rules live in [`manifest`]; the serial floor persisted here
|
||||
//! (`update-state.json`) is what makes a replayed older manifest an *error*, not a silent
|
||||
//! downgrade of our knowledge. `PUNKTFUNK_UPDATE_CHECK=0` disables all network activity —
|
||||
//! status then reports `check_disabled` and carries whatever identity facts need no network.
|
||||
|
||||
pub(crate) mod detect;
|
||||
pub(crate) mod jobs;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
pub(crate) mod manifest;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod windows;
|
||||
|
||||
use crate::store::index::PublicKey;
|
||||
use manifest::Manifest;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The Ed25519 public keys this binary trusts for update manifests — two slots so a key
|
||||
/// rotation is "sign with the new one, ship a host trusting both, retire the old" (the
|
||||
/// plugin-store `OFFICIAL_KEYS` drill). The private half is the `UPDATE_MANIFEST_KEY` CI
|
||||
/// secret; it also lives in the operator's offline backup (plan U0.1 DoD).
|
||||
pub(crate) const UPDATE_KEYS: [&str; 2] = [
|
||||
"ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=",
|
||||
"", // rotation slot
|
||||
];
|
||||
|
||||
/// Feed base — `<base>/<channel>/manifest.json` + `.sig`. Override for tests/dev feeds via
|
||||
/// `PUNKTFUNK_UPDATE_FEED` (a base URL, not request-time input: env is operator config).
|
||||
const DEFAULT_FEED_BASE: &str = "https://git.unom.io/api/packages/unom/generic/punktfunk-update";
|
||||
|
||||
/// A cache older than this is refreshed in the background on the next status read.
|
||||
const AUTO_REFRESH_AFTER: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
|
||||
/// Forced checks (`POST /update/check`) are rate-limited to one per this interval.
|
||||
pub(crate) const FORCE_MIN_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A manifest whose publish serial is older than this is flagged stale in status — the
|
||||
/// freeze-detection hint (design §3.2), not an error.
|
||||
const STALE_AFTER: Duration = Duration::from_secs(45 * 24 * 60 * 60);
|
||||
|
||||
/// One fetch's wall-clock budget (mirrors the store catalog fetch).
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Update checks disabled by operator config (env or `host.env`).
|
||||
pub(crate) fn check_disabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("PUNKTFUNK_UPDATE_CHECK").as_deref(),
|
||||
Ok("0") | Ok("false") | Ok("off")
|
||||
)
|
||||
}
|
||||
|
||||
/// One-click apply disabled by operator config — the host-side kill switch (design §4.2): the
|
||||
/// apply route 409s and status reports `notify` even on kinds an apply leg exists for. The
|
||||
/// check surface is unaffected.
|
||||
pub(crate) fn apply_disabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("PUNKTFUNK_UPDATE_APPLY").as_deref(),
|
||||
Ok("0") | Ok("false") | Ok("off")
|
||||
)
|
||||
}
|
||||
|
||||
/// What the console may offer for this install: `full` (one-click apply), `staged` (apply +
|
||||
/// reboot to finish — rpm-ostree), or `notify` (show the command). Linux legs additionally
|
||||
/// require the packaged root helper AND the operator's group opt-in; pacman also the
|
||||
/// root-owned full-sysupgrade config (design §5).
|
||||
pub(crate) fn apply_support() -> &'static str {
|
||||
if apply_disabled() {
|
||||
return "notify";
|
||||
}
|
||||
let (kind, _) = detect::detect();
|
||||
match kind {
|
||||
detect::InstallKind::WindowsInstaller => "full",
|
||||
#[cfg(target_os = "linux")]
|
||||
detect::InstallKind::Apt | detect::InstallKind::Dnf | detect::InstallKind::Sysext
|
||||
if linux::helper_installed() && linux::opted_in() =>
|
||||
{
|
||||
"full"
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
detect::InstallKind::RpmOstree if linux::helper_installed() && linux::opted_in() => {
|
||||
"staged"
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
detect::InstallKind::Pacman
|
||||
if linux::helper_installed() && linux::opted_in() && linux::pacman_opted_in() =>
|
||||
{
|
||||
"full"
|
||||
}
|
||||
// The Deck source rebuild is user-owned — no helper, no group.
|
||||
#[cfg(target_os = "linux")]
|
||||
detect::InstallKind::SteamosSource => "full",
|
||||
_ => "notify",
|
||||
}
|
||||
}
|
||||
|
||||
/// The opt-in instruction for status: this install COULD one-click apply (helper shipped)
|
||||
/// but the operator hasn't joined the `punktfunk-update` group yet.
|
||||
pub(crate) fn opt_in_hint() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let (kind, _) = detect::detect();
|
||||
let capable = matches!(
|
||||
kind,
|
||||
detect::InstallKind::Apt
|
||||
| detect::InstallKind::Dnf
|
||||
| detect::InstallKind::Sysext
|
||||
| detect::InstallKind::RpmOstree
|
||||
| detect::InstallKind::Pacman
|
||||
);
|
||||
if capable && !apply_disabled() && linux::helper_installed() && !linux::opted_in() {
|
||||
return Some(linux::opt_in_hint());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn feed_base() -> String {
|
||||
std::env::var("PUNKTFUNK_UPDATE_FEED")
|
||||
.ok()
|
||||
.filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1"))
|
||||
.unwrap_or_else(|| DEFAULT_FEED_BASE.to_string())
|
||||
}
|
||||
|
||||
fn pinned_keys() -> Vec<PublicKey> {
|
||||
UPDATE_KEYS
|
||||
.iter()
|
||||
.filter(|k| !k.is_empty())
|
||||
.filter_map(|k| PublicKey::parse(k).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- runtime state
|
||||
|
||||
/// What the last successful refresh produced.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Checked {
|
||||
pub manifest: Manifest,
|
||||
pub fetched_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Runtime {
|
||||
checked: Option<Checked>,
|
||||
last_error: Option<String>,
|
||||
/// Refresh in flight (status kicks at most one).
|
||||
refreshing: bool,
|
||||
/// Wall-clock guard for the forced-check rate limit.
|
||||
last_forced: Option<Instant>,
|
||||
/// Last attempt of any kind — drives the auto-refresh cadence.
|
||||
last_attempt: Option<Instant>,
|
||||
/// The manifest version an `update.available` event was already emitted for, so a
|
||||
/// steady-state "newer exists" doesn't re-announce every 6 h.
|
||||
announced: Option<String>,
|
||||
/// The live apply job, when one is running (single-flight).
|
||||
job: Option<jobs::JobSnapshot>,
|
||||
}
|
||||
|
||||
fn runtime() -> &'static Mutex<Runtime> {
|
||||
static RT: OnceLock<Mutex<Runtime>> = OnceLock::new();
|
||||
RT.get_or_init(|| Mutex::new(Runtime::default()))
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- serial floor
|
||||
|
||||
/// Persisted anti-rollback state: the highest manifest serial ever accepted per channel.
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
struct FloorFile {
|
||||
#[serde(default)]
|
||||
serial_floor: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("update-state.json")
|
||||
}
|
||||
|
||||
fn load_floor(path: &Path, channel: &str) -> u64 {
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<FloorFile>(&b).ok())
|
||||
.and_then(|f| f.serial_floor.get(channel).copied())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
|
||||
fn store_floor(path: &Path, channel: &str, serial: u64) {
|
||||
let mut file: FloorFile = std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
let slot = file.serial_floor.entry(channel.to_string()).or_insert(0);
|
||||
if serial <= *slot {
|
||||
return;
|
||||
}
|
||||
*slot = serial;
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&file) else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- refresh
|
||||
|
||||
/// Fetch + verify the channel manifest. Blocking (`ureq`) — call from a blocking thread.
|
||||
fn fetch_manifest_blocking(channel: &str) -> Result<Manifest, String> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
// Follow the registry's 303-to-object-storage redirect; the signature is verified
|
||||
// over the FINAL bytes (the sysext-feed lesson).
|
||||
.redirects(3)
|
||||
.user_agent(&format!(
|
||||
"punktfunk-host/{} (update-check)",
|
||||
env!("PUNKTFUNK_VERSION")
|
||||
))
|
||||
.build();
|
||||
let base = feed_base();
|
||||
let url = format!("{base}/{channel}/manifest.json");
|
||||
let sig_url = format!("{url}.sig");
|
||||
|
||||
let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?;
|
||||
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
|
||||
let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?;
|
||||
|
||||
let keys = pinned_keys();
|
||||
if keys.is_empty() {
|
||||
// Both slots empty would mean a build with the feature disarmed; refuse rather than
|
||||
// silently skipping verification.
|
||||
return Err("no update key is pinned in this build".into());
|
||||
}
|
||||
manifest::verify_and_parse(&body, &sig_text, &keys, channel).map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
fn fetch_err(e: ureq::Error) -> String {
|
||||
match e {
|
||||
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
|
||||
other => format!("feed fetch failed: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read as _;
|
||||
let mut buf = Vec::new();
|
||||
let mut reader = resp
|
||||
.into_reader()
|
||||
.take(manifest::MAX_MANIFEST_BYTES as u64 + 1);
|
||||
reader
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("read failed: {e}"))?;
|
||||
if buf.len() > manifest::MAX_MANIFEST_BYTES {
|
||||
return Err("response exceeds the manifest size cap".into());
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache,
|
||||
/// announce a newly available release on the event bus. Returns the user-facing error string
|
||||
/// on failure (also cached for status).
|
||||
pub(crate) fn refresh_blocking() -> Result<Checked, String> {
|
||||
let (kind, channel) = detect::detect();
|
||||
let result = fetch_manifest_blocking(channel.as_str()).and_then(|m| {
|
||||
let path = state_path();
|
||||
let floor = load_floor(&path, channel.as_str());
|
||||
if m.serial < floor {
|
||||
return Err(format!(
|
||||
"manifest serial {} is older than the last accepted {} — refusing rollback",
|
||||
m.serial, floor
|
||||
));
|
||||
}
|
||||
store_floor(&path, channel.as_str(), m.serial);
|
||||
Ok(m)
|
||||
});
|
||||
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
rt.last_attempt = Some(Instant::now());
|
||||
rt.refreshing = false;
|
||||
match result {
|
||||
Ok(m) => {
|
||||
let checked = Checked {
|
||||
manifest: m,
|
||||
fetched_unix: now_unix(),
|
||||
};
|
||||
let newer = detect::is_newer(
|
||||
&checked.manifest.version,
|
||||
checked.manifest.ci_run,
|
||||
env!("PUNKTFUNK_VERSION"),
|
||||
channel,
|
||||
);
|
||||
if newer && rt.announced.as_deref() != Some(checked.manifest.version.as_str()) {
|
||||
rt.announced = Some(checked.manifest.version.clone());
|
||||
crate::events::emit(crate::events::EventKind::UpdateAvailable {
|
||||
version: checked.manifest.version.clone(),
|
||||
channel: channel.as_str().to_string(),
|
||||
install_kind: kind.as_str().to_string(),
|
||||
});
|
||||
}
|
||||
rt.last_error = None;
|
||||
rt.checked = Some(checked.clone());
|
||||
Ok(checked)
|
||||
}
|
||||
Err(e) => {
|
||||
rt.last_error = Some(e.clone());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The status handler's read: current cache + errors, kicking a background refresh when the
|
||||
/// cache is cold and checks are enabled.
|
||||
pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot {
|
||||
let mut kick = false;
|
||||
let snap = {
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
let cold = rt
|
||||
.last_attempt
|
||||
.map(|t| t.elapsed() >= AUTO_REFRESH_AFTER)
|
||||
.unwrap_or(true);
|
||||
if cold && !rt.refreshing && !check_disabled() {
|
||||
rt.refreshing = true;
|
||||
rt.last_attempt = Some(Instant::now());
|
||||
kick = true;
|
||||
}
|
||||
Snapshot {
|
||||
checked: rt.checked.clone(),
|
||||
last_error: rt.last_error.clone(),
|
||||
job: rt.job.clone(),
|
||||
last_result: jobs::read_result(&jobs::result_path()),
|
||||
}
|
||||
};
|
||||
if kick {
|
||||
// Fire-and-forget; the console's next poll reads the outcome.
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let _ = refresh_blocking();
|
||||
});
|
||||
}
|
||||
snap
|
||||
}
|
||||
|
||||
/// A forced check (`POST /update/check`): rate-limited, blocking until the refresh finishes.
|
||||
pub(crate) async fn force_check() -> Result<Snapshot, ForceError> {
|
||||
if check_disabled() {
|
||||
return Err(ForceError::Disabled);
|
||||
}
|
||||
{
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
if let Some(t) = rt.last_forced {
|
||||
if t.elapsed() < FORCE_MIN_INTERVAL {
|
||||
return Err(ForceError::TooSoon);
|
||||
}
|
||||
}
|
||||
rt.last_forced = Some(Instant::now());
|
||||
rt.refreshing = true;
|
||||
}
|
||||
let _ = tokio::task::spawn_blocking(refresh_blocking).await;
|
||||
let rt = runtime().lock().unwrap();
|
||||
Ok(Snapshot {
|
||||
checked: rt.checked.clone(),
|
||||
last_error: rt.last_error.clone(),
|
||||
job: rt.job.clone(),
|
||||
last_result: jobs::read_result(&jobs::result_path()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) enum ForceError {
|
||||
Disabled,
|
||||
TooSoon,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- apply (U1: Windows)
|
||||
|
||||
/// Why an apply request was refused (mapped to 409s by the API layer).
|
||||
pub(crate) enum ApplyError {
|
||||
/// This install kind has no one-click leg (or the operator kill switch is on) — the
|
||||
/// console shows the command instead.
|
||||
Unsupported,
|
||||
/// `PUNKTFUNK_UPDATE_APPLY=0`.
|
||||
Disabled,
|
||||
/// An apply is already running (or a spawned installer hasn't resolved yet).
|
||||
JobRunning,
|
||||
/// A stream is live and the request didn't say `force`.
|
||||
SessionActive,
|
||||
/// No verified manifest announcing something newer (or it lacks the Windows asset).
|
||||
NothingToApply,
|
||||
}
|
||||
|
||||
/// Start the (Windows) apply pipeline. The request carries **no version, url, or channel** —
|
||||
/// everything comes from the verified cached manifest (invariant §0.3 of the design).
|
||||
pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), ApplyError> {
|
||||
if apply_disabled() {
|
||||
return Err(ApplyError::Disabled);
|
||||
}
|
||||
let (kind, channel) = detect::detect();
|
||||
let windows_leg = kind == detect::InstallKind::WindowsInstaller;
|
||||
let linux_leg = matches!(
|
||||
kind,
|
||||
detect::InstallKind::Apt
|
||||
| detect::InstallKind::Dnf
|
||||
| detect::InstallKind::Sysext
|
||||
| detect::InstallKind::RpmOstree
|
||||
| detect::InstallKind::Pacman
|
||||
| detect::InstallKind::SteamosSource
|
||||
);
|
||||
if !windows_leg && !linux_leg {
|
||||
return Err(ApplyError::Unsupported);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if linux_leg && kind != detect::InstallKind::SteamosSource {
|
||||
// The Deck source rebuild is user-owned and needs no root helper; every other Linux
|
||||
// leg goes through it.
|
||||
if !linux::helper_installed() {
|
||||
return Err(ApplyError::Unsupported);
|
||||
}
|
||||
// The pacman leg additionally requires the root-owned full-sysupgrade opt-in — the
|
||||
// helper enforces it too; refusing here keeps the console honest before any spawn.
|
||||
if kind == detect::InstallKind::Pacman && !linux::pacman_opted_in() {
|
||||
return Err(ApplyError::Unsupported);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if linux_leg {
|
||||
return Err(ApplyError::Unsupported);
|
||||
}
|
||||
if session_active && !force {
|
||||
return Err(ApplyError::SessionActive);
|
||||
}
|
||||
|
||||
let (target_version, serial, asset) = {
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
if rt.job.is_some() {
|
||||
return Err(ApplyError::JobRunning);
|
||||
}
|
||||
// A spawned installer that hasn't resolved (fresh intent, old version) is still an
|
||||
// apply in flight — reconcile owns it; don't start a second one under it.
|
||||
if matches!(
|
||||
jobs::reconcile(
|
||||
jobs::read_intent(&jobs::intent_path()),
|
||||
env!("PUNKTFUNK_VERSION"),
|
||||
now_unix()
|
||||
),
|
||||
jobs::Reconciled::StillApplying
|
||||
) {
|
||||
return Err(ApplyError::JobRunning);
|
||||
}
|
||||
let Some(checked) = rt.checked.as_ref() else {
|
||||
return Err(ApplyError::NothingToApply);
|
||||
};
|
||||
let newer = detect::is_newer(
|
||||
&checked.manifest.version,
|
||||
checked.manifest.ci_run,
|
||||
env!("PUNKTFUNK_VERSION"),
|
||||
channel,
|
||||
);
|
||||
if !newer {
|
||||
return Err(ApplyError::NothingToApply);
|
||||
}
|
||||
// Only the Windows leg needs the manifest's installer asset; the Linux legs resolve
|
||||
// artifacts through the package manager.
|
||||
let asset = checked.manifest.windows_host.clone();
|
||||
if windows_leg && asset.is_none() {
|
||||
return Err(ApplyError::NothingToApply);
|
||||
}
|
||||
let version = checked.manifest.version.clone();
|
||||
let serial = checked.manifest.serial;
|
||||
rt.job = Some(jobs::JobSnapshot {
|
||||
target_version: version.clone(),
|
||||
stage: if windows_leg {
|
||||
"downloading"
|
||||
} else {
|
||||
"applying"
|
||||
},
|
||||
received_bytes: 0,
|
||||
total_bytes: None,
|
||||
started_unix: now_unix(),
|
||||
});
|
||||
(version, serial, asset)
|
||||
};
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let stage = |s: &'static str| {
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
if let Some(job) = rt.job.as_mut() {
|
||||
job.stage = s;
|
||||
}
|
||||
};
|
||||
let outcome: Result<PostApply, (&'static str, String)> = {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let progress = |received: u64, total: Option<u64>| {
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
if let Some(job) = rt.job.as_mut() {
|
||||
job.received_bytes = received;
|
||||
job.total_bytes = total;
|
||||
}
|
||||
};
|
||||
let asset = asset.expect("windows leg reserved with an asset");
|
||||
windows::run_apply(&asset, &target_version, serial, &progress, &stage)
|
||||
.map(|()| PostApply::AwaitRestart)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = &asset; // the Linux legs resolve through the package manager
|
||||
let run = if detect::detect().0 == detect::InstallKind::SteamosSource {
|
||||
linux::run_apply_steamos(&target_version, serial, &stage)
|
||||
} else {
|
||||
linux::run_apply(&target_version, serial, &stage)
|
||||
};
|
||||
run.map(|()| {
|
||||
// Staged / nothing-to-do wrote a durable result and this process lives
|
||||
// on; an in-place change wrote the intent and our restart is queued.
|
||||
// Either way the in-process job is finished.
|
||||
PostApply::Done
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
let _ = (&asset, &target_version, serial, &stage);
|
||||
Err(("applying", "no apply leg for this platform".to_string()))
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Ok(PostApply::AwaitRestart) => {
|
||||
// Stage stays `restarting`; the installer is about to stop the service and
|
||||
// kill this process. Boot reconciliation writes the durable outcome.
|
||||
}
|
||||
Ok(PostApply::Done) => {
|
||||
runtime().lock().unwrap().job = None;
|
||||
}
|
||||
Err((stage_name, error)) => {
|
||||
let record = jobs::ResultRecord {
|
||||
ok: false,
|
||||
from: env!("PUNKTFUNK_VERSION").into(),
|
||||
to: target_version.clone(),
|
||||
finished_unix: now_unix(),
|
||||
stage: Some(stage_name.into()),
|
||||
error: Some(error),
|
||||
log_path: None,
|
||||
staged: false,
|
||||
};
|
||||
let _ = jobs::write_json_atomic(&jobs::result_path(), &record);
|
||||
runtime().lock().unwrap().job = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What an apply leg leaves behind for the spawn wrapper. (Each platform constructs only
|
||||
/// its own variant; the other is matched-but-never-built there.)
|
||||
#[allow(dead_code)]
|
||||
enum PostApply {
|
||||
/// The process is about to die (installer / self-restart); reconcile owns the outcome.
|
||||
AwaitRestart,
|
||||
/// The leg finished in-process (staged, nothing-to-do) — clear the job.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Boot-time reconciliation (design §4.2): close out an intent record left by a previous
|
||||
/// apply. Called once from `mgmt::run` before the API serves.
|
||||
pub(crate) fn reconcile_at_boot() {
|
||||
let path = jobs::intent_path();
|
||||
let intent = jobs::read_intent(&path);
|
||||
// Read off the intent BEFORE `reconcile` consumes it. Restored on both terminal outcomes: a
|
||||
// rolled-back or aborted install killed the tray just as thoroughly as a successful one. NOT on
|
||||
// StillApplying — the installer may still be running and would only kill it again.
|
||||
let restore_tray = intent.as_ref().is_some_and(|i| i.tray_was_running);
|
||||
match jobs::reconcile(intent, env!("PUNKTFUNK_VERSION"), now_unix()) {
|
||||
jobs::Reconciled::None | jobs::Reconciled::StillApplying => {}
|
||||
jobs::Reconciled::Success(record) => {
|
||||
tracing::info!(from = %record.from, to = %record.to, "host update applied");
|
||||
let _ = jobs::write_json_atomic(&jobs::result_path(), &record);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
crate::events::emit(crate::events::EventKind::UpdateApplied {
|
||||
from: record.from,
|
||||
to: record.to,
|
||||
});
|
||||
}
|
||||
jobs::Reconciled::Failed(record) => {
|
||||
tracing::warn!(
|
||||
from = %record.from,
|
||||
to = %record.to,
|
||||
error = record.error.as_deref().unwrap_or(""),
|
||||
"host update did NOT stick"
|
||||
);
|
||||
let _ = jobs::write_json_atomic(&jobs::result_path(), &record);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if restore_tray {
|
||||
windows::relaunch_tray();
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = restore_tray; // the Linux packages never kill a running tray
|
||||
}
|
||||
|
||||
/// What status hands to the API layer.
|
||||
pub(crate) struct Snapshot {
|
||||
pub checked: Option<Checked>,
|
||||
pub last_error: Option<String>,
|
||||
/// The live apply job, when one runs. When the process was restarted mid-apply this is
|
||||
/// `None` but a fresh intent still reads as in-flight — the API layer surfaces that via
|
||||
/// [`Snapshot::applying_from_intent`].
|
||||
pub job: Option<jobs::JobSnapshot>,
|
||||
/// Durable outcome of the most recent apply attempt.
|
||||
pub last_result: Option<jobs::ResultRecord>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// An apply is in flight even though no in-process job exists: a fresh intent record from
|
||||
/// a spawn that hasn't resolved (this process may be the OLD host in its last seconds, or
|
||||
/// a restarted host inside the grace window). The API surfaces it as a `restarting` job.
|
||||
pub(crate) fn applying_from_intent(&self) -> Option<jobs::IntentRecord> {
|
||||
if self.job.is_some() {
|
||||
return None;
|
||||
}
|
||||
let intent = jobs::read_intent(&jobs::intent_path())?;
|
||||
match jobs::reconcile(Some(intent.clone()), env!("PUNKTFUNK_VERSION"), now_unix()) {
|
||||
jobs::Reconciled::StillApplying => Some(intent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stale-feed hint: last successful check is fine but the manifest itself was
|
||||
/// published suspiciously long ago (freeze detection, design §3.2).
|
||||
pub(crate) fn stale(&self) -> bool {
|
||||
self.checked
|
||||
.as_ref()
|
||||
.map(|c| now_unix().saturating_sub(c.manifest.serial) > STALE_AFTER.as_secs())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn floor_roundtrip_and_monotonicity() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-update-floor-{}", std::process::id()));
|
||||
let path = dir.join("update-state.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert_eq!(load_floor(&path, "stable"), 0);
|
||||
store_floor(&path, "stable", 100);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
// Lowering is a no-op.
|
||||
store_floor(&path, "stable", 50);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
// Channels are independent.
|
||||
store_floor(&path, "canary", 7);
|
||||
assert_eq!(load_floor(&path, "canary"), 7);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_floor_file_reads_as_zero() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-update-floor2-{}", std::process::id()));
|
||||
let path = dir.join("update-state.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, b"not json").unwrap();
|
||||
assert_eq!(load_floor(&path, "stable"), 0);
|
||||
// And writing over it recovers.
|
||||
store_floor(&path, "stable", 5);
|
||||
assert_eq!(load_floor(&path, "stable"), 5);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_keys_skip_empty_rotation_slot() {
|
||||
let keys = pinned_keys();
|
||||
assert_eq!(keys.len(), 1, "one live key, one empty rotation slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_math() {
|
||||
let mk = |serial| Snapshot {
|
||||
checked: Some(Checked {
|
||||
manifest: manifest::parse_verified(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"schema": 1, "channel": "stable", "serial": serial,
|
||||
"version": "0.23.0",
|
||||
"notes_url": "https://git.unom.io/unom/punktfunk/releases",
|
||||
}))
|
||||
.unwrap()
|
||||
.as_slice(),
|
||||
"stable",
|
||||
)
|
||||
.unwrap(),
|
||||
fetched_unix: now_unix(),
|
||||
}),
|
||||
last_error: None,
|
||||
job: None,
|
||||
last_result: None,
|
||||
};
|
||||
assert!(!mk(now_unix()).stale());
|
||||
assert!(mk(now_unix() - STALE_AFTER.as_secs() - 10).stale());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! **How was this host installed, and on which channel?** (design §4.1)
|
||||
//!
|
||||
//! The apply strategy — and, until apply lands, the command hint the console shows — hangs
|
||||
//! off the install kind. Detection is a ladder over root-owned facts: packaging writes a
|
||||
//! marker (`/usr/share/punktfunk/install-kind`, e.g. `apt stable`), the sysext self-identifies
|
||||
//! via its merged extension-release, Nix by store path, and so on. The API only ever *reads*
|
||||
//! this; nothing request-side can influence it.
|
||||
//!
|
||||
//! The ladder itself is a pure function over a [`Probe`] so every branch is unit-testable;
|
||||
//! [`detect`] gathers the real probe once per process.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Where the Linux packages stamp how they were installed. First word = kind
|
||||
/// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`).
|
||||
const MARKER_PATH: &str = "/usr/share/punktfunk/install-kind";
|
||||
|
||||
/// The merged sysext names itself here (written by `build-sysext.sh`); its presence means the
|
||||
/// running `/usr` overlay came from the sysext image, regardless of any leftover marker.
|
||||
const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk";
|
||||
|
||||
/// The sysext updater's own config (`CHANNEL=stable|canary`).
|
||||
const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InstallKind {
|
||||
WindowsInstaller,
|
||||
Sysext,
|
||||
RpmOstree,
|
||||
Apt,
|
||||
Dnf,
|
||||
Pacman,
|
||||
SteamosSource,
|
||||
Nix,
|
||||
Source,
|
||||
}
|
||||
|
||||
impl InstallKind {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
InstallKind::WindowsInstaller => "windows-installer",
|
||||
InstallKind::Sysext => "sysext",
|
||||
InstallKind::RpmOstree => "rpm-ostree",
|
||||
InstallKind::Apt => "apt",
|
||||
InstallKind::Dnf => "dnf",
|
||||
InstallKind::Pacman => "pacman",
|
||||
InstallKind::SteamosSource => "steamos-source",
|
||||
InstallKind::Nix => "nix",
|
||||
InstallKind::Source => "source",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Channel {
|
||||
Stable,
|
||||
Canary,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Stable => "stable",
|
||||
Channel::Canary => "canary",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The root-owned facts the ladder reads, gathered once by [`gather`] (tests build these
|
||||
/// directly).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Probe {
|
||||
/// Running on Windows (cfg, not a file).
|
||||
pub windows: bool,
|
||||
/// The running exe's path.
|
||||
pub exe: PathBuf,
|
||||
/// `$HOME`, if any.
|
||||
pub home: Option<PathBuf>,
|
||||
/// Contents of [`MARKER_PATH`], if present.
|
||||
pub marker: Option<String>,
|
||||
/// [`SYSEXT_MARKER`] exists (merged sysext overlay).
|
||||
pub sysext: bool,
|
||||
/// Contents of [`SYSEXT_CONF`], if present.
|
||||
pub sysext_conf: Option<String>,
|
||||
/// `/run/ostree-booted` exists (rpm-ostree / bootc family).
|
||||
pub ostree_booted: bool,
|
||||
}
|
||||
|
||||
fn gather() -> Probe {
|
||||
Probe {
|
||||
windows: cfg!(target_os = "windows"),
|
||||
exe: std::env::current_exe().unwrap_or_default(),
|
||||
home: std::env::var_os("HOME").map(PathBuf::from),
|
||||
marker: std::fs::read_to_string(MARKER_PATH).ok(),
|
||||
sysext: Path::new(SYSEXT_MARKER).exists(),
|
||||
sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(),
|
||||
ostree_booted: Path::new("/run/ostree-booted").exists(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder (design §4.1). Order matters and each rung is a root-owned fact:
|
||||
/// sysext overlay > Nix store path > dev/source tree > user-owned Deck build > package
|
||||
/// marker (flipped to rpm-ostree when the box is ostree-booted) > `source` fallback.
|
||||
pub(crate) fn classify(p: &Probe) -> (InstallKind, Channel) {
|
||||
if p.windows {
|
||||
// The installer is the only supported Windows delivery; a loose cargo build shows
|
||||
// itself by not living under Program Files. Channel: canary installers carry the CI
|
||||
// run as the third component (`M.m.<run>`), see `windows_channel_of`.
|
||||
let installed = p
|
||||
.exe
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains("\\program files\\punktfunk");
|
||||
return if installed {
|
||||
(
|
||||
InstallKind::WindowsInstaller,
|
||||
windows_channel_of(env!("PUNKTFUNK_VERSION")),
|
||||
)
|
||||
} else {
|
||||
(InstallKind::Source, Channel::Stable)
|
||||
};
|
||||
}
|
||||
|
||||
if p.sysext {
|
||||
let channel = p
|
||||
.sysext_conf
|
||||
.as_deref()
|
||||
.and_then(conf_channel)
|
||||
.unwrap_or(Channel::Stable);
|
||||
return (InstallKind::Sysext, channel);
|
||||
}
|
||||
|
||||
if p.exe.starts_with("/nix/store") {
|
||||
return (InstallKind::Nix, Channel::Stable);
|
||||
}
|
||||
|
||||
// A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the
|
||||
// Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is
|
||||
// user-owned but NOT a plain `target/` dir — that distinction is the marker here.
|
||||
let exe_str = p.exe.to_string_lossy().to_string();
|
||||
if exe_str.contains("/target/") {
|
||||
return (InstallKind::Source, Channel::Stable);
|
||||
}
|
||||
if let Some(home) = &p.home {
|
||||
if p.exe.starts_with(home) {
|
||||
return (InstallKind::SteamosSource, Channel::Canary);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(marker) = &p.marker {
|
||||
let mut words = marker.split_whitespace();
|
||||
let kind = words.next().unwrap_or("");
|
||||
let channel = match words.next() {
|
||||
Some("canary") => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
};
|
||||
let kind = match kind {
|
||||
"apt" => Some(InstallKind::Apt),
|
||||
// An ostree-booted box consumed the RPM by layering (or an image build); either
|
||||
// way `dnf upgrade` is not how it updates. bootc-vs-layered is refined in U2 via
|
||||
// `rpm-ostree status` — until then both report `rpm-ostree` (notify text is
|
||||
// identical in U0).
|
||||
"dnf" if p.ostree_booted => Some(InstallKind::RpmOstree),
|
||||
"dnf" => Some(InstallKind::Dnf),
|
||||
"pacman" => Some(InstallKind::Pacman),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = kind {
|
||||
return (kind, channel);
|
||||
}
|
||||
}
|
||||
|
||||
(InstallKind::Source, Channel::Stable)
|
||||
}
|
||||
|
||||
/// `CHANNEL=canary` in `/etc/punktfunk-sysext.conf` (the sysext updater's own format).
|
||||
fn conf_channel(conf: &str) -> Option<Channel> {
|
||||
for line in conf.lines() {
|
||||
if let Some(v) = line.trim().strip_prefix("CHANNEL=") {
|
||||
return Some(match v.trim() {
|
||||
"canary" => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Windows canary installers are versioned `M.m.<run>` where `<run>` is a 4+ digit CI run
|
||||
/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10).
|
||||
fn windows_channel_of(version: &str) -> Channel {
|
||||
match triple(version) {
|
||||
Some((_, _, patch)) if patch >= 1000 => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide answer, computed once.
|
||||
pub(crate) fn detect() -> (InstallKind, Channel) {
|
||||
static DETECTED: OnceLock<(InstallKind, Channel)> = OnceLock::new();
|
||||
*DETECTED.get_or_init(|| classify(&gather()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- version comparison
|
||||
|
||||
/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`).
|
||||
pub(crate) fn triple(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = v
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter(|s| !s.is_empty());
|
||||
// Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three
|
||||
// ONLY if the string actually starts with digits (else it's not a version at all).
|
||||
if !v.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// The CI run number embedded in a canary version string, wherever the channel's format hid
|
||||
/// it: `0.23.0~ci10250.g<sha>` (deb), `0.23.0-0.ci10250.g<sha>` (rpm), `0.23.10250`
|
||||
/// (Windows/decky style, run-as-patch). A stable string yields `None`.
|
||||
pub(crate) fn canary_run(version: &str) -> Option<u64> {
|
||||
// `ci` immediately followed by digits, anywhere.
|
||||
let mut rest = version;
|
||||
while let Some(pos) = rest.find("ci") {
|
||||
let digits: String = rest[pos + 2..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if !digits.is_empty() {
|
||||
return digits.parse().ok();
|
||||
}
|
||||
rest = &rest[pos + 2..];
|
||||
}
|
||||
match triple(version) {
|
||||
Some((_, _, patch)) if patch >= 1000 => Some(patch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the manifest's release newer than what this process runs? Definitive-or-false: an
|
||||
/// unparseable pair never flags (the console still shows both version strings — the badge
|
||||
/// just doesn't light up on guesswork). Canary compares `(major, minor)` then the CI run,
|
||||
/// because canary patch fields mean different things per channel (R10).
|
||||
pub(crate) fn is_newer(
|
||||
manifest_version: &str,
|
||||
manifest_ci_run: Option<u64>,
|
||||
current: &str,
|
||||
channel: Channel,
|
||||
) -> bool {
|
||||
let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else {
|
||||
return false;
|
||||
};
|
||||
match channel {
|
||||
Channel::Stable => m > c,
|
||||
Channel::Canary => {
|
||||
if (m.0, m.1) != (c.0, c.1) {
|
||||
return (m.0, m.1) > (c.0, c.1);
|
||||
}
|
||||
let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version));
|
||||
match (manifest_run, canary_run(current)) {
|
||||
(Some(mr), Some(cr)) => mr > cr,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-kind "how to update" command the console shows while (or instead of) an apply
|
||||
/// path existing (design §5). One line, copy-pastable, no placeholders.
|
||||
pub(crate) fn channel_hint(kind: InstallKind) -> &'static str {
|
||||
match kind {
|
||||
InstallKind::WindowsInstaller => {
|
||||
"winget upgrade unom.PunktfunkHost (or re-run the newer installer)"
|
||||
}
|
||||
InstallKind::Sysext => "sudo punktfunk-sysext update",
|
||||
InstallKind::RpmOstree => {
|
||||
"sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)"
|
||||
}
|
||||
InstallKind::Apt => "sudo apt update && sudo apt install --only-upgrade punktfunk-host",
|
||||
InstallKind::Dnf => "sudo dnf upgrade punktfunk",
|
||||
InstallKind::Pacman => "sudo pacman -Syu",
|
||||
InstallKind::SteamosSource => "bash ~/punktfunk/scripts/steamdeck/update.sh --pull",
|
||||
InstallKind::Nix => "nix flake update punktfunk (then rebuild your system)",
|
||||
InstallKind::Source => "git pull && cargo build --release -p punktfunk-host",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn probe() -> Probe {
|
||||
Probe {
|
||||
windows: false,
|
||||
exe: PathBuf::from("/usr/bin/punktfunk-host"),
|
||||
home: Some(PathBuf::from("/home/deck")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_sysext_beats_marker() {
|
||||
let mut p = probe();
|
||||
p.sysext = true;
|
||||
p.marker = Some("dnf canary".into());
|
||||
p.sysext_conf = Some("CHANNEL=canary\n".into());
|
||||
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Canary));
|
||||
p.sysext_conf = None;
|
||||
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Stable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_nix_store_path() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::Nix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_cargo_target_is_source_even_under_home() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::Source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_deck_build_is_steamos_source() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::SteamosSource);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_markers() {
|
||||
for (marker, ostree, kind, channel) in [
|
||||
("apt stable", false, InstallKind::Apt, Channel::Stable),
|
||||
("apt canary", false, InstallKind::Apt, Channel::Canary),
|
||||
("dnf stable", false, InstallKind::Dnf, Channel::Stable),
|
||||
("dnf stable", true, InstallKind::RpmOstree, Channel::Stable),
|
||||
("pacman canary", false, InstallKind::Pacman, Channel::Canary),
|
||||
] {
|
||||
let mut p = probe();
|
||||
p.marker = Some(marker.into());
|
||||
p.ostree_booted = ostree;
|
||||
assert_eq!(classify(&p), (kind, channel), "marker `{marker}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_unknown_marker_falls_through_to_source() {
|
||||
let mut p = probe();
|
||||
p.marker = Some("snap stable".into());
|
||||
assert_eq!(classify(&p).0, InstallKind::Source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triples() {
|
||||
assert_eq!(triple("0.23.0"), Some((0, 23, 0)));
|
||||
assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0)));
|
||||
assert_eq!(triple("0.23.10250"), Some((0, 23, 10250)));
|
||||
assert_eq!(triple("garbage"), None);
|
||||
assert_eq!(triple("1.2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canary_runs() {
|
||||
assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250));
|
||||
assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777));
|
||||
assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky)
|
||||
assert_eq!(canary_run("0.23.0"), None); // stable string
|
||||
assert_eq!(canary_run("0.23.0-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_stable() {
|
||||
assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable));
|
||||
assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable));
|
||||
assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags
|
||||
assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_canary_compares_runs_not_patch() {
|
||||
// deb canary current vs Windows-style manifest version, same run ⇒ NOT newer,
|
||||
// even though a naive triple compare says 10250 > 0.
|
||||
assert!(!is_newer(
|
||||
"0.23.10250",
|
||||
Some(10250),
|
||||
"0.23.0~ci10250.gab12cd34",
|
||||
Channel::Canary
|
||||
));
|
||||
assert!(is_newer(
|
||||
"0.23.10251",
|
||||
Some(10251),
|
||||
"0.23.0~ci10250.gab12cd34",
|
||||
Channel::Canary
|
||||
));
|
||||
// Minor bump wins outright.
|
||||
assert!(is_newer(
|
||||
"0.24.100",
|
||||
Some(100),
|
||||
"0.23.0~ci10250.g12",
|
||||
Channel::Canary
|
||||
));
|
||||
// No run extractable on either side ⇒ conservative false.
|
||||
assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_channel_heuristic() {
|
||||
assert_eq!(windows_channel_of("0.22.2"), Channel::Stable);
|
||||
assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Apply-job bookkeeping: the in-memory job snapshot the console polls, and the two on-disk
|
||||
//! records that let an update **report its own outcome across its own restart** (design §4.2,
|
||||
//! plan U1.1):
|
||||
//!
|
||||
//! - the **intent record** (`update-intent.json`) — written just before anything irreversible
|
||||
//! (spawning the installer). It is the only witness once the installer kills this process.
|
||||
//! - the **result record** (`update-result.json`) — the durable outcome of the *last* apply,
|
||||
//! written either by the failing stage itself or by boot-time reconciliation.
|
||||
//!
|
||||
//! Reconciliation ([`reconcile`]) runs once per boot: intent + running-the-target-version ⇒
|
||||
//! success; intent + still the old version after the grace window ⇒ failure with the installer
|
||||
//! log path attached; a fresh intent ⇒ the apply is still in flight (the installer may not have
|
||||
//! stopped us yet). Pure over its inputs so every branch is table-testable.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How long after intent-write we still call the apply "in flight" when the running version is
|
||||
/// unchanged. Beyond it, the host restarting *without* the new version is a failed apply
|
||||
/// (installer aborted, rolled back, or never ran) — surfaced, never silent.
|
||||
pub(crate) const APPLY_GRACE_SECS: u64 = 10 * 60;
|
||||
|
||||
/// Written immediately before the point of no return.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct IntentRecord {
|
||||
/// The version that started the apply (us, at write time).
|
||||
pub from: String,
|
||||
/// The version being installed.
|
||||
pub to: String,
|
||||
/// The manifest serial that announced it.
|
||||
pub serial: u64,
|
||||
/// Unix seconds at write.
|
||||
pub started_unix: u64,
|
||||
/// SHA-256 of the verified installer (diagnostics — ties a result to exact bytes).
|
||||
pub installer_sha256: String,
|
||||
/// Where the installer was told to log (`/LOG=`).
|
||||
pub log_path: String,
|
||||
/// A source rebuild (Steam Deck `update.sh`), where version equality proves nothing —
|
||||
/// the workspace version only moves on bumps. The flow's own ordering carries the
|
||||
/// proof instead: the script restarts the host ONLY after a successful build+install,
|
||||
/// and its failure path is reported live (the host survives a failed build). So an
|
||||
/// intent with this flag still present at boot ⟹ the rebuild succeeded.
|
||||
#[serde(default)]
|
||||
pub source_build: bool,
|
||||
/// A per-user status tray was running when we spawned the installer, so boot reconciliation
|
||||
/// should put it back.
|
||||
///
|
||||
/// The installer's `StopTrays` force-kills every session's `punktfunk-tray.exe` (it is one of
|
||||
/// the files being replaced), and its `[Run]` relaunch carries `skipifsilent` — which a
|
||||
/// console-initiated update, spawned with `/VERYSILENT`, always trips. The tray therefore died
|
||||
/// on every in-console update and stayed dead until the next sign-in. The installer cannot fix
|
||||
/// this itself: spawned from the SYSTEM host service, its `runasoriginaluser` resolves to
|
||||
/// SYSTEM, which would put a SYSTEM-owned tray in the user's session squatting the
|
||||
/// `Local\PunktfunkTray` mutex and blocking the real one. The host relaunches it instead — it
|
||||
/// already owns the `WTSQueryUserToken` primitive for landing a process in the interactive
|
||||
/// session as the logged-in user.
|
||||
///
|
||||
/// `#[serde(default)]`: an intent written by an older host reads as false (no relaunch), which
|
||||
/// is the pre-existing behaviour.
|
||||
#[serde(default)]
|
||||
pub tray_was_running: bool,
|
||||
}
|
||||
|
||||
/// The durable outcome of the most recent apply attempt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct ResultRecord {
|
||||
pub ok: bool,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub finished_unix: u64,
|
||||
/// The stage that failed (`downloading` | `verifying` | `applying` | `restarting`);
|
||||
/// absent on success.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stage: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
/// The installer log, when one was in play by the time it failed (or succeeded).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_path: Option<String>,
|
||||
/// The update is applied but activates on the next reboot (rpm-ostree).
|
||||
#[serde(default)]
|
||||
pub staged: bool,
|
||||
}
|
||||
|
||||
/// The live job the console polls, mirrored into `GET /update/status`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct JobSnapshot {
|
||||
pub target_version: String,
|
||||
/// `downloading` | `verifying` | `applying` | `restarting`.
|
||||
pub stage: &'static str,
|
||||
pub received_bytes: u64,
|
||||
pub total_bytes: Option<u64>,
|
||||
pub started_unix: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn intent_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("update-intent.json")
|
||||
}
|
||||
|
||||
pub(crate) fn result_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("update-result.json")
|
||||
}
|
||||
|
||||
pub(crate) fn read_intent(path: &Path) -> Option<IntentRecord> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
// An unparseable intent (half-written before atomic-rename existed, disk fault) must not
|
||||
// crash reconcile — it reads as "no intent" and the stale file is swept.
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn read_result(path: &Path) -> Option<ResultRecord> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
/// Atomic tmp+rename JSON write (the intent/result records must never be half-written — R12).
|
||||
pub(crate) fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
/// What boot-time reconciliation decided.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Reconciled {
|
||||
/// No intent on disk — nothing to close out.
|
||||
None,
|
||||
/// Intent is fresh and we still run the old version: the installer likely hasn't stopped
|
||||
/// us yet (or is mid-copy). Leave the intent in place; status shows the apply in flight.
|
||||
StillApplying,
|
||||
/// We came back at the target version.
|
||||
Success(ResultRecord),
|
||||
/// We came back at the wrong version after the grace window.
|
||||
Failed(ResultRecord),
|
||||
}
|
||||
|
||||
/// Pure reconcile: current version + wall clock vs. the intent record.
|
||||
pub(crate) fn reconcile(
|
||||
intent: Option<IntentRecord>,
|
||||
current_version: &str,
|
||||
now_unix: u64,
|
||||
) -> Reconciled {
|
||||
let Some(intent) = intent else {
|
||||
return Reconciled::None;
|
||||
};
|
||||
if intent.source_build {
|
||||
// See `IntentRecord::source_build`: presence at boot IS the success signal; the
|
||||
// running version is the truthful "to" (a rebuild can legitimately keep it).
|
||||
return Reconciled::Success(ResultRecord {
|
||||
ok: true,
|
||||
from: intent.from,
|
||||
to: current_version.to_string(),
|
||||
finished_unix: now_unix,
|
||||
stage: None,
|
||||
error: None,
|
||||
log_path: Some(intent.log_path),
|
||||
staged: false,
|
||||
});
|
||||
}
|
||||
if current_version == intent.to {
|
||||
return Reconciled::Success(ResultRecord {
|
||||
ok: true,
|
||||
from: intent.from,
|
||||
to: intent.to,
|
||||
finished_unix: now_unix,
|
||||
stage: None,
|
||||
error: None,
|
||||
log_path: Some(intent.log_path),
|
||||
staged: false,
|
||||
});
|
||||
}
|
||||
if now_unix.saturating_sub(intent.started_unix) < APPLY_GRACE_SECS {
|
||||
return Reconciled::StillApplying;
|
||||
}
|
||||
Reconciled::Failed(ResultRecord {
|
||||
ok: false,
|
||||
from: intent.from.clone(),
|
||||
to: intent.to.clone(),
|
||||
finished_unix: now_unix,
|
||||
stage: Some("restarting".into()),
|
||||
error: Some(format!(
|
||||
"the host restarted still running {} (expected {}) — the installer aborted or \
|
||||
rolled back; see its log",
|
||||
intent.from, intent.to
|
||||
)),
|
||||
log_path: Some(intent.log_path),
|
||||
staged: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn intent(started: u64) -> IntentRecord {
|
||||
IntentRecord {
|
||||
from: "0.23.100".into(),
|
||||
to: "0.23.200".into(),
|
||||
serial: 42,
|
||||
started_unix: started,
|
||||
installer_sha256: "ab".repeat(32),
|
||||
log_path: "/logs/update-0.23.200.log".into(),
|
||||
source_build: false,
|
||||
tray_was_running: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An intent written by a host from before the tray-relaunch field must still load, and read
|
||||
/// as "no tray to put back" — the behaviour that shipped before it existed.
|
||||
#[test]
|
||||
fn an_intent_without_the_tray_field_deserializes_as_false() {
|
||||
let json = r#"{"from":"0.23.100","to":"0.23.200","serial":42,"started_unix":1000,
|
||||
"installer_sha256":"ab","log_path":"/logs/x.log"}"#;
|
||||
let i: IntentRecord = serde_json::from_str(json).expect("older intent still parses");
|
||||
assert!(!i.tray_was_running);
|
||||
assert!(!i.source_build);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_build_intent_is_success_with_the_running_version() {
|
||||
let mut i = intent(0);
|
||||
i.source_build = true;
|
||||
// Same version as `from` (a rebuild without a bump) — still success, and `to` is
|
||||
// what actually runs, not the manifest's label.
|
||||
match reconcile(Some(i), "0.23.100", 10_000_000) {
|
||||
Reconciled::Success(r) => {
|
||||
assert!(r.ok);
|
||||
assert_eq!(r.to, "0.23.100");
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_intent_is_none() {
|
||||
assert_eq!(reconcile(None, "0.23.100", 1000), Reconciled::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_version_is_success_regardless_of_age() {
|
||||
for now in [1, 10_000_000] {
|
||||
match reconcile(Some(intent(0)), "0.23.200", now) {
|
||||
Reconciled::Success(r) => {
|
||||
assert!(r.ok);
|
||||
assert_eq!(r.to, "0.23.200");
|
||||
assert_eq!(r.log_path.as_deref(), Some("/logs/update-0.23.200.log"));
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_intent_old_version_is_still_applying() {
|
||||
assert_eq!(
|
||||
reconcile(Some(intent(1000)), "0.23.100", 1000 + APPLY_GRACE_SECS - 1),
|
||||
Reconciled::StillApplying
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_intent_old_version_is_failure_with_log() {
|
||||
match reconcile(Some(intent(1000)), "0.23.100", 1000 + APPLY_GRACE_SECS) {
|
||||
Reconciled::Failed(r) => {
|
||||
assert!(!r.ok);
|
||||
assert_eq!(r.stage.as_deref(), Some("restarting"));
|
||||
assert!(r.error.as_deref().unwrap().contains("0.23.200"));
|
||||
assert!(r.log_path.is_some());
|
||||
}
|
||||
other => panic!("expected failure, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intent_and_result_roundtrip_atomically() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-update-jobs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let ip = dir.join("update-intent.json");
|
||||
write_json_atomic(&ip, &intent(7)).unwrap();
|
||||
assert_eq!(read_intent(&ip).unwrap().started_unix, 7);
|
||||
// Corrupt bytes read as "no intent", never a crash.
|
||||
std::fs::write(&ip, b"{half").unwrap();
|
||||
assert!(read_intent(&ip).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! The Linux apply leg (design §7, plan U2.3): ask systemd to run the `pf-update` root
|
||||
//! oneshot, read its result record, and — when the on-disk binary actually changed — hand
|
||||
//! the outcome across our own restart via the same intent/reconcile machinery the Windows
|
||||
//! leg uses.
|
||||
//!
|
||||
//! Privilege model: this process is unprivileged; `systemctl start punktfunk-update.service`
|
||||
//! is authorized by polkit for members of the `punktfunk-update` group (an explicit,
|
||||
//! auditable opt-in — the packaged group is empty). The request we send carries **nothing**:
|
||||
//! the unit's ExecStart is fixed, and the helper derives everything from root-owned state.
|
||||
//! polkit resolves group membership via NSS at request time, so a fresh
|
||||
//! `usermod -aG punktfunk-update` counts without re-login (the *hint* probe in
|
||||
//! [`opted_in`] uses the same NSS route for the same reason).
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use super::jobs::{self, IntentRecord};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Where the root helper writes its outcome (mirrors `pf-update`'s `HelperResult`).
|
||||
const HELPER_RESULT: &str = "/var/lib/punktfunk/update-result.json";
|
||||
|
||||
/// The unit the polkit rule scopes to. Its presence is the "helper is installed" probe.
|
||||
const UNIT_PATH: &str = "/usr/lib/systemd/system/punktfunk-update.service";
|
||||
|
||||
/// The pacman escape hatch (root-owned; see the design's §5 pacman stance).
|
||||
const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf";
|
||||
|
||||
/// Wall-clock cap on one helper run (a full `pacman -Syu` on a stale box is slow; a stuck
|
||||
/// package manager should still surface as an error eventually).
|
||||
const HELPER_TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct HelperResult {
|
||||
ok: bool,
|
||||
#[serde(default)]
|
||||
before_version: String,
|
||||
#[serde(default)]
|
||||
after_version: String,
|
||||
#[serde(default)]
|
||||
changed: bool,
|
||||
#[serde(default)]
|
||||
staged: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
finished_unix: u64,
|
||||
}
|
||||
|
||||
/// The root helper (+ its unit) is installed on this box.
|
||||
pub(super) fn helper_installed() -> bool {
|
||||
Path::new(UNIT_PATH).exists()
|
||||
}
|
||||
|
||||
/// The pacman full-sysupgrade escape hatch is explicitly enabled (root-owned config).
|
||||
pub(super) fn pacman_opted_in() -> bool {
|
||||
std::fs::read_to_string(PACMAN_OPTIN_CONF)
|
||||
.map(|c| c.lines().any(|l| l.trim() == "PACMAN_FULL_SYSUPGRADE=1"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Is this process's user in the `punktfunk-update` group, **by NSS** (matching how polkit
|
||||
/// will decide) — not by our (possibly stale) process credentials. Cached briefly; the
|
||||
/// status endpoint polls this.
|
||||
pub(super) fn opted_in() -> bool {
|
||||
static CACHE: std::sync::OnceLock<std::sync::Mutex<Option<(Instant, bool)>>> =
|
||||
std::sync::OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(None));
|
||||
{
|
||||
let cached = cache.lock().unwrap();
|
||||
if let Some((at, val)) = *cached {
|
||||
if at.elapsed() < Duration::from_secs(60) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
}
|
||||
let val = probe_group_membership().unwrap_or(false);
|
||||
*cache.lock().unwrap() = Some((Instant::now(), val));
|
||||
val
|
||||
}
|
||||
|
||||
fn probe_group_membership() -> Option<bool> {
|
||||
let user = capture(Command::new("id").arg("-un"))?;
|
||||
let groups = capture(Command::new("id").args(["-nG", user.trim()]))?;
|
||||
Some(groups.split_whitespace().any(|g| g == "punktfunk-update"))
|
||||
}
|
||||
|
||||
fn capture(cmd: &mut Command) -> Option<String> {
|
||||
let out = cmd.output().ok()?;
|
||||
out.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// The console-facing opt-in instruction, shown instead of an Apply button.
|
||||
pub(super) fn opt_in_hint() -> String {
|
||||
"sudo usermod -aG punktfunk-update $USER # enables web-triggered updates for this host"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The Steam Deck source-rebuild leg (plan U3.1): run `~/punktfunk/scripts/steamdeck/update.sh
|
||||
/// --pull` in a TRANSIENT user unit — `systemd-run`, because the script ends by restarting
|
||||
/// `punktfunk-host.service`, and a child inside our own cgroup would be killed by that restart
|
||||
/// mid-run. No root involved (the Deck install is user-owned). Outcome plumbing:
|
||||
/// - build FAILS → the script exits without restarting us → the poll below sees the unit fail
|
||||
/// and reports it live, log attached;
|
||||
/// - build SUCCEEDS → the script restarts us → we die mid-poll; the `source_build` intent at
|
||||
/// next boot IS the success signal (`jobs::reconcile`).
|
||||
pub(super) fn run_apply_steamos(
|
||||
target_version: &str,
|
||||
serial: u64,
|
||||
stage: &dyn Fn(&'static str),
|
||||
) -> Result<(), (&'static str, String)> {
|
||||
let home = std::env::var("HOME").map_err(|_| ("applying", "no $HOME".to_string()))?;
|
||||
let script = std::path::Path::new(&home).join("punktfunk/scripts/steamdeck/update.sh");
|
||||
if !script.exists() {
|
||||
return Err((
|
||||
"applying",
|
||||
format!(
|
||||
"{} not found — is this the Deck on-device install?",
|
||||
script.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let log = pf_paths::config_dir()
|
||||
.join("logs")
|
||||
.join("update-steamos.log");
|
||||
if let Some(dir) = log.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
|
||||
jobs::write_json_atomic(
|
||||
&jobs::intent_path(),
|
||||
&IntentRecord {
|
||||
from: env!("PUNKTFUNK_VERSION").into(),
|
||||
to: target_version.into(),
|
||||
serial,
|
||||
started_unix: super::now_unix(),
|
||||
installer_sha256: String::new(),
|
||||
log_path: log.display().to_string(),
|
||||
source_build: true,
|
||||
// Windows-only concern: no Linux package force-kills a running tray, and the desktop
|
||||
// autostart entry owns bringing it up.
|
||||
tray_was_running: false,
|
||||
},
|
||||
)
|
||||
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
|
||||
|
||||
const UNIT: &str = "pf-source-update";
|
||||
// `--collect` reaps the transient unit even on failure, so a retry can reuse the name.
|
||||
let launched = Command::new("systemd-run")
|
||||
.args(["--user", "--collect", "--unit", UNIT, "bash", "-c"])
|
||||
.arg(format!(
|
||||
"exec >> '{}' 2>&1; exec bash '{}' --pull",
|
||||
log.display(),
|
||||
script.display()
|
||||
))
|
||||
.status();
|
||||
match launched {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(s) => {
|
||||
let _ = std::fs::remove_file(jobs::intent_path());
|
||||
return Err(("applying", format!("systemd-run exited {s}")));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(jobs::intent_path());
|
||||
return Err(("applying", format!("launch systemd-run: {e}")));
|
||||
}
|
||||
}
|
||||
stage("applying");
|
||||
|
||||
// Follow the transient unit. A successful build restarts this process before the unit
|
||||
// goes inactive, so leaving this loop alive means either "still building" or "failed".
|
||||
let deadline = Instant::now() + Duration::from_secs(90 * 60);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
let state = capture(Command::new("systemctl").args(["--user", "is-active", UNIT]))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "failed".into());
|
||||
match state.as_str() {
|
||||
"active" | "activating" | "deactivating" | "reloading" => {
|
||||
if Instant::now() > deadline {
|
||||
// Leave the build running (killing a half-linked build helps nobody) but
|
||||
// stop claiming it; the intent stays for reconcile if it ever finishes.
|
||||
return Err((
|
||||
"applying",
|
||||
format!(
|
||||
"the source rebuild is still running after 90 min — following it \
|
||||
ends here; see {}",
|
||||
log.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The unit ended and we are STILL ALIVE ⇒ the script never reached its restart
|
||||
// step ⇒ the build failed (an up-to-date tree still rebuilds+restarts).
|
||||
_ => {
|
||||
let _ = std::fs::remove_file(jobs::intent_path());
|
||||
return Err((
|
||||
"applying",
|
||||
format!("the source rebuild failed — see {}", log.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the whole Linux apply: start the oneshot, wait, interpret its result record, and for
|
||||
/// an in-place binary change, write the intent and restart ourselves (boot reconciliation
|
||||
/// reports the outcome). Blocking — run on a blocking thread.
|
||||
pub(super) fn run_apply(
|
||||
target_version: &str,
|
||||
serial: u64,
|
||||
stage: &dyn Fn(&'static str),
|
||||
) -> Result<(), (&'static str, String)> {
|
||||
let started_unix = super::now_unix();
|
||||
|
||||
let mut child = Command::new("systemctl")
|
||||
.args(["start", "punktfunk-update.service"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| ("applying", format!("launch systemctl: {e}")))?;
|
||||
|
||||
let deadline = Instant::now() + HELPER_TIMEOUT;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() > deadline => {
|
||||
let _ = child.kill();
|
||||
return Err((
|
||||
"applying",
|
||||
format!(
|
||||
"the update helper is still running after {} min — see \
|
||||
`journalctl -u punktfunk-update.service`",
|
||||
HELPER_TIMEOUT.as_secs() / 60
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(None) => std::thread::sleep(Duration::from_millis(500)),
|
||||
Err(e) => return Err(("applying", format!("wait for systemctl: {e}"))),
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
let mut err = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
use std::io::Read as _;
|
||||
let _ = stderr.read_to_string(&mut err);
|
||||
}
|
||||
let denial = err.contains("interactive authentication")
|
||||
|| err.contains("Access denied")
|
||||
|| err.contains("Permission denied");
|
||||
return Err((
|
||||
"applying",
|
||||
if denial {
|
||||
format!(
|
||||
"not authorized to start the update helper — enable web-triggered \
|
||||
updates first: {}",
|
||||
opt_in_hint()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"update helper failed ({status}) — see \
|
||||
`journalctl -u punktfunk-update.service`. {err}"
|
||||
)
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// The unit succeeded; its record is the ground truth. Refuse a stale record (an old run's
|
||||
// leftovers with a fresh exit 0 would mean the helper never wrote — surface that).
|
||||
let result: HelperResult = std::fs::read(HELPER_RESULT)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.ok_or((
|
||||
"applying",
|
||||
format!("the update helper wrote no readable result at {HELPER_RESULT}"),
|
||||
))?;
|
||||
if result.finished_unix + 5 < started_unix {
|
||||
return Err((
|
||||
"applying",
|
||||
format!(
|
||||
"the update helper's result record predates this run \
|
||||
({} < {started_unix}) — it never wrote one",
|
||||
result.finished_unix
|
||||
),
|
||||
));
|
||||
}
|
||||
if !result.ok {
|
||||
return Err((
|
||||
"applying",
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "the update helper reported failure without detail".into()),
|
||||
));
|
||||
}
|
||||
|
||||
let current = env!("PUNKTFUNK_VERSION");
|
||||
if result.staged {
|
||||
// rpm-ostree: the new deployment activates on reboot — durable outcome now, no restart.
|
||||
let _ = jobs::write_json_atomic(
|
||||
&jobs::result_path(),
|
||||
&jobs::ResultRecord {
|
||||
ok: true,
|
||||
from: current.into(),
|
||||
to: target_version.into(),
|
||||
finished_unix: super::now_unix(),
|
||||
stage: None,
|
||||
error: None,
|
||||
log_path: None,
|
||||
staged: true,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !result.changed {
|
||||
// The channel had nothing newer than what's installed (announce leads the mirrors) —
|
||||
// a real answer, not an error. from == to renders as "already up to date".
|
||||
let _ = jobs::write_json_atomic(
|
||||
&jobs::result_path(),
|
||||
&jobs::ResultRecord {
|
||||
ok: true,
|
||||
from: current.into(),
|
||||
to: current.into(),
|
||||
finished_unix: super::now_unix(),
|
||||
stage: None,
|
||||
error: None,
|
||||
log_path: None,
|
||||
staged: false,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The on-disk binary changed (and the helper's run-the-binary gate already proved it
|
||||
// runs). Cross the restart on the intent record, exactly like the Windows leg.
|
||||
let to = result
|
||||
.after_version
|
||||
.split_whitespace()
|
||||
.last()
|
||||
.unwrap_or(target_version)
|
||||
.to_string();
|
||||
jobs::write_json_atomic(
|
||||
&jobs::intent_path(),
|
||||
&IntentRecord {
|
||||
from: current.into(),
|
||||
to,
|
||||
serial,
|
||||
started_unix: super::now_unix(),
|
||||
installer_sha256: String::new(),
|
||||
log_path: "journalctl -u punktfunk-update.service".into(),
|
||||
source_build: false,
|
||||
tray_was_running: false, // Windows-only concern (see the source-build intent above)
|
||||
},
|
||||
)
|
||||
.map_err(|e| ("restarting", format!("write intent record: {e}")))?;
|
||||
|
||||
stage("restarting");
|
||||
// Web console first (its own unit; a brief console blip the frontend rides out), then
|
||||
// ourselves — --no-block, because this very process is what's being restarted.
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "--no-block", "restart", "punktfunk-web.service"])
|
||||
.status();
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "--no-block", "restart", "punktfunk-host.service"])
|
||||
.status();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The signed **update manifest**: the check truth for "a newer host exists"
|
||||
//! (design `host-update-from-web-console.md` §3).
|
||||
//!
|
||||
//! One small JSON document per channel, Ed25519-signed with keys pinned in this binary
|
||||
//! ([`super::UPDATE_KEYS`]) and verified by the exact code path the plugin store already
|
||||
//! trusts ([`crate::store::index::verify_signature`]). TLS and the registry that serves the
|
||||
//! document are transport, never trust.
|
||||
//!
|
||||
//! Rules, all fail-closed (the sysext 303 lesson encoded):
|
||||
//! 1. **Signature before parse** — over the exact fetched bytes, then strict JSON. An HTML
|
||||
//! error page, a redirect stub, or a truncated body dies before any field is read.
|
||||
//! 2. **Channel binding** — the document names its channel and it must match the one we
|
||||
//! asked for, so a validly-signed canary manifest replayed onto the stable URL is refused.
|
||||
//! 3. **Monotonic serial** — the publish-time serial can never go backwards for a channel
|
||||
//! (the anti-downgrade/anti-replay floor, persisted by [`super`]).
|
||||
//! 4. **Pinned notes origin** — the release-notes link the console renders must live on our
|
||||
//! forge, so a signed-but-wrong document can't send the operator to a lookalike page.
|
||||
|
||||
use crate::store::index::{verify_signature, PublicKey};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The only manifest schema this host understands. A breaking change bumps this; old hosts
|
||||
/// report "unsupported schema" instead of guessing.
|
||||
pub(crate) const SCHEMA: u32 = 1;
|
||||
|
||||
/// Hard cap on a fetched manifest (and its signature). The real document is <1 KB.
|
||||
pub(crate) const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// The only origin a manifest may point the operator at for release notes.
|
||||
const NOTES_ORIGIN: &str = "https://git.unom.io/";
|
||||
|
||||
/// The signed update manifest, as served (and signed) per channel.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct Manifest {
|
||||
/// Document schema — must equal [`SCHEMA`].
|
||||
pub schema: u32,
|
||||
/// The channel this document was published for (`stable` | `canary`). Bound-checked
|
||||
/// against the channel we fetched, see module docs rule 2.
|
||||
pub channel: String,
|
||||
/// Unix seconds at publish. Strictly increasing per channel; also drives the stale-feed
|
||||
/// hint (freshness needs no date parsing and no trust in `published_at`).
|
||||
pub serial: u64,
|
||||
/// RFC-3339 publish time. Display only.
|
||||
#[serde(default)]
|
||||
pub published_at: String,
|
||||
/// The released host version this manifest announces.
|
||||
pub version: String,
|
||||
/// Release-notes link the console renders. Must be on [`NOTES_ORIGIN`].
|
||||
#[serde(default)]
|
||||
pub notes_url: String,
|
||||
/// Canary only: the CI run number, the definitive "newer" axis where per-channel version
|
||||
/// strings differ (`~ciN`, `0.ciN`, a padded pkgrel, `M.m.run`).
|
||||
#[serde(default)]
|
||||
pub ci_run: Option<u64>,
|
||||
/// The Windows installer leg (design §6) — parsed and carried now so a U0 host is already
|
||||
/// schema-complete, consumed by the U1 apply path.
|
||||
#[serde(default)]
|
||||
pub windows_host: Option<WindowsHostAsset>,
|
||||
}
|
||||
|
||||
/// Where the Windows host installer for [`Manifest::version`] lives and how to verify it.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct WindowsHostAsset {
|
||||
/// Immutable per-version download URL — never a mutable `latest/` alias, so the hash
|
||||
/// below can't race an alias re-upload.
|
||||
pub url: String,
|
||||
/// SHA-256 (hex) of the installer bytes.
|
||||
pub sha256: String,
|
||||
/// Accepted Authenticode signing-leaf SHA-256 fingerprints. Living in the signed manifest
|
||||
/// (not the binary) is what makes the self-signed → Trusted Signing migration a manifest
|
||||
/// edit instead of a lockstep host release.
|
||||
#[serde(default)]
|
||||
pub authenticode_sha256: Vec<String>,
|
||||
/// Minimum Windows build (display/preflight only).
|
||||
#[serde(default)]
|
||||
pub min_os: String,
|
||||
}
|
||||
|
||||
/// Verify `sig_text` over the exact `bytes` against `keys`, then strictly parse and validate
|
||||
/// the document for `expected_channel`. The only constructor — there is no unsigned path.
|
||||
pub(crate) fn verify_and_parse(
|
||||
bytes: &[u8],
|
||||
sig_text: &str,
|
||||
keys: &[PublicKey],
|
||||
expected_channel: &str,
|
||||
) -> Result<Manifest> {
|
||||
verify_signature(bytes, sig_text, keys).context("update manifest signature")?;
|
||||
parse_verified(bytes, expected_channel)
|
||||
}
|
||||
|
||||
/// Parse + validate a document whose signature has already been checked. Split out so tests
|
||||
/// can exercise validation without minting signatures for every case.
|
||||
pub(crate) fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result<Manifest> {
|
||||
if bytes.len() > MAX_MANIFEST_BYTES {
|
||||
bail!("manifest is larger than the {MAX_MANIFEST_BYTES}-byte cap");
|
||||
}
|
||||
let m: Manifest = serde_json::from_slice(bytes).context("update manifest is not valid JSON")?;
|
||||
if m.schema != SCHEMA {
|
||||
bail!(
|
||||
"unsupported manifest schema {} (this host understands {SCHEMA})",
|
||||
m.schema
|
||||
);
|
||||
}
|
||||
if m.channel != expected_channel {
|
||||
bail!(
|
||||
"manifest is for channel `{}` but this host asked for `{expected_channel}`",
|
||||
m.channel
|
||||
);
|
||||
}
|
||||
if m.version.is_empty() || m.version.len() > 64 {
|
||||
bail!("manifest version is empty or implausibly long");
|
||||
}
|
||||
if m.serial == 0 {
|
||||
bail!("manifest serial is zero");
|
||||
}
|
||||
if !m.notes_url.is_empty() && !m.notes_url.starts_with(NOTES_ORIGIN) {
|
||||
bail!("manifest notes_url is not on {NOTES_ORIGIN}");
|
||||
}
|
||||
if let Some(w) = &m.windows_host {
|
||||
if !w.url.starts_with("https://") {
|
||||
bail!("windows_host.url must be https");
|
||||
}
|
||||
if w.sha256.len() != 64 || !w.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
bail!("windows_host.sha256 is not a hex SHA-256");
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"schema": 1,
|
||||
"channel": "stable",
|
||||
"serial": 1785400000u64,
|
||||
"published_at": "2026-07-30T12:00:00Z",
|
||||
"version": "0.23.0",
|
||||
"notes_url": "https://git.unom.io/unom/punktfunk/releases/tag/v0.23.0",
|
||||
"windows_host": {
|
||||
"url": "https://git.unom.io/unom/punktfunk/releases/download/v0.23.0/punktfunk-host-setup-0.23.0.exe",
|
||||
"sha256": "aa".repeat(32),
|
||||
"authenticode_sha256": ["bb".repeat(32)],
|
||||
"min_os": "10.0.22621"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn bytes(v: &serde_json::Value) -> Vec<u8> {
|
||||
serde_json::to_vec(v).unwrap()
|
||||
}
|
||||
|
||||
/// End-to-end: sign with a fresh ring keypair, verify with the matching pinned-key
|
||||
/// string — the format contract with the CI signer (raw 64-byte sig, base64; raw
|
||||
/// 32-byte key, `ed25519:<base64>`).
|
||||
#[test]
|
||||
fn signed_roundtrip_and_tamper() {
|
||||
use base64::Engine as _;
|
||||
use ring::signature::KeyPair as _;
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
|
||||
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
|
||||
let key_str = format!(
|
||||
"ed25519:{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
|
||||
);
|
||||
let keys = vec![PublicKey::parse(&key_str).unwrap()];
|
||||
|
||||
let body = bytes(&doc());
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(kp.sign(&body));
|
||||
|
||||
let m = verify_and_parse(&body, &sig, &keys, "stable").unwrap();
|
||||
assert_eq!(m.version, "0.23.0");
|
||||
assert_eq!(
|
||||
m.windows_host.as_ref().unwrap().authenticode_sha256.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// One flipped byte ⇒ refused before parse.
|
||||
let mut tampered = body.clone();
|
||||
tampered[10] ^= 1;
|
||||
assert!(verify_and_parse(&tampered, &sig, &keys, "stable").is_err());
|
||||
|
||||
// Signed for stable, replayed as canary ⇒ refused (channel binding).
|
||||
assert!(verify_and_parse(&body, &sig, &keys, "canary").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_error_page_is_not_a_manifest() {
|
||||
// The Gitea 303 stub that poisoned the sysext feed — must die in strict parse.
|
||||
let html = b"<a href=\"https://objects.example/x\">See Other</a>.";
|
||||
assert!(parse_verified(html, "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_json_refused() {
|
||||
let body = bytes(&doc());
|
||||
assert!(parse_verified(&body[..body.len() - 5], "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_schema_refused() {
|
||||
let mut v = doc();
|
||||
v["schema"] = serde_json::json!(2);
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_serial_refused() {
|
||||
let mut v = doc();
|
||||
v["serial"] = serde_json::json!(0);
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsite_notes_url_refused() {
|
||||
let mut v = doc();
|
||||
v["notes_url"] = serde_json::json!("https://evil.example/notes");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_asset_validation() {
|
||||
let mut v = doc();
|
||||
v["windows_host"]["sha256"] = serde_json::json!("nothex");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
let mut v = doc();
|
||||
v["windows_host"]["url"] = serde_json::json!("http://git.unom.io/x.exe");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
// No windows leg at all is fine (PM channels don't need it).
|
||||
let mut v = doc();
|
||||
v.as_object_mut().unwrap().remove("windows_host");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_refused() {
|
||||
let mut v = doc();
|
||||
v["published_at"] = serde_json::json!("x".repeat(MAX_MANIFEST_BYTES));
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The Windows apply leg (design §6, plan U1.2/U1.3): download the manifest's immutable
|
||||
//! per-version installer, verify it (manifest SHA-256, then Authenticode), persist the intent
|
||||
//! record, and spawn the installer detached — which stops the service and thereby kills this
|
||||
//! process *by design*; boot-time reconciliation (`jobs::reconcile`) closes the loop.
|
||||
//!
|
||||
//! Verification order and rules:
|
||||
//! 1. **SHA-256 == the signed manifest's** — the primary integrity gate (the manifest is the
|
||||
//! Ed25519-verified document; this check makes the downloaded bytes those exact bytes).
|
||||
//! 2. **Authenticode**: the embedded signature must be cryptographically valid, tolerating
|
||||
//! `CERT_E_UNTRUSTEDROOT` while the shipping cert is self-signed (`CN=unom`); when the
|
||||
//! manifest carries leaf pins, the signing leaf's SHA-256 must match one. The leaf is taken
|
||||
//! from the SAME `WinVerifyTrust` state (`WTHelperGetProvSignerFromChain`), never a second
|
||||
//! parse — no verify-vs-inspect gap. An empty pin list skips only the pin comparison (the
|
||||
//! manifest hash already binds content; pins arrive via `AUTHENTICODE_SHA256` in CI once
|
||||
//! the cert story settles — the field exists so Trusted Signing is a manifest edit).
|
||||
//!
|
||||
//! The spawn uses `CREATE_BREAKAWAY_FROM_JOB`: the service worker's job object is kill-on-close
|
||||
//! (a stopping service would otherwise take the installer down with it) and was created
|
||||
//! breakaway-ok for exactly this shape (`windows/service.rs`). Failure to break away is a hard,
|
||||
//! reported error (plan R3), never a silent fallback.
|
||||
|
||||
#![cfg(target_os = "windows")]
|
||||
|
||||
use super::jobs::{self, IntentRecord};
|
||||
use super::manifest::WindowsHostAsset;
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Free-space preflight: require this multiple of the download size (installer + Inno's
|
||||
/// unpack scratch + headroom).
|
||||
const DISK_MARGIN: u64 = 3;
|
||||
|
||||
/// Keep the target + this many previous installers cached for the manual-rollback path.
|
||||
const KEEP_INSTALLERS: usize = 2;
|
||||
|
||||
const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// The winget-blessed silent flags (`packaging/winget/unom.PunktfunkHost.installer.yaml`).
|
||||
const SILENT_ARGS: [&str; 4] = ["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-"];
|
||||
|
||||
fn staging_dir() -> PathBuf {
|
||||
pf_paths::config_dir().join("updates")
|
||||
}
|
||||
|
||||
/// Put the per-user tray back after an update that killed it.
|
||||
///
|
||||
/// Runs from boot reconciliation, i.e. in the NEW host, as SYSTEM — so `crate::tray::start` takes
|
||||
/// its session-crossing path and lands the tray in the active console session under the logged-in
|
||||
/// user's own token. That is the whole reason this is the host's job and not the installer's (see
|
||||
/// `IntentRecord::tray_was_running`).
|
||||
///
|
||||
/// Best-effort throughout: nobody's update outcome depends on the icon, and the common benign
|
||||
/// failure is simply that nobody has signed in yet — which the HKLM `Run` value covers at the next
|
||||
/// logon. Hence `info`, not a warning the operator must act on.
|
||||
pub(crate) fn relaunch_tray() {
|
||||
match crate::tray::start() {
|
||||
Ok((Some(pid), how)) => tracing::info!(pid, how, "status tray relaunched after the update"),
|
||||
Ok((None, _)) => tracing::debug!("status tray was already running after the update"),
|
||||
Err(e) => tracing::info!(error = %e, "could not relaunch the status tray after the update"),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_path(version: &str) -> PathBuf {
|
||||
pf_paths::config_dir()
|
||||
.join("logs")
|
||||
.join(format!("update-{version}.log"))
|
||||
}
|
||||
|
||||
/// The whole pipeline, run on a blocking thread. Reports progress/stage through the callbacks
|
||||
/// so this file stays free of the runtime-state lock.
|
||||
pub(super) fn run_apply(
|
||||
asset: &WindowsHostAsset,
|
||||
target_version: &str,
|
||||
serial: u64,
|
||||
progress: &dyn Fn(u64, Option<u64>),
|
||||
stage: &dyn Fn(&'static str),
|
||||
) -> Result<(), (&'static str, String)> {
|
||||
let dir = staging_dir();
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| ("downloading", format!("create staging dir: {e}")))?;
|
||||
|
||||
let final_path = dir.join(format!("punktfunk-host-setup-{target_version}.exe"));
|
||||
let part_path = dir.join(format!("punktfunk-host-setup-{target_version}.exe.part"));
|
||||
|
||||
download(&asset.url, &part_path, progress).map_err(|e| ("downloading", e))?;
|
||||
|
||||
stage("verifying");
|
||||
verify_sha256(&part_path, &asset.sha256).map_err(|e| {
|
||||
quarantine(&part_path);
|
||||
("verifying", e)
|
||||
})?;
|
||||
verify_authenticode(&part_path, &asset.authenticode_sha256).map_err(|e| {
|
||||
quarantine(&part_path);
|
||||
("verifying", e)
|
||||
})?;
|
||||
std::fs::rename(&part_path, &final_path)
|
||||
.map_err(|e| ("verifying", format!("stage rename: {e}")))?;
|
||||
prune_installers(&dir, &final_path);
|
||||
|
||||
stage("applying");
|
||||
let log = log_path(target_version);
|
||||
if let Some(parent) = log.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// The point of no return: after this record exists, boot reconciliation owns the outcome.
|
||||
jobs::write_json_atomic(
|
||||
&jobs::intent_path(),
|
||||
&IntentRecord {
|
||||
from: env!("PUNKTFUNK_VERSION").into(),
|
||||
to: target_version.into(),
|
||||
serial,
|
||||
started_unix: super::now_unix(),
|
||||
installer_sha256: asset.sha256.to_ascii_lowercase(),
|
||||
log_path: log.display().to_string(),
|
||||
source_build: false,
|
||||
// Captured BEFORE the installer runs: it force-kills every tray to unlock
|
||||
// punktfunk-tray.exe and, under /VERYSILENT, never runs its relaunch entry. See
|
||||
// `IntentRecord::tray_was_running`; `relaunch_tray` puts it back at reconcile.
|
||||
tray_was_running: crate::tray::is_running(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
|
||||
|
||||
// Let the 202 + the console's next status poll leave the box before the installer starts
|
||||
// stopping the service under us (plan R4).
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
|
||||
let spawned = {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
std::process::Command::new(&final_path)
|
||||
.args(SILENT_ARGS)
|
||||
.arg(format!("/LOG={}", log.display()))
|
||||
.current_dir(&dir)
|
||||
.creation_flags(CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
};
|
||||
match spawned {
|
||||
Ok(child) => {
|
||||
// Detached on purpose: the child must outlive us. Dropping a Child does not kill it.
|
||||
drop(child);
|
||||
stage("restarting");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Most likely: the job object stopped allowing breakaway (R3). Clear the intent —
|
||||
// nothing irreversible happened — and surface the real error.
|
||||
let _ = std::fs::remove_file(jobs::intent_path());
|
||||
Err((
|
||||
"applying",
|
||||
format!(
|
||||
"spawn installer (CREATE_BREAKAWAY_FROM_JOB — if this is ACCESS_DENIED, \
|
||||
the service job object no longer permits breakaway): {e}"
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn quarantine(part: &Path) {
|
||||
let bad = part.with_extension("bad");
|
||||
let _ = std::fs::remove_file(&bad);
|
||||
let _ = std::fs::rename(part, &bad);
|
||||
}
|
||||
|
||||
/// Download `url` to `part`, resuming an existing partial file when the server honors Range.
|
||||
fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Result<(), String> {
|
||||
if !url.starts_with("https://") {
|
||||
return Err("installer url must be https".into());
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(15))
|
||||
.redirects(3)
|
||||
.user_agent(&format!(
|
||||
"punktfunk-host/{} (update-apply)",
|
||||
env!("PUNKTFUNK_VERSION")
|
||||
))
|
||||
.build();
|
||||
|
||||
let existing = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
|
||||
let mut req = agent.get(url);
|
||||
if existing > 0 {
|
||||
req = req.set("Range", &format!("bytes={existing}-"));
|
||||
}
|
||||
let resp = req.call().map_err(|e| match e {
|
||||
ureq::Error::Status(code, _) => format!("download returned HTTP {code}"),
|
||||
other => format!("download failed: {other}"),
|
||||
})?;
|
||||
|
||||
let resumed = resp.status() == 206;
|
||||
let content_len: Option<u64> = resp.header("content-length").and_then(|v| v.parse().ok());
|
||||
let total = content_len.map(|l| if resumed { existing + l } else { l });
|
||||
if let Some(t) = total {
|
||||
preflight_disk(part, t.saturating_mul(DISK_MARGIN))?;
|
||||
}
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
// Never truncate at open: on a 206 we append to the existing partial, and the
|
||||
// fresh-download path truncates explicitly via `set_len(0)` below.
|
||||
.truncate(false)
|
||||
.open(part)
|
||||
.map_err(|e| format!("open staging file: {e}"))?;
|
||||
let mut received = if resumed {
|
||||
file.seek(std::io::SeekFrom::End(0))
|
||||
.map_err(|e| format!("seek: {e}"))?
|
||||
} else {
|
||||
file.set_len(0).map_err(|e| format!("truncate: {e}"))?;
|
||||
0
|
||||
};
|
||||
progress(received, total);
|
||||
|
||||
let mut reader = resp.into_reader();
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| format!("read: {e}"))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(&buf[..n])
|
||||
.map_err(|e| format!("write: {e}"))?;
|
||||
received += n as u64;
|
||||
progress(received, total);
|
||||
}
|
||||
file.sync_all().map_err(|e| format!("fsync: {e}"))?;
|
||||
if let Some(t) = total {
|
||||
if received != t {
|
||||
return Err(format!("download truncated: {received} of {t} bytes"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_sha256(path: &Path, expected_hex: &str) -> Result<(), String> {
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("open for hashing: {e}"))?;
|
||||
let mut ctx = ring::digest::Context::new(&ring::digest::SHA256);
|
||||
let mut buf = [0u8; 128 * 1024];
|
||||
loop {
|
||||
let n = file
|
||||
.read(&mut buf)
|
||||
.map_err(|e| format!("read for hashing: {e}"))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
ctx.update(&buf[..n]);
|
||||
}
|
||||
let got = hex(ctx.finish().as_ref());
|
||||
if got != expected_hex.to_ascii_lowercase() {
|
||||
return Err(format!(
|
||||
"installer sha256 mismatch: got {got}, manifest says {expected_hex}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn preflight_disk(at: &Path, needed: u64) -> Result<(), String> {
|
||||
use windows::core::HSTRING;
|
||||
use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
||||
let dir = at.parent().unwrap_or(at);
|
||||
let mut free: u64 = 0;
|
||||
// SAFETY: the HSTRING is a valid NUL-terminated path living across the call, and the out
|
||||
// param points at a live local u64; the API retains neither.
|
||||
unsafe { GetDiskFreeSpaceExW(&HSTRING::from(dir.as_os_str()), Some(&mut free), None, None) }
|
||||
.map_err(|e| format!("disk preflight: {e}"))?;
|
||||
if free < needed {
|
||||
return Err(format!(
|
||||
"not enough disk space for the update: {free} bytes free, {needed} needed"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticode: valid embedded signature (untrusted root tolerated — self-signed `CN=unom`),
|
||||
/// signing-leaf SHA-256 ∈ `pins` when pins are present. The leaf comes out of the same
|
||||
/// `WinVerifyTrust` state via `WTHelperGetProvSignerFromChain`. (`pub(crate)`: the service
|
||||
/// supervisor's boot-loop rollback re-checks the cached previous installer with it.)
|
||||
pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), String> {
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
use windows::Win32::Foundation::{CERT_E_UNTRUSTEDROOT, S_OK};
|
||||
use windows::Win32::Security::WinTrust::{
|
||||
WTHelperGetProvSignerFromChain, WTHelperProvDataFromStateData, WinVerifyTrust,
|
||||
WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, WINTRUST_FILE_INFO,
|
||||
WTD_CHOICE_FILE, WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY,
|
||||
WTD_UI_NONE,
|
||||
};
|
||||
|
||||
let wide: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let file_info = WINTRUST_FILE_INFO {
|
||||
cbStruct: std::mem::size_of::<WINTRUST_FILE_INFO>() as u32,
|
||||
pcwszFilePath: PCWSTR(wide.as_ptr()),
|
||||
hFile: Default::default(),
|
||||
pgKnownSubject: std::ptr::null_mut(),
|
||||
};
|
||||
let mut data = WINTRUST_DATA {
|
||||
cbStruct: std::mem::size_of::<WINTRUST_DATA>() as u32,
|
||||
dwUIChoice: WTD_UI_NONE,
|
||||
fdwRevocationChecks: WTD_REVOKE_NONE,
|
||||
dwUnionChoice: WTD_CHOICE_FILE,
|
||||
Anonymous: WINTRUST_DATA_0 {
|
||||
pFile: &file_info as *const _ as *mut _,
|
||||
},
|
||||
dwStateAction: WTD_STATEACTION_VERIFY,
|
||||
..Default::default()
|
||||
};
|
||||
let mut action: GUID = WINTRUST_ACTION_GENERIC_VERIFY_V2;
|
||||
|
||||
// SAFETY: `data`, the `file_info` it points to, and the path's wide buffer all outlive
|
||||
// the call; `action` is a live mutable GUID. WinVerifyTrust reads the structs and stores
|
||||
// its state into `data.hWVTStateData`, released by the CLOSE call below.
|
||||
let status = unsafe {
|
||||
WinVerifyTrust(
|
||||
Default::default(),
|
||||
&mut action,
|
||||
&mut data as *mut _ as *mut core::ffi::c_void,
|
||||
)
|
||||
};
|
||||
let verdict = (|| {
|
||||
let ok = status == S_OK.0 || status == CERT_E_UNTRUSTEDROOT.0;
|
||||
if !ok {
|
||||
return Err(format!(
|
||||
"installer Authenticode signature is invalid (WinVerifyTrust 0x{status:08x})"
|
||||
));
|
||||
}
|
||||
if pins.is_empty() {
|
||||
tracing::warn!(
|
||||
"update manifest carries no Authenticode leaf pins — accepting on the \
|
||||
manifest sha256 + signature validity alone"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
// Same-state leaf extraction: no second parse of the file.
|
||||
// SAFETY: `hWVTStateData` is the live verification state the VERIFY call above
|
||||
// populated (status checked OK); the returned pointer borrows that state, which stays
|
||||
// alive until the CLOSE call below, and is null-checked before use.
|
||||
let prov = unsafe { WTHelperProvDataFromStateData(data.hWVTStateData) };
|
||||
if prov.is_null() {
|
||||
return Err("WinVerifyTrust returned no provider state".into());
|
||||
}
|
||||
// SAFETY: `prov` was null-checked and borrows the same live verification state;
|
||||
// index 0 addresses the primary (only) signer, no counter-signer requested.
|
||||
let signer = unsafe { WTHelperGetProvSignerFromChain(prov, 0, false, 0) };
|
||||
if signer.is_null() {
|
||||
return Err("no signer in the Authenticode chain".into());
|
||||
}
|
||||
// SAFETY: `signer` was null-checked and borrows the live verification state; the
|
||||
// chain array is length/null-checked before indexing, and the CERT_CONTEXT borrows
|
||||
// the same state — every read of it happens before the CLOSE call below.
|
||||
let leaf = unsafe {
|
||||
let s = &*signer;
|
||||
if s.csCertChain == 0 || s.pasCertChain.is_null() {
|
||||
return Err("empty Authenticode cert chain".into());
|
||||
}
|
||||
// pasCertChain[0] is the SIGNING cert (leaf → root order).
|
||||
&*(*s.pasCertChain).pCert
|
||||
};
|
||||
// SAFETY: `pbCertEncoded`/`cbCertEncoded` describe the DER buffer owned by the live
|
||||
// cert context above; the slice is consumed (hashed) before the state is closed.
|
||||
let der =
|
||||
unsafe { std::slice::from_raw_parts(leaf.pbCertEncoded, leaf.cbCertEncoded as usize) };
|
||||
let fp = hex(ring::digest::digest(&ring::digest::SHA256, der).as_ref());
|
||||
if !pins.iter().any(|p| p.eq_ignore_ascii_case(&fp)) {
|
||||
return Err(format!(
|
||||
"installer signing-leaf fingerprint {fp} matches none of the manifest's \
|
||||
{} pin(s)",
|
||||
pins.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
// Always release the verification state.
|
||||
data.dwStateAction = WTD_STATEACTION_CLOSE;
|
||||
// SAFETY: same live `data`/`file_info`/`action` as the VERIFY call; CLOSE releases
|
||||
// `hWVTStateData`, after which no borrow of the state remains (the leaf/DER reads all
|
||||
// happened inside `verdict` above).
|
||||
unsafe {
|
||||
WinVerifyTrust(
|
||||
Default::default(),
|
||||
&mut action,
|
||||
&mut data as *mut _ as *mut core::ffi::c_void,
|
||||
)
|
||||
};
|
||||
verdict
|
||||
}
|
||||
|
||||
/// Keep the freshly-verified target plus the newest previous installers; sweep the rest (and
|
||||
/// any stale `.part`/`.bad` from other versions).
|
||||
fn prune_installers(dir: &Path, keep_newest: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut exes: Vec<(std::time::SystemTime, PathBuf)> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p != keep_newest)
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with("punktfunk-host-setup-"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|p| {
|
||||
let t = p
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
(t, p)
|
||||
})
|
||||
.collect();
|
||||
exes.sort_by_key(|e| std::cmp::Reverse(e.0));
|
||||
for (_, p) in exes.into_iter().skip(KEEP_INSTALLERS - 1) {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
@@ -621,7 +621,31 @@ fn stop_web_console() {
|
||||
for pid in web_listener_pids() {
|
||||
run_quiet("taskkill", &["/PID", &pid, "/F"]);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
// Both stops are asynchronous - `schtasks /end` returns once the end has been REQUESTED, and
|
||||
// `taskkill /F` is TerminateProcess - so the outgoing console can still own :47992 after this
|
||||
// returns. The very next thing `web_setup` does is start the new task, and its bun cannot bind a
|
||||
// port the corpse still holds; the blind 1 s sleep this replaces was not always enough. Poll
|
||||
// until the listener is really gone (~10 s), re-killing any straggler halfway through, and warn
|
||||
// rather than block forever if something else owns the port (Apollo/Sunshine, a dev console).
|
||||
for i in 0..40 {
|
||||
let pids = web_listener_pids();
|
||||
if pids.is_empty() {
|
||||
return;
|
||||
}
|
||||
if i == 20 {
|
||||
// Re-end the TASK, not just the listener: `web-run.cmd` now supervises bun and relaunches
|
||||
// it on any exit, so killing bun alone would be undone by its own restart loop. Ending the
|
||||
// task takes the launcher down with it, which is why the `/end` above comes first.
|
||||
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
|
||||
for pid in pids {
|
||||
run_quiet("taskkill", &["/PID", &pid, "/F"]);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
eprintln!(
|
||||
"warning: something still holds TCP 47992 - the web console may not start until it exits"
|
||||
);
|
||||
}
|
||||
|
||||
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
|
||||
|
||||
@@ -360,6 +360,9 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
let job = make_job().context("create job object")?;
|
||||
|
||||
let mut restarts: u32 = 0;
|
||||
// One-shot latch for the update boot-loop rollback (plan U3.2) — a rollback that itself
|
||||
// fails must not spawn installers in a loop.
|
||||
let mut rollback_attempted = false;
|
||||
loop {
|
||||
if wait_one(stop, 0) {
|
||||
break;
|
||||
@@ -462,6 +465,7 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
}
|
||||
|
||||
restarts += 1;
|
||||
maybe_boot_loop_rollback(restarts, &mut rollback_attempted);
|
||||
let backoff = restarts.min(10) * 500; // 0.5s..5s
|
||||
if wait_one(stop, backoff) {
|
||||
break;
|
||||
@@ -1129,3 +1133,107 @@ fn run_quiet(cmd: &str, args: &[&str]) -> bool {
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Update boot-loop rollback (planning: host-update-from-web-console.md §6, plan U3.2): a
|
||||
/// FRESH update-intent record plus a crash-looping child that IS the intent's target version
|
||||
/// means the just-installed host doesn't stay up. The supervisor — which survives the upgrade,
|
||||
/// making it the right place — rolls back by re-running the CACHED previous installer: once
|
||||
/// per intent, Authenticode-checked, outcome written where the console reads it. The service
|
||||
/// process is not inside the kill-on-close job, so the spawned installer survives the service
|
||||
/// stop it is about to perform.
|
||||
fn maybe_boot_loop_rollback(restarts: u32, attempted: &mut bool) {
|
||||
if *attempted || restarts < 3 {
|
||||
return;
|
||||
}
|
||||
let intent_path = crate::update::jobs::intent_path();
|
||||
let Some(intent) = crate::update::jobs::read_intent(&intent_path) else {
|
||||
return;
|
||||
};
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
// A stale intent never triggers rollbacks, and a boot-looping OLD binary (version ≠ the
|
||||
// intent's target) is not an update failure — boot reconciliation owns that story.
|
||||
if now.saturating_sub(intent.started_unix) > 30 * 60 || env!("PUNKTFUNK_VERSION") != intent.to {
|
||||
return;
|
||||
}
|
||||
*attempted = true;
|
||||
|
||||
let updates = pf_paths::config_dir().join("updates");
|
||||
let previous = std::fs::read_dir(&updates)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| {
|
||||
n.starts_with("punktfunk-host-setup-")
|
||||
&& n.ends_with(".exe")
|
||||
&& !n.contains(intent.to.as_str())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.max_by_key(|p| {
|
||||
p.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
let Some(previous) = previous else {
|
||||
tracing::error!(
|
||||
to = %intent.to,
|
||||
"updated host is crash-looping and no cached previous installer exists — \
|
||||
leaving the intent for reconcile; manual reinstall required"
|
||||
);
|
||||
return;
|
||||
};
|
||||
// Validity-only Authenticode check: the failed update's manifest pins are gone with it,
|
||||
// and the cached file was fully verified (manifest sha256 + pins) when first downloaded.
|
||||
if let Err(e) = crate::update::windows::verify_authenticode(&previous, &[]) {
|
||||
tracing::error!(
|
||||
installer = %previous.display(),
|
||||
error = %e,
|
||||
"cached previous installer fails its signature check — not rolling back"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let log = pf_paths::config_dir()
|
||||
.join("logs")
|
||||
.join(format!("update-rollback-from-{}.log", intent.to));
|
||||
let record = crate::update::jobs::ResultRecord {
|
||||
ok: false,
|
||||
from: intent.from.clone(),
|
||||
to: intent.to.clone(),
|
||||
finished_unix: now,
|
||||
stage: Some("rolled-back".into()),
|
||||
error: Some(format!(
|
||||
"the updated host crash-looped after install; rolled back via {}",
|
||||
previous.display()
|
||||
)),
|
||||
log_path: Some(log.display().to_string()),
|
||||
staged: false,
|
||||
};
|
||||
let _ = crate::update::jobs::write_json_atomic(&crate::update::jobs::result_path(), &record);
|
||||
// Delete the intent BEFORE spawning: the incoming (previous) host must boot clean, and a
|
||||
// missing intent keeps this one-shot even across a service restart.
|
||||
let _ = std::fs::remove_file(&intent_path);
|
||||
|
||||
tracing::warn!(
|
||||
failed_version = %intent.to,
|
||||
back_to = %previous.display(),
|
||||
"updated host is crash-looping — rolling back via the cached previous installer"
|
||||
);
|
||||
match std::process::Command::new(&previous)
|
||||
.args(["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-"])
|
||||
.arg(format!("/LOG={}", log.display()))
|
||||
.spawn()
|
||||
{
|
||||
// Detached on purpose: it stops this service and reinstalls the previous version.
|
||||
Ok(child) => drop(child),
|
||||
Err(e) => tracing::error!(error = %e, "failed to spawn the rollback installer"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Tray lifecycle: the one place that knows how to find, start, stop and check the per-user status
|
||||
//! tray. Shared by the `tray` CLI subcommand and by post-update reconciliation
|
||||
//! (`update::windows::relaunch_tray`).
|
||||
//!
|
||||
//! Why this exists at all: `punktfunk-tray.exe` is a per-USER, per-SESSION GUI process with no
|
||||
//! recovery path of its own. The HKLM `Run` value only fires at sign-in, and nothing in the product
|
||||
//! restarts a tray that died — so anything that kills one (an upgrade's `StopTrays`, a crash) left
|
||||
//! the operator without an icon until they signed out and back in.
|
||||
//!
|
||||
//! The launch has to cross a privilege boundary in one direction but not the other, so [`start`]
|
||||
//! tries the session-crossing path first and falls back to a plain spawn:
|
||||
//!
|
||||
//! * **From the host service (SYSTEM)** — the tray must land in the active console session under
|
||||
//! the *logged-in user's* token, not SYSTEM's. That is
|
||||
//! [`crate::interactive::spawn_in_active_session`] (`WTSQueryUserToken` +
|
||||
//! `CreateProcessAsUserW`), and it needs `SE_TCB`, which only SYSTEM holds.
|
||||
//! * **From an interactive shell** — the caller IS the user in the right session already, so
|
||||
//! `WTSQueryUserToken` fails (an administrator does not hold `SE_TCB` either) and a plain spawn
|
||||
//! is both sufficient and correct.
|
||||
//!
|
||||
//! Trying the privileged path and falling back on failure discriminates the two without a token
|
||||
//! inspection, and without adding any `unsafe` to this crate.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The tray's image name, as the installer lays it down next to `punktfunk-host.exe`.
|
||||
pub const TRAY_EXE: &str = "punktfunk-tray.exe";
|
||||
|
||||
pub fn main(args: &[String]) -> Result<()> {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("start") => {
|
||||
let (pid, how) = start()?;
|
||||
match pid {
|
||||
Some(pid) => println!("status tray started (pid {pid}, {how})"),
|
||||
None => println!("status tray is already running"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some("stop") => {
|
||||
if stop() {
|
||||
println!("status tray stopped");
|
||||
} else {
|
||||
println!("no status tray was running");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some("status") => {
|
||||
let path = tray_exe();
|
||||
println!(
|
||||
"status tray: {}",
|
||||
match (&path, is_running()) {
|
||||
(None, _) => "not installed".to_string(),
|
||||
(Some(_), true) => "running".to_string(),
|
||||
(Some(_), false) => "not running".to_string(),
|
||||
}
|
||||
);
|
||||
if let Some(p) = path {
|
||||
println!("executable: {}", p.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => bail!("usage: punktfunk-host tray <start|stop|status>"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `punktfunk-tray.exe` next to this executable, when it is actually installed (the `trayicon`
|
||||
/// task is optional, so its absence is a legitimate state, not an error).
|
||||
pub fn tray_exe() -> Option<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join(TRAY_EXE)))
|
||||
.filter(|p| p.exists())
|
||||
}
|
||||
|
||||
/// Is a tray running in ANY session? Reuses the conflicting-host scan's Toolhelp snapshot rather
|
||||
/// than opening a second one. Best-effort: a failed snapshot reads as "not running", so callers
|
||||
/// must treat this as a hint — never as proof for a destructive decision.
|
||||
pub fn is_running() -> bool {
|
||||
let stem = TRAY_EXE.trim_end_matches(".exe");
|
||||
crate::detect::running_process_names()
|
||||
.iter()
|
||||
.any(|n| n == stem)
|
||||
}
|
||||
|
||||
/// Start the tray if it is not already up.
|
||||
///
|
||||
/// `Ok(None)` = one was already running (idempotent by design: the tray also guards itself with a
|
||||
/// `Local\PunktfunkTray` mutex, so even a lost race merely makes the second instance exit). The
|
||||
/// `&'static str` names which mechanism launched it, for an honest CLI message.
|
||||
///
|
||||
/// Note for an ELEVATED interactive caller: the fallback spawn inherits this process's token, so
|
||||
/// the tray then runs elevated. It works, but UIPI stops a medium-integrity `--quit` from closing
|
||||
/// it later. Prefer `tray start` from a normal shell, or let the host service do it.
|
||||
pub fn start() -> Result<(Option<u32>, &'static str)> {
|
||||
let Some(exe) = tray_exe() else {
|
||||
bail!("{TRAY_EXE} is not installed next to this executable");
|
||||
};
|
||||
if is_running() {
|
||||
return Ok((None, "already running"));
|
||||
}
|
||||
// Quoted: the install directory is operator-chosen and routinely contains spaces.
|
||||
let quoted = format!("\"{}\"", exe.display());
|
||||
let session_err = match crate::interactive::spawn_in_active_session("ed, None) {
|
||||
Ok(pid) => return Ok((Some(pid), "into the active console session")),
|
||||
Err(e) => e,
|
||||
};
|
||||
// Only SYSTEM holds SE_TCB, so reaching here means an ordinary (possibly elevated) user — which
|
||||
// is fine ONLY if we are already sitting in the session the icon has to appear in. Refuse
|
||||
// loudly otherwise: a plain spawn from an ssh/RDP session would put a tray in a session nobody
|
||||
// is looking at, report success, and leave the operator staring at an empty notification area.
|
||||
if let Some((own, console)) = crate::interactive::console_session_mismatch() {
|
||||
bail!(
|
||||
"cannot place the tray in session {console} from session {own}: {session_err}\n\
|
||||
crossing sessions needs SE_TCB, which only SYSTEM holds — run this from the console \
|
||||
session itself, or let the host service do it"
|
||||
);
|
||||
}
|
||||
let child = std::process::Command::new(&exe)
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn {}", exe.display()))?;
|
||||
Ok((Some(child.id()), "in this session"))
|
||||
}
|
||||
|
||||
/// Stop every tray instance. Returns whether one was running.
|
||||
///
|
||||
/// Graceful first, mirroring the uninstaller's own order (`[UninstallRun]`): `--quit` posts
|
||||
/// WM_CLOSE to the tray window, which lets it remove its icon via `NIM_DELETE` instead of leaving a
|
||||
/// ghost in the notification area until the shell next sweeps it. `--quit` only reaches the session
|
||||
/// it runs in, so a force-kill reaps any instance in another session.
|
||||
pub fn stop() -> bool {
|
||||
let was_running = is_running();
|
||||
if !was_running {
|
||||
return false;
|
||||
}
|
||||
if let Some(exe) = tray_exe() {
|
||||
// Best-effort and short: if the graceful close does not land we force it below anyway.
|
||||
if let Ok(mut child) = std::process::Command::new(&exe).arg("--quit").spawn() {
|
||||
let _ = child.wait();
|
||||
}
|
||||
for _ in 0..8 {
|
||||
if !is_running() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/IM", TRAY_EXE])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
true
|
||||
}
|
||||
@@ -194,7 +194,12 @@ fn poll_loop(
|
||||
} else {
|
||||
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
|
||||
};
|
||||
let console_url = format!("https://127.0.0.1:{web_port}/");
|
||||
// `/login`, not `/`: `/` is auth-gated and 302s to `/login`, and ureq follows redirects by
|
||||
// default — so probing `/` spent TLS + `/` + a full cold `/login` SSR render inside one 2 s
|
||||
// budget, and a console that was merely warming up read as down. `/login` is the cheapest page
|
||||
// that proves the server is answering, and the agent below refuses redirects so the probe is
|
||||
// exactly one round trip. (A 302 still counts as up via the `Status` arm in `probe_console`.)
|
||||
let console_url = format!("https://127.0.0.1:{web_port}/login");
|
||||
let agent = agent(load_pin());
|
||||
let mut last: Option<(TrayStatus, bool)> = None;
|
||||
// When the summary became unreachable while the service was running (grace anchor).
|
||||
@@ -313,6 +318,10 @@ fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
|
||||
.tls_config(Arc::new(cfg))
|
||||
.timeout_connect(Duration::from_secs(2))
|
||||
.timeout(Duration::from_secs(2))
|
||||
// No redirect-following. Neither user of this agent wants it: the summary is a terminal
|
||||
// JSON route, and the console probe treats any HTTP answer (302 included) as "up", so
|
||||
// chasing the hop only spends the 2 s budget re-rendering a page nobody reads.
|
||||
.redirects(0)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -559,9 +559,14 @@ fn elevate_service(hwnd: HWND, verb: &str) {
|
||||
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
|
||||
/// menu entry promised — the pairing queue, the virtual displays — instead of the dashboard.
|
||||
fn open_web_console(hwnd: HWND, path: &str) {
|
||||
// 127.0.0.1, not `localhost`: the console binds HOST=0.0.0.0 (scripts/windows/web-run.cmd),
|
||||
// which is IPv4-ONLY, while Windows resolves `localhost` to ::1 first. A browser that does not
|
||||
// fall back cleanly got connection-refused on a perfectly healthy console — and because the
|
||||
// poller probes 127.0.0.1, the tray would call it up while handing over a URL that fails. Same
|
||||
// literal in both places, so the menu can never disagree with the status next to it.
|
||||
shell_open(
|
||||
hwnd,
|
||||
&format!("https://localhost:{}/{path}", app().web_port),
|
||||
&format!("https://127.0.0.1:{}/{path}", app().web_port),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -193,29 +193,25 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
|
||||
## Recipe: full controller passthrough (VirtualHere)
|
||||
|
||||
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
|
||||
triggers, USB rumble — instead of the emulated pad, hand the physical device from the couch to the
|
||||
host over [VirtualHere](https://www.virtualhere.com/) (USB-over-IP), bound only while a client is
|
||||
connected so the couch keeps its own controller the rest of the time.
|
||||
triggers, USB rumble — or to use a device no emulation can stand in for, like a racing wheel or a
|
||||
HOTAS, hand the physical device from the couch to the host over
|
||||
[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) while you play.
|
||||
|
||||
**The two sides.** VirtualHere is a server/client pair, and you run both:
|
||||
**Use the plugin.** [VirtualHere passthrough](/docs/plugins#virtualhere-usb-passthrough) does all of
|
||||
this for you: it finds the device by name (so it survives the couch rebooting), brackets it around
|
||||
the session, gives it back if anything crashes, and tells you which half of the setup is broken when
|
||||
it isn't working. That is the supported route, and the rest of this section is only for people who
|
||||
would rather not install a plugin.
|
||||
|
||||
- **Server — on the couch** (where the pad is physically plugged in). Run the VirtualHere USB
|
||||
Server there; it shares the pad on the LAN. Leave it running.
|
||||
- **Client — on the host** (where the game and this automation run). Install the VirtualHere
|
||||
Client (as a service, or the tray app). It auto-discovers the couch's shared pad on the same
|
||||
LAN; across subnets, add it once with `<VH_CLIENT> -t "MANUAL HUB ADD,<couch-ip>:7575"`. The
|
||||
client binary is `vhclientx86_64` on Linux, `vhui64.exe` on Windows, `vhclientosx` on macOS,
|
||||
`vhclientarm64` on ARM Linux.
|
||||
|
||||
The client's `-t` flag is a one-shot IPC to the already-running client: `-t LIST` prints every
|
||||
visible device with its address (`server.port`, e.g. `couch-deck.11`); `-t "USE,<addr>"` mounts it
|
||||
onto the host; `-t "STOP USING,<addr>"` hands it back. The automation just brackets
|
||||
`USE` / `STOP USING` around a session.
|
||||
**The two sides.** VirtualHere is a server/client pair, and you run both: the **server on the couch**
|
||||
(where the device is plugged in) shares it, and the **client on the host** mounts it. The client's
|
||||
`-t` flag is a one-shot IPC to the already-running client — `-t LIST` prints every visible device
|
||||
with its address (`server.port`, e.g. `couch-deck.11`), `-t "USE,<addr>"` mounts it, and
|
||||
`-t "STOP USING,<addr>"` hands it back.
|
||||
|
||||
### Zero-code: two hooks
|
||||
|
||||
Bracket it on the stream with two [hooks](#hooks-hooksjson) — mount when video starts, release
|
||||
when it stops. This is all most setups need:
|
||||
Bracket it on the stream with two [hooks](#hooks-hooksjson):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -226,42 +222,14 @@ when it stops. This is all most setups need:
|
||||
}
|
||||
```
|
||||
|
||||
`couch-deck.11` is the device's VirtualHere address from `vhclientx86_64 -t LIST`.
|
||||
`couch-deck.11` is the device's address from `vhclientx86_64 -t LIST`.
|
||||
|
||||
### Scripted: resolve by name, release on shutdown
|
||||
|
||||
The hooks above hard-code the address, and can strand the pad on the host if it's stopped
|
||||
mid-stream (the `stream.stopped` hook never fires). The
|
||||
Know what this trades away, because the plugin exists to fix exactly these: the address is
|
||||
hard-coded, so it breaks when the couch reboots or the device moves port; and if the stream ends
|
||||
abnormally the `stream.stopped` hook never fires, leaving the device stranded on the host until
|
||||
somebody notices. There is also a
|
||||
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
|
||||
SDK example is the robust version: it resolves the device by **name substring** (survives the
|
||||
address changing), can filter to **one couch** in a multi-client setup, and **releases the pad on
|
||||
a clean shutdown** (`systemctl stop`, ^C) so the couch always gets its controller back.
|
||||
SDK example if you want a worked script to build your own on.
|
||||
|
||||
It's a standalone [`@punktfunk/host` script](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
|
||||
— run it in its own directory. The one edit is its import: the in-repo copy imports from
|
||||
`../src/index.js`; outside the repo it's the published package:
|
||||
|
||||
```sh
|
||||
mkdir ~/punktfunk-scripts && cd ~/punktfunk-scripts
|
||||
bun init -y
|
||||
bun add @punktfunk/host # point the @punktfunk scope at the registry first — see the SDK README
|
||||
# save the example as virtualhere-dualsense.ts, and change its first import to the package:
|
||||
# - import { connect } from "../src/index.js";
|
||||
# + import { connect } from "@punktfunk/host";
|
||||
VH_DEVICE=DualSense bun virtualhere-dualsense.ts
|
||||
```
|
||||
|
||||
`VH_DEVICE` is required — a VirtualHere address (`couch-deck.11`) or a device-name substring
|
||||
(`DualSense`). Optional: `VH_CLIENT` overrides the client binary (default `vhclientx86_64`);
|
||||
`VH_ONLY_CLIENT` binds only for one punktfunk client label. Running on the host box the SDK needs
|
||||
no token or URL — it reads the host's loopback credentials itself (see
|
||||
[Connection resolution](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#connection-resolution)).
|
||||
|
||||
Keep it running as a
|
||||
[systemd user unit](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
|
||||
(its default `SIGTERM` triggers the script's own release step — so `systemctl stop` hands the pad
|
||||
back), or drop it under the [scripting runner](/docs/plugins) with your other units.
|
||||
|
||||
> The example brackets on `client.connected` / `client.disconnected` — the pad returns to the
|
||||
> couch the moment they disconnect. Switch to `stream.started` / `stream.stopped` if you'd rather
|
||||
> pass it through only while video is actually flowing; both are noted in the file's header.
|
||||
> VirtualHere is a commercial product, sold separately by VirtualHere Pty. Ltd. — free for one
|
||||
> shared device, licensed beyond that. Punktfunk is not affiliated with it.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"steamos-host",
|
||||
"windows-host",
|
||||
"web-console",
|
||||
"updating",
|
||||
"---Configure your desktop---",
|
||||
"configuration",
|
||||
"kde",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
---
|
||||
title: Plugins
|
||||
description: First-party plugins that sync your ROM collection or your Playnite library into the game library — and how to install them.
|
||||
description: First-party plugins — sync your ROM collection or Playnite library into the game library, or hand a real USB device on the couch to the host — and how to install them.
|
||||
---
|
||||
|
||||
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
|
||||
plugin runs alongside the host, reconciles titles into your **game library** as a provider — so they
|
||||
appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
|
||||
|
||||
Two first-party plugins today:
|
||||
Three first-party plugins today:
|
||||
|
||||
| Plugin | What it does |
|
||||
|---|---|
|
||||
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
|
||||
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
|
||||
| **VirtualHere** | Hands a real USB device on the couch — wheel, HOTAS, pad — to the host while you play, and gives it back after. Needs [VirtualHere](https://www.virtualhere.com/), sold separately. |
|
||||
|
||||
## Installing from the console
|
||||
|
||||
@@ -200,6 +201,48 @@ seconds of any library change. Filters (installed-only, per-store, hidden) live
|
||||
`%ProgramData%\punktfunk\playnite\config.json`. Details are in
|
||||
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-playnite).
|
||||
|
||||
## VirtualHere (USB passthrough)
|
||||
|
||||
`@punktfunk/plugin-virtualhere` — hands a **physical USB device** on your couch machine to the
|
||||
host while you play, and gives it back afterwards. The game sees the real device, so this is the
|
||||
answer for a racing wheel, a HOTAS, pedals, an arcade stick, or any controller whose value is that
|
||||
it is not emulated.
|
||||
|
||||
<Callout type="warn">
|
||||
This plugin drives [VirtualHere](https://www.virtualhere.com/), a commercial USB-over-IP product
|
||||
**sold separately** by VirtualHere Pty. Ltd. Nothing from VirtualHere is bundled or downloaded by
|
||||
Punktfunk — you install and license it yourself. The plugin is not affiliated with or endorsed by
|
||||
VirtualHere.
|
||||
</Callout>
|
||||
|
||||
You need both halves of VirtualHere running before the plugin is any use:
|
||||
|
||||
- **The USB Server on the couch**, sharing the device. Free for one device; beyond that, and to run
|
||||
the client as a service, VirtualHere requires a purchased licence.
|
||||
- **The USB Client on the host**, ideally installed as a service so it survives logging out.
|
||||
|
||||
Servers exist for Windows, Linux, macOS and Android couches. **There is no VirtualHere server for
|
||||
iOS or tvOS**, so iPhones, iPads and Apple TVs cannot pass devices through — nothing on the
|
||||
Punktfunk side can change that.
|
||||
|
||||
```sh
|
||||
punktfunk-host plugins add virtualhere
|
||||
punktfunk-host plugins enable
|
||||
```
|
||||
|
||||
Then open the console's **VirtualHere** page. The **Devices** tab lists whatever the couch is
|
||||
sharing; pick one and it writes a rule matching the device *by name*, which keeps working after the
|
||||
couch reboots or the device moves to another port. By default the device is handed over when video
|
||||
starts and returned when it stops, so the couch keeps its own controller the rest of the time —
|
||||
you can widen that to the whole session, or to the entire time a client is connected.
|
||||
|
||||
If nothing happens, the **Diagnostics** tab walks the whole two-sided setup and tells you which
|
||||
part to fix. The same checks are available as `punktfunk-plugin-virtualhere doctor`, which is the
|
||||
useful thing to paste into a support thread.
|
||||
|
||||
Full configuration is in
|
||||
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-virtualhere).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`punktfunk-host: command not found`** — on Windows, open a new terminal so it picks up the
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: Updating the Host
|
||||
description: How to see when a newer punktfunk host is available — the web console's update card — and the update command for every install method.
|
||||
---
|
||||
|
||||
The web console tells you when a newer host is out. The **Host** page has an **Updates** card
|
||||
showing the version you run, the channel you follow (stable or canary), how this host was
|
||||
installed, and — once a newer release exists — the exact command that updates it. The
|
||||
"update available" state also fires an `update.available` event on the host
|
||||
[event stream](/docs/automation), so hooks and scripts can react to it too.
|
||||
|
||||
The check is a small signed manifest the host fetches from the punktfunk release feed and
|
||||
verifies against keys built into the host itself — a tampered or replayed feed is rejected, and
|
||||
the console will tell you when a check failed rather than silently showing stale facts.
|
||||
|
||||
## Updating, per install method
|
||||
|
||||
The console shows the right one of these automatically; for reference:
|
||||
|
||||
| How you installed | How to update |
|
||||
|---|---|
|
||||
| Windows installer / winget | `winget upgrade unom.PunktfunkHost`, or run the newer `punktfunk-host-setup.exe` |
|
||||
| Ubuntu / Debian (apt) | `sudo apt update && sudo apt install --only-upgrade punktfunk-host` |
|
||||
| Fedora (dnf) | `sudo dnf upgrade punktfunk` |
|
||||
| Bazzite sysext (recommended) | `sudo punktfunk-sysext update` |
|
||||
| Bazzite rpm-ostree layer | `sudo /usr/share/punktfunk/update-punktfunk.sh` (staged — reboot to finish) |
|
||||
| Arch / CachyOS (pacman) | `sudo pacman -Syu` (a normal full system upgrade) |
|
||||
| Steam Deck (on-device build) | `bash ~/punktfunk/scripts/steamdeck/update.sh --pull` |
|
||||
| NixOS | update the flake input and rebuild |
|
||||
|
||||
After a Linux package update, restart the host to pick up the new binary:
|
||||
|
||||
```bash
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
(The Windows installer restarts the service itself; `punktfunk-sysext update` prints the same
|
||||
restart hint when it's needed.)
|
||||
|
||||
## One-click updating (Windows)
|
||||
|
||||
On a Windows host the card shows an **Update now** button instead of a command. It asks for the
|
||||
console password again (a saved login alone can't restart your host), then the host downloads
|
||||
the installer, verifies it against the signed release manifest **and** its code signature, and
|
||||
runs it silently — the service restarts at the end and the page reconnects by itself. If a
|
||||
stream is live you'll be warned first: updating drops it.
|
||||
|
||||
Every attempt leaves a result in the card (and an installer log under
|
||||
`C:\ProgramData\punktfunk\logs\update-<version>.log`) — including across the restart, so a
|
||||
failed update is never silent. To disable the button entirely on a host, set
|
||||
`PUNKTFUNK_UPDATE_APPLY=0` in `host.env`; the card then shows the manual command instead.
|
||||
|
||||
## One-click updating (Linux — opt-in)
|
||||
|
||||
The apt, dnf, Bazzite-sysext, and rpm-ostree installs can one-click update too, via a small
|
||||
root helper the packages ship (`pf-update` + a `punktfunk-update.service` oneshot). It's **off
|
||||
until you opt in**, because a web button that ends in root deserves an explicit decision:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG punktfunk-update $USER # then log out and back in
|
||||
```
|
||||
|
||||
That group membership is the entire grant — a polkit rule lets its members start exactly that
|
||||
one service, whose only job is "run this system's normal package update for the punktfunk
|
||||
packages, then prove the new binary runs". The button never chooses versions or URLs; your
|
||||
package manager's own signed repositories stay the source of truth. The card shows the opt-in
|
||||
command until you've done this, and the manual command always keeps working.
|
||||
|
||||
Notes per method: on **rpm-ostree** the update is staged and the card will say so — reboot to
|
||||
finish (the console never reboots your machine). On **Arch/pacman** the button additionally
|
||||
requires `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf`, because the only safe
|
||||
pacman update is a full `pacman -Syu` — partial upgrades are how Arch boxes break, and we
|
||||
won't run one. After a successful update the host restarts itself and the page reconnects.
|
||||
|
||||
The **Steam Deck on-device build** gets the button too, with no opt-in (it's your own user's
|
||||
install, no root involved): it runs the same `update.sh` rebuild the docs describe, which
|
||||
compiles on the Deck — expect it to take a while; the card keeps showing progress and the log
|
||||
lands in `~/.config/punktfunk/logs/update-steamos.log`.
|
||||
|
||||
## Turning the check off
|
||||
|
||||
The check contacts `git.unom.io` (the punktfunk forge) and nothing else, and sends nothing but a
|
||||
normal download request. If you'd rather the host never checks, set:
|
||||
|
||||
```bash
|
||||
PUNKTFUNK_UPDATE_CHECK=0
|
||||
```
|
||||
|
||||
in the host's environment (`host.env` on Windows, the systemd user unit environment on Linux).
|
||||
The card then shows checks as disabled; everything else keeps working.
|
||||
|
||||
## If the card says the feed is stale
|
||||
|
||||
"Feed hasn't changed in over 45 days" means checks *succeed* but nothing new arrives. Usually
|
||||
that just means no release happened for a while; if the [releases page](https://git.unom.io/unom/punktfunk/releases)
|
||||
shows something newer than the card does, something between this host and the feed is pinning old
|
||||
data — worth a look at proxies or DNS on the way to `git.unom.io`.
|
||||
@@ -0,0 +1,38 @@
|
||||
Wire-compatible with 0.21.x and 0.22.x — nothing about streaming changed, and everything already paired keeps working. This release repairs the Windows installer and gives every host something new: the web console now tells you when a newer Punktfunk is out, and can install it for you where the platform allows. The apps on your phone, tablet, Mac and TV are unchanged.
|
||||
|
||||
**If you stream to a Windows PC, update that PC.** The 0.22.1 and 0.22.2 installers were built without the web console in them at all — not broken, absent. If the tray menu on your PC says "Open web console (not responding)" and the page never loads no matter what you try, that is this, and reinstalling those versions could never have fixed it.
|
||||
|
||||
## New
|
||||
|
||||
- **The console tells you when a newer host is out.** The Host page has an **Updates** card: the version you're running, the channel you follow (stable or canary), and — once something newer exists — release notes and the exact way to update *your* kind of install. The check is a small signed file the host verifies against keys built into it, so nothing between you and the project can feed it a fake update, and a feed that ever tried to roll you backwards is refused outright. It contacts only the Punktfunk forge, and `PUNKTFUNK_UPDATE_CHECK=0` turns it off entirely. Scripts can react too: the host emits `update.available` and, after a successful update, `update.applied` on its event stream.
|
||||
|
||||
- **On Windows, updating the host is now one click.** The card grows an **Update now** button: it asks for your console password again (a remembered login alone can't restart your PC's host), then downloads the installer, checks it against the signed release manifest *and* its code signature, and runs it silently — the service restarts and the page reconnects by itself. If someone is streaming you're warned first, because updating drops the stream. Every attempt leaves its outcome in the card even across the restart, with the installer's log path when something went wrong — and if a freshly installed version ever crash-loops, the host puts the previous installer back on its own and says so. `PUNKTFUNK_UPDATE_APPLY=0` in `host.env` removes the button on hosts that should never have it.
|
||||
|
||||
- **Linux hosts can opt in to the same button.** apt, Fedora, Bazzite (sysext and rpm-ostree) installs get one-click updating through a deliberately tiny root helper the packages now ship. It's off until you decide otherwise — enabling it is one command, `sudo usermod -aG punktfunk-update $USER`, and that group membership is the entire grant: the button can only ever run your package manager's normal update for the Punktfunk packages, from the same signed repositories you already use, and it never picks versions or URLs. rpm-ostree updates are staged and the card asks you to reboot (it never reboots for you). On Arch the button additionally requires opting into a full `pacman -Syu` in `/etc/punktfunk/update.conf`, because a partial upgrade is how Arch boxes break — we won't run one. Steam Deck on-device installs get the button with no opt-in at all: it runs the same rebuild `update.sh` always did, just from the couch. The full story is on the new [Updating the Host](https://punktfunk.unom.io/docs/updating) docs page.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **The web console is in the installer again.** On 0.22.1 and 0.22.2 the Windows installer shipped with the console missing entirely: the files it runs from were never included, so the PC had nothing to start and nothing to open. The host itself was fine the whole time — streaming, pairing and controllers all worked — but the settings page you reach in a browser simply was not there. The tray reported it as "not responding", which was true and also the only clue you got, and because every reinstall produced the same installer, uninstalling and reinstalling made no difference. Updating to 0.22.3 restores it; you don't need to touch your settings, your password, or your paired devices.
|
||||
|
||||
- **Updating no longer stops on a "DeleteFile failed; code 5" error.** Partway through an update, the installer could stop on a dialog complaining that it could not replace `bun\bun.exe`, offering only Try again, Skip, or Cancel — and Try again usually failed the same way. Windows refuses to replace a program while it is running, and the pieces of Punktfunk that were still running weren't the ones the installer knew how to stop. It now shuts all of them down, waits for them to actually exit rather than assuming, and if something still holds the file it finishes the install and puts the new copy in place on your next restart instead of leaving you stuck at a dialog.
|
||||
|
||||
- **Updating no longer switches your plugins off.** If you had enabled the script and plugin runner, every update quietly disabled it again, so plugins stopped running until you noticed and re-enabled them by hand. Updates now leave it exactly as you had it — on if it was on, and still off by default for everyone who has never turned it on.
|
||||
|
||||
## Improved
|
||||
|
||||
- **You can save or share the host's log from the console.** The Logs page has two new buttons: one downloads what you're looking at as a timestamped `.log` file, the other hands it to your phone or tablet's share sheet, or copies it to the clipboard on a desktop. Handy when someone asks you for a log — the file carries full dates and times, so it still makes sense once it leaves your browser. Whatever level filter and search you have applied is what you get, and it saves everything that matches rather than just the part on screen.
|
||||
|
||||
- **Televisions that struggle with the startup speed test can be told to ease off.** Two seconds into a session Punktfunk briefly bursts traffic to measure how much room your network really has. On some TVs that burst is enough to disturb the very link it is measuring — one LG set showed no video for fourteen seconds when it went badly. The burst's size can now be capped, so a device that already limits its own speed test can keep ours in line with it. Nothing changes unless a device asks for it.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- The console's absence was one CI variable in the wrong place. `windows-host.yml` exported `WEB_OUTPUT_DIR` on the last line of "Build + smoke-boot web console (bun)", and `be2fabcf` had just put that step behind `if: steps.webconsole.outputs.cache-hit != 'true'`. So the first build after the cache was populated — and every build after it with `web/**` and `sdk/**` unchanged — skipped the step, left the variable unset, and `pack-host-installer.ps1` reads an unset `WEB_OUTPUT_DIR` as "omit the console", announcing it in a single `Write-Host` before packing happily. Confirmed from the job logs rather than inferred: on both the v0.22.1 (run 14235) and v0.22.2 (run 14272) tag builds, step 12 is `skipped` and the job is green. `BUN_EXE` and `SCRIPTING_BUNDLE` are exported from unconditional steps, which is why those installers still carried bun and the plugin runner but no `{app}\web`.
|
||||
- Downstream, `web setup` bailed at `web launcher missing: {app}\web\web-run.cmd` before registering anything, so there was no `PunktfunkWeb` task, no listener on 47992, and no firewall rule — and Inno ignores `[Run]` exit codes, so the failure left no trace in the install at all. `WEB_OUTPUT_DIR` now comes from its own unconditional step that throws when `web\.output\server\index.mjs` is absent, and a new pre-pack step asserts all five payloads (console, bun, plugin runner, FFmpeg DLLs, VB-CABLE) so a missing input fails the build rather than silently redefining the installer. The shape is borrowed from the packer's existing VB-CABLE check. FFmpeg was the most dangerous of the silent ones: an `amf-qsv` host link-imports avcodec, so an installer without those DLLs ships a host that cannot start at all.
|
||||
- The same cache-hit build is why `bun.exe` locked. It ships under `WithWeb` **or** `WithScripting`, but the installer's pre-copy stop was `#ifdef WithWeb` — so a console-less installer shipped bun while the only code that stopped bun was compiled out. `StopBunRuntimes` replaces `StopWebConsole` behind the same `WithWeb || WithScripting` gate the payload uses, and covers what the old routine structurally could not: neither bun runs under the host service (both are Task Scheduler tasks — `PunktfunkWeb` as SYSTEM, `PunktfunkScripting` as LocalService), and the runner listens on no port, so a task-name-plus-port sweep could never see it. It now disables both tasks before stopping them — they carry restart-on-failure at 10× and 999× a minute, and the web task also has a logon trigger, so a force-kill alone invited a respawn into the middle of a copy that takes over a minute at `lzma2/max` — kills any bun whose image lives under the install dir (by path, so an unrelated bun survives), and polls until they are gone, since `Stop-ScheduledTask` returns on request and `Stop-Process` is `TerminateProcess`. `bun.exe` also gained `restartreplace`, so a survivor defers to reboot instead of dead-ending the install.
|
||||
- Disabling a task is not free: unlike a stopped one it does not return at the next boot, so an aborted install would have taken the console down permanently. Two restores cover it — a last-in-`[Run]` entry for the normal flow and `DeinitializeSetup`, which Inno calls even on user cancel — both re-enabling only what was enabled before the copy. That is also the plugin-runner fix: the scripting `[Run]` entry re-registers its task and then unconditionally disables it, correct on a first install and wrong on every upgrade since.
|
||||
- `PUNKTFUNK_ABR_PROBE_KBPS` sets the startup capacity probe's burst target for embedders. Unset, zero or unparseable keeps the 2 Gbps default, so existing sessions are unchanged; `PUNKTFUNK_ABR_PROBE=0` remains a poor substitute because it pins the climb ceiling at the negotiated ~20 Mbps.
|
||||
- The console's log export serializes the filters' full result rather than the rendered tail — the 1000-row cap is a DOM budget and has no business truncating a file destined for a bug report — and stamps each line with a local ISO 8601 timestamp plus numeric offset. Web Share level 2 is probed at runtime after mount (SSR has no `navigator`, and guessing there would mismatch on hydration), falling back to the clipboard, and the button is omitted only where neither exists.
|
||||
- The update check's truth is a per-channel Ed25519-signed manifest (`…/generic/punktfunk-update/{stable,canary}/manifest.json` + `.sig`), verified against keys pinned in the host binary via the plugin-index machinery, with a persisted monotonic serial as the anti-rollback floor and the channel name bound into the signed document. Stable manifests publish from `announce.yml` — the existing manual fleet-green gate doubles as the gate for the fleet learning about a release; canary rides `windows-host.yml`. TLS and the registry are transport, never trust.
|
||||
- `/api/v1/update/*` is admin-lane only: whole-prefix denied to the plugin token and absent from the paired-cert allowlist. Apply requests carry no version, URL, or channel — the privileged side derives everything from its own verified state — and the console's BFF re-verifies the console password per apply (sharing the login throttle) while every mutating route now requires a same-origin `Sec-Fetch-Site`. Outcomes survive the host's own restart via an intent record reconciled at boot.
|
||||
- The Linux packages grow four pieces: the dep-free root helper `/usr/libexec/punktfunk/pf-update`, the fixed-ExecStart oneshot `punktfunk-update.service`, a polkit rule granting exactly that unit's `start` verb to the `punktfunk-update` group, and the group itself — created empty by every postinst, populated by no one.
|
||||
- No wire, ABI or driver-protocol changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0, 0.22.1 and 0.22.2.
|
||||
@@ -528,6 +528,22 @@
|
||||
#define VIDEO_CAP_CHACHA20 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client's decoder accepts **multi-slice access units** — H.264/
|
||||
// HEVC frames carrying several slice NALs (latency plan §7 LN1: the encoder splits frames so
|
||||
// sub-frame readback can ship early slices while the tail encodes). Decoder-level, so the
|
||||
// EMBEDDER sets it from what its decode stack actually handles: the desktop clients' FFmpeg/
|
||||
// D3D11VA/Vulkan-video decoders are fine, but mobile/TV MediaCodec is per-SoC — Amlogic HEVC
|
||||
// decoders (Chromecast with Google TV, Fire TV) wedge the whole DEVICE on multi-slice frames
|
||||
// (the 0.17.0 field regression: the 4-slice Linux default froze streams on first frame and
|
||||
// watchdog-rebooted the CCwGTV), which is exactly why Moonlight requests 1 slice per frame for
|
||||
// every hardware decoder. The host defaults to >1 slice ONLY toward a client that sets this
|
||||
// bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions);
|
||||
// every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the
|
||||
// video_caps byte's last free bit — the next video cap needs a second byte (ABI bump).
|
||||
#define VIDEO_CAP_MULTI_SLICE 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||
@@ -571,6 +587,15 @@
|
||||
#define CLIENT_CAP_CURSOR 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// `Hello.client_caps` bit: this client runs a vsync-aware presenter and will send
|
||||
// [`PhaseReport`](super::control::PhaseReport)s (~1 Hz) so the host can phase-lock its
|
||||
// capture/send tick to the client's display latch (design/phase-locked-capture.md). Without
|
||||
// the bit the host never arms the phase controller; toward an older host the reports are
|
||||
// simply ignored — no behavior change in either direction.
|
||||
#define CLIENT_CAP_PHASE_LOCK 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
@@ -740,6 +765,11 @@
|
||||
#define MSG_CLOCK_ECHO 49
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PhaseReport`].
|
||||
#define MSG_PHASE_REPORT 50
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||
// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||
@@ -2753,6 +2783,20 @@ PunktfunkStatus punktfunk_connection_report_decode_us(const PunktfunkConnection
|
||||
uint32_t us);
|
||||
#endif
|
||||
|
||||
// Report this client's display-latch grid so the host can phase-lock its capture tick
|
||||
// (design/phase-locked-capture.md). `next_latch_host_ns` must already be host clock — convert
|
||||
// with the connection's clock offset (`T_host = T_client + offset`). Fire-and-forget; call ~1 Hz
|
||||
// from a vsync-aware presenter. No-op toward a host that never negotiated the capability.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is an opaque handle from a `*_new`/`*_pair` the caller has not yet freed, or null (an
|
||||
// error, not UB).
|
||||
PunktfunkStatus punktfunk_connection_report_phase(const PunktfunkConnection *c,
|
||||
uint64_t next_latch_host_ns,
|
||||
uint32_t latch_period_ns,
|
||||
uint32_t uncertainty_ns,
|
||||
uint32_t arrival_lead_ns);
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Whether [`punktfunk_connection_report_decode_us`] is worth calling this session: writes 1 to
|
||||
// `out` only when the adaptive-bitrate controller is armed (Automatic bitrate, non-PyroWave), so a
|
||||
|
||||
+19
-1
@@ -100,7 +100,8 @@ build() {
|
||||
-p punktfunk-cli
|
||||
else
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli \
|
||||
-p pf-update
|
||||
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
|
||||
# across everything in one build, so co-building the tray with the host pulls the host's
|
||||
# ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio
|
||||
@@ -159,6 +160,17 @@ package_punktfunk-host() {
|
||||
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
|
||||
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
# Web-console-triggered updates (host-update-from-web-console.md §7): root helper + oneshot
|
||||
# unit + group-scoped polkit rule. Same no-libexec relocation as pf-dm-helper, with the
|
||||
# unit's ExecStart rewritten to match. On pacman the helper additionally requires the
|
||||
# explicit PACMAN_FULL_SYSUPGRADE opt-in (partial upgrades are against Arch doctrine).
|
||||
install -Dm0755 "$R/target/release/pf-update" "$pkgdir/usr/lib/punktfunk/pf-update"
|
||||
install -Dm0644 "$R/packaging/linux/punktfunk-update.service" \
|
||||
"$pkgdir/usr/lib/systemd/system/punktfunk-update.service"
|
||||
sed -i 's#/usr/libexec/punktfunk/pf-update#/usr/lib/punktfunk/pf-update#' \
|
||||
"$pkgdir/usr/lib/systemd/system/punktfunk-update.service"
|
||||
install -Dm0644 "$R/packaging/linux/49-punktfunk-update.rules" \
|
||||
"$pkgdir/usr/share/polkit-1/rules.d/49-punktfunk-update.rules"
|
||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
|
||||
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
|
||||
@@ -173,6 +185,12 @@ package_punktfunk-host() {
|
||||
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \
|
||||
"$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf"
|
||||
# Install-kind + channel marker, read by the host's update-check surface (planning:
|
||||
# host-update-from-web-console.md §4.1). A canary build's pkgrel is `0.<zero-padded run>`.
|
||||
local _pf_update_channel=stable
|
||||
[[ $pkgrel == 0.* ]] && _pf_update_channel=canary
|
||||
printf 'pacman %s\n' "$_pf_update_channel" | \
|
||||
install -Dm0644 /dev/stdin "$pkgdir/usr/share/punktfunk/install-kind"
|
||||
install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \
|
||||
"$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# pacman install scriptlet — mirrors the RPM %post / deb postinst.
|
||||
_ensure_update_group() {
|
||||
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
|
||||
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_install() {
|
||||
_ensure_update_group
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
udevadm trigger --subsystem-match=misc 2>/dev/null || true
|
||||
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
|
||||
@@ -48,6 +54,7 @@ MSG
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
_ensure_update_group
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@@ -164,6 +164,9 @@ post_merge() {
|
||||
install -Dm0644 /usr/lib/modules-load.d/punktfunk.conf /etc/modules-load.d/punktfunk.conf 2>/dev/null || :
|
||||
install -Dm0644 /usr/lib/udev/rules.d/60-punktfunk.rules /etc/udev/rules.d/60-punktfunk.rules 2>/dev/null || :
|
||||
udevadm control --reload 2>/dev/null || :
|
||||
# The (empty) opt-in group for web-console-triggered updates (the sysext ships the pf-update
|
||||
# helper + unit + polkit rule in its /usr; the group can't ride an image) — nobody is auto-added.
|
||||
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
|
||||
modprobe vhci-hcd 2>/dev/null || :
|
||||
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
|
||||
# the input-group ownership even when the module's original add event predated the reloaded rule.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user