Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab4c9e44cc | |||
| ac60db5410 | |||
| 6b9f261dff | |||
| 75b3c94f60 | |||
| b8da32e8b6 | |||
| c4645a8938 | |||
| 252960291e | |||
| 01266ff18d | |||
| b50b698078 | |||
| 762a627df9 | |||
| a93f5a71ce | |||
| a7d4a93ff2 | |||
| 499bf2dae8 | |||
| ff38933312 | |||
| 56f9c8c4b4 | |||
| 5249d31dfa | |||
| 694bec4ead | |||
| e1d7fa2a30 | |||
| 7b9337c7d0 | |||
| 0325e1cf6f | |||
| e807ffbff8 | |||
| 6425edb8e4 | |||
| f24379c2f8 | |||
| a40ae49cf8 | |||
| 7f1680b043 | |||
| a959e731da | |||
| 81edd27155 | |||
| 4d2cc2a3a7 | |||
| d352e4e456 | |||
| 739a5f76bf | |||
| a70811043e | |||
| 9127c3465f | |||
| 2621b6e6b1 | |||
| 705a8baddf | |||
| 1fc9ef0050 | |||
| 12148243bd | |||
| 8dc5d672e2 | |||
| 719b1ef403 | |||
| eb8a659319 | |||
| fa4df1de9e | |||
| ef862454b0 | |||
| f77eec1299 | |||
| 575975687c | |||
| 49ba1cd11b | |||
| e71cb9b7bd | |||
| 9724fb4a4e | |||
| 767f028bdf | |||
| 4c3b11445c | |||
| 1b73361372 | |||
| d2b4e3d71c | |||
| 0bca67f73e | |||
| 9d67dc18aa | |||
| b349724fe9 | |||
| 32e5594a9a | |||
| f4f6c5556f | |||
| 5c7e0afa99 | |||
| 5a384fe788 | |||
| a2433d77cf | |||
| a87b279c2b | |||
| 9bf72cdfb5 | |||
| a1af916e38 | |||
| 46b7ffc001 | |||
| 9b7fc127ef | |||
| 1a559e8d5e | |||
| 160914c48b | |||
| ed0ce5dc6d | |||
| f2fa7828d6 | |||
| 85513d1697 | |||
| 0058f624a2 | |||
| a7a1e871e8 | |||
| 840e5d590e | |||
| d58524c899 | |||
| 6db91cbf40 | |||
| 60d4653083 | |||
| 927a571414 | |||
| f3b6ccaa7f | |||
| d8e8529cd7 | |||
| 4201851c7f | |||
| eb4bca11c5 | |||
| 69f30f30b6 | |||
| f7356d0820 | |||
| 51cdaea3f3 | |||
| ea2e3578e2 | |||
| 8d8168b0e0 | |||
| 61c752e91e | |||
| 8c854e0a19 | |||
| 70a74b0d7c | |||
| 41be73fbc6 | |||
| 1830e095f8 | |||
| 45bde370e2 | |||
| 57d89217fb | |||
| 650acda334 | |||
| 89aa52bc58 | |||
| 384fc30833 | |||
| 365d4bb8f1 | |||
| f1efd3091e | |||
| 446818eea6 | |||
| 4d6c2394dc | |||
| 2bea02b0ea | |||
| 528a51d75c | |||
| b597bb74bd |
@@ -0,0 +1,18 @@
|
||||
# Workspace-wide build flags.
|
||||
#
|
||||
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
|
||||
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
|
||||
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
|
||||
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
|
||||
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
|
||||
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
|
||||
# extensions falls back safely.
|
||||
#
|
||||
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
|
||||
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
|
||||
# `--cfg aes_armv8` themselves.
|
||||
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
|
||||
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
|
||||
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
|
||||
[target.'cfg(target_arch = "aarch64")']
|
||||
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
|
||||
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||
|
||||
## Before you push
|
||||
|
||||
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
||||
workspace + the UMDF driver workspace) on every commit and push, so a push can never fail CI
|
||||
on formatting alone:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
```
|
||||
|
||||
Then the usual full pass:
|
||||
|
||||
```sh
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
@@ -2145,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2756,9 +2756,10 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
@@ -2766,6 +2767,7 @@ dependencies = [
|
||||
"pf-ffvk",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
@@ -2778,7 +2780,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2799,7 +2801,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2808,7 +2810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2992,7 +2994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3008,7 +3010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3024,7 +3026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3039,7 +3041,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3058,7 +3060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3089,7 +3091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3117,9 +3119,11 @@ dependencies = [
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"opus",
|
||||
"parking_lot",
|
||||
"pf-driver-proto",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"quinn",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
@@ -3161,7 +3165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3175,7 +3179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3190,6 +3194,14 @@ dependencies = [
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/pf-console-ui",
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"crates/pyrowave-sys",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/session",
|
||||
@@ -35,7 +36,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.9.1"
|
||||
"version": "0.11.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2043,11 +2043,12 @@
|
||||
},
|
||||
"ApiCodec": {
|
||||
"type": "string",
|
||||
"description": "Video codec identifier.",
|
||||
"description": "Video codec identifier. The wire token matches the codec's canonical name used across the\nstack (SDP/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder's\n[`Codec::label`]) — notably `H.265` serializes as `\"hevc\"`, not `\"h265\"`, so the same codec\nreads identically on every console page.",
|
||||
"enum": [
|
||||
"h264",
|
||||
"h265",
|
||||
"av1"
|
||||
"hevc",
|
||||
"av1",
|
||||
"pyrowave"
|
||||
]
|
||||
},
|
||||
"ApiDisplayInfo": {
|
||||
@@ -2811,6 +2812,7 @@
|
||||
"app_version",
|
||||
"gfe_version",
|
||||
"codecs",
|
||||
"gamestream",
|
||||
"ports"
|
||||
],
|
||||
"properties": {
|
||||
@@ -2831,6 +2833,10 @@
|
||||
},
|
||||
"description": "Codecs the host can encode (NVENC)."
|
||||
},
|
||||
"gamestream": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the\nsecure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the\nMoonlight PIN pairing card, which could never receive a PIN when this is `false`)."
|
||||
},
|
||||
"gfe_version": {
|
||||
"type": "string",
|
||||
"description": "GFE version advertised to Moonlight clients."
|
||||
@@ -3393,9 +3399,16 @@
|
||||
"video_streaming",
|
||||
"audio_streaming",
|
||||
"pin_pending",
|
||||
"paired_clients"
|
||||
"paired_clients",
|
||||
"active_sessions"
|
||||
],
|
||||
"properties": {
|
||||
"active_sessions": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
|
||||
"minimum": 0
|
||||
},
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
@@ -3417,7 +3430,7 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionInfo",
|
||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
||||
"description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3428,7 +3441,7 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/StreamInfo",
|
||||
"description": "The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE)."
|
||||
"description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3599,6 +3612,7 @@
|
||||
"armed",
|
||||
"sample_count",
|
||||
"started_unix_ms",
|
||||
"elapsed_ms",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
@@ -3606,6 +3620,12 @@
|
||||
"type": "boolean",
|
||||
"description": "Capture currently running."
|
||||
},
|
||||
"elapsed_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Host-measured elapsed time of the in-progress capture, in ms (`0` if idle). Computed from the\nhost's MONOTONIC clock, so a console can show elapsed time without subtracting `started_unix_ms`\nfrom its own (possibly skewed) wall clock.",
|
||||
"minimum": 0
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
||||
|
||||
@@ -27,12 +27,27 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Gamepad rumble feedback. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
|
||||
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
|
||||
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
|
||||
devices without a gamepad. -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
<!-- Neutralize Play's IMPLIED hard requirements, which filtered real TVs as "not compatible"
|
||||
(reported on a Philips OLED707): RECORD_AUDIO implies android.hardware.microphone and the
|
||||
Wi-Fi state permissions imply android.hardware.wifi, both required=true unless declared
|
||||
otherwise. Some TVs declare no microphone (mic uplink is optional and runtime-gated) and
|
||||
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
||||
<!-- Steam Controller 2 capture: USB host for the wired pad / Puck dongle, Bluetooth for the
|
||||
direct-BLE pad — both optional (the feature quietly disengages without them). -->
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
||||
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||
@@ -58,10 +73,16 @@
|
||||
android:name="android.game_mode_config"
|
||||
android:resource="@xml/game_mode_config" />
|
||||
|
||||
<!-- configChanges includes `keyboard` (not just keyboardHidden): claiming a Steam
|
||||
Controller 2's USB HID interface removes its lizard-mode keyboard/mouse input
|
||||
devices, which flips CONFIG_KEYBOARD (QWERTY→NOKEYS) — without `keyboard` declared,
|
||||
Android RECREATES the activity, disposing StreamScreen and killing the stream the
|
||||
moment the capture engages (tester-diagnosed on-glass, 2026-07-15). Releasing the
|
||||
interfaces at session end brings the devices back — same flip, same need. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|density|navigation"
|
||||
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|screenLayout|density|navigation"
|
||||
android:theme="@style/Theme.PunktfunkAndroid">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -303,7 +303,8 @@ internal fun PairPinDialog(
|
||||
if (fp.isNotEmpty()) {
|
||||
onPaired(fp) // verified host fp — caller saves + connects
|
||||
} else {
|
||||
err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
||||
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
/**
|
||||
* Cause-specific user-facing messages for failed pair/connect attempts, keyed on the stable
|
||||
* machine token from [NativeBridge.nativeTakeLastError]. One vocabulary for both the PIN
|
||||
* ceremony and the request-access (delegated approval) path, so a dead network path is never
|
||||
* reported as "wrong PIN" and an operator denial is never reported as a timeout — the exact
|
||||
* collapse behind more than one support thread.
|
||||
*/
|
||||
object ConnectErrors {
|
||||
/** Message for a failed SPAKE2 PIN ceremony ([NativeBridge.nativePair] returned `""`). */
|
||||
fun pairMessage(token: String): String = when (token) {
|
||||
"crypto" -> "Wrong PIN — check the PIN on the host's Pairing page and try again."
|
||||
else -> shared(token) ?: transport(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a failed connect / request-access ([NativeBridge.nativeConnect] returned `0`).
|
||||
* [requestAccess] tunes the fallback wording for the delegated-approval path.
|
||||
*/
|
||||
fun connectMessage(token: String, requestAccess: Boolean): String =
|
||||
shared(token) ?: when (token) {
|
||||
"crypto" ->
|
||||
"The host's identity doesn't match the saved fingerprint — re-pair with this host."
|
||||
"timeout", "io", "" ->
|
||||
if (requestAccess) {
|
||||
"The request never reached the host, or nobody approved it in time — " +
|
||||
"check the network path (no VPN, no guest-Wi-Fi isolation) and the " +
|
||||
"host's console."
|
||||
} else {
|
||||
transport(token)
|
||||
}
|
||||
else -> "Connection failed — check host/port and logcat."
|
||||
}
|
||||
|
||||
/** The host's typed rejection reasons — identical wording across every punktfunk client. */
|
||||
private fun shared(token: String): String? = when (token) {
|
||||
"not-armed" ->
|
||||
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try again."
|
||||
"bound-other" ->
|
||||
"The host's pairing window is armed for a different device — arm it for this one."
|
||||
"rate-limited" -> "Too many pairing attempts — wait a couple of seconds and try again."
|
||||
"identity-required" ->
|
||||
"The host requires pairing — pair this device (PIN or request access) first."
|
||||
"denied" -> "The host declined this device's request."
|
||||
"approval-timeout" ->
|
||||
"Nobody approved the request on the host in time — approve this device in the " +
|
||||
"host's console or web UI, then request access again."
|
||||
"superseded" ->
|
||||
"A newer request from this device replaced this one — approve the latest request " +
|
||||
"on the host."
|
||||
"wire-version" -> "Client and host versions don't match — update both to the same release."
|
||||
"busy" -> "The host is busy with another session."
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Transport-level causes (nothing typed arrived from the host). */
|
||||
private fun transport(token: String): String = when (token) {
|
||||
"timeout" ->
|
||||
"The host didn't answer — check that this device and the host are on the same " +
|
||||
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
||||
"io" ->
|
||||
"Couldn't reach the host — check that this device and the host are on the same " +
|
||||
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
||||
else -> "Pairing failed — the host didn't answer or closed the connection (see logcat)."
|
||||
}
|
||||
}
|
||||
@@ -305,13 +305,17 @@ fun ConnectScreen(
|
||||
onConnected(handle)
|
||||
} else {
|
||||
discovery.start()
|
||||
if (onFailure != null) {
|
||||
// Hand off to the wake-and-wait flow — clearing `attempt` above and setting
|
||||
// `waker.waking` here land in one recompose, so the overlay slides
|
||||
val token = NativeBridge.nativeTakeLastError()
|
||||
val unreachable = token == "timeout" || token == "io" || token.isEmpty()
|
||||
if (onFailure != null && unreachable) {
|
||||
// Unreachable — hand off to the wake-and-wait flow — clearing `attempt` above
|
||||
// and setting `waker.waking` here land in one recompose, so the overlay slides
|
||||
// Connecting → Waking without a blank frame.
|
||||
onFailure()
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
// A typed host rejection (busy / versions differ / pairing required) means the
|
||||
// host is awake — waking it would be nonsense; show the stated reason instead.
|
||||
status = ConnectErrors.connectMessage(token, requestAccess = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,7 +420,12 @@ fun ConnectScreen(
|
||||
}
|
||||
onConnected(handle)
|
||||
} else {
|
||||
status = "Request timed out — approve this device in the host's console, then retry."
|
||||
// Cause-specific: an operator denial, an approval timeout, and a request that
|
||||
// never reached the host are different problems with different fixes.
|
||||
status = ConnectErrors.connectMessage(
|
||||
NativeBridge.nativeTakeLastError(),
|
||||
requestAccess = true,
|
||||
)
|
||||
discovery.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -44,6 +45,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -147,8 +149,38 @@ 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) }
|
||||
DisposableEffect(Unit) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
||||
}
|
||||
val filter = android.content.IntentFilter().apply {
|
||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(android.hardware.usb.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)
|
||||
}
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
val sc2Probe = remember { Sc2Capture(context) }
|
||||
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(sc2Generation) {
|
||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) sc2Probe.pairedBleAddress() else null
|
||||
}
|
||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||
|
||||
Group("Gamepads") {
|
||||
if (pads.isEmpty()) {
|
||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||
@@ -214,6 +246,79 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam Controller 2 card — capture-side state, since a (claimed or lizard-mode) SC2 never
|
||||
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
|
||||
* these menus now; streamed as-is in a session), and a grant button when USB access is missing.
|
||||
*/
|
||||
@Composable
|
||||
private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivity?) {
|
||||
val context = LocalContext.current
|
||||
val settingOn = remember { SettingsStore(context).load().sc2Capture }
|
||||
val active = activity?.sc2MenuActive == true
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
||||
val permitted = usbDev != null && usbManager.hasPermission(usbDev)
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Steam Controller 2",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (active) {
|
||||
Text(
|
||||
"navigating this UI",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
when {
|
||||
usbDev == null -> "Paired via Bluetooth"
|
||||
usbDev.productId == io.unom.punktfunk.kit.Sc2Device.PID_WIRED -> "Wired (USB)"
|
||||
else -> "Puck dongle (USB)"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when {
|
||||
!settingOn -> Text(
|
||||
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
|
||||
"passthrough\" to capture it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
active -> Text(
|
||||
"Captured — streams as-is: the host presents a real Steam Controller 2 " +
|
||||
"that its Steam drives directly (trackpads, gyro, haptics).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
usbDev != null && !permitted -> {
|
||||
Text(
|
||||
"Needs USB access to be captured.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { activity?.startSc2MenuNav(forceAsk = true) }) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
"Detected — capture engages automatically.",
|
||||
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) {
|
||||
@@ -387,6 +492,10 @@ private fun prefLabel(pref: Int): String = when (pref) {
|
||||
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
||||
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
||||
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
||||
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
|
||||
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
|
||||
Gamepad.PREF_STEAMCONTROLLER2 -> "Steam Controller 2"
|
||||
Gamepad.PREF_STEAMCONTROLLER2_PUCK -> "Steam Controller 2 Puck"
|
||||
else -> "Automatic"
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,12 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||
}
|
||||
pairing = false
|
||||
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
if (fp.isNotEmpty()) {
|
||||
onPaired(fp)
|
||||
} else {
|
||||
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
||||
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
@@ -82,7 +84,10 @@ fun GamepadSettingsScreen(
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
fun update(next: Settings) { s = next; onChange(next) }
|
||||
|
||||
val rows = buildSettingsRows(s, ::update)
|
||||
val context = LocalContext.current
|
||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
@@ -257,8 +262,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
||||
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
|
||||
private fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||
@@ -354,7 +364,18 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
"phoneRumble", null, "Rumble on this phone",
|
||||
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||
"for clip-on pads without rumble motors.",
|
||||
s.rumbleOnPhone,
|
||||
) { update(s.copy(rumbleOnPhone = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
choice(
|
||||
"hud", "Interface", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.os.Looper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -46,6 +47,10 @@ fun isTvDevice(context: Context): Boolean {
|
||||
@Composable
|
||||
fun rememberControllerConnected(): State<Boolean> {
|
||||
val context = LocalContext.current
|
||||
// A menu-captured Steam Controller 2 counts as connected: it drives the console UI through
|
||||
// the capture link, but never surfaces as an Android InputDevice (lizard mode is kb/mouse,
|
||||
// and the claim removes even those) — the InputManager path below can't see it.
|
||||
val activity = context as? MainActivity
|
||||
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||
@@ -59,5 +64,7 @@ fun rememberControllerConnected(): State<Boolean> {
|
||||
connected.value = Gamepad.firstPad() != null
|
||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||
}
|
||||
return connected
|
||||
return remember {
|
||||
derivedStateOf { connected.value || activity?.sc2MenuActive == true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyCharacterMap
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -20,6 +28,9 @@ import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
|
||||
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
||||
@@ -73,6 +84,30 @@ class MainActivity : ComponentActivity() {
|
||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||
private var highRefreshModeId = 0
|
||||
|
||||
/**
|
||||
* Menu-time Steam Controller 2 capture (UI mode — no router): a captured SC2 never produces
|
||||
* ordinary gamepad events (lizard mode is kb/mouse; the claim removes even those), so this
|
||||
* drives the console UI directly from the parsed reports via [sc2NavKey]. Runs while the app
|
||||
* is foreground and NOT streaming; StreamScreen pauses it around its own stream-mode capture.
|
||||
* [sc2MenuActive] is observed by the console-UI gate ([rememberControllerConnected]) and the
|
||||
* Controllers screen.
|
||||
*/
|
||||
private var sc2Menu: io.unom.punktfunk.kit.Sc2Capture? = null
|
||||
var sc2MenuActive by mutableStateOf(false)
|
||||
private set
|
||||
private var sc2Receiver: BroadcastReceiver? = null
|
||||
private var sc2PermissionAsked = 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
|
||||
* ViewRootImpl's focus-navigation stage — the one that grants initial focus for a real
|
||||
* pad's first D-pad press — so on a phone in touch mode it lands on a focus-less window
|
||||
* and does nothing (first on-glass run: only B worked, since it bypasses key events
|
||||
* entirely). `FocusManager.moveFocus` is the public API for exactly this.
|
||||
*/
|
||||
private var sc2MoveFocus: ((androidx.compose.ui.focus.FocusDirection) -> Boolean)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lastPadIsGamepad = !isTvDevice(this)
|
||||
@@ -90,13 +125,166 @@ class MainActivity : ComponentActivity() {
|
||||
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||
// SC2 hot-plug + the menu-time USB-permission grant both (re)start the menu capture.
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
sc2PermissionAsked = false // a fresh attach may ask once again
|
||||
startSc2MenuNav()
|
||||
}
|
||||
SC2_MENU_PERMISSION -> {
|
||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
startSc2MenuNav()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sc2Receiver = receiver
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(SC2_MENU_PERMISSION)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(receiver, filter)
|
||||
}
|
||||
setContent {
|
||||
PunktfunkTheme {
|
||||
// Focus hook for the SC2's synthetic navigation (see [sc2MoveFocus]). `Next` is
|
||||
// the bootstrap: directional moves need an already-focused node, while one-
|
||||
// dimensional traversal assigns initial focus when there is none.
|
||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
sc2MoveFocus = { dir ->
|
||||
focusManager.moveFocus(dir) ||
|
||||
focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
|
||||
}
|
||||
onDispose { sc2MoveFocus = null }
|
||||
}
|
||||
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
startSc2MenuNav()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
// Release the claim while backgrounded so the OS (and other apps) get the pad back.
|
||||
stopSc2MenuNav()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
sc2Receiver?.let { runCatching { unregisterReceiver(it) } }
|
||||
sc2Receiver = null
|
||||
stopSc2MenuNav()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
|
||||
* pad attached (asking for USB permission at most once per attach — [forceAsk] re-arms the
|
||||
* dialog, for the Controllers screen's explicit grant button) — else an already-paired BLE
|
||||
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
|
||||
*/
|
||||
fun startSc2MenuNav(forceAsk: Boolean = false) {
|
||||
if (forceAsk) sc2PermissionAsked = false
|
||||
if (streamHandle != 0L) return // StreamScreen owns the pad while streaming
|
||||
if (sc2Menu?.isActive == true) return
|
||||
if (!SettingsStore(this).load().sc2Capture) return
|
||||
val cap = sc2Menu ?: io.unom.punktfunk.kit.Sc2Capture(this).also { c ->
|
||||
c.onUiKey = { key, down -> runOnUiThread { sc2NavKey(key, down) } }
|
||||
c.onActiveChanged = { on -> runOnUiThread { sc2MenuActive = on } }
|
||||
sc2Menu = c
|
||||
}
|
||||
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val dev = cap.findUsbDevice()
|
||||
when {
|
||||
dev != null && usbManager.hasPermission(dev) -> cap.startUsb(dev)
|
||||
dev != null && !sc2PermissionAsked -> {
|
||||
sc2PermissionAsked = true
|
||||
usbManager.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
this, 1,
|
||||
Intent(SC2_MENU_PERMISSION).setPackage(packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
cap.pairedBleAddress()?.let { cap.startBle(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Release the menu-time SC2 capture (backgrounded / stream taking over). Idempotent. */
|
||||
fun stopSc2MenuNav() {
|
||||
sc2Menu?.stop()
|
||||
sc2MenuActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* A activates the focused element, everything else (D-pad, shoulders, Start/Select) goes to
|
||||
* the framework's focus navigation. Also claims the console-UI glyphs for the pad.
|
||||
*/
|
||||
private fun sc2NavKey(keyCode: Int, down: Boolean) {
|
||||
if (streamHandle != 0L) return // raced a stream start — the wire path owns input now
|
||||
lastPadIsGamepad = true
|
||||
lastPadStyle = Gamepad.PadStyle.XBOX // Valve pads carry A/B/X/Y in Xbox positions
|
||||
val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
|
||||
// The console UI navigates through padKeyProbe (GamepadNavEffect's held-state + repeat
|
||||
// machinery — A/X/Y/D-pad/Select), NOT the focus system: synthesized events must be
|
||||
// offered there first, exactly like real ones in dispatchKeyEvent (tester-diagnosed:
|
||||
// routing everything via super.dispatchKeyEvent bypassed the probe, so only B — which
|
||||
// never rides key events — did anything). The probes gate on keycode only, so a
|
||||
// synthetic KeyEvent satisfies them.
|
||||
padKeyProbe?.let { if (it(KeyEvent(action, keyCode))) return }
|
||||
when (keyCode) {
|
||||
// B → back, on release (same edge the real-pad path uses).
|
||||
KeyEvent.KEYCODE_BUTTON_B -> if (!down) onBackPressedDispatcher.onBackPressed()
|
||||
// A → activate the focused element (the focus system understands DPAD_CENTER; the
|
||||
// Compose node focused via the moveFocus hook receives it once the ComposeView
|
||||
// holds view-focus).
|
||||
KeyEvent.KEYCODE_BUTTON_A ->
|
||||
super.dispatchKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_DPAD_CENTER))
|
||||
// D-pad → Compose's own focus API (a synthetic DPAD KeyEvent can't grant initial
|
||||
// focus — see [sc2MoveFocus]); one move per press edge.
|
||||
KeyEvent.KEYCODE_DPAD_UP -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Up)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Down)
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Left)
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Right)
|
||||
else -> super.dispatchKeyEvent(KeyEvent(action, keyCode))
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveSc2Focus(dir: androidx.compose.ui.focus.FocusDirection) {
|
||||
val hook = sc2MoveFocus
|
||||
if (hook == null || !hook(dir)) {
|
||||
// No composition hook (shouldn't happen) — fall back to the raw key dispatch.
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dirToKey(dir)))
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dirToKey(dir)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun dirToKey(dir: androidx.compose.ui.focus.FocusDirection): Int = when (dir) {
|
||||
androidx.compose.ui.focus.FocusDirection.Up -> KeyEvent.KEYCODE_DPAD_UP
|
||||
androidx.compose.ui.focus.FocusDirection.Down -> KeyEvent.KEYCODE_DPAD_DOWN
|
||||
androidx.compose.ui.focus.FocusDirection.Left -> KeyEvent.KEYCODE_DPAD_LEFT
|
||||
else -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
}
|
||||
|
||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||
private fun resolveHighRefreshMode() {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -129,9 +317,9 @@ class MainActivity : ComponentActivity() {
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
|
||||
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
|
||||
// accidental brush of the four buttons no longer quits instantly.
|
||||
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
|
||||
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
|
||||
// can still leave — but an accidental brush of the four buttons no longer quits.
|
||||
gamepadRouter?.onButton(event, bit)
|
||||
return true // consumed
|
||||
}
|
||||
@@ -153,7 +341,18 @@ class MainActivity : ComponentActivity() {
|
||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||
val vk = Keymap.toVk(event)
|
||||
if (vk != 0) {
|
||||
// Soft-keyboard events (the IME's virtual device — the stream's
|
||||
// KeyCaptureView path) carry Shift only as META state, where a real
|
||||
// keyboard sends discrete Shift transitions — so mirror the meta bit as
|
||||
// a VK_LSHIFT wrap or every IME capital/symbol lands unshifted on the
|
||||
// host. Never applied to hardware events: their Shift already went over
|
||||
// the wire, and a synthetic release here would un-hold a physical Shift
|
||||
// the user is still pressing.
|
||||
val imeShift = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD &&
|
||||
event.isShiftPressed && vk != 0xA0 && vk != 0xA1
|
||||
if (down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, true, 0)
|
||||
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
||||
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
|
||||
return true // consumed — don't let the system also act on it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,23 @@ data class Settings(
|
||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||
*/
|
||||
val autoWakeEnabled: Boolean = true,
|
||||
/**
|
||||
* Opt-in: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
* phone's own vibration motor — for clip-on gamepads that ship without rumble motors, where
|
||||
* the phone body is the only actuator in the player's hands. Off by default; read once per
|
||||
* session by StreamScreen (it hands GamepadFeedback the device vibrator only when set). The
|
||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val rumbleOnPhone: Boolean = false,
|
||||
|
||||
/**
|
||||
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
||||
* and pass it through AS-IS: the host presents a real `28DE:1302` that its Steam drives
|
||||
* directly (Linux hosts). ON by default — it engages only when such a controller is actually
|
||||
* present at stream start, so it costs nothing otherwise; the toggle exists for the rare
|
||||
* setup where the OS-level pad (lizard mode) is preferred.
|
||||
*/
|
||||
val sc2Capture: Boolean = true,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
@@ -142,6 +159,8 @@ class SettingsStore(context: Context) {
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -162,6 +181,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -197,6 +218,8 @@ class SettingsStore(context: Context) {
|
||||
*/
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
|
||||
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
|
||||
/**
|
||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||
@@ -414,6 +415,26 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = onOpenControllers,
|
||||
)
|
||||
// Only where the device has a body vibrator to mirror onto (a TV box doesn't).
|
||||
val context = LocalContext.current
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
if (hasBodyVibrator) {
|
||||
ToggleRow(
|
||||
title = "Rumble on this phone",
|
||||
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
|
||||
"motor — for clip-on pads without rumble motors",
|
||||
checked = s.rumbleOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Steam Controller 2 passthrough",
|
||||
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
|
||||
"Bluetooth): it navigates these menus and streams as-is — Steam on the " +
|
||||
"host drives it like the physical pad (trackpads, gyro, haptics)",
|
||||
checked = s.sc2Capture,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -24,9 +39,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -34,7 +51,9 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -149,6 +168,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
}.onEach { it.setReferenceCounted(false) }
|
||||
}
|
||||
|
||||
// True while the gamepad exit chord (Select+Start+L1+R1) is held and counting down — drives the
|
||||
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
|
||||
var exitArming by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
wifiLocks.forEach { lock ->
|
||||
@@ -166,6 +189,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||
val priorSoftInput = window?.attributes?.softInputMode
|
||||
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
// 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
|
||||
@@ -185,22 +214,88 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// the same way the Back gesture does.
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
router.onExitArmed = { armed -> exitArming = armed }
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// 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.
|
||||
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
||||
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||
// rumble onto the device's own vibrator — for clip-on pads without rumble motors.
|
||||
val feedback = GamepadFeedback(
|
||||
handle,
|
||||
router,
|
||||
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||
).also { it.start() }
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
||||
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
||||
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
||||
// through feedback.onHidRaw onto the physical controller. Engages only when such a pad is
|
||||
// actually present; the wire slot is claimed lazily on its first state report.
|
||||
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
|
||||
// claim the interfaces; it resumes in onDispose once the stream releases them.
|
||||
activity?.stopSc2MenuNav()
|
||||
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
|
||||
var sc2UsbReceiver: BroadcastReceiver? = null
|
||||
if (sc2 != null) {
|
||||
feedback.onHidRaw = sc2::onHidRaw
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = sc2.findUsbDevice()
|
||||
when {
|
||||
usbDev != null && usbManager.hasPermission(usbDev) -> sc2.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.SC2_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) sc2.startUsb(usbDev) else Log.i("punktfunk", "SC2 USB permission denied")
|
||||
}
|
||||
}
|
||||
sc2UsbReceiver = receiver
|
||||
ContextCompat.registerReceiver(
|
||||
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
PendingIntent.getBroadcast(
|
||||
context, 0,
|
||||
Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
sc2.pairedBleAddress()?.let { addr ->
|
||||
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
|
||||
sc2.startBle(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
feedback.onHidRaw = 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)
|
||||
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
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
// 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
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@@ -221,6 +316,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
|
||||
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
|
||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -271,8 +369,22 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
|
||||
// broken. Purely visual; it sits above the video and below the gesture layer.
|
||||
if (exitArming) {
|
||||
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||
}
|
||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
|
||||
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
|
||||
AndroidView(
|
||||
modifier = Modifier.size(1.dp),
|
||||
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
|
||||
)
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
||||
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
||||
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
@@ -281,9 +393,63 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
|
||||
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
|
||||
* the press registered and tells the user to keep holding. Purely visual; [GamepadRouter.onExitArmed]
|
||||
* toggles its visibility.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExitChordHint(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
"Hold to quit…",
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
|
||||
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
|
||||
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
|
||||
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
|
||||
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
|
||||
* `MainActivity.dispatchKeyEvent`).
|
||||
*/
|
||||
private class KeyCaptureView(context: Context) : View(context) {
|
||||
init {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
override fun onCheckIsTextEditor(): Boolean = true
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||
outAttrs.inputType = InputType.TYPE_NULL
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||
return BaseInputConnection(this, false)
|
||||
}
|
||||
|
||||
fun setImeVisible(show: Boolean) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
?: return
|
||||
if (show) {
|
||||
requestFocus()
|
||||
imm.showSoftInput(this, 0)
|
||||
} else {
|
||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ private const val TAP_SLOP = 12f
|
||||
private const val TAP_DRAG_MS = 250L
|
||||
private const val SCROLL_DIV = 4f
|
||||
|
||||
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
|
||||
// summon (up) / dismiss (down) the local soft keyboard.
|
||||
private const val KB_SWIPE_FRACTION = 0.10f
|
||||
|
||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||
@@ -40,7 +44,9 @@ private const val ACCEL_MAX = 3.0f
|
||||
*
|
||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
|
||||
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
|
||||
* typing on the host).
|
||||
*/
|
||||
/**
|
||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||
@@ -94,6 +100,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
onCycleStats: () -> Unit,
|
||||
onKeyboard: (show: Boolean) -> Unit,
|
||||
) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
@@ -128,6 +135,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
var maxFingers = 1
|
||||
var scrolling = false
|
||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
||||
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
|
||||
// scroll anchor) and a once-per-gesture latch.
|
||||
var kbCount = 0
|
||||
var kbAnchorX = 0f
|
||||
var kbAnchorY = 0f
|
||||
var kbFired = false
|
||||
var prevCx = startX
|
||||
var prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
@@ -148,9 +161,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
break
|
||||
}
|
||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
|
||||
// bounce re-anchors instead of reading the count change as swipe travel.
|
||||
if (pressed.size < 3) kbCount = 0
|
||||
|
||||
if (pressed.size >= 2) {
|
||||
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
||||
if (pressed.size == 2) {
|
||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||
@@ -177,6 +193,36 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
prevCx = cx
|
||||
moved = true
|
||||
}
|
||||
} else if (pressed.size >= 3) {
|
||||
// Three+ fingers → the keyboard swipe, never scroll (the documented
|
||||
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
|
||||
// accident of its old `>= 2` bound). Anchor the centroid per finger count
|
||||
// (same reasoning as the scroll anchor above) and fire once per gesture when
|
||||
// the vertical travel crosses the threshold: up = show, down = hide.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
if (pressed.size != kbCount) {
|
||||
kbCount = pressed.size
|
||||
kbAnchorX = cx
|
||||
kbAnchorY = cy
|
||||
} else {
|
||||
val dy = cy - kbAnchorY
|
||||
// Real centroid travel disqualifies the tap classification below (else a
|
||||
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
|
||||
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
|
||||
kbFired = true
|
||||
onKeyboard(dy < 0) // finger up → show, finger down → hide
|
||||
}
|
||||
}
|
||||
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
|
||||
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
|
||||
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
|
||||
// down, so dropping straight back to one finger must re-anchor (zero delta),
|
||||
// not replay the whole 3-finger phase as one cursor jump.
|
||||
scrolling = false
|
||||
scrollCount = 0
|
||||
trackId = PointerId(Long.MIN_VALUE)
|
||||
} else if (!scrolling) {
|
||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||
// back to one finger doesn't jerk the cursor).
|
||||
|
||||
@@ -36,6 +36,16 @@ object Gamepad {
|
||||
const val BTN_X = 0x4000
|
||||
const val BTN_Y = 0x8000
|
||||
|
||||
// Extended bits (Moonlight `buttonFlags2 << 16` namespace — `input.rs::gamepad`): the four
|
||||
// back grips (Steam L4/L5/R4/R5 ≙ Elite P1–P4), touchpad click, and the misc/QAM button.
|
||||
// Android's standard InputDevice path never produces these; the SC2 capture link does.
|
||||
const val BTN_PADDLE1 = 0x10000
|
||||
const val BTN_PADDLE2 = 0x20000
|
||||
const val BTN_PADDLE3 = 0x40000
|
||||
const val BTN_PADDLE4 = 0x80000
|
||||
const val BTN_TOUCHPAD = 0x100000
|
||||
const val BTN_MISC1 = 0x200000
|
||||
|
||||
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
|
||||
const val AXIS_LS_X = 0
|
||||
const val AXIS_LS_Y = 1
|
||||
@@ -52,6 +62,10 @@ object Gamepad {
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
const val PREF_STEAMCONTROLLER = 5
|
||||
const val PREF_STEAMDECK = 6
|
||||
const val PREF_DUALSENSEEDGE = 7
|
||||
const val PREF_SWITCHPRO = 8
|
||||
const val PREF_STEAMCONTROLLER2 = 9
|
||||
const val PREF_STEAMCONTROLLER2_PUCK = 10
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
@@ -59,10 +73,19 @@ object Gamepad {
|
||||
private const val VID_VALVE = 0x28DE
|
||||
private const val VID_NINTENDO = 0x057E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
// Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct
|
||||
// host pad types — the Edge's back paddles get native slots on the virtual Edge (Android
|
||||
// forwards no paddle input yet, but the identity + rich planes match the physical pad).
|
||||
private val PID_DUALSENSE = setOf(0x0CE6)
|
||||
private val PID_DUALSENSEEDGE = setOf(0x0DF2)
|
||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||
|
||||
// Nintendo: Switch Pro Controller — the host builds the virtual hid-nintendo pad (correct
|
||||
// glyphs + positional layout). The Switch 2 Pro Controller (0x2069) and a Joy-Con 2 pair
|
||||
// (0x2068) are the same full pad surface and ride the same virtual pad (SDL folds them to
|
||||
// its NINTENDO_SWITCH_PRO type too).
|
||||
private val PID_SWITCHPRO = setOf(0x2009, 0x2069, 0x2068)
|
||||
|
||||
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
|
||||
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
|
||||
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
||||
@@ -70,6 +93,12 @@ object Gamepad {
|
||||
private val PID_STEAMDECK = setOf(0x1205)
|
||||
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
||||
|
||||
// Steam Controller 2: wired (0x1302), BLE (0x1303), and Puck dongles (0x1304/0x1305).
|
||||
// Sc2Capture normally claims these directly; the plain InputDevice path is only a degraded
|
||||
// fallback. Keep Puck distinct so even that path requests the native multi-interface identity.
|
||||
private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303)
|
||||
private val PID_STEAMCONTROLLER2_PUCK = setOf(0x1304, 0x1305)
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
@@ -91,10 +120,15 @@ object Gamepad {
|
||||
val pid = dev.productId
|
||||
return when {
|
||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||
vid == VID_SONY && pid in PID_DUALSENSEEDGE -> PREF_DUALSENSEEDGE
|
||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2_PUCK ->
|
||||
PREF_STEAMCONTROLLER2_PUCK
|
||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2 -> PREF_STEAMCONTROLLER2
|
||||
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.hardware.lights.Light
|
||||
import android.hardware.lights.LightState
|
||||
@@ -33,13 +34,24 @@ import java.nio.ByteBuffer
|
||||
*
|
||||
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
||||
*
|
||||
* [deviceVibrator] is the opt-in phone mirror ("Rumble on this phone", off by default): when
|
||||
* non-null, rumble the host addresses to wire pad 0 (controller 1) is ALSO played on this
|
||||
* device's own vibration motor — for clip-on gamepads that ship without rumble motors, where the
|
||||
* phone body is the only actuator in the player's hands. StreamScreen passes it only when the
|
||||
* setting is on (see [deviceBodyVibrator]).
|
||||
*/
|
||||
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
|
||||
class GamepadFeedback(
|
||||
private val handle: Long,
|
||||
private val router: GamepadRouter?,
|
||||
private val deviceVibrator: Vibrator? = null,
|
||||
) {
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
||||
// A new host renews far below this, so it never actually holds this long there.
|
||||
const val LEGACY_RUMBLE_MS = 60_000L
|
||||
@@ -101,7 +113,8 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
val buf = ByteBuffer.allocateDirect(64)
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
@@ -127,7 +140,9 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
// Threads are dead — drop any held rumble and close every lights session.
|
||||
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
|
||||
// lights session.
|
||||
runCatching { deviceVibrator?.cancel() }
|
||||
synchronized(bindsLock) {
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
@@ -203,6 +218,11 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
*/
|
||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
||||
// serves have no vibrator of their own, so their bind below is null. It follows
|
||||
// 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)
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
@@ -246,6 +266,29 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The opt-in phone mirror: play a wire-pad-0 rumble on this device's own vibration motor —
|
||||
* one physical actuator, so both wire motors blend into one effect (the same blend as the
|
||||
* single-motor controller path). Same envelope semantics too: a one-shot held for the host's
|
||||
* TTL, cancel on (0,0).
|
||||
*/
|
||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||
val v = deviceVibrator ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { v.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
runCatching {
|
||||
v.vibrate(
|
||||
if (v.hasAmplitudeControl()) oneShot(a, durationMs)
|
||||
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
private fun toAmplitude(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
@@ -290,10 +333,32 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
}
|
||||
TAG_HID_RAW -> {
|
||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||
// [kind: 0=output, 1=feature][report bytes, id first]. Handed to the capture link
|
||||
// for verbatim replay on the physical controller; dropped when no link owns the pad.
|
||||
val kind = buf.get().toInt() and 0xFF
|
||||
val len = n - 3
|
||||
if (len > 0) {
|
||||
val data = ByteArray(len)
|
||||
buf.get(data)
|
||||
onHidRaw?.invoke(pad, kind, data)
|
||||
}
|
||||
}
|
||||
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HID-report replay hook for the as-is Steam Controller 2 passthrough: invoked (on the
|
||||
* hidout poll thread) with the wire pad index, the report kind (0 = output report, 1 =
|
||||
* feature report), and the full report bytes (id first) the host's hidraw consumer wrote.
|
||||
* `StreamScreen` wires this to the SC2 capture so Steam's rumble/settings land on the
|
||||
* physical controller.
|
||||
*/
|
||||
@Volatile
|
||||
var onHidRaw: ((pad: Int, kind: Int, data: ByteArray) -> Unit)? = null
|
||||
|
||||
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
||||
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||
0b00000 -> 0
|
||||
@@ -349,3 +414,18 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This device's own body vibrator (the phone, not a controller), or null where there is none
|
||||
* (TVs) — gates the "Rumble on this phone" setting's visibility and feeds
|
||||
* [GamepadFeedback.deviceVibrator] when it's on.
|
||||
*/
|
||||
fun deviceBodyVibrator(context: Context): Vibrator? {
|
||||
val v = if (Build.VERSION.SDK_INT >= 31) {
|
||||
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
return v?.takeIf { it.hasVibrator() }
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
var onExitChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with `true` the moment the exit chord completes and the hold countdown
|
||||
* starts, and `false` when it's cancelled (a button lifted early) or the timer elapses. `StreamScreen`
|
||||
* wires this to a "hold to quit" hint so the hold is discoverable — the chord no longer quits on a
|
||||
* quick press, and without an on-screen cue that reads as the shortcut being broken.
|
||||
*/
|
||||
var onExitArmed: ((armed: Boolean) -> Unit)? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||
private var pendingExit: Runnable? = null
|
||||
@@ -84,28 +92,37 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
||||
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
||||
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
|
||||
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
|
||||
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is
|
||||
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
|
||||
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly
|
||||
* ([onExitArmed] fires so the UI can show a "hold to quit" hint); [onExitChord] fires only if the
|
||||
* chord is still held at expiry (a brief accidental brush is ignored), matching `DISCONNECT_HOLD`
|
||||
* on the SDL/Apple clients. Any controller can leave.
|
||||
*/
|
||||
fun onButton(event: KeyEvent, bit: Int) {
|
||||
val slot = slotFor(event.device) ?: return
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||
disarmExit()
|
||||
}
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
KeyEvent.ACTION_DOWN -> slotButton(slot, bit, down = true, send = event.repeatCount == 0)
|
||||
KeyEvent.ACTION_UP -> slotButton(slot, bit, down = false, send = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
if (down) {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
} else {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||
disarmExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +132,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
if (pendingExit != null) return // already counting down
|
||||
val r = Runnable {
|
||||
pendingExit = null
|
||||
onExitArmed?.invoke(false) // countdown over — drop the hint whether or not we leave
|
||||
// Fire only if the chord survived the full hold on some pad.
|
||||
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
||||
if (held.isNotEmpty()) {
|
||||
@@ -126,12 +144,15 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
}
|
||||
pendingExit = r
|
||||
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
||||
onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint
|
||||
}
|
||||
|
||||
/** Cancel a pending exit-chord hold timer. */
|
||||
private fun disarmExit() {
|
||||
val wasArmed = pendingExit != null
|
||||
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
||||
pendingExit = null
|
||||
if (wasArmed) onExitArmed?.invoke(false) // released early — drop the hint
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,8 +173,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/**
|
||||
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
|
||||
* holds no live slot (a pad that just unplugged — the update is then dropped). Read from the
|
||||
* feedback poll threads.
|
||||
* holds no live slot (a pad that just unplugged — the update is then dropped) OR the slot is
|
||||
* an [ExternalPad] (its synthetic id resolves to no InputDevice, so rumble binds naturally
|
||||
* fall through to the capture link's own feedback path). Read from the feedback poll threads.
|
||||
*/
|
||||
fun deviceForPad(pad: Int): InputDevice? {
|
||||
for ((deviceId, slot) in slots) {
|
||||
@@ -162,6 +184,50 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
|
||||
* flush + Remove on [close], and full participation in the emergency exit chord.
|
||||
*/
|
||||
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
|
||||
// Live lookup instead of a captured reference: after [close] (or a router release) the
|
||||
// slot is gone from the table and every entry point below degrades to a safe no-op.
|
||||
private val slot get() = slots[syntheticId]
|
||||
|
||||
/** One button transition (a wire [Gamepad].BTN_* bit). On-change only — the caller diffs. */
|
||||
fun button(bit: Int, down: Boolean) {
|
||||
slot?.let { slotButton(it, bit, down, send = true) }
|
||||
}
|
||||
|
||||
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
|
||||
fun axis(id: Int, value: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
}
|
||||
|
||||
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
|
||||
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||
fun close() = closeSlot(syntheticId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
|
||||
* indices are taken. Main thread (like the hot-plug callbacks).
|
||||
*/
|
||||
fun openExternal(pref: Int): ExternalPad? {
|
||||
val index = lowestFreeIndex() ?: return null
|
||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||
val syntheticId = EXTERNAL_ID_BASE - index
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
@@ -250,7 +316,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
||||
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
|
||||
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
|
||||
const val EXIT_HOLD_MS = 1500L
|
||||
/**
|
||||
* How long the exit chord must be held before the stream leaves — long enough that an
|
||||
* accidental brush of the four buttons doesn't quit, short enough to feel responsive (the
|
||||
* on-screen hint covers the gap). Roughly matches SDL/Apple `DISCONNECT_HOLD`.
|
||||
*/
|
||||
const val EXIT_HOLD_MS = 1000L
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,16 @@ object NativeBridge {
|
||||
name: String,
|
||||
): String
|
||||
|
||||
/**
|
||||
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
|
||||
* (`""` when none) — call right after a `0` handle / `""` fingerprint. A typed host rejection
|
||||
* yields its wire token ("not-armed", "denied", "approval-timeout", "superseded", "busy",
|
||||
* "rate-limited", "bound-other", "identity-required", "wire-version"); transport-level causes
|
||||
* yield "crypto" (wrong PIN / identity mismatch), "timeout", "io", or "error". Lets the UI say
|
||||
* WHY instead of the old catch-all that blamed the PIN for dead network paths.
|
||||
*/
|
||||
external fun nativeTakeLastError(): String
|
||||
|
||||
/**
|
||||
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
|
||||
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
||||
@@ -291,6 +301,14 @@ object NativeBridge {
|
||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
|
||||
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
|
||||
* first [len] bytes are the report, id byte first (0x42/0x45/0x47 state, 0x43 battery, …);
|
||||
* len is clamped to 64. Called from the capture thread at the controller's own report rate.
|
||||
*/
|
||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
@@ -302,10 +320,11 @@ object NativeBridge {
|
||||
external fun nativeNextRumble(handle: Long): Long
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
|
||||
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
|
||||
* byte count, or -1 on timeout / session closed.
|
||||
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
|
||||
* capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
|
||||
* Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…, raw as-is
|
||||
* passthrough report=pad 05 kind report-bytes (kind 0 = output report, 1 = feature report).
|
||||
* Returns the byte count, or -1 on timeout / session closed.
|
||||
*/
|
||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* BLE transport for a Steam Controller 2 paired directly with the device (no Puck). The standard
|
||||
* HID service (0x1812) is claimed by the OS (and would feed the pad through the ordinary input
|
||||
* stack in lizard-crippled form), so this talks Valve's vendor GATT service instead — the same
|
||||
* approach Steam itself uses on hosts without a dongle.
|
||||
*
|
||||
* GATT operations are serialized by a small state machine (connect → MTU → discover → subscribe
|
||||
* each notify char → lizard-off → ready); duplicate callbacks (the Android stack sometimes fires
|
||||
* `onMtuChanged` twice) are ignored. Notified state reports arrive with the report-id byte
|
||||
* stripped by the transport, so `0x45` (`ID_STATE_BLE`) is re-prepended for ≥40-byte payloads —
|
||||
* the wire then carries the same id-first framing as USB.
|
||||
*
|
||||
* Requires BLUETOOTH_CONNECT (the caller gates on it); connection priority is bumped to HIGH to
|
||||
* pull the connection interval from ~50 ms down to ~11 ms.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class Sc2BleLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
private enum class State { IDLE, CONNECTING, MTU_REQUESTED, DISCOVERING, SUBSCRIBING, READY }
|
||||
|
||||
private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var writeChar: BluetoothGattCharacteristic? = null
|
||||
private val pendingSubs = mutableListOf<BluetoothGattCharacteristic>()
|
||||
private var subsIndex = 0
|
||||
private val writeBusy = AtomicBoolean(false)
|
||||
private var lizardTicker: Thread? = null
|
||||
|
||||
@Volatile private var state = State.IDLE
|
||||
|
||||
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
|
||||
fun pairedControllers(): List<BluetoothDevice> = runCatching {
|
||||
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
||||
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
||||
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
|
||||
fun start(address: String): Boolean {
|
||||
val adapter = manager.adapter ?: return false
|
||||
if (!adapter.isEnabled) return false
|
||||
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
|
||||
state = State.CONNECTING
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one raw report from the host: output reports (rumble) ride WRITE_NO_RESPONSE so they
|
||||
* can't queue behind acks at the 25 Hz resend rate; feature reports (settings) use an acked
|
||||
* write. The report-id byte stays in the payload (the firmware's vendor-channel framing).
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (state != State.READY || data.isEmpty()) return
|
||||
val g = gatt ?: return
|
||||
val ch = writeChar ?: return
|
||||
runCatching {
|
||||
ch.value = data
|
||||
ch.writeType = if (kind == 0) {
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
} else {
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
}
|
||||
g.writeCharacteristic(ch)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendLizardOff() {
|
||||
if (state != State.READY) return
|
||||
val g = gatt ?: return
|
||||
val ch = writeChar ?: return
|
||||
if (!writeBusy.compareAndSet(false, true)) return // previous acked write still in flight
|
||||
runCatching {
|
||||
ch.value = Sc2Device.DISABLE_LIZARD
|
||||
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
if (!g.writeCharacteristic(ch)) writeBusy.set(false)
|
||||
}.onFailure { writeBusy.set(false) }
|
||||
}
|
||||
|
||||
/** Disconnect and stop the lizard ticker. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
lizardTicker?.interrupt()
|
||||
lizardTicker = null
|
||||
runCatching { gatt?.disconnect() }
|
||||
runCatching { gatt?.close() }
|
||||
gatt = null
|
||||
writeChar = null
|
||||
pendingSubs.clear()
|
||||
subsIndex = 0
|
||||
state = State.IDLE
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
// ~11 ms connection interval instead of the ~50 ms default — input latency.
|
||||
g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
||||
if (state == State.CONNECTING) {
|
||||
state = State.MTU_REQUESTED
|
||||
if (!g.requestMtu(DESIRED_MTU)) {
|
||||
state = State.DISCOVERING
|
||||
g.discoverServices()
|
||||
}
|
||||
}
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
val wasLive = state != State.IDLE
|
||||
runCatching { g.close() }
|
||||
gatt = null
|
||||
writeChar = null
|
||||
pendingSubs.clear()
|
||||
subsIndex = 0
|
||||
state = State.IDLE
|
||||
if (wasLive) onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
if (state != State.MTU_REQUESTED) return // fired twice on some stacks — act once
|
||||
state = State.DISCOVERING
|
||||
g.discoverServices()
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (state != State.DISCOVERING || status != BluetoothGatt.GATT_SUCCESS) return
|
||||
val valve = g.getService(VALVE_SERVICE) ?: run {
|
||||
Log.e(TAG, "Valve vendor service missing — not an SC2?")
|
||||
return
|
||||
}
|
||||
pendingSubs.clear()
|
||||
writeChar = null
|
||||
for (ch in valve.characteristics) {
|
||||
val short = shortUuid(ch.uuid) ?: continue
|
||||
val canNotify = ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
||||
val canWrite = ch.properties and (
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE or
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE
|
||||
) != 0
|
||||
if (canNotify && short in NOTIFY_LOW..NOTIFY_HIGH) pendingSubs.add(ch)
|
||||
if (canWrite && short in WRITE_LOW..WRITE_HIGH && writeChar == null) writeChar = ch
|
||||
}
|
||||
subsIndex = 0
|
||||
state = State.SUBSCRIBING
|
||||
subscribeNext(g)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) {
|
||||
if (state == State.SUBSCRIBING) subscribeNext(g)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||
writeBusy.set(false)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
||||
val data = ch.value ?: return
|
||||
// BLE strips the report-id prefix; restore 0x45 on state-sized payloads so the raw
|
||||
// wire framing matches USB. Short payloads (battery/status) pass through as-is.
|
||||
if (data.size >= 40) {
|
||||
val framed = ByteArray(data.size + 1)
|
||||
framed[0] = Sc2Device.ID_STATE_BLE.toByte()
|
||||
System.arraycopy(data, 0, framed, 1, data.size)
|
||||
onReport(framed, framed.size)
|
||||
} else {
|
||||
onReport(data, data.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeNext(g: BluetoothGatt) {
|
||||
if (subsIndex >= pendingSubs.size) {
|
||||
state = State.READY
|
||||
Log.i(TAG, "SC2 BLE link up (${pendingSubs.size} notify chars)")
|
||||
sendLizardOff()
|
||||
// The firmware watchdog re-enables lizard mode; refresh on SDL's cadence until the
|
||||
// host's Steam takes over via the raw plane (its writes land through writeRaw too).
|
||||
lizardTicker = Thread({
|
||||
while (state == State.READY) {
|
||||
try {
|
||||
Thread.sleep(Sc2Device.LIZARD_REFRESH_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
sendLizardOff()
|
||||
}
|
||||
}, "pf-sc2-lizard").apply { isDaemon = true; start() }
|
||||
return
|
||||
}
|
||||
val ch = pendingSubs[subsIndex++]
|
||||
g.setCharacteristicNotification(ch, true)
|
||||
val cccd = ch.getDescriptor(CCCD) ?: return subscribeNext(g)
|
||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
if (!g.writeDescriptor(cccd)) subscribeNext(g) // lose this one, try the rest
|
||||
}
|
||||
|
||||
/** The 32-bit short id of a Valve vendor UUID, or null for foreign UUIDs. */
|
||||
private fun shortUuid(uuid: UUID): Long? {
|
||||
val s = uuid.toString()
|
||||
if (!s.endsWith(VALVE_UUID_TAIL)) return null
|
||||
return s.substring(0, 8).toLongOrNull(16)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2BleLink"
|
||||
|
||||
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
||||
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
||||
const val NOTIFY_LOW = 0x100f6c75L
|
||||
const val NOTIFY_HIGH = 0x100f6c7aL
|
||||
const val WRITE_LOW = 0x100f6cb5L
|
||||
const val WRITE_HIGH = 0x100f6cbeL
|
||||
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
||||
|
||||
/** Enough for a state payload (45 B) + ATT header with margin. */
|
||||
const val DESIRED_MTU = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* One captured Steam Controller 2 — the glue between a transport link ([Sc2UsbLink] /
|
||||
* [Sc2BleLink]) and one of two consumers:
|
||||
*
|
||||
* **Stream mode** (`router != null`, owned by StreamScreen):
|
||||
* - **Raw plane (the point):** every input report is forwarded verbatim
|
||||
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
|
||||
* Steam Input drives like the physical controller.
|
||||
* - **Typed mirror:** buttons/sticks/triggers are ALSO diffed onto the ordinary per-transition
|
||||
* plane, so the emergency exit chord works, and a host that degraded the kind (no UHID → the
|
||||
* Xbox 360 pad) still gets a playable controller.
|
||||
* - **Raw return:** the host's hidraw writes (Steam's `0x80` rumble output reports, lizard/IMU
|
||||
* feature settings) arrive via [GamepadFeedback.onHidRaw] → [onHidRaw] → the link, landing on
|
||||
* the real controller's motors/firmware.
|
||||
*
|
||||
* **UI mode** (`router == null`, owned by MainActivity while NOT streaming): the lizard-mode
|
||||
* kb/mouse never produces gamepad events, so an uncaptured SC2 can't drive the console UI at
|
||||
* all. Here the parsed state is edge-detected into [onUiKey] navigation transitions instead
|
||||
* (D-pad + face buttons + Start/Select; the left stick synthesizes one D-pad step per push,
|
||||
* mirroring MainActivity's stick-to-focus behavior for ordinary pads).
|
||||
*
|
||||
* The wire slot is claimed lazily on the FIRST state report — a Puck with no controller powered
|
||||
* on stays invisible to the host — and released (with a wireless-disconnect event or on [stop])
|
||||
* so pad indices never leak. Report callbacks arrive on the link's own thread; the router's slot
|
||||
* table and chord timer are thread-safe for this (same contract as the feedback poll threads),
|
||||
* and UI-mode consumers hop to the main thread themselves.
|
||||
*/
|
||||
class Sc2Capture(
|
||||
context: Context,
|
||||
private val router: GamepadRouter? = null,
|
||||
) {
|
||||
private val usb = Sc2UsbLink(context, ::onReport, ::onLinkClosed)
|
||||
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
|
||||
private var activeLink: Int = LINK_NONE
|
||||
|
||||
/** True when the USB link is a Puck dongle — the only transport whose wireless-status
|
||||
* reports are authoritative. A WIRED pad also emits them, truthfully reporting "no radio
|
||||
* link" — acting on that tore the slot down 255 ms after creation (first on-glass run). */
|
||||
private var dongleLink = false
|
||||
|
||||
private var pad: GamepadRouter.ExternalPad? = null
|
||||
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
|
||||
/** Puck connect arrives before its first state report (and therefore before a wire pad exists).
|
||||
* Preserve it so the native virtual Puck slot sees the same connect edge before state. */
|
||||
private val pendingWireless = ByteArray(2)
|
||||
private var pendingWirelessLen = 0
|
||||
|
||||
// Typed-mirror diff state (wire units).
|
||||
private val state = Sc2Device.State()
|
||||
private var wireButtons = 0
|
||||
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||
|
||||
/** Report ids seen so far — each logged once, for remote diagnosis of what the pad emits. */
|
||||
private val seenIds = HashSet<Int>()
|
||||
|
||||
// UI-mode state (router == null): held navigation keys + the stick's current synth direction.
|
||||
private var uiHeld = HashSet<Int>()
|
||||
private var uiStickDir = 0
|
||||
|
||||
/**
|
||||
* UI-mode sink: one navigation key transition (an Android `KeyEvent.KEYCODE_*`), invoked on
|
||||
* the LINK thread — the consumer hops to the main thread. Set before [startUsb]/[startBle].
|
||||
*/
|
||||
@Volatile
|
||||
var onUiKey: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Fired (link thread) when the capture engages or drops — lets the app surface "SC2
|
||||
* connected" in the console-UI gate and the Controllers screen.
|
||||
*/
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
val isActive: Boolean get() = activeLink != LINK_NONE
|
||||
|
||||
/** First attached SC2/Puck USB device, for the permission flow. */
|
||||
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||
|
||||
/**
|
||||
* The first already-bonded BLE Steam Controller's address, or null. The caller checks
|
||||
* BLUETOOTH_CONNECT first (without it the bonded list reads as empty anyway).
|
||||
*/
|
||||
fun pairedBleAddress(): String? = ble.pairedControllers().firstOrNull()?.address
|
||||
|
||||
/** Start capturing [dev] over USB (permission already granted). */
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (activeLink != LINK_NONE) return false
|
||||
val ok = usb.start(dev)
|
||||
if (ok) {
|
||||
activeLink = LINK_USB
|
||||
dongleLink = dev.productId != Sc2Device.PID_WIRED
|
||||
onActiveChanged?.invoke(true)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Start capturing the bonded BLE controller at [address]. */
|
||||
fun startBle(address: String): Boolean {
|
||||
if (activeLink != LINK_NONE) return false
|
||||
val ok = ble.start(address)
|
||||
if (ok) {
|
||||
activeLink = LINK_BLE
|
||||
onActiveChanged?.invoke(true)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Replay a host raw write on the physical pad — wire to [GamepadFeedback.onHidRaw]. */
|
||||
fun onHidRaw(padIndex: Int, kind: Int, data: ByteArray) {
|
||||
if (padIndex != pad?.index) return // addressed to some other controller
|
||||
when (activeLink) {
|
||||
LINK_USB -> usb.writeRaw(kind, data)
|
||||
LINK_BLE -> ble.writeRaw(kind, data)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
val wasActive = activeLink != LINK_NONE
|
||||
when (activeLink) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
// ---- link callbacks (link thread) ----
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val id = report[0].toInt() and 0xFF
|
||||
if (seenIds.add(id)) Log.i(TAG, "SC2 report id=0x%02x seen (len=%d)".format(id, len))
|
||||
// Wireless status: authoritative ONLY through a Puck dongle (powering the pad off frees
|
||||
// its wire index + the host's virtual device). A wired/BLE pad emits it too — truthfully
|
||||
// saying "no radio link" — and must NOT tear the slot down (SDL's wired path likewise
|
||||
// marks the controller connected unconditionally and reconnects on any state report).
|
||||
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
|
||||
if (dongleLink) {
|
||||
when (report[1].toInt() and 0xFF) {
|
||||
Sc2Device.WIRELESS_CONNECT -> {
|
||||
pendingWireless[0] = report[0]
|
||||
pendingWireless[1] = report[1]
|
||||
pendingWirelessLen = 2
|
||||
}
|
||||
Sc2Device.WIRELESS_DISCONNECT -> {
|
||||
pendingWirelessLen = 0
|
||||
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!Sc2Device.parseState(report, len, state)) {
|
||||
// Battery/status and future report types still belong to the as-is stream.
|
||||
forwardRaw(report, len)
|
||||
return
|
||||
}
|
||||
if (router == null) {
|
||||
mirrorUi()
|
||||
return
|
||||
}
|
||||
val pref = if (dongleLink) {
|
||||
Gamepad.PREF_STEAMCONTROLLER2_PUCK
|
||||
} else {
|
||||
Gamepad.PREF_STEAMCONTROLLER2
|
||||
}
|
||||
val p = pad ?: router.openExternal(pref)?.also {
|
||||
pad = it
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 captured → wire pad ${it.index} (${if (dongleLink) "Puck" else "direct"} passthrough)",
|
||||
)
|
||||
if (pendingWirelessLen > 0) {
|
||||
forwardRaw(pendingWireless, pendingWirelessLen)
|
||||
pendingWirelessLen = 0
|
||||
}
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
forwardRaw(report, len)
|
||||
mirrorTyped(p)
|
||||
}
|
||||
|
||||
private fun forwardRaw(report: ByteArray, len: Int) {
|
||||
val p = pad ?: return
|
||||
val n = len.coerceAtMost(rawBuf.capacity())
|
||||
rawBuf.clear()
|
||||
rawBuf.put(report, 0, n)
|
||||
p.hidReport(rawBuf, n)
|
||||
}
|
||||
|
||||
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||
val wired = Sc2Device.wireButtons(state.buttons)
|
||||
var changed = wired xor wireButtons
|
||||
while (changed != 0) {
|
||||
val bit = changed and -changed // lowest changed bit
|
||||
p.button(bit, wired and bit != 0)
|
||||
changed = changed and bit.inv()
|
||||
}
|
||||
wireButtons = wired
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* UI mode: edge-detect the parsed state into navigation key transitions. Buttons map to
|
||||
* their Android keycodes (press AND release, so the focus system sees real holds); the left
|
||||
* stick synthesizes ONE D-pad step per push past half deflection — the same single-move
|
||||
* behavior MainActivity gives ordinary pads' sticks.
|
||||
*/
|
||||
private fun mirrorUi() {
|
||||
val sink = onUiKey ?: return
|
||||
val held = HashSet<Int>(8)
|
||||
var i = 0
|
||||
while (i < UI_KEY_MAP.size) {
|
||||
if (state.buttons and UI_KEY_MAP[i] != 0) held.add(UI_KEY_MAP[i + 1])
|
||||
i += 2
|
||||
}
|
||||
for (key in held) if (key !in uiHeld) sink(key, true)
|
||||
for (key in uiHeld) if (key !in held) sink(key, false)
|
||||
uiHeld = held
|
||||
// Left stick → a HELD D-pad direction (device convention: +y = up): pressed while
|
||||
// deflected, released on centre/direction change. The console UI's probe machinery
|
||||
// turns a held direction into its own auto-repeat, exactly like a physical D-pad; the
|
||||
// focus-hook path moves once per press edge either way.
|
||||
val dir = when {
|
||||
state.lsX <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_LEFT
|
||||
state.lsX >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
state.lsY >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_UP
|
||||
state.lsY <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_DOWN
|
||||
else -> 0
|
||||
}
|
||||
if (dir != uiStickDir) {
|
||||
// The D-pad bits share these keycodes; don't release a direction the physical
|
||||
// D-pad itself still holds (uiHeld tracks the button-sourced state).
|
||||
if (uiStickDir != 0 && uiStickDir !in uiHeld) sink(uiStickDir, false)
|
||||
if (dir != 0 && dir !in uiHeld) sink(dir, true)
|
||||
uiStickDir = dir
|
||||
}
|
||||
}
|
||||
|
||||
/** Release every held UI-mode key (link drop / stop) so nothing sticks in the focus system. */
|
||||
private fun releaseUiKeys() {
|
||||
val sink = onUiKey
|
||||
if (sink != null) {
|
||||
for (key in uiHeld) sink(key, false)
|
||||
if (uiStickDir != 0 && uiStickDir !in uiHeld) sink(uiStickDir, false)
|
||||
}
|
||||
uiHeld = HashSet()
|
||||
uiStickDir = 0
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
private fun releaseSlot() {
|
||||
pad?.close()
|
||||
pad = null
|
||||
wireButtons = 0
|
||||
lastAxis.fill(Int.MIN_VALUE)
|
||||
pendingWirelessLen = 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2Capture"
|
||||
const val LINK_NONE = 0
|
||||
const val LINK_USB = 1
|
||||
const val LINK_BLE = 2
|
||||
|
||||
/** Half deflection (device i16 range) — the stick-to-focus threshold. */
|
||||
const val STICK_NAV = 16384
|
||||
|
||||
/** UI-mode mapping: SC2 button bit → Android keycode, as (bit, key) pairs. */
|
||||
val UI_KEY_MAP = intArrayOf(
|
||||
Sc2Device.DPAD_UP, android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||
Sc2Device.DPAD_DOWN, android.view.KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
Sc2Device.DPAD_LEFT, android.view.KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
Sc2Device.DPAD_RIGHT, android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
Sc2Device.A, android.view.KeyEvent.KEYCODE_BUTTON_A,
|
||||
Sc2Device.B, android.view.KeyEvent.KEYCODE_BUTTON_B,
|
||||
Sc2Device.X, android.view.KeyEvent.KEYCODE_BUTTON_X,
|
||||
Sc2Device.Y, android.view.KeyEvent.KEYCODE_BUTTON_Y,
|
||||
Sc2Device.LB, android.view.KeyEvent.KEYCODE_BUTTON_L1,
|
||||
Sc2Device.RB, android.view.KeyEvent.KEYCODE_BUTTON_R1,
|
||||
Sc2Device.MENU, android.view.KeyEvent.KEYCODE_BUTTON_START,
|
||||
Sc2Device.VIEW, android.view.KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Steam Controller 2 (2026, Valve "Ibex" / SDL "Triton") protocol constants + the light state
|
||||
* parser the CLIENT needs. The full report rides the wire verbatim (`nativeSendPadHidReport` →
|
||||
* the host's as-is virtual pad); this parser only extracts what the client itself consumes: the
|
||||
* button word for the typed mirror + exit chord, and sticks/triggers for the degrade path.
|
||||
*
|
||||
* Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
|
||||
* (Valve-maintained), mirrored host-side in `punktfunk-host`'s `triton_proto.rs`.
|
||||
*/
|
||||
object Sc2Device {
|
||||
const val VID_VALVE = 0x28DE
|
||||
|
||||
/** Wired controller. */
|
||||
const val PID_WIRED = 0x1302
|
||||
|
||||
/** Direct BLE identity (transport handled by [Sc2BleLink], not USB). */
|
||||
const val PID_BLE = 0x1303
|
||||
|
||||
/** The wireless Puck dongles (Proteus / Nereid) — controller on USB interfaces 2..5. */
|
||||
const val PID_DONGLE_PROTEUS = 0x1304
|
||||
const val PID_DONGLE_NEREID = 0x1305
|
||||
|
||||
val USB_PIDS = setOf(PID_WIRED, PID_DONGLE_PROTEUS, PID_DONGLE_NEREID)
|
||||
|
||||
/** Dongle interface range that carries controllers (SDL: "interfaces 2..5, currently"). */
|
||||
val DONGLE_IFACES = 2..5
|
||||
|
||||
// Input report ids (`ETritonReportIDTypes`). State layouts share every offset the client
|
||||
// reads (seq/buttons/triggers/sticks); 0x47 only diverges from byte 18 (trackpad timestamp).
|
||||
const val ID_STATE = 0x42
|
||||
const val ID_BATTERY = 0x43
|
||||
const val ID_STATE_BLE = 0x45
|
||||
const val ID_WIRELESS_X = 0x46
|
||||
const val ID_STATE_TIMESTAMP = 0x47
|
||||
const val ID_WIRELESS = 0x79
|
||||
|
||||
/** Wireless status payload byte: controller connected/disconnected through the Puck. */
|
||||
const val WIRELESS_DISCONNECT = 1
|
||||
const val WIRELESS_CONNECT = 2
|
||||
|
||||
// Button bits in the state report's u32 (SDL `TritonButtons`).
|
||||
const val A = 0x00000001
|
||||
const val B = 0x00000002
|
||||
const val X = 0x00000004
|
||||
const val Y = 0x00000008
|
||||
const val QAM = 0x00000010
|
||||
const val R3 = 0x00000020
|
||||
const val VIEW = 0x00000040
|
||||
const val R4 = 0x00000080
|
||||
const val R5 = 0x00000100
|
||||
const val RB = 0x00000200
|
||||
const val DPAD_DOWN = 0x00000400
|
||||
const val DPAD_RIGHT = 0x00000800
|
||||
const val DPAD_LEFT = 0x00001000
|
||||
const val DPAD_UP = 0x00002000
|
||||
const val MENU = 0x00004000
|
||||
const val L3 = 0x00008000
|
||||
const val STEAM = 0x00010000
|
||||
const val L4 = 0x00020000
|
||||
const val L5 = 0x00040000
|
||||
const val LB = 0x00080000
|
||||
const val RPAD_CLICK = 0x00400000
|
||||
|
||||
/**
|
||||
* The feature report that turns lizard mode (built-in keyboard/mouse emulation) off:
|
||||
* `[report id 1][ID_SET_SETTINGS_VALUES 0x87][length 3][SETTING_LIZARD_MODE 9]
|
||||
* [LIZARD_MODE_OFF u16]`, zero-padded to the 64-byte feature size. The firmware watchdog
|
||||
* re-enables lizard mode after a few seconds of silence, so this is re-sent every
|
||||
* [LIZARD_REFRESH_MS] (SDL's cadence) — and the host's Steam sends its own through the raw
|
||||
* plane once it grabs the virtual pad, which lands here too.
|
||||
*/
|
||||
val DISABLE_LIZARD: ByteArray = ByteArray(64).also {
|
||||
it[0] = 0x01 // feature report id
|
||||
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
||||
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
||||
it[3] = 9 // SETTING_LIZARD_MODE
|
||||
// [4..6] = LIZARD_MODE_OFF (0) — already zero
|
||||
}
|
||||
|
||||
/**
|
||||
* Force firmware-calibrated signed i16 stick coordinates. Steam sends this during physical
|
||||
* controller initialization (`SETTING_ENABLE_RAW_JOYSTICK` = 0x2e, value 0); without it a
|
||||
* controller previously opened in raw mode reports ADC coordinates around 0..3200, which a
|
||||
* Triton consumer interprets as only a few percent of full travel.
|
||||
*/
|
||||
val NORMALIZE_JOYSTICKS: ByteArray = ByteArray(64).also {
|
||||
it[0] = 0x01 // feature report id
|
||||
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
||||
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
||||
it[3] = 0x2E // SETTING_ENABLE_RAW_JOYSTICK
|
||||
// [4..6] = disabled (0) — firmware emits calibrated signed i16 values
|
||||
}
|
||||
|
||||
const val LIZARD_REFRESH_MS = 3000L
|
||||
|
||||
/** Wire mapping: SC2 button bit → punktfunk `Gamepad.BTN_*`, the inverse of the host's
|
||||
* typed-fallback mapping (`triton_proto::from_gamepad`): paddles R4/L4/R5/L5 =
|
||||
* PADDLE1/2/3/4, QAM = MISC1, right-pad click = the touchpad wire bit. */
|
||||
private val WIRE_MAP = intArrayOf(
|
||||
A, Gamepad.BTN_A,
|
||||
B, Gamepad.BTN_B,
|
||||
X, Gamepad.BTN_X,
|
||||
Y, Gamepad.BTN_Y,
|
||||
LB, Gamepad.BTN_LB,
|
||||
RB, Gamepad.BTN_RB,
|
||||
VIEW, Gamepad.BTN_BACK,
|
||||
MENU, Gamepad.BTN_START,
|
||||
STEAM, Gamepad.BTN_GUIDE,
|
||||
L3, Gamepad.BTN_LS_CLICK,
|
||||
R3, Gamepad.BTN_RS_CLICK,
|
||||
DPAD_UP, Gamepad.BTN_DPAD_UP,
|
||||
DPAD_DOWN, Gamepad.BTN_DPAD_DOWN,
|
||||
DPAD_LEFT, Gamepad.BTN_DPAD_LEFT,
|
||||
DPAD_RIGHT, Gamepad.BTN_DPAD_RIGHT,
|
||||
QAM, Gamepad.BTN_MISC1,
|
||||
R4, Gamepad.BTN_PADDLE1,
|
||||
L4, Gamepad.BTN_PADDLE2,
|
||||
R5, Gamepad.BTN_PADDLE3,
|
||||
L5, Gamepad.BTN_PADDLE4,
|
||||
RPAD_CLICK, Gamepad.BTN_TOUCHPAD,
|
||||
)
|
||||
|
||||
/** Translate an SC2 button word into the wire `Gamepad.BTN_*` bitmask. */
|
||||
fun wireButtons(sc2: Int): Int {
|
||||
var out = 0
|
||||
var i = 0
|
||||
while (i < WIRE_MAP.size) {
|
||||
if (sc2 and WIRE_MAP[i] != 0) out = out or WIRE_MAP[i + 1]
|
||||
i += 2
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The typed-mirror fields of one state report (buttons/sticks/triggers only). */
|
||||
class State {
|
||||
var buttons = 0 // SC2 bit layout
|
||||
var lsX = 0; var lsY = 0 // i16, +y = up (device convention = wire convention)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255 (device 0..32767 scaled down)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the client-consumed fields out of a state report (`0x42`/`0x45`/`0x47` — identical
|
||||
* offsets for everything read here) into [out]. Returns false for non-state / short reports.
|
||||
*/
|
||||
fun parseState(report: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 18) return false
|
||||
when (report[0].toInt() and 0xFF) {
|
||||
ID_STATE, ID_STATE_BLE, ID_STATE_TIMESTAMP -> {}
|
||||
else -> return false
|
||||
}
|
||||
fun i16(o: Int) = ((report[o + 1].toInt() shl 8) or (report[o].toInt() and 0xFF)).toShort().toInt()
|
||||
out.buttons = (report[2].toInt() and 0xFF) or
|
||||
((report[3].toInt() and 0xFF) shl 8) or
|
||||
((report[4].toInt() and 0xFF) shl 16) or
|
||||
((report[5].toInt() and 0xFF) shl 24)
|
||||
out.lt = (i16(6).coerceIn(0, 32767)) shr 7
|
||||
out.rt = (i16(8).coerceIn(0, 32767)) shr 7
|
||||
out.lsX = i16(10); out.lsY = i16(12)
|
||||
out.rsX = i16(14); out.rsY = i16(16)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
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.
|
||||
*
|
||||
* **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
|
||||
* only interface 2 read silence while Android's input stack kept the others (the round-2
|
||||
* 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).
|
||||
*/
|
||||
class Sc2UsbLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val 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
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -229,9 +229,11 @@ fn run_sync(
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
// HUD stage split: 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 for the `decode` stage. Only fed while the HUD is visible.
|
||||
// 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
|
||||
// for the `decode` stage. Fed while the HUD is visible OR the adaptive-bitrate controller wants
|
||||
// the decode signal (`measure_decode`) — the decoder-backlog bottleneck the network can't see.
|
||||
let measure_decode = client.wants_decode_latency();
|
||||
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
||||
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||
@@ -272,40 +274,45 @@ fn run_sync(
|
||||
&p[..p.len().min(6)]
|
||||
);
|
||||
}
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
||||
// steady state skips the wall-clock read and the lock entirely. The receipt
|
||||
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
|
||||
// the output buffer) for the decoded-point pairing in `drain`.
|
||||
if stats.enabled() {
|
||||
// Receipt stamp for the `decode` stage pairing, parked in `in_flight` (keyed by
|
||||
// the pts the codec echoes on its output buffer) whenever it's needed: the HUD
|
||||
// being visible, or the ABR decode signal (`measure_decode`). The HUD-only
|
||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||
if stats.enabled() || measure_decode {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||
if in_flight.len() > IN_FLIGHT_CAP {
|
||||
in_flight.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts.
|
||||
if stats.enabled() {
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) =
|
||||
pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +352,8 @@ fn run_sync(
|
||||
};
|
||||
let (r, d) = drain(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&window,
|
||||
&mut applied_ds,
|
||||
wait,
|
||||
@@ -866,6 +875,9 @@ fn run_async(
|
||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||
// HUD is visible.
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
// Whether the adaptive-bitrate controller wants the `decode` stage as its decoder-backlog
|
||||
// signal (Automatic, non-PyroWave): then `in_flight` is fed regardless of the HUD.
|
||||
let measure_decode = client.wants_decode_latency();
|
||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
@@ -886,7 +898,15 @@ fn run_async(
|
||||
std::thread::Builder::new()
|
||||
.name("pf-decode-feed".into())
|
||||
.spawn(move || {
|
||||
feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
|
||||
feeder_loop(
|
||||
client,
|
||||
stats,
|
||||
measure_decode,
|
||||
in_flight,
|
||||
clock_offset,
|
||||
shutdown,
|
||||
ev_tx,
|
||||
);
|
||||
})
|
||||
.ok()
|
||||
};
|
||||
@@ -976,6 +996,8 @@ fn run_async(
|
||||
let had_output = !ready.is_empty();
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
@@ -1052,6 +1074,7 @@ fn run_async(
|
||||
fn feeder_loop(
|
||||
client: Arc<NativeClient>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
measure_decode: bool,
|
||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -1067,13 +1090,11 @@ fn feeder_loop(
|
||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
if stats.enabled() {
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
if stats.enabled() || measure_decode {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||
let lat_us =
|
||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
{
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
@@ -1083,19 +1104,27 @@ fn feeder_loop(
|
||||
g.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
}
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front();
|
||||
if stats.enabled() {
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,6 +1250,8 @@ fn feed_ready(
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||
fn present_ready(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||
@@ -1234,12 +1265,22 @@ fn present_ready(
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
}
|
||||
if stats.enabled() {
|
||||
// Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only
|
||||
// while visible) — both consume the receipt map, so enter for either.
|
||||
if stats.enabled() || measure_decode {
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for o in ready.iter() {
|
||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
&mut g,
|
||||
clock_offset,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||
@@ -1460,6 +1501,8 @@ fn feed(
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
||||
fn drain(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
window: &NativeWindow,
|
||||
applied_ds: &mut Option<DataSpace>,
|
||||
first_wait: Duration,
|
||||
@@ -1489,11 +1532,20 @@ fn drain(
|
||||
let flags = take_flags(recovery_flags, pts_us);
|
||||
held_present =
|
||||
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||
let meta = if stats.enabled() {
|
||||
let meta = if stats.enabled() || measure_decode {
|
||||
// The dequeue IS the sync loop's decoded-availability instant.
|
||||
let decoded_ns = now_realtime_ns();
|
||||
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
|
||||
Some((pts_us, decoded_ns))
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
in_flight,
|
||||
clock_offset,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
);
|
||||
// The tracker's `display` stage is a HUD concern — park only when visible.
|
||||
stats.enabled().then_some((pts_us, decoded_ns))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1564,6 +1616,8 @@ fn drain(
|
||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||
/// stamp (async loop).
|
||||
fn note_decoded_pts(
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
@@ -1582,12 +1636,25 @@ fn note_decoded_pts(
|
||||
break;
|
||||
}
|
||||
}
|
||||
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time
|
||||
// to < 1 µs — negligible against the ms-scale figures shown.
|
||||
let e2e_ns = decoded_ns + clock_offset 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 decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
// Adaptive bitrate: the `decode` stage (received→decoded, single-clock local) IS the decoder-
|
||||
// backlog signal — the only bottleneck the host-side network signals can't see (a fast LAN
|
||||
// feeding a slower mobile decoder). Report it whenever the controller is armed, regardless of
|
||||
// the HUD; `report_decode_us` is a cheap accumulate the pump windows.
|
||||
if measure_decode {
|
||||
if let Some(us) = decode_us {
|
||||
client.report_decode_us(us.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
}
|
||||
// HUD histogram: only while the overlay is visible (a measure-only caller enters here for the
|
||||
// ABR report alone). `end-to-end` = capture→decoded (skew-corrected) tiles the `decode` stage.
|
||||
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time to
|
||||
// < 1 µs — negligible against the ms-scale figures shown.
|
||||
if stats.enabled() {
|
||||
let e2e_ns = decoded_ns + clock_offset 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);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
}
|
||||
|
||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||
|
||||
@@ -22,6 +22,7 @@ const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
const TAG_TRIGGER: u8 = 0x03;
|
||||
const TAG_HID_RAW: u8 = 0x05;
|
||||
|
||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||
@@ -143,6 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
// rumble already rides the universal 0xCA plane).
|
||||
return -1;
|
||||
}
|
||||
HidOutput::HidRaw { pad, kind, data } => {
|
||||
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
|
||||
// the virtual pad; Kotlin replays it verbatim on the physical controller.
|
||||
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
|
||||
let n = 3 + data.len();
|
||||
if cap < n {
|
||||
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_HID_RAW;
|
||||
out[2] = kind;
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -11,6 +11,43 @@ use std::time::Duration;
|
||||
|
||||
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
||||
|
||||
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
|
||||
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
|
||||
/// catch-all "wrong PIN, or the host isn't armed" (which blamed the PIN for dead network paths
|
||||
/// — the moko0878-class support threads). The app runs one attempt at a time, so one slot
|
||||
/// suffices; a stale token is harmless (it is taken immediately after the failed call).
|
||||
static LAST_ERROR: Mutex<String> = Mutex::new(String::new());
|
||||
|
||||
/// Stable token for a failed pair/connect cause, matched by Kotlin (`ConnectErrors.kt`):
|
||||
/// a typed host rejection yields its `RejectReason::as_str()` token ("not-armed", "denied",
|
||||
/// "approval-timeout", …); transport-level causes map to "crypto" / "timeout" / "io" / "error".
|
||||
fn note_error(e: &punktfunk_core::error::PunktfunkError) {
|
||||
use punktfunk_core::error::PunktfunkError as E;
|
||||
let token = match e {
|
||||
E::Rejected(r) => r.as_str(),
|
||||
E::Crypto => "crypto",
|
||||
E::Timeout => "timeout",
|
||||
E::Io(_) => "io",
|
||||
_ => "error",
|
||||
};
|
||||
*LAST_ERROR.lock().unwrap() = token.to_string();
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
|
||||
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
|
||||
/// handle / `""` fingerprint.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
) -> jni::sys::jstring {
|
||||
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
|
||||
match env.new_string(token) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||
@@ -185,6 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
||||
note_error(&e);
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -318,7 +356,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
|
||||
Ok(host_fp) => hex32(&host_fp),
|
||||
Err(e) => {
|
||||
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
||||
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
|
||||
log::error!("nativePair to {host}:{port} failed: {e}");
|
||||
note_error(&e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
||||
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
||||
|
||||
use jni::objects::JObject;
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
@@ -236,3 +237,43 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
) {
|
||||
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadHidReport(handle, pad, buf, len)` — one raw HID input report from a
|
||||
/// client-captured controller (the as-is Steam Controller 2 passthrough), forwarded verbatim on
|
||||
/// the rich-input plane (`RichInput::HidReport`, 0xCC). `buf` is a DIRECT ByteBuffer whose first
|
||||
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
|
||||
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
|
||||
/// own report rate (~250–500 Hz) — the direct-buffer read avoids a JNI array copy per report.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
buf: JByteBuffer,
|
||||
len: jint,
|
||||
) {
|
||||
if handle == 0 || len <= 0 {
|
||||
return;
|
||||
}
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return,
|
||||
};
|
||||
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
|
||||
let mut data = [0u8; HID_REPORT_MAX];
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
|
||||
// `n` is bounded by both the buffer capacity and the fixed wire body.
|
||||
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
|
||||
// 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::HidReport {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
len: n as u8,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ let package = Package(
|
||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"]),
|
||||
.testTarget(
|
||||
name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"],
|
||||
resources: [
|
||||
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
||||
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
||||
// Vulkan box — see PyroWaveDecoderTests.swift).
|
||||
.copy("PyroWaveFixtures")
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -436,7 +436,6 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -478,7 +477,6 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
|
||||
@@ -46,6 +46,7 @@ struct ContentView: View {
|
||||
case "h264": return PunktfunkConnection.codecH264
|
||||
case "hevc": return PunktfunkConnection.codecHEVC
|
||||
case "av1": return PunktfunkConnection.codecAV1
|
||||
case "pyrowave": return PunktfunkConnection.codecPyroWave
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +239,18 @@ final class SessionModel: ObservableObject {
|
||||
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
||||
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
||||
// advertises the bit and prefers it — the host never auto-selects it, and the
|
||||
// picker only offers it when the Metal decode probe passed (simdgroup floor ≈ A13;
|
||||
// every M-series Mac and the ATV 4K gen 3 pass). The codec is 8-bit 4:2:0 SDR
|
||||
// BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this
|
||||
// session — HDR sessions stay HEVC/AV1 (plan §4.7).
|
||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||
videoCaps &= ~(PunktfunkConnection.videoCap10Bit
|
||||
| PunktfunkConnection.videoCapHDR
|
||||
| PunktfunkConnection.videoCap444)
|
||||
}
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
width: width, height: height, refreshHz: hz,
|
||||
@@ -284,10 +296,15 @@ final class SessionModel: ObservableObject {
|
||||
self.errorMessage = "\(host.displayName) is not paired yet. "
|
||||
+ "Pair with its PIN before streaming."
|
||||
}
|
||||
case .failure:
|
||||
case .failure(let error):
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
if let onUnreachable, !requestAccess {
|
||||
if case PunktfunkClientError.rejected(let rejection) = error {
|
||||
// The host answered and stated its reason (declined / approval timed
|
||||
// out / busy / versions differ) — show that, and never wake-retry a
|
||||
// host that is demonstrably awake.
|
||||
self.errorMessage = "\(host.displayName): \(rejection.userMessage)"
|
||||
} else if let onUnreachable, !requestAccess {
|
||||
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||||
// own overlay explains what's happening.
|
||||
onUnreachable()
|
||||
|
||||
@@ -15,6 +15,9 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
#if os(iOS)
|
||||
import CoreHaptics
|
||||
#endif
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -38,6 +41,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
|
||||
#if os(iOS)
|
||||
@@ -230,7 +236,7 @@ struct GamepadSettingsView: View {
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||
return [
|
||||
var list: [Row] = [
|
||||
choiceRow(
|
||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||
label: "Resolution",
|
||||
@@ -329,6 +335,23 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
#if os(iOS)
|
||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||
// practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
||||
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
||||
label: "Rumble on this iPhone",
|
||||
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
||||
+ "for clip-on pads without rumble motors.",
|
||||
value: $rumbleOnDevice),
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
return list
|
||||
}
|
||||
|
||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||
|
||||
@@ -79,6 +79,13 @@ enum SettingsOptions {
|
||||
if AV1.hardwareDecodeSupported {
|
||||
options.insert(("AV1", "av1"), at: 2)
|
||||
}
|
||||
// PyroWave is the opt-in wired-LAN low-latency codec (100–400 Mbps all-intra wavelet,
|
||||
// 8-bit SDR): selecting it advertises + prefers it for the session. Offered only when
|
||||
// the Metal decode probe passes (same gate SessionModel advertises by) — elsewhere the
|
||||
// host could never emit it.
|
||||
if MetalWaveletDecoder.supported {
|
||||
options.append(("PyroWave (wired LAN)", "pyrowave"))
|
||||
}
|
||||
return options
|
||||
}()
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||
// composed by the per-platform bodies in SettingsView.swift.
|
||||
|
||||
#if os(iOS)
|
||||
import CoreHaptics
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@@ -471,6 +474,12 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
#endif
|
||||
@@ -487,6 +496,11 @@ extension SettingsView {
|
||||
// for its own footer and has no such toggle to describe.
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(Self.controllersFooter)
|
||||
#if os(iOS)
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
Text(Self.deviceRumbleFooter)
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Text(Self.gamepadUIFooter)
|
||||
#endif
|
||||
|
||||
@@ -88,6 +88,13 @@ extension SettingsView {
|
||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||
+ "Applies from the next session."
|
||||
|
||||
#if os(iOS)
|
||||
static let deviceRumbleFooter =
|
||||
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
|
||||
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
|
||||
+ "from the next session."
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
static let gamepadUIFooter =
|
||||
"When a controller connects, the host list and library switch to a controller-"
|
||||
|
||||
@@ -55,6 +55,7 @@ struct SettingsView: View {
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
|
||||
@@ -212,14 +212,18 @@ struct PairSheet: View {
|
||||
case .failure(PunktfunkClientError.wrongPIN):
|
||||
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||
+ "and try again."
|
||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||
// The host answered and said why (not armed / rate-limited / armed for
|
||||
// another device) — show that instead of the guessing-game fallback.
|
||||
errorText = rejection.userMessage
|
||||
case .failure(is ClientIdentityStore.IdentityError):
|
||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||
+ "pairing would not survive a relaunch. Unlock the login "
|
||||
+ "keychain and try again."
|
||||
case .failure:
|
||||
errorText = "Pairing failed. Is the host reachable, pairing armed "
|
||||
+ "(web console → Pairing), and not mid-session? Retries are "
|
||||
+ "rate-limited to one per 2 seconds."
|
||||
errorText = "Pairing failed — the host didn't answer. Is it running, "
|
||||
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
||||
+ "isolation)?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ public func pair(
|
||||
switch rc {
|
||||
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
||||
default: throw PunktfunkClientError.status(rc)
|
||||
default:
|
||||
// A typed host rejection (pairing not armed / rate-limited / armed for another
|
||||
// device) carries its own reason — never report it as a bad PIN or dead network.
|
||||
if let rejection = HostRejection(status: rc) {
|
||||
throw PunktfunkClientError.rejected(rejection)
|
||||
}
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,68 @@ public enum PunktfunkClientError: Error {
|
||||
case wrongPIN
|
||||
case closed
|
||||
case status(Int32)
|
||||
/// The host deliberately turned the attempt away and said why (its typed QUIC
|
||||
/// application close) — distinct from `.connectFailed` (unreachable/timeout) so the UI
|
||||
/// can show the stated reason instead of blaming the network.
|
||||
case rejected(HostRejection)
|
||||
}
|
||||
|
||||
/// Why a host turned a connect/pair attempt away — decoded from the
|
||||
/// `PUNKTFUNK_STATUS_REJECTED_*` block. Lets the UI say "approve the request on the host"
|
||||
/// or "pairing isn't armed" instead of a generic "could not connect".
|
||||
public enum HostRejection: Sendable {
|
||||
case pairingNotArmed
|
||||
case pairingBoundToOtherDevice
|
||||
case pairingRateLimited
|
||||
case identityRequired
|
||||
case denied
|
||||
case approvalTimeout
|
||||
case superseded
|
||||
case wireVersionMismatch
|
||||
case busy
|
||||
|
||||
init?(status: Int32) {
|
||||
switch status {
|
||||
case PUNKTFUNK_STATUS_REJECTED_NOT_ARMED.rawValue: self = .pairingNotArmed
|
||||
case PUNKTFUNK_STATUS_REJECTED_BOUND_OTHER.rawValue: self = .pairingBoundToOtherDevice
|
||||
case PUNKTFUNK_STATUS_REJECTED_RATE_LIMITED.rawValue: self = .pairingRateLimited
|
||||
case PUNKTFUNK_STATUS_REJECTED_IDENTITY_REQUIRED.rawValue: self = .identityRequired
|
||||
case PUNKTFUNK_STATUS_REJECTED_DENIED.rawValue: self = .denied
|
||||
case PUNKTFUNK_STATUS_REJECTED_APPROVAL_TIMEOUT.rawValue: self = .approvalTimeout
|
||||
case PUNKTFUNK_STATUS_REJECTED_SUPERSEDED.rawValue: self = .superseded
|
||||
case PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION.rawValue: self = .wireVersionMismatch
|
||||
case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing sentence — wording shared with the desktop clients.
|
||||
public var userMessage: String {
|
||||
switch self {
|
||||
case .pairingNotArmed:
|
||||
return "Pairing isn't armed on the host — arm it on the host's Pairing page, "
|
||||
+ "then try again."
|
||||
case .pairingBoundToOtherDevice:
|
||||
return "The host's pairing window is armed for a different device — arm it "
|
||||
+ "for this one."
|
||||
case .pairingRateLimited:
|
||||
return "Too many pairing attempts — wait a couple of seconds and try again."
|
||||
case .identityRequired:
|
||||
return "The host requires pairing — pair this device (PIN or request access) first."
|
||||
case .denied:
|
||||
return "The host declined this device's request."
|
||||
case .approvalTimeout:
|
||||
return "Nobody approved the request on the host in time — approve this device "
|
||||
+ "in the host's console or web UI, then request access again."
|
||||
case .superseded:
|
||||
return "A newer request from this device replaced this one — approve the "
|
||||
+ "latest request on the host."
|
||||
case .wireVersionMismatch:
|
||||
return "Client and host versions don't match — update both to the same release."
|
||||
case .busy:
|
||||
return "The host is busy with another session."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `withCString` over an optional — nil maps to a NULL C pointer.
|
||||
@@ -188,6 +250,19 @@ public final class PunktfunkConnection {
|
||||
// exist so the resolved type round-trips and name parsing matches the host.
|
||||
case steamController = 5
|
||||
case steamDeck = 6
|
||||
/// DualSense Edge (Linux UHID / Windows UMDF hosts): the DualSense plus native back/Fn
|
||||
/// buttons. GameController exposes the Edge as a `GCDualSenseGamepad` with its own
|
||||
/// product category; paddle CAPTURE is still gated on G22, but the declared identity +
|
||||
/// rich planes match the physical pad.
|
||||
case dualSenseEdge = 7
|
||||
/// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo
|
||||
/// glyphs + positional layout on the host side.
|
||||
case switchPro = 8
|
||||
/// New Steam Controller (2026, `28DE:1302`), passed through as-is on Linux hosts (raw
|
||||
/// report mirroring; Steam Input is the consumer). Parity only on Apple — GameController
|
||||
/// never surfaces the raw Valve device, so the client can't capture one; exists so the
|
||||
/// resolved type round-trips and name parsing matches the host.
|
||||
case steamController2 = 9
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -200,6 +275,11 @@ public final class PunktfunkConnection {
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||
case "steamcontroller2", "steam-controller-2", "steamcon2", "sc2", "ibex":
|
||||
self = .steamController2
|
||||
case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge
|
||||
case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
|
||||
self = .switchPro
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
@@ -257,9 +337,15 @@ public final class PunktfunkConnection {
|
||||
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||
|
||||
/// The video codec the host resolved for this session (`Welcome.codec`, `PUNKTFUNK_CODEC_*`):
|
||||
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The
|
||||
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
||||
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1, `8` = PyroWave (only when this
|
||||
/// client opted in). Build the decoder from THIS. The resolved value honors the client's
|
||||
/// `preferredCodec` when the host could emit it.
|
||||
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||
|
||||
/// The session's negotiated wire shard payload (`Welcome.shard_payload`, bytes) — the
|
||||
/// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs
|
||||
/// never need it.
|
||||
public private(set) var shardPayload: UInt32 = 1408
|
||||
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||
@@ -301,6 +387,10 @@ public final class PunktfunkConnection {
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
// Why a failed connect failed (PunktfunkStatus): lets a typed host rejection
|
||||
// ("denied in the console", "approval timed out", "host busy") surface as
|
||||
// `.rejected` instead of the undifferentiated `.connectFailed`.
|
||||
var connectStatus: Int32 = 0
|
||||
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
|
||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||
@@ -311,24 +401,29 @@ public final class PunktfunkConnection {
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex7(
|
||||
punktfunk_connect_ex8(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs)
|
||||
cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex7(
|
||||
return punktfunk_connect_ex8(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
nil, &observed, cert, key, timeoutMs)
|
||||
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard handle != nil else { throw PunktfunkClientError.connectFailed }
|
||||
guard handle != nil else {
|
||||
if let rejection = HostRejection(status: connectStatus) {
|
||||
throw PunktfunkClientError.rejected(rejection)
|
||||
}
|
||||
throw PunktfunkClientError.connectFailed
|
||||
}
|
||||
hostFingerprint = Data(observed)
|
||||
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
||||
@@ -363,6 +458,9 @@ public final class PunktfunkConnection {
|
||||
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||
_ = punktfunk_connection_codec(handle, &codec)
|
||||
resolvedCodec = codec
|
||||
var shard: UInt32 = 1408
|
||||
_ = punktfunk_connection_shard_payload(handle, &shard)
|
||||
shardPayload = shard
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
@@ -480,6 +578,30 @@ public final class PunktfunkConnection {
|
||||
return out
|
||||
}
|
||||
|
||||
/// Report one decoded frame's decode-stage latency, in microseconds (the AU leaving `nextAU`
|
||||
/// through its VideoToolbox output). This feeds the Automatic bitrate controller's decode
|
||||
/// signal — the only one that sees this device's decoder — so the rate is capped at the real
|
||||
/// decode limit instead of climbing to the network link ceiling and choking the decoder. Cheap;
|
||||
/// silently dropped after close. Only worth calling when `wantsDecodeLatency()` is true.
|
||||
public func reportDecodeUs(_ us: UInt32) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_report_decode_us(h, us)
|
||||
}
|
||||
|
||||
/// Whether `reportDecodeUs` is worth calling this session: true only when the adaptive-bitrate
|
||||
/// controller is armed (Automatic bitrate, non-PyroWave). Query once — constant for the session
|
||||
/// — and skip the per-frame decode measurement entirely when it's false. False after close.
|
||||
public func wantsDecodeLatency() -> Bool {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return false }
|
||||
var out = false
|
||||
_ = punktfunk_connection_wants_decode_latency(h, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
@@ -701,6 +823,15 @@ public final class PunktfunkConnection {
|
||||
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
||||
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
||||
public static let codecAV1: UInt8 = UInt8(PUNKTFUNK_CODEC_AV1)
|
||||
/// PyroWave (opt-in wired-LAN wavelet codec, 8-bit SDR): the host only ever resolves it
|
||||
/// when the client both advertises the bit AND names it `preferredCodec` — never
|
||||
/// auto-selected. Decoded by the Metal wavelet decoder, not VideoToolbox.
|
||||
public static let codecPyroWave: UInt8 = UInt8(PUNKTFUNK_CODEC_PYROWAVE)
|
||||
|
||||
/// `AccessUnit.flags` bit: the AU is shard-aligned self-delimiting chunks (the wire's
|
||||
/// `USER_FLAG_CHUNK_ALIGNED`, PyroWave datagram-aligned mode §4.4) — walk it
|
||||
/// window-by-window at `shardPayload`. (The C `#define` doesn't import into Swift.)
|
||||
public static let userFlagChunkAligned: UInt32 = 64
|
||||
|
||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
// (triggers off, player index unset) and its renderer silenced.
|
||||
|
||||
import Combine
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import GameController
|
||||
|
||||
@@ -50,9 +51,26 @@ public final class GamepadFeedback {
|
||||
private let routingLock = NSLock()
|
||||
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
||||
|
||||
/// Opt-in device mirror (`DefaultsKey.rumbleOnDevice`, iPhone only): rumble the host
|
||||
/// addresses to controller 1 (wire pad 0) is ALSO rendered on this device's own Taptic
|
||||
/// Engine — for phone-clip pads that ship without rumble motors, where the phone body is the
|
||||
/// only actuator in the player's hands. Session-scoped (the setting is read once here); nil
|
||||
/// when off or where the device has no haptic actuator.
|
||||
private let deviceRumble: RumbleRenderer?
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
#if os(iOS)
|
||||
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||
} else {
|
||||
deviceRumble = nil
|
||||
}
|
||||
#else
|
||||
deviceRumble = nil
|
||||
#endif
|
||||
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
|
||||
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -189,6 +207,7 @@ public final class GamepadFeedback {
|
||||
return r
|
||||
}
|
||||
for r in renderers { r.stop() }
|
||||
deviceRumble?.stop()
|
||||
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
||||
// teardown must not replay this session's triggers/LEDs.
|
||||
Task { @MainActor in
|
||||
@@ -203,6 +222,10 @@ public final class GamepadFeedback {
|
||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
||||
let renderer = withRouting { rumbleByPad[pad] }
|
||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
||||
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
|
||||
}
|
||||
|
||||
private func withRouting<R>(_ body: () -> R) -> R {
|
||||
|
||||
@@ -42,13 +42,14 @@ public final class GamepadManager: ObservableObject {
|
||||
public let hasHaptics: Bool
|
||||
public let hasMotion: Bool
|
||||
public let hasAdaptiveTriggers: Bool
|
||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
||||
/// player LEDs) and the PlayStation glyph in Settings.
|
||||
public var isDualSense: Bool { kind == .dualSense }
|
||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
||||
/// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the
|
||||
/// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph
|
||||
/// in Settings.
|
||||
public var isDualSense: Bool { kind == .dualSense || kind == .dualSenseEdge }
|
||||
/// A PlayStation pad with a touchpad + motion (DualSense family OR DualShock 4) — gates
|
||||
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||
public var hasTouchpadAndMotion: Bool {
|
||||
kind == .dualSense || kind == .dualShock4
|
||||
kind == .dualSense || kind == .dualSenseEdge || kind == .dualShock4
|
||||
}
|
||||
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||
public let batteryLevel: Float?
|
||||
@@ -227,7 +228,7 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||
let extended = c.extendedGamepad
|
||||
let kind = padKind(extended)
|
||||
let kind = padKind(extended, productCategory: c.productCategory)
|
||||
return DiscoveredController(
|
||||
id: id,
|
||||
name: c.vendorName ?? c.productCategory,
|
||||
@@ -237,28 +238,40 @@ public final class GamepadManager: ObservableObject {
|
||||
hasLight: c.light != nil,
|
||||
hasHaptics: c.haptics != nil,
|
||||
hasMotion: c.motion != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
||||
// DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
|
||||
// Edge included); the DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
|
||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||
isCharging: c.battery?.batteryState == .charging,
|
||||
controller: c)
|
||||
}
|
||||
|
||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
||||
/// subclass (+ the product-category string where the subclass is shared). Detection order
|
||||
/// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a
|
||||
/// `GCDualSenseGamepad` too — its distinct product category splits it out), then
|
||||
/// DualShock 4, any Xbox pad, then Nintendo Switch pads by category (GameController has no
|
||||
/// dedicated subclass for them). A non-extended / absent profile falls back to `.xbox360`
|
||||
/// (it's never forwarded anyway).
|
||||
private static func padKind(
|
||||
_ extended: GCExtendedGamepad?
|
||||
_ extended: GCExtendedGamepad?,
|
||||
productCategory: String
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
guard let extended else { return .xbox360 }
|
||||
let category = productCategory.lowercased()
|
||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||
// here, so no `@available` guard is needed — matching the unguarded
|
||||
// `GCDualSenseGamepad` use elsewhere in the package.
|
||||
if extended is GCDualSenseGamepad { return .dualSense }
|
||||
if extended is GCDualSenseGamepad {
|
||||
return category.contains("edge") ? .dualSenseEdge : .dualSense
|
||||
}
|
||||
if extended is GCDualShockGamepad { return .dualShock4 }
|
||||
if extended is GCXboxGamepad { return .xboxOne }
|
||||
// Nintendo Switch Pro Controller / a paired Joy-Con set (a full pad surface). Single
|
||||
// Joy-Cons ("Joy-Con (L)" / "(R)") stay on the Xbox 360 fallback — half a pad.
|
||||
if category.contains("switch pro") || category.contains("joy-con (l/r)") {
|
||||
return .switchPro
|
||||
}
|
||||
return .xbox360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
static let manual = Policy(staleAfter: nil)
|
||||
}
|
||||
|
||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) — the opt-in
|
||||
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
|
||||
/// Device mode ignores `retarget`'s controller and always renders one combined motor
|
||||
/// (a phone body has a single actuator).
|
||||
enum Actuator {
|
||||
case controller
|
||||
case device
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
private let policy: Policy
|
||||
private let actuator: Actuator
|
||||
|
||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
|
||||
@@ -198,8 +209,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session) {
|
||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||
self.policy = policy
|
||||
self.actuator = actuator
|
||||
}
|
||||
|
||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||
@@ -468,6 +480,10 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||
private func setup() {
|
||||
if actuator == .device {
|
||||
setupDevice()
|
||||
return
|
||||
}
|
||||
guard let haptics = controller?.haptics else {
|
||||
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
||||
// rumble through GameController (works on Android via the standard Vibrator path, but
|
||||
@@ -517,10 +533,41 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Device-actuator mode: one combined motor on this device's own Taptic Engine. Only an
|
||||
/// iPhone has one — everything else (iPad, Mac, TV) reports no haptic hardware and latches
|
||||
/// off (nothing to retry; the settings toggle is hidden there anyway, this is the backstop).
|
||||
private func setupDevice() {
|
||||
#if os(iOS)
|
||||
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
|
||||
log.info("rumble: this device has no haptic actuator — device rumble unavailable")
|
||||
broken = true
|
||||
reportHealth("This device has no haptic actuator.")
|
||||
return
|
||||
}
|
||||
do {
|
||||
low = startMotor(try CHHapticEngine(), sharpness: RumbleTuning.sharpnessCombined)
|
||||
} catch {
|
||||
log.warning("rumble: device haptic engine creation failed: \(error, privacy: .public)")
|
||||
}
|
||||
if low == nil {
|
||||
// Same shape as the controller path: haptics exist but the engine couldn't be built
|
||||
// right now — back off and retry, don't latch off.
|
||||
scheduleRetryBackoff()
|
||||
}
|
||||
#else
|
||||
broken = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func makeMotor(
|
||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||
) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
return startMotor(engine, sharpness: sharpness)
|
||||
}
|
||||
|
||||
/// Configure + start an engine (controller-locality or the device's own) into a [`Motor`].
|
||||
private func startMotor(_ engine: CHHapticEngine, sharpness: Float) -> Motor? {
|
||||
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
||||
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
||||
// letting a haptics-only engine join it is a needless coupling that can get its
|
||||
@@ -546,7 +593,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
try engine.start()
|
||||
return Motor(engine: engine, sharpness: sharpness)
|
||||
} catch {
|
||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||
log.warning("haptic engine setup failed: \(error, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,3 +118,44 @@ extension InputCapture {
|
||||
]
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// US-layout character → Windows VK for the on-screen keyboard (`StreamLayerUIView`'s
|
||||
/// UIKeyInput). Unlike every other key source, `insertText` delivers CHARACTERS, not key
|
||||
/// positions, so this is the inverse of a US layout: `shift` means "wrap in VK_LSHIFT so the
|
||||
/// host types the shifted symbol". Same contract as `hidToVK`: emit only VKs the host's
|
||||
/// vk_to_evdev knows; anything unmapped is dropped by the caller.
|
||||
enum SoftKeyMap {
|
||||
static func vk(for ch: Character) -> (vk: UInt32, shift: Bool)? {
|
||||
guard let ascii = ch.asciiValue else { return nil }
|
||||
switch ascii {
|
||||
case UInt8(ascii: "a")...UInt8(ascii: "z"): return (UInt32(ascii) - 0x20, false)
|
||||
case UInt8(ascii: "A")...UInt8(ascii: "Z"): return (UInt32(ascii), true)
|
||||
case UInt8(ascii: "0")...UInt8(ascii: "9"): return (UInt32(ascii), false)
|
||||
case 0x0A, 0x0D: return (0x0D, false) // return
|
||||
case 0x09: return (0x09, false) // tab
|
||||
case 0x20: return (0x20, false) // space
|
||||
default: return symbols[ch]
|
||||
}
|
||||
}
|
||||
|
||||
/// US punctuation, plain and shifted, on the OEM VKs (mirrors `hidToVK`'s OEM block) plus
|
||||
/// the shifted digit row.
|
||||
private static let symbols: [Character: (vk: UInt32, shift: Bool)] = [
|
||||
"-": (0xBD, false), "_": (0xBD, true),
|
||||
"=": (0xBB, false), "+": (0xBB, true),
|
||||
"[": (0xDB, false), "{": (0xDB, true),
|
||||
"]": (0xDD, false), "}": (0xDD, true),
|
||||
"\\": (0xDC, false), "|": (0xDC, true),
|
||||
";": (0xBA, false), ":": (0xBA, true),
|
||||
"'": (0xDE, false), "\"": (0xDE, true),
|
||||
"`": (0xC0, false), "~": (0xC0, true),
|
||||
",": (0xBC, false), "<": (0xBC, true),
|
||||
".": (0xBE, false), ">": (0xBE, true),
|
||||
"/": (0xBF, false), "?": (0xBF, true),
|
||||
"!": (0x31, true), "@": (0x32, true), "#": (0x33, true), "$": (0x34, true),
|
||||
"%": (0x35, true), "^": (0x36, true), "&": (0x37, true), "*": (0x38, true),
|
||||
"(": (0x39, true), ")": (0x30, true),
|
||||
]
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||
// (off → compact → normal → detailed, matching Android):
|
||||
// (off → compact → normal → detailed, matching Android) · three-finger swipe up/down =
|
||||
// summon/dismiss the local soft keyboard for typing on the host (`onKeyboardGesture`):
|
||||
//
|
||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||
@@ -61,6 +62,9 @@ final class TouchMouse {
|
||||
static let accelGain: CGFloat = 0.6
|
||||
static let accelSpeedFloor: CGFloat = 0.3
|
||||
static let accelMax: CGFloat = 3.0
|
||||
/// Three-finger vertical swipe: the fraction of the view height the centroid must
|
||||
/// travel to summon (up) / dismiss (down) the local soft keyboard.
|
||||
static let keyboardSwipeFraction: CGFloat = 0.10
|
||||
|
||||
/// Acceleration multiplier for a finger speed in physical px per ms.
|
||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||
@@ -72,6 +76,9 @@ final class TouchMouse {
|
||||
var send: ((PunktfunkInputEvent) -> Void)?
|
||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
||||
/// Three-finger vertical swipe crossed the threshold: `true` = show the local soft
|
||||
/// keyboard (swipe up), `false` = dismiss it (swipe down). Fires at most once per gesture.
|
||||
var onKeyboardGesture: ((Bool) -> Void)?
|
||||
|
||||
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||
@@ -95,6 +102,11 @@ final class TouchMouse {
|
||||
private var carryY: CGFloat = 0
|
||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||
private var scrollAnchor = CGPoint.zero
|
||||
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the scroll
|
||||
// anchor) and a once-per-gesture latch.
|
||||
private var kbCount = 0
|
||||
private var kbAnchor = CGPoint.zero
|
||||
private var kbFired = false
|
||||
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||
private var lastTapUp: TimeInterval = 0
|
||||
private var lastTapPoint = CGPoint.zero
|
||||
@@ -114,6 +126,8 @@ final class TouchMouse {
|
||||
maxFingers = 0
|
||||
moved = false
|
||||
scrolling = false
|
||||
kbCount = 0
|
||||
kbFired = false
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
// button for this whole gesture (laptop-trackpad convention).
|
||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||
@@ -140,8 +154,13 @@ final class TouchMouse {
|
||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||
}
|
||||
if lastPos.count >= 2 {
|
||||
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3 bounce
|
||||
// re-anchors instead of reading the count change as swipe travel.
|
||||
if lastPos.count < 3 { kbCount = 0 }
|
||||
if lastPos.count == 2 {
|
||||
scrollByCentroid()
|
||||
} else if lastPos.count >= 3 {
|
||||
keyboardSwipe(in: view)
|
||||
} else if !scrolling, let touch = touches.first(where: {
|
||||
lastPos[ObjectIdentifier($0)] != nil
|
||||
}) {
|
||||
@@ -208,9 +227,9 @@ final class TouchMouse {
|
||||
|
||||
// MARK: - Per-event work
|
||||
|
||||
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
||||
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
||||
/// right scrolls right (the host WHEEL(120) convention).
|
||||
/// Two fingers → scroll by the centroid delta; never move the cursor. Fires a notch per
|
||||
/// `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger right
|
||||
/// scrolls right (the host WHEEL(120) convention).
|
||||
private func scrollByCentroid() {
|
||||
let n = CGFloat(lastPos.count)
|
||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||
@@ -233,6 +252,38 @@ final class TouchMouse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Three+ fingers → the keyboard swipe, never scroll (the documented vocabulary is
|
||||
/// TWO-finger scroll; 3+ only fell into the scroll path as an accident of its old `>= 2`
|
||||
/// bound). The centroid is anchored per finger count — real fingers never land or lift in
|
||||
/// the same event, so a count change must re-anchor rather than read as travel — and the
|
||||
/// gesture fires at most once, when the vertical travel crosses the threshold: up = show
|
||||
/// the local soft keyboard, down = dismiss it.
|
||||
private func keyboardSwipe(in view: UIView) {
|
||||
let n = CGFloat(lastPos.count)
|
||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
|
||||
if lastPos.count != kbCount {
|
||||
kbCount = lastPos.count
|
||||
kbAnchor = CGPoint(x: cx, y: cy)
|
||||
} else {
|
||||
let dy = cy - kbAnchor.y
|
||||
// Real centroid travel disqualifies the tap classification in `ended` (else a
|
||||
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||
if abs(dy) > Tuning.tapSlop || abs(cx - kbAnchor.x) > Tuning.tapSlop { moved = true }
|
||||
if !kbFired, abs(dy) >= view.bounds.height * Tuning.keyboardSwipeFraction {
|
||||
kbFired = true
|
||||
onKeyboardGesture?(dy < 0) // finger up → show, finger down → dismiss
|
||||
}
|
||||
}
|
||||
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel notch;
|
||||
// clearing it makes a return to two fingers re-anchor fresh. Same for the trackpad's
|
||||
// tracked finger: its prev position froze while 3+ fingers were down, so dropping
|
||||
// straight back to one finger must re-anchor (zero delta), not replay the whole
|
||||
// 3-finger phase as one cursor jump.
|
||||
scrolling = false
|
||||
trackKey = nil
|
||||
}
|
||||
|
||||
/// One finger (and the gesture never became a scroll — dropping back from two fingers to
|
||||
/// one must not jerk the cursor).
|
||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
||||
|
||||
@@ -27,8 +27,10 @@ public enum DefaultsKey {
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||
public static let audioChannels = "punktfunk.audioChannels"
|
||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference —
|
||||
/// the host emits it when it can, else falls back. Drives the decoder via `Welcome.codec`.
|
||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, `"av1"`, or
|
||||
/// `"pyrowave"` (the opt-in wired-LAN wavelet codec — picking it advertises AND prefers it,
|
||||
/// and forces the session SDR). A soft preference — the host emits it when it can, else
|
||||
/// falls back. Drives the decoder via `Welcome.codec`.
|
||||
public static let codec = "punktfunk.codec"
|
||||
public static let micEnabled = "punktfunk.micEnabled"
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
@@ -97,6 +99,12 @@ public enum DefaultsKey {
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
|
||||
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
|
||||
/// has a haptic actuator (no iPad/Mac/TV).
|
||||
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
|
||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||
|
||||
@@ -543,19 +543,24 @@ public enum AV1 {
|
||||
|
||||
extension VideoCodec {
|
||||
/// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence
|
||||
/// header, the NAL codecs on in-band parameter sets — one call site in each pump.
|
||||
/// header, the NAL codecs on in-band parameter sets — one call site in each pump. PyroWave
|
||||
/// has no CoreMedia representation at all (its pump feeds the Metal wavelet decoder raw).
|
||||
public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
|
||||
self == .av1
|
||||
? AV1.formatDescription(fromKeyframe: au)
|
||||
: AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||
switch self {
|
||||
case .av1: return AV1.formatDescription(fromKeyframe: au)
|
||||
case .pyrowave: return nil
|
||||
default: return AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
|
||||
public func sampleBuffer(
|
||||
au: AccessUnit, format: CMVideoFormatDescription
|
||||
) -> CMSampleBuffer? {
|
||||
self == .av1
|
||||
? AV1.sampleBuffer(au: au, format: format)
|
||||
: AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
||||
switch self {
|
||||
case .av1: return AV1.sampleBuffer(au: au, format: format)
|
||||
case .pyrowave: return nil
|
||||
default: return AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,18 @@ public enum VideoCodec: Equatable {
|
||||
case h264
|
||||
case hevc
|
||||
case av1
|
||||
/// PyroWave wavelet (opt-in wired-LAN low-latency codec): not a NAL/OBU codec and not
|
||||
/// VideoToolbox-decoded at all — the Metal wavelet decoder consumes the raw AUs
|
||||
/// (Stage2Pipeline's PyroWave pump). Only ever resolved when this client both advertised
|
||||
/// and preferred it.
|
||||
case pyrowave
|
||||
|
||||
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC).
|
||||
public init(wire: UInt8) {
|
||||
switch wire {
|
||||
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
|
||||
case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1
|
||||
case 0x08: self = .pyrowave // PUNKTFUNK_CODEC_PYROWAVE
|
||||
default: self = .hevc // PUNKTFUNK_CODEC_HEVC — the default / older-host codec
|
||||
}
|
||||
}
|
||||
@@ -147,8 +153,8 @@ public enum AnnexB {
|
||||
sets = [vps, sps, pps]
|
||||
case .h264:
|
||||
sets = [sps, pps]
|
||||
case .av1:
|
||||
return nil // OBU stream, no parameter-set NALs — handled in AV1.swift, never here
|
||||
case .av1, .pyrowave:
|
||||
return nil // no parameter-set NALs — dispatched in AV1.swift, never reaches here
|
||||
}
|
||||
|
||||
var format: CMVideoFormatDescription?
|
||||
@@ -184,8 +190,8 @@ public enum AnnexB {
|
||||
parameterSetSizes: sizes,
|
||||
nalUnitHeaderLength: 4,
|
||||
formatDescriptionOut: &format)
|
||||
case .av1:
|
||||
break // unreachable — the .av1 arm above already returned
|
||||
case .av1, .pyrowave:
|
||||
break // unreachable — the arm above already returned
|
||||
}
|
||||
}
|
||||
return status == noErr ? format : nil
|
||||
|
||||
@@ -149,6 +149,28 @@ fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
||||
}
|
||||
|
||||
// PyroWave planar SDR: three separate R8 planes (Y full-res, Cb/Cr half-res 4:2:0) from the
|
||||
// Metal wavelet decoder — the Metal twin of pf-presenter's planar_csc.frag. Same bicubic luma
|
||||
// and left-cosited chroma correction as the biplanar path (chromaUV self-disables at 4:4:4).
|
||||
fragment float4 pf_frag_planar(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> cbTex [[texture(1)]],
|
||||
texture2d<float> crTex [[texture(2)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
#ifdef PF_BILINEAR_LUMA
|
||||
float lumaY = lumaTex.sample(s, in.uv).r;
|
||||
#else
|
||||
float lumaY = catmullRomLuma(lumaTex, s, in.uv);
|
||||
#endif
|
||||
float2 cuv = chromaUV(lumaTex, cbTex, in.uv);
|
||||
float3 yuv = float3(lumaY, cbTex.sample(s, cuv).r, crTex.sample(s, cuv).r);
|
||||
float3 rgb = saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||
return float4(rgb, 1.0);
|
||||
}
|
||||
|
||||
// HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded Y′CbCr) → full-range PQ R′G′B′, output as-is —
|
||||
// the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are
|
||||
// PQ, so it does the PQ→display tone-map. No EOTF here. The rows fold in the exact 10-bit
|
||||
@@ -215,8 +237,16 @@ public final class MetalVideoPresenter {
|
||||
/// tvOS only: the in-shader PQ→SDR tone-map fallback (pf_frag_hdr_tv → bgra8), used whenever
|
||||
/// the display is composited without HDR headroom — see `setDisplayHeadroom`. nil elsewhere.
|
||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||
private let pipelinePlanar: MTLRenderPipelineState
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
|
||||
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means
|
||||
/// decode, CSC and present share textures with zero interop, and one queue means Metal's
|
||||
/// hazard tracking orders a ring-slot rewrite after the render still sampling it.
|
||||
var metalDevice: MTLDevice { device }
|
||||
var metalQueue: MTLCommandQueue { queue }
|
||||
|
||||
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
||||
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||
@@ -258,6 +288,7 @@ public final class MetalVideoPresenter {
|
||||
let pipelineSDR: MTLRenderPipelineState
|
||||
let pipelineHDR: MTLRenderPipelineState
|
||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
let pipelinePlanar: MTLRenderPipelineState
|
||||
do {
|
||||
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
||||
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
||||
@@ -292,6 +323,11 @@ public final class MetalVideoPresenter {
|
||||
#else
|
||||
pipelineHDRToneMap = nil
|
||||
#endif
|
||||
let planar = MTLRenderPipelineDescriptor()
|
||||
planar.vertexFunction = vtx
|
||||
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||
planar.colorAttachments[0].pixelFormat = .bgra8Unorm // PyroWave is 8-bit SDR
|
||||
pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -331,12 +367,14 @@ public final class MetalVideoPresenter {
|
||||
|
||||
return MetalVideoPresenter(
|
||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||
textureCache: textureCache, layer: layer)
|
||||
}
|
||||
|
||||
private init(
|
||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||
pipelinePlanar: MTLRenderPipelineState,
|
||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||
) {
|
||||
self.device = device
|
||||
@@ -344,6 +382,7 @@ public final class MetalVideoPresenter {
|
||||
self.pipelineSDR = pipelineSDR
|
||||
self.pipelineHDR = pipelineHDR
|
||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||
self.pipelinePlanar = pipelinePlanar
|
||||
self.textureCache = textureCache
|
||||
self.layer = layer
|
||||
}
|
||||
@@ -514,6 +553,67 @@ public final class MetalVideoPresenter {
|
||||
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
||||
else { return false }
|
||||
|
||||
#if os(tvOS)
|
||||
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
||||
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
|
||||
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
|
||||
let pipeline = hdrActive ? hdrPipeline : pipelineSDR
|
||||
#else
|
||||
let pipeline = hdrActive ? pipelineHDR : pipelineSDR
|
||||
#endif
|
||||
let decodedSize = CGSize(
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
return encodePresent(
|
||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
||||
keepAlive: [luma, chroma, pixelBuffer]
|
||||
) { encoder in
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw one PyroWave planar frame (three R8 planes off the Metal wavelet decoder) and
|
||||
/// present it. RENDER THREAD, same contract as `render` — PyroWave is 8-bit SDR, so the
|
||||
/// layer always takes the plain SDR config, and the CSC rows arrive precomputed from the
|
||||
/// stream's own sequence-header signaling (no CVPixelBuffer to inspect).
|
||||
@discardableResult
|
||||
func renderPlanar(
|
||||
_ planes: WaveletPlanes,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
let targetFromLayout = drawableTarget
|
||||
stagingLock.unlock()
|
||||
configure(hdr: false)
|
||||
var csc = planes.csc
|
||||
return encodePresent(
|
||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||
targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
// The ring textures stay valid by ring depth; retaining them here also pins the
|
||||
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||
) { encoder in
|
||||
encoder.setFragmentTexture(planes.y, index: 0)
|
||||
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
private func encodePresent(
|
||||
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||
) -> Bool {
|
||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||
// SessionPresenter.layout via `setDrawableTarget` — not read off the layer, whose geometry the
|
||||
// main thread owns) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass:
|
||||
@@ -522,8 +622,6 @@ public final class MetalVideoPresenter {
|
||||
// Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT
|
||||
// track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change
|
||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||
let decodedSize = CGSize(
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? targetFromLayout : decodedSize
|
||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||
@@ -542,17 +640,8 @@ public final class MetalVideoPresenter {
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
#if os(tvOS)
|
||||
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
||||
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
|
||||
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
|
||||
encoder.setRenderPipelineState(hdrActive ? hdrPipeline : pipelineSDR)
|
||||
#else
|
||||
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
||||
#endif
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
encoder.setRenderPipelineState(pipeline)
|
||||
bind(encoder)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
if let onPresented {
|
||||
@@ -580,9 +669,8 @@ public final class MetalVideoPresenter {
|
||||
} else {
|
||||
commandBuffer.present(drawable)
|
||||
}
|
||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
||||
// sampling — releasing them at scope exit could free the backing mid-read.
|
||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||
commandBuffer.commit()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
// PyroWave native Metal decoder — the Apple twin of pf-client-core's Vulkan decoder
|
||||
// (crates/pf-client-core/src/video_pyrowave.rs), reimplemented on the presenter's own MTLDevice
|
||||
// so decode + CSC + present share one device with zero interop (design/pyrowave-codec-plan.md
|
||||
// §4.7). No upstream C/C++ ships in the app: the bitstream parse below reimplements
|
||||
// pyrowave_decoder.cpp's push_packet/decode_packet walk, and the two compute kernels
|
||||
// (MetalWaveletShaders.swift) are hand-ported from the vendored GLSL. The §4.2 upstream pin
|
||||
// covers this hand-port: a vendored bump means re-diffing two decode shaders and the two 8-byte
|
||||
// header structs, and it is already a protocol-version event.
|
||||
//
|
||||
// Wire shape (all fixed by the host encoder, punktfunk-host encode/linux/pyrowave.rs):
|
||||
// • One AU = one frame = a self-delimiting stream of packets. Each packet is one 32x32
|
||||
// coefficient block for one (component, level, band), self-sized by its 8-byte
|
||||
// BitstreamHeader; a per-frame START_OF_FRAME sequence header carries dims + total block
|
||||
// count + the VUI bits (chroma 4:2:0, BT.709/BT.2020, limited/full).
|
||||
// • With `USER_FLAG_CHUNK_ALIGNED` (Phase 4) the AU is a whole number of `shard_payload`-sized
|
||||
// windows, each 4-byte-prefixed (used-len u16 LE + kind u16 LE): kind 0 = whole packets,
|
||||
// 1/2/3 = FRAG chain for a packet bigger than one window. A missing shard of a partial frame
|
||||
// arrives as an all-zero window (used = 0) → skipped, its blocks reconstruct as zeros
|
||||
// (localized blur, the Phase-4 design intent). The reassembler enables partial delivery
|
||||
// core-side automatically for PyroWave sessions.
|
||||
// • Decode acceptance mirrors upstream decode_is_ready(allow_partial=true): a frame with no
|
||||
// SOF or with no more than half its blocks is dropped rather than decoded to garbage.
|
||||
//
|
||||
// GPU structure per frame (mirroring pyrowave_decoder.cpp's barriers): one concurrent compute
|
||||
// encoder with all ~42 dequant dispatches (each writes a distinct band layer — no intra-stage
|
||||
// hazards), then one concurrent encoder per iDWT level (5) — encoder boundaries provide the
|
||||
// write→sampled-read synchronization the Vulkan version expresses as pipeline barriers. The
|
||||
// output is a ring of 4 plane sets (Y full-res + Cb/Cr half-res R8Unorm); ring depth plus
|
||||
// same-queue hazard tracking keeps a set alive while the presenter still samples it (the same
|
||||
// scheme as the Vulkan client's ring).
|
||||
|
||||
#if canImport(Metal)
|
||||
import Foundation
|
||||
import Metal
|
||||
import os
|
||||
|
||||
private let waveletLog = Logger(subsystem: "io.unom.punktfunk", category: "pyrowave")
|
||||
|
||||
/// The per-(component, level, band) 32x32-block table — the exact Swift port of
|
||||
/// `WaveletBuffers::init_block_meta` (pyrowave_common.cpp): the walk order (level 4→0,
|
||||
/// component 0→2 skipping level-0 chroma in 4:2:0, band (level==4 ? 0 : 1)→3) DEFINES the
|
||||
/// global `block_index` space the wire packets address, so it must match the encoder exactly.
|
||||
struct WaveletLayout {
|
||||
static let decompositionLevels = 5
|
||||
static let alignment = 32
|
||||
static let minimumImageSize = 128
|
||||
|
||||
let width: Int
|
||||
let height: Int
|
||||
let alignedWidth: Int
|
||||
let alignedHeight: Int
|
||||
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
||||
/// band not coded (level-0 chroma in 4:2:0).
|
||||
let blockMeta: [[[(offset: Int, stride: Int)]]]
|
||||
let blockCount32: Int
|
||||
|
||||
/// Band-image extent at `level` — mip `level` of the (aligned/2)-sized coefficient image.
|
||||
/// Exact halving: the aligned dims are 32-aligned, so /2 is 16-aligned and survives 4 shifts.
|
||||
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
||||
func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level }
|
||||
|
||||
init(width: Int, height: Int) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
let align = { (v: Int) in
|
||||
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
||||
}
|
||||
alignedWidth = align(width)
|
||||
alignedHeight = align(height)
|
||||
|
||||
var meta = [[[(offset: Int, stride: Int)]]](
|
||||
repeating: [[(offset: Int, stride: Int)]](
|
||||
repeating: [(offset: Int, stride: Int)](repeating: (-1, 0), count: 4),
|
||||
count: Self.decompositionLevels),
|
||||
count: 3)
|
||||
var count32 = 0
|
||||
let aw = alignedWidth
|
||||
let ah = alignedHeight
|
||||
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 { continue } // 4:2:0: no top-level chroma
|
||||
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||
let levelW = (aw / 2) >> level
|
||||
let levelH = (ah / 2) >> level
|
||||
let blocksX8 = (levelW + 7) / 8
|
||||
let blocksY8 = (levelH + 7) / 8
|
||||
let blocksX32 = (levelW + 31) / 32
|
||||
meta[component][level][band] = (count32, blocksX32)
|
||||
// accumulate_block_mapping's 32x32 count.
|
||||
count32 += ((blocksX8 + 3) / 4) * ((blocksY8 + 3) / 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
blockMeta = meta
|
||||
blockCount32 = count32
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed frame, CPU side: the per-block payload offset table + the flat payload words the
|
||||
/// dequant kernel consumes (packet words INCLUDING each 8-byte header, as upstream uploads
|
||||
/// them), plus the sequence header's facts.
|
||||
struct ParsedWaveletFrame {
|
||||
var layout: WaveletLayout
|
||||
/// Per 32x32 block: u32 word offset into `payload`, or UInt32.max = block missing.
|
||||
var offsets: [UInt32]
|
||||
var payload: [UInt32]
|
||||
var totalBlocks: Int
|
||||
var decodedBlocks: Int
|
||||
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
||||
var bt2020: Bool
|
||||
var fullRange: Bool
|
||||
|
||||
/// The frame's Y′CbCr→RGB signal for the presenter's planar CSC. PyroWave today is always
|
||||
/// BT.709 limited (the host's fixed contract), but the sequence header signals it, so honor
|
||||
/// what it says.
|
||||
var cscSignal: CscRows.Signal {
|
||||
CscRows.Signal(matrix: bt2020 ? 9 : 1, fullRange: fullRange)
|
||||
}
|
||||
}
|
||||
|
||||
enum WaveletBitstream {
|
||||
/// Window kinds of the chunk-aligned framing (host WIN_* constants).
|
||||
private static let winPacked: UInt16 = 0
|
||||
private static let winFragFirst: UInt16 = 1
|
||||
private static let winFragCont: UInt16 = 2
|
||||
private static let winFragLast: UInt16 = 3
|
||||
|
||||
/// Parse one AU into the dequant kernel's inputs. `windowSize` > 0 with `chunkAligned`
|
||||
/// walks the Phase-4 shard-window framing first; otherwise the AU is one packet stream.
|
||||
/// nil = drop the frame (malformed, no SOF, or not enough blocks survived loss to be worth
|
||||
/// decoding — upstream's `decoded_blocks > total/2` partial rule).
|
||||
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
||||
var state = ParseState()
|
||||
let ok = au.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
||||
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||
return false
|
||||
}
|
||||
let count = raw.count
|
||||
if chunkAligned, windowSize >= 8 {
|
||||
// Whole windows only; a trailing partial window would be a framing bug.
|
||||
guard count % windowSize == 0 else { return false }
|
||||
var frag: [UInt8] = []
|
||||
var fragLive = false
|
||||
var pos = 0
|
||||
while pos < count {
|
||||
let win = UnsafeBufferPointer(start: base + pos, count: windowSize)
|
||||
pos += windowSize
|
||||
let used = Int(win[0]) | (Int(win[1]) << 8)
|
||||
let kind = UInt16(win[2]) | (UInt16(win[3]) << 8)
|
||||
// A zeroed (missing) shard or an overrun drops the window AND breaks any
|
||||
// fragment chain riding across it (mirrors video_pyrowave.rs push_window).
|
||||
guard used > 0, 4 + used <= windowSize else {
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
fragLive = false
|
||||
continue
|
||||
}
|
||||
let body = UnsafeBufferPointer(start: win.baseAddress! + 4, count: used)
|
||||
switch kind {
|
||||
case winPacked:
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
fragLive = false
|
||||
guard state.pushPackets(body) else { return false }
|
||||
case winFragFirst:
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
frag.append(contentsOf: body)
|
||||
fragLive = true
|
||||
case winFragCont:
|
||||
if fragLive { frag.append(contentsOf: body) }
|
||||
case winFragLast:
|
||||
if fragLive {
|
||||
frag.append(contentsOf: body)
|
||||
let ok = frag.withUnsafeBufferPointer { state.pushPackets($0) }
|
||||
guard ok else { return false }
|
||||
}
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
fragLive = false
|
||||
default:
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
fragLive = false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return state.pushPackets(UnsafeBufferPointer(start: base, count: count))
|
||||
}
|
||||
guard ok, let frame = state.finish() else { return nil }
|
||||
// Upstream decode_is_ready(allow_partial=true): with no SOF the frame is undecodable;
|
||||
// at half the blocks or fewer it is presumed garbage.
|
||||
guard frame.totalBlocks > 0, frame.decodedBlocks > frame.totalBlocks / 2 else {
|
||||
return nil
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
/// Streaming packet-walk state (pyrowave_decoder.cpp push_packet + decode_packet). The
|
||||
/// SOF sequence header arrives first in every host AU, which fixes the dims → layout →
|
||||
/// offset-table size before any coefficient packet lands; a coefficient packet before the
|
||||
/// SOF (its window was lost) is skipped — its block just stays missing.
|
||||
private struct ParseState {
|
||||
var layout: WaveletLayout?
|
||||
var offsets: [UInt32] = []
|
||||
var payload: [UInt32] = []
|
||||
var totalBlocks = 0
|
||||
var decodedBlocks = 0
|
||||
var bt2020 = false
|
||||
var fullRange = false
|
||||
var sawSOF = false
|
||||
|
||||
mutating func pushPackets(_ buf: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||
guard let base = buf.baseAddress else { return true }
|
||||
var pos = 0
|
||||
let count = buf.count
|
||||
while count - pos >= 8 {
|
||||
let word0 = loadWord(base, pos)
|
||||
let word1 = loadWord(base, pos + 4)
|
||||
let extended = (word0 >> 31) & 1
|
||||
if extended != 0 {
|
||||
// BitstreamSequenceHeader: w-1[0:14] h-1[14:28] seq[28:31] ext[31];
|
||||
// total[0:24] code[24:26] chroma[26] prim[27] trc[28] mtx[29] range[30]
|
||||
// siting[31].
|
||||
let code = (word1 >> 24) & 0x3
|
||||
guard code == 0 else { return false } // only START_OF_FRAME is defined
|
||||
let chromaRes = (word1 >> 26) & 1
|
||||
guard chromaRes == 0 else { return false } // host contract: 4:2:0
|
||||
let w = Int(word0 & 0x3fff) + 1
|
||||
let h = Int((word0 >> 14) & 0x3fff) + 1
|
||||
guard w >= 2, h >= 2, w % 2 == 0, h % 2 == 0 else { return false }
|
||||
if sawSOF {
|
||||
// One frame, one geometry — a second SOF must agree.
|
||||
guard layout?.width == w, layout?.height == h else { return false }
|
||||
} else {
|
||||
sawSOF = true
|
||||
let l = WaveletLayout(width: w, height: h)
|
||||
layout = l
|
||||
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
||||
payload.reserveCapacity(64 * 1024 / 4)
|
||||
totalBlocks = Int(word1 & 0xff_ffff)
|
||||
bt2020 = (word1 >> 29) & 1 != 0
|
||||
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
|
||||
}
|
||||
pos += 8
|
||||
continue
|
||||
}
|
||||
// BitstreamHeader: ballot[0:16] payload_words[16:28] seq[28:31] ext[31];
|
||||
// quant_code[0:8] block_index[8:32]. payload_words counts u32s INCLUDING the
|
||||
// 8-byte header.
|
||||
let payloadWords = Int((word0 >> 16) & 0xfff)
|
||||
guard payloadWords >= 2, pos + payloadWords * 4 <= count else { return false }
|
||||
let blockIndex = Int(word1 >> 8)
|
||||
if let layout, blockIndex < layout.blockCount32 {
|
||||
// First write wins (duplicate packets are ignored, like upstream).
|
||||
if offsets[blockIndex] == .max {
|
||||
offsets[blockIndex] = UInt32(payload.count)
|
||||
decodedBlocks += 1
|
||||
payload.reserveCapacity(payload.count + payloadWords)
|
||||
for w in 0..<payloadWords {
|
||||
payload.append(loadWord(base, pos + w * 4))
|
||||
}
|
||||
}
|
||||
} else if layout != nil {
|
||||
return false // out-of-bounds block index — corrupt stream
|
||||
}
|
||||
// No layout yet (SOF lost): skip the packet, the block stays missing.
|
||||
pos += payloadWords * 4
|
||||
}
|
||||
// In the windowed framing, `used` delimits exactly; dense AUs must also consume
|
||||
// fully (upstream errors on trailing bytes).
|
||||
return pos == count
|
||||
}
|
||||
|
||||
private func loadWord(_ base: UnsafePointer<UInt8>, _ offset: Int) -> UInt32 {
|
||||
UInt32(base[offset])
|
||||
| (UInt32(base[offset + 1]) << 8)
|
||||
| (UInt32(base[offset + 2]) << 16)
|
||||
| (UInt32(base[offset + 3]) << 24)
|
||||
}
|
||||
|
||||
func finish() -> ParsedWaveletFrame? {
|
||||
guard let layout else { return nil }
|
||||
return ParsedWaveletFrame(
|
||||
layout: layout, offsets: offsets, payload: payload,
|
||||
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
|
||||
bt2020: bt2020, fullRange: fullRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded frame's output planes, handed to the presenter's planar render path. The
|
||||
/// textures belong to the decoder's ring — ring depth (4) plus same-queue hazard tracking keep
|
||||
/// them valid while referenced. Public because it rides inside `ReadyImage`.
|
||||
public struct WaveletPlanes: @unchecked Sendable {
|
||||
public let y: MTLTexture
|
||||
public let cb: MTLTexture
|
||||
public let cr: MTLTexture
|
||||
public let csc: CscUniform
|
||||
public var width: Int { y.width }
|
||||
public var height: Int { y.height }
|
||||
}
|
||||
|
||||
public final class MetalWaveletDecoder {
|
||||
/// Matches the Vulkan client's ring: deep enough that a slot is never rewritten while the
|
||||
/// presenter still samples it in practice; same-queue hazard tracking is the hard backstop.
|
||||
private static let ringDepth = 4
|
||||
|
||||
/// Device-capability gate for advertisement (SessionModel) and the settings picker: the
|
||||
/// dequant kernel needs simdgroup prefix sums with its 16 header lanes inside one
|
||||
/// simdgroup, so compile the real kernels once and check the pipeline facts. Apple6 (A13)
|
||||
/// and every Mac2 device pass the family check; the compile probe is authoritative.
|
||||
public static let supported: Bool = {
|
||||
guard let device = MTLCreateSystemDefaultDevice() else { return false }
|
||||
guard device.supportsFamily(.apple6) || device.supportsFamily(.mac2) else { return false }
|
||||
do {
|
||||
let lib = try device.makeLibrary(source: waveletShaderSource, options: nil)
|
||||
guard let dequant = lib.makeFunction(name: "wavelet_dequant") else { return false }
|
||||
let p = try device.makeComputePipelineState(function: dequant)
|
||||
var shift = false
|
||||
let fc = MTLFunctionConstantValues()
|
||||
fc.setConstantValue(&shift, type: .bool, index: 0)
|
||||
_ = try lib.makeFunction(name: "idwt", constantValues: fc)
|
||||
return p.threadExecutionWidth >= 16 && p.maxTotalThreadsPerThreadgroup >= 128
|
||||
} catch {
|
||||
waveletLog.info("pyrowave probe: kernels rejected (\(error, privacy: .public))")
|
||||
return false
|
||||
}
|
||||
}()
|
||||
|
||||
private let device: MTLDevice
|
||||
private let queue: MTLCommandQueue
|
||||
private let dequantPipeline: MTLComputePipelineState
|
||||
private let idwtPipeline: MTLComputePipelineState
|
||||
private let idwtShiftPipeline: MTLComputePipelineState
|
||||
private let mirrorSampler: MTLSamplerState
|
||||
|
||||
// Size-dependent state, rebuilt when the SOF dims change (this is also the mid-stream
|
||||
// Reconfigure/resize path — the wavelet decoder is fixed-size per geometry).
|
||||
private var layout: WaveletLayout?
|
||||
/// coefficients[component][level]: 4-slice R16Float (levels 0–1) / R32Float (levels 2–4)
|
||||
/// texture2d_array — the band images (precision-1 split, see MetalWaveletShaders).
|
||||
private var coefficients: [[MTLTexture]] = []
|
||||
/// llViews[component][level]: slice-0 (LL band) 2D write view of `coefficients` — the iDWT
|
||||
/// output target chaining level L+1 into level L.
|
||||
private var llViews: [[MTLTexture]] = []
|
||||
|
||||
private struct Slot {
|
||||
var y: MTLTexture
|
||||
var cb: MTLTexture
|
||||
var cr: MTLTexture
|
||||
var offsets: MTLBuffer
|
||||
var payload: MTLBuffer
|
||||
}
|
||||
|
||||
private var slots: [Slot] = []
|
||||
private var nextSlot = 0
|
||||
|
||||
/// The current geometry (from the last SOF that built the resources) — the pump reports
|
||||
/// decoded-size changes to the resize overlay from this. PUMP THREAD.
|
||||
var decodedSize: (width: Int, height: Int)? {
|
||||
layout.map { ($0.width, $0.height) }
|
||||
}
|
||||
|
||||
/// The pump thread owns `decode`; everything mutable is confined to it.
|
||||
init?(device: MTLDevice, queue: MTLCommandQueue) {
|
||||
self.device = device
|
||||
self.queue = queue
|
||||
do {
|
||||
let lib = try device.makeLibrary(source: waveletShaderSource, options: nil)
|
||||
guard let dequantFn = lib.makeFunction(name: "wavelet_dequant") else { return nil }
|
||||
dequantPipeline = try device.makeComputePipelineState(function: dequantFn)
|
||||
var shift = false
|
||||
let fcOff = MTLFunctionConstantValues()
|
||||
fcOff.setConstantValue(&shift, type: .bool, index: 0)
|
||||
idwtPipeline = try device.makeComputePipelineState(
|
||||
function: try lib.makeFunction(name: "idwt", constantValues: fcOff))
|
||||
shift = true
|
||||
let fcOn = MTLFunctionConstantValues()
|
||||
fcOn.setConstantValue(&shift, type: .bool, index: 0)
|
||||
idwtShiftPipeline = try device.makeComputePipelineState(
|
||||
function: try lib.makeFunction(name: "idwt", constantValues: fcOn))
|
||||
} catch {
|
||||
waveletLog.error("pyrowave: pipeline build failed (\(error, privacy: .public))")
|
||||
return nil
|
||||
}
|
||||
guard dequantPipeline.threadExecutionWidth >= 16,
|
||||
dequantPipeline.maxTotalThreadsPerThreadgroup >= 128
|
||||
else { return nil }
|
||||
// Upstream's mirror_repeat_sampler: mirrored repeat, NEAREST everything, normalized
|
||||
// coords — the idwt gather footprint + coordinate nudge depend on exactly this.
|
||||
let samp = MTLSamplerDescriptor()
|
||||
samp.sAddressMode = .mirrorRepeat
|
||||
samp.tAddressMode = .mirrorRepeat
|
||||
samp.minFilter = .nearest
|
||||
samp.magFilter = .nearest
|
||||
samp.mipFilter = .notMipmapped
|
||||
samp.normalizedCoordinates = true
|
||||
guard let sampler = device.makeSamplerState(descriptor: samp) else { return nil }
|
||||
mirrorSampler = sampler
|
||||
}
|
||||
|
||||
/// Decode one AU. Synchronous CPU parse + async GPU decode: returns false when the frame
|
||||
/// was dropped (malformed / SOF lost / not enough blocks); on true, `completion` fires on a
|
||||
/// Metal callback thread once the planes are decoded (nil = the GPU pass errored).
|
||||
/// PUMP THREAD only.
|
||||
func decode(
|
||||
au: Data, chunkAligned: Bool, windowSize: Int,
|
||||
completion: @escaping @Sendable (WaveletPlanes?) -> Void
|
||||
) -> Bool {
|
||||
guard
|
||||
let frame = WaveletBitstream.parse(
|
||||
au: au, chunkAligned: chunkAligned, windowSize: windowSize)
|
||||
else { return false }
|
||||
|
||||
if layout?.width != frame.layout.width || layout?.height != frame.layout.height {
|
||||
guard rebuild(layout: frame.layout) else { return false }
|
||||
}
|
||||
guard let layout, !slots.isEmpty else { return false }
|
||||
|
||||
var slot = slots[nextSlot]
|
||||
// Grow the payload buffer to the frame (+16-byte zeroed guard: the kernel's 64-bit
|
||||
// sign-window load and eager plane-byte prefetch may read past the payload end —
|
||||
// upstream pads its Vulkan buffer for exactly this).
|
||||
let payloadBytes = frame.payload.count * 4
|
||||
if slot.payload.length < payloadBytes + 16 {
|
||||
guard
|
||||
let grown = device.makeBuffer(
|
||||
length: max(64 * 1024, (payloadBytes + 16) * 2), options: .storageModeShared)
|
||||
else { return false }
|
||||
slot.payload = grown
|
||||
slots[nextSlot] = slot
|
||||
}
|
||||
frame.offsets.withUnsafeBytes { src in
|
||||
slot.offsets.contents().copyMemory(
|
||||
from: src.baseAddress!, byteCount: min(src.count, slot.offsets.length))
|
||||
}
|
||||
frame.payload.withUnsafeBytes { src in
|
||||
slot.payload.contents().copyMemory(from: src.baseAddress!, byteCount: src.count)
|
||||
}
|
||||
memset(slot.payload.contents() + payloadBytes, 0, 16)
|
||||
|
||||
guard let cmd = queue.makeCommandBuffer() else { return false }
|
||||
|
||||
// Stage 1: dequant — every (component, level, band) block grid in one concurrent
|
||||
// encoder (each dispatch writes its own band layer; no intra-stage hazards, exactly
|
||||
// like the barrier-free Vulkan dispatch loop).
|
||||
guard let dequant = cmd.makeComputeCommandEncoder(dispatchType: .concurrent) else {
|
||||
return false
|
||||
}
|
||||
dequant.label = "pyrowave dequant"
|
||||
dequant.setComputePipelineState(dequantPipeline)
|
||||
dequant.setBuffer(slot.offsets, offset: 0, index: 0)
|
||||
dequant.setBuffer(slot.payload, offset: 0, index: 1)
|
||||
for level in 0..<WaveletLayout.decompositionLevels {
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 { continue } // 4:2:0
|
||||
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||
let meta = layout.blockMeta[component][level][band]
|
||||
let w = layout.levelWidth(level)
|
||||
let h = layout.levelHeight(level)
|
||||
var regs = DequantRegisters(
|
||||
resolution: SIMD2(Int32(w), Int32(h)),
|
||||
outputLayer: Int32(band),
|
||||
blockOffset32x32: Int32(meta.offset),
|
||||
blockStride32x32: Int32(meta.stride))
|
||||
dequant.setTexture(coefficients[component][level], index: 0)
|
||||
dequant.setBytes(
|
||||
®s, length: MemoryLayout<DequantRegisters>.stride, index: 2)
|
||||
dequant.dispatchThreadgroups(
|
||||
MTLSize(width: (w + 31) / 32, height: (h + 31) / 32, depth: 1),
|
||||
threadsPerThreadgroup: MTLSize(width: 128, height: 1, depth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
dequant.endEncoding()
|
||||
|
||||
// Stage 2: iDWT, coarsest level in — one encoder per level; the encoder boundary is
|
||||
// the write→sampled-read barrier chaining each level's LL into the next.
|
||||
for inputLevel in stride(from: WaveletLayout.decompositionLevels - 1, through: 0, by: -1) {
|
||||
guard let idwt = cmd.makeComputeCommandEncoder(dispatchType: .concurrent) else {
|
||||
return false
|
||||
}
|
||||
idwt.label = "pyrowave idwt L\(inputLevel)"
|
||||
idwt.setSamplerState(mirrorSampler, index: 0)
|
||||
// Resolution rides TRANSPOSED (the kernel transposes on load and store).
|
||||
let rx = layout.levelHeight(inputLevel)
|
||||
let ry = layout.levelWidth(inputLevel)
|
||||
var regs = IdwtRegisters(
|
||||
resolution: SIMD2(Int32(rx), Int32(ry)),
|
||||
invResolution: SIMD2(1.0 / Float(rx), 1.0 / Float(ry)))
|
||||
idwt.setBytes(®s, length: MemoryLayout<IdwtRegisters>.stride, index: 0)
|
||||
let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
|
||||
let group = MTLSize(width: 64, height: 1, depth: 1)
|
||||
if inputLevel == 0 {
|
||||
// 4:2:0: the final full-res pass is luma only (chroma finished at level 1).
|
||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||
idwt.setTexture(coefficients[0][0], index: 0)
|
||||
idwt.setTexture(slot.y, index: 1)
|
||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||
} else {
|
||||
for component in 0..<3 {
|
||||
idwt.setTexture(coefficients[component][inputLevel], index: 0)
|
||||
if component != 0 && inputLevel == 1 {
|
||||
// 4:2:0 chroma emits its final half-res plane one level early.
|
||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
||||
} else {
|
||||
idwt.setComputePipelineState(idwtPipeline)
|
||||
idwt.setTexture(llViews[component][inputLevel - 1], index: 1)
|
||||
}
|
||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||
}
|
||||
}
|
||||
idwt.endEncoding()
|
||||
}
|
||||
|
||||
let planes = WaveletPlanes(
|
||||
y: slot.y, cb: slot.cb, cr: slot.cr,
|
||||
csc: CscRows.rows(frame.cscSignal, depth: 8, msbPacked: false))
|
||||
cmd.addCompletedHandler { buffer in
|
||||
completion(buffer.error == nil ? planes : nil)
|
||||
}
|
||||
cmd.commit()
|
||||
nextSlot = (nextSlot + 1) % Self.ringDepth
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream
|
||||
/// resize path: a Reconfigure shows up here as new SOF dims.
|
||||
private func rebuild(layout newLayout: WaveletLayout) -> Bool {
|
||||
waveletLog.info(
|
||||
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks)")
|
||||
var coeff: [[MTLTexture]] = []
|
||||
var lls: [[MTLTexture]] = []
|
||||
for component in 0..<3 {
|
||||
var perLevel: [MTLTexture] = []
|
||||
var perLevelLL: [MTLTexture] = []
|
||||
for level in 0..<WaveletLayout.decompositionLevels {
|
||||
let desc = MTLTextureDescriptor()
|
||||
desc.textureType = .type2DArray
|
||||
desc.arrayLength = 4
|
||||
// Upstream precision 1: fp16 storage for the two finest levels, fp32 for the
|
||||
// coarse levels whose values feed every later reconstruction step.
|
||||
desc.pixelFormat = level < 2 ? .r16Float : .r32Float
|
||||
desc.width = newLayout.levelWidth(level)
|
||||
desc.height = newLayout.levelHeight(level)
|
||||
desc.usage = [.shaderRead, .shaderWrite]
|
||||
desc.storageMode = .private
|
||||
guard let tex = device.makeTexture(descriptor: desc) else { return false }
|
||||
tex.label = "pyrowave coeff c\(component) L\(level)"
|
||||
guard
|
||||
let ll = tex.makeTextureView(
|
||||
pixelFormat: desc.pixelFormat, textureType: .type2D,
|
||||
levels: 0..<1, slices: 0..<1)
|
||||
else { return false }
|
||||
ll.label = "pyrowave LL c\(component) L\(level)"
|
||||
perLevel.append(tex)
|
||||
perLevelLL.append(ll)
|
||||
}
|
||||
coeff.append(perLevel)
|
||||
lls.append(perLevelLL)
|
||||
}
|
||||
|
||||
var newSlots: [Slot] = []
|
||||
for i in 0..<Self.ringDepth {
|
||||
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .r8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.shaderRead, .shaderWrite]
|
||||
desc.storageMode = .private
|
||||
let t = self.device.makeTexture(descriptor: desc)
|
||||
t?.label = name
|
||||
return t
|
||||
}
|
||||
guard
|
||||
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
||||
let cb = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cb[\(i)]"),
|
||||
let cr = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cr[\(i)]"),
|
||||
let offsets = device.makeBuffer(
|
||||
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
||||
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
||||
else { return false }
|
||||
newSlots.append(Slot(y: y, cb: cb, cr: cr, offsets: offsets, payload: payload))
|
||||
}
|
||||
|
||||
coefficients = coeff
|
||||
llViews = lls
|
||||
slots = newSlots
|
||||
nextSlot = 0
|
||||
layout = newLayout
|
||||
return true
|
||||
}
|
||||
|
||||
// MSL-side layouts (MetalWaveletShaders.swift) — keep in lockstep.
|
||||
private struct DequantRegisters {
|
||||
var resolution: SIMD2<Int32>
|
||||
var outputLayer: Int32
|
||||
var blockOffset32x32: Int32
|
||||
var blockStride32x32: Int32
|
||||
}
|
||||
|
||||
private struct IdwtRegisters {
|
||||
var resolution: SIMD2<Int32>
|
||||
var invResolution: SIMD2<Float>
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,551 @@
|
||||
// PyroWave decode compute kernels — the Metal port of the vendored Vulkan shaders
|
||||
// (crates/pyrowave-sys/vendor/pyrowave/shaders/wavelet_dequant.comp + idwt.comp, upstream pin
|
||||
// 509e4f88, MIT © 2025 Hans-Kristian Arntzen). Runtime-compiled Swift strings per client
|
||||
// convention (no metallib build step — see GamepadChrome.swift's rationale); these are the
|
||||
// client's first compute pipelines.
|
||||
//
|
||||
// Port notes (design/pyrowave-codec-plan.md §4.7):
|
||||
// • Only the STORAGE_MODE 0 path exists: MSL device pointers replace the 8/16-bit-storage SSBO
|
||||
// aliases; the texel-buffer (mode 1) and linear-image (mode 2) fallbacks are non-Apple IHV
|
||||
// workarounds and are dropped, as is the fragment-iDWT path (Mali/Adreno only).
|
||||
// • Subgroup ops map 1:1: subgroupInclusiveAdd → simd_prefix_inclusive_sum, and the fixed
|
||||
// 32-wide Apple simdgroups take the GLSL's `SubgroupSize <= 32` scan branch; the shuffle-up
|
||||
// and LDS fallbacks for exotic wave sizes are dead code here. The dequant kernel needs the
|
||||
// 16 header lanes inside ONE simdgroup — MetalWaveletDecoder's probe enforces
|
||||
// threadExecutionWidth >= 16.
|
||||
// • Precision matches upstream's desktop default (PYROWAVE_PRECISION=1): float arithmetic,
|
||||
// half2 threadgroup storage; the coefficient textures are R16Float for DWT levels 0–1 and
|
||||
// R32Float for levels 2–4 (the low-res levels feed long reconstruction chains — upstream
|
||||
// keeps them fp32 for exactly that reason).
|
||||
// • The gather + mirrored-repeat addressing in idwt is the precision-sensitive spot (upstream
|
||||
// fought a Mali compiler bug there); the golden-frame PSNR fixtures are the guard.
|
||||
|
||||
import Foundation
|
||||
|
||||
let waveletShaderSource = """
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared helpers (dwt_swizzle.h / constants.h / dwt_quant_scale.h)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static inline int2 unswizzle8x8(uint index)
|
||||
{
|
||||
uint y = extract_bits(index, 0, 1);
|
||||
uint x = extract_bits(index, 1, 2);
|
||||
y |= extract_bits(index, 3, 2) << 1;
|
||||
x |= extract_bits(index, 5, 1) << 2;
|
||||
return int2(int(x), int(y));
|
||||
}
|
||||
|
||||
// GLSL bitfieldExtract(x, 0, n) where n may be 0; MSL extract_bits(bits=0) is not guaranteed
|
||||
// to return 0, so mask explicitly.
|
||||
static inline uint mask_lo(uint x, int n)
|
||||
{
|
||||
return (n <= 0) ? 0u : (x & (0xffffffffu >> (32 - n)));
|
||||
}
|
||||
|
||||
// pyrowave_common.hpp decode_quant: custom FP formulation, MaxScaleExp = 4.
|
||||
static inline float decode_quant(uint quant_code)
|
||||
{
|
||||
int e = 4 - int(quant_code >> 3);
|
||||
int m = int(quant_code) & 0x7;
|
||||
return (1.0f / (8.0f * 1024.0f * 1024.0f)) * float((8 + m) * (1 << (20 + e)));
|
||||
}
|
||||
|
||||
// dwt_quant_scale.h: per-8x8 quant scale, min 0.25, max ~2.2.
|
||||
static inline float decode_quant_scale(uint code)
|
||||
{
|
||||
return float(code) / 8.0f + 0.25f;
|
||||
}
|
||||
|
||||
// constants.h
|
||||
constant int QUANT_SCALE_OFFSET = 20;
|
||||
constant int QUANT_SCALE_BITS = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// wavelet_dequant — one 128-thread threadgroup decodes one 32x32 coefficient block
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
struct DequantRegisters {
|
||||
int2 resolution;
|
||||
int output_layer;
|
||||
int block_offset_32x32;
|
||||
int block_stride_32x32;
|
||||
};
|
||||
|
||||
struct DecodedPair { float4 col0; float4 col1; }; // GLSL mat2x4: m[j][i] -> colJ[i]
|
||||
|
||||
// Bit-plane magnitude decode for one thread's 4x2 coefficient group (decode_payload in the
|
||||
// GLSL). `code_word` is the 8x8 block's 16-bit control word (2 bits of extra planes per 4x2
|
||||
// group), `q_bits` the base plane count, `offset` the block's plane-payload start byte,
|
||||
// `block_index` this thread's group (0..7). Nonzero magnitudes get the +0.5 deadzone
|
||||
// reconstruction bias.
|
||||
static DecodedPair decode_payload(const device uchar *payload_u8,
|
||||
uint code_word, uint q_bits, uint offset, uint block_index)
|
||||
{
|
||||
DecodedPair m;
|
||||
m.col0 = float4(0.0f);
|
||||
m.col1 = float4(0.0f);
|
||||
if (code_word == 0)
|
||||
return m;
|
||||
|
||||
int bit_offset = 2 * int(block_index);
|
||||
|
||||
uint lsbs = code_word & 0x5555u;
|
||||
uint msbs = code_word & 0xaaaau;
|
||||
uint msbs_shift = msbs >> 1;
|
||||
msbs |= msbs_shift;
|
||||
|
||||
uint byte_offset =
|
||||
popcount(mask_lo(lsbs, bit_offset)) +
|
||||
popcount(mask_lo(msbs, bit_offset)) +
|
||||
q_bits * block_index + offset;
|
||||
|
||||
uint payload = uint(payload_u8[byte_offset]);
|
||||
|
||||
uint local_control_word = extract_bits(code_word, uint(bit_offset), 2);
|
||||
int decoded_abs[8] = {0, 0, 0, 0, 0, 0, 0, 0};
|
||||
int plane_iterations = int(q_bits + local_control_word);
|
||||
|
||||
for (int q = plane_iterations - 1; q >= 0; q--)
|
||||
{
|
||||
for (int b = 0; b < 8; b++)
|
||||
{
|
||||
int decoded = int(extract_bits(payload, uint(b), 1));
|
||||
decoded_abs[b] = insert_bits(decoded_abs[b], decoded, uint(q), 1);
|
||||
}
|
||||
byte_offset++;
|
||||
payload = uint(payload_u8[byte_offset]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
for (int j = 0; j < 2; j++)
|
||||
{
|
||||
float v = float(decoded_abs[i * 2 + j]);
|
||||
if (v != 0.0f)
|
||||
v += 0.5f;
|
||||
if (j == 0) m.col0[i] = v; else m.col1[i] = v;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
kernel void wavelet_dequant(
|
||||
texture2d_array<float, access::write> uDequantImg [[texture(0)]],
|
||||
const device uint *payload_offsets [[buffer(0)]],
|
||||
const device uint *payload_u32 [[buffer(1)]],
|
||||
constant DequantRegisters ®isters [[buffer(2)]],
|
||||
uint3 wg_id [[threadgroup_position_in_grid]],
|
||||
uint local_index [[thread_index_in_threadgroup]],
|
||||
uint simd_lane [[thread_index_in_simdgroup]],
|
||||
uint simd_group [[simdgroup_index_in_threadgroup]],
|
||||
uint simd_size [[threads_per_simdgroup]])
|
||||
{
|
||||
// STORAGE_MODE 0's three aliased SSBO views over one buffer, as typed pointers.
|
||||
const device ushort *payload_u16 = reinterpret_cast<const device ushort *>(payload_u32);
|
||||
const device uchar *payload_u8 = reinterpret_cast<const device uchar *>(payload_u32);
|
||||
|
||||
threadgroup uint shared_sign_offset;
|
||||
threadgroup uint shared_plane_byte_offsets[16];
|
||||
threadgroup uint shared_sign_scan[128 / 4];
|
||||
|
||||
int block_index_32x32 = int(uint(registers.block_offset_32x32) +
|
||||
wg_id.y * uint(registers.block_stride_32x32) +
|
||||
wg_id.x);
|
||||
|
||||
uint block_local_index = extract_bits(local_index, 0, 3);
|
||||
uint block_x = extract_bits(local_index, 3, 2);
|
||||
uint block_y = extract_bits(local_index, 5, 2);
|
||||
uint linear_block = block_y * 4 + block_x;
|
||||
|
||||
// Each thread individually decodes 8 values (a 4x2 group of its 8x8 block).
|
||||
int2 local_coord = unswizzle8x8(block_local_index << 3);
|
||||
|
||||
int2 coord = int2(wg_id.xy) * 32;
|
||||
coord += 8 * int2(int(block_x), int(block_y));
|
||||
coord += local_coord;
|
||||
|
||||
uint offset_u32 = payload_offsets[block_index_32x32];
|
||||
|
||||
// Missing / lost block: zero coefficients (this is how a partial frame's holes decode).
|
||||
if (offset_u32 == ~0u)
|
||||
{
|
||||
for (int j = 0; j < 2; j++)
|
||||
for (int i = 0; i < 4; i++)
|
||||
uDequantImg.write(float4(0.0f), uint2(coord + int2(i, j)), uint(registers.output_layer));
|
||||
return;
|
||||
}
|
||||
|
||||
uint ballot = payload_u32[offset_u32] & 0xffffu;
|
||||
uint q_code = payload_u32[offset_u32 + 1] & 0xffu;
|
||||
|
||||
// Threads 0..15 (one per 8x8 block, all inside simdgroup 0) prefix-scan the per-block
|
||||
// plane-payload byte costs into shared_plane_byte_offsets, and lane 15 records where the
|
||||
// sign bitstream starts.
|
||||
if (local_index < 16)
|
||||
{
|
||||
uint control_word = 0;
|
||||
uint q_bits = 0;
|
||||
|
||||
if (extract_bits(ballot, local_index, 1) != 0)
|
||||
{
|
||||
uint local_code_offset = popcount(mask_lo(ballot, int(local_index)));
|
||||
control_word = uint(payload_u16[offset_u32 * 2 + 4 + local_code_offset]);
|
||||
q_bits = uint(payload_u8[offset_u32 * 4 + 8 + popcount(ballot) * 2 + local_code_offset]) & 0xfu;
|
||||
}
|
||||
|
||||
uint lsbs = control_word & 0x5555u;
|
||||
uint msbs = control_word & 0xaaaau;
|
||||
uint msbs_shift = msbs >> 1;
|
||||
msbs |= msbs_shift;
|
||||
uint byte_cost = popcount(lsbs) + popcount(msbs) + q_bits * 8;
|
||||
|
||||
uint byte_scan = offset_u32 * 4 + 8 + 3 * popcount(ballot) + simd_prefix_inclusive_sum(byte_cost);
|
||||
if (local_index == 15)
|
||||
shared_sign_offset = 8 * byte_scan;
|
||||
shared_plane_byte_offsets[local_index] = byte_scan - byte_cost;
|
||||
}
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
DecodedPair v;
|
||||
int significant_count;
|
||||
|
||||
if (extract_bits(ballot, linear_block, 1) != 0)
|
||||
{
|
||||
uint local_code_offset = popcount(mask_lo(ballot, int(linear_block)));
|
||||
|
||||
uint control_word = uint(payload_u16[offset_u32 * 2 + 4 + local_code_offset]);
|
||||
uint control_word2 = uint(payload_u8[offset_u32 * 4 + 8 + popcount(ballot) * 2 + local_code_offset]);
|
||||
|
||||
v = decode_payload(payload_u8, control_word, control_word2 & 0xfu,
|
||||
shared_plane_byte_offsets[linear_block], block_local_index);
|
||||
|
||||
significant_count = 0;
|
||||
for (int j = 0; j < 2; j++)
|
||||
for (int i = 0; i < 4; i++)
|
||||
significant_count += int(((j == 0) ? v.col0[i] : v.col1[i]) != 0.0f);
|
||||
|
||||
float q = decode_quant(q_code);
|
||||
float inv_scale = q * decode_quant_scale(extract_bits(control_word2, uint(QUANT_SCALE_OFFSET - 16), uint(QUANT_SCALE_BITS)));
|
||||
|
||||
v.col0 *= inv_scale;
|
||||
v.col1 *= inv_scale;
|
||||
}
|
||||
else
|
||||
{
|
||||
v.col0 = float4(0.0f);
|
||||
v.col1 = float4(0.0f);
|
||||
significant_count = 0;
|
||||
}
|
||||
|
||||
// Cross-threadgroup scan of significant-coefficient counts → each thread's first sign-bit
|
||||
// position. Apple simdgroups are >= 16 wide, so this is the GLSL's `SubgroupSize <= 32`
|
||||
// branch; the shuffle/LDS fallbacks are unnecessary.
|
||||
int significant_scan = int(simd_prefix_inclusive_sum(uint(significant_count)));
|
||||
if (simd_lane == simd_size - 1)
|
||||
shared_sign_scan[simd_group] = uint(significant_scan);
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
uint num_simdgroups = (128 + simd_size - 1) / simd_size;
|
||||
if (local_index < num_simdgroups)
|
||||
shared_sign_scan[local_index] = simd_prefix_inclusive_sum(shared_sign_scan[local_index]);
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
uint sign_offset = shared_sign_offset + uint(significant_scan - significant_count);
|
||||
if (simd_group != 0)
|
||||
sign_offset += shared_sign_scan[simd_group - 1];
|
||||
|
||||
// Load 64 bits of sign stream and bit-align (may read one word past the payload — the
|
||||
// buffer carries a 16-byte zeroed guard tail for exactly this).
|
||||
uint sign_word = payload_u32[sign_offset / 32 + 0];
|
||||
uint sign_word_upper = payload_u32[sign_offset / 32 + 1];
|
||||
|
||||
uint masked_sign_offset = sign_offset & 31u;
|
||||
if (masked_sign_offset != 0)
|
||||
{
|
||||
sign_word >>= masked_sign_offset;
|
||||
sign_word |= sign_word_upper << (32 - masked_sign_offset);
|
||||
}
|
||||
|
||||
int sign_counter = 0;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
for (int j = 0; j < 2; j++)
|
||||
{
|
||||
float val = (j == 0) ? v.col0[i] : v.col1[i];
|
||||
if (val != 0.0f)
|
||||
{
|
||||
val *= 1.0f - 2.0f * float(extract_bits(sign_word, uint(sign_counter), 1));
|
||||
sign_counter++;
|
||||
if (j == 0) v.col0[i] = val; else v.col1[i] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int j = 0; j < 2; j++)
|
||||
for (int i = 0; i < 4; i++)
|
||||
uDequantImg.write(float4((j == 0) ? v.col0[i] : v.col1[i]),
|
||||
uint2(coord + int2(i, j)), uint(registers.output_layer));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// idwt — inverse CDF 9/7; one 64-thread threadgroup reconstructs one 32x32 output tile from the
|
||||
// four half-res band layers (LL/HL/LH/HH), with a 4-sample mirror apron. The caller passes the
|
||||
// band-image resolution TRANSPOSED (the kernel transposes on load and store, so one kernel does
|
||||
// both the horizontal and vertical passes).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
constant bool DCShift [[function_constant(0)]];
|
||||
|
||||
struct IdwtRegisters {
|
||||
int2 resolution;
|
||||
float2 inv_resolution;
|
||||
};
|
||||
|
||||
constant int APRON = 4;
|
||||
constant int APRON_HALF = APRON / 2;
|
||||
constant int BLOCK_SIZE = 32;
|
||||
constant int BLOCK_SIZE_HALF = BLOCK_SIZE >> 1;
|
||||
|
||||
// CDF 9/7 lifting constants (dwt_common.h).
|
||||
constant float ALPHA = -1.586134342059924f;
|
||||
constant float BETA = -0.052980118572961f;
|
||||
constant float GAMMA = 0.882911075530934f;
|
||||
constant float DELTA = 0.443506852043971f;
|
||||
constant float K = 1.230174104914001f;
|
||||
constant float inv_K = 1.0f / 1.230174104914001f;
|
||||
|
||||
constant int SHARED_ROWS = (BLOCK_SIZE + 2 * APRON) / 2; // 20
|
||||
constant int SHARED_COLS = (BLOCK_SIZE + 2 * APRON) + 1; // 41 (+1 avoids bank conflicts)
|
||||
|
||||
static inline float2 load_shared(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], int y, int x)
|
||||
{
|
||||
return float2(blk[y][x]);
|
||||
}
|
||||
|
||||
static inline void store_shared(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], int y, int x, float2 v)
|
||||
{
|
||||
blk[y][x] = half2(v);
|
||||
}
|
||||
|
||||
// Even/odd-phase coordinate nudge so mirrored-repeat gather reproduces JPEG2000 whole-sample
|
||||
// mirroring at the image borders, then transpose (uv.yx) on load.
|
||||
static inline float2 generate_mirror_uv(int2 coord, bool even_x, bool even_y,
|
||||
int2 resolution, float2 inv_resolution)
|
||||
{
|
||||
coord.x -= int(even_x && coord.x < 0);
|
||||
coord.y -= int(even_y && coord.y < 0);
|
||||
coord += 1;
|
||||
coord.x += int(!even_x && coord.x >= resolution.x);
|
||||
coord.y += int(!even_y && coord.y >= resolution.y);
|
||||
float2 uv = float2(coord) * inv_resolution;
|
||||
return uv.yx;
|
||||
}
|
||||
|
||||
static inline void write_shared_4x4(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||
int2 coord, float4 t0, float4 t1, float4 t2, float4 t3)
|
||||
{
|
||||
store_shared(blk, coord.y + 0, 2 * coord.x + 0, float2(t0.x, t2.x));
|
||||
store_shared(blk, coord.y + 0, 2 * coord.x + 1, float2(t1.x, t3.x));
|
||||
store_shared(blk, coord.y + 0, 2 * coord.x + 2, float2(t0.y, t2.y));
|
||||
store_shared(blk, coord.y + 0, 2 * coord.x + 3, float2(t1.y, t3.y));
|
||||
store_shared(blk, coord.y + 1, 2 * coord.x + 0, float2(t0.z, t2.z));
|
||||
store_shared(blk, coord.y + 1, 2 * coord.x + 1, float2(t1.z, t3.z));
|
||||
store_shared(blk, coord.y + 1, 2 * coord.x + 2, float2(t0.w, t2.w));
|
||||
store_shared(blk, coord.y + 1, 2 * coord.x + 3, float2(t1.w, t3.w));
|
||||
}
|
||||
|
||||
// textureGather(...).wxzy — Metal's gather returns the same counter-clockwise-from-(i0,j1)
|
||||
// component order as Vulkan, so the reorder is identical.
|
||||
static inline float4 gather_layer(texture2d_array<float, access::sample> tex, sampler smp,
|
||||
float2 uv, uint layer)
|
||||
{
|
||||
float4 g = tex.gather(smp, uv, layer);
|
||||
return float4(g.w, g.x, g.z, g.y);
|
||||
}
|
||||
|
||||
static void load_image_with_apron(texture2d_array<float, access::sample> tex, sampler smp,
|
||||
threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||
uint local_index, uint2 wg_id,
|
||||
int2 resolution, float2 inv_resolution)
|
||||
{
|
||||
int2 base_coord = int2(wg_id) * BLOCK_SIZE_HALF - APRON_HALF;
|
||||
int2 local_coord0 = 2 * unswizzle8x8(local_index);
|
||||
int2 coord0 = base_coord + local_coord0;
|
||||
|
||||
// Band layers gathered in 0/2/1/3 order (LL/LH/HL/HH interleave for the 2x2 scatter).
|
||||
float4 texels0 = gather_layer(tex, smp, generate_mirror_uv(coord0, true, true, resolution, inv_resolution), 0);
|
||||
float4 texels1 = gather_layer(tex, smp, generate_mirror_uv(coord0, false, true, resolution, inv_resolution), 2);
|
||||
float4 texels2 = gather_layer(tex, smp, generate_mirror_uv(coord0, true, false, resolution, inv_resolution), 1);
|
||||
float4 texels3 = gather_layer(tex, smp, generate_mirror_uv(coord0, false, false, resolution, inv_resolution), 3);
|
||||
write_shared_4x4(blk, local_coord0, texels0, texels1, texels2, texels3);
|
||||
|
||||
int2 local_coord_horiz = int2(BLOCK_SIZE_HALF + 2 * int(local_index % 2u), 2 * int(local_index / 2u));
|
||||
if (local_coord_horiz.y < BLOCK_SIZE_HALF + 2 * APRON_HALF)
|
||||
{
|
||||
int2 c = base_coord + local_coord_horiz;
|
||||
texels0 = gather_layer(tex, smp, generate_mirror_uv(c, true, true, resolution, inv_resolution), 0);
|
||||
texels1 = gather_layer(tex, smp, generate_mirror_uv(c, false, true, resolution, inv_resolution), 2);
|
||||
texels2 = gather_layer(tex, smp, generate_mirror_uv(c, true, false, resolution, inv_resolution), 1);
|
||||
texels3 = gather_layer(tex, smp, generate_mirror_uv(c, false, false, resolution, inv_resolution), 3);
|
||||
write_shared_4x4(blk, local_coord_horiz, texels0, texels1, texels2, texels3);
|
||||
}
|
||||
|
||||
int2 local_coord_vert = local_coord_horiz.yx;
|
||||
if (local_coord_vert.x < BLOCK_SIZE_HALF)
|
||||
{
|
||||
int2 c = base_coord + local_coord_vert;
|
||||
texels0 = gather_layer(tex, smp, generate_mirror_uv(c, true, true, resolution, inv_resolution), 0);
|
||||
texels1 = gather_layer(tex, smp, generate_mirror_uv(c, false, true, resolution, inv_resolution), 2);
|
||||
texels2 = gather_layer(tex, smp, generate_mirror_uv(c, true, false, resolution, inv_resolution), 1);
|
||||
texels3 = gather_layer(tex, smp, generate_mirror_uv(c, false, false, resolution, inv_resolution), 3);
|
||||
write_shared_4x4(blk, local_coord_vert, texels0, texels1, texels2, texels3);
|
||||
}
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
}
|
||||
|
||||
static void inverse_transform8x2(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], uint local_index)
|
||||
{
|
||||
const int SIZE = 8;
|
||||
const int PADDED_SIZE = SIZE + 2 * APRON;
|
||||
const int PADDED_SIZE_HALF = PADDED_SIZE / 2;
|
||||
float2 values[PADDED_SIZE];
|
||||
|
||||
int2 local_coord = int2(8 * int(local_index % 4u), int(local_index / 4u));
|
||||
|
||||
for (int i = 0; i < PADDED_SIZE; i += 2)
|
||||
{
|
||||
float2 v0 = load_shared(blk, local_coord.y, local_coord.x + i + 0);
|
||||
float2 v1 = load_shared(blk, local_coord.y, local_coord.x + i + 1);
|
||||
values[i + 0] = v0 * K;
|
||||
values[i + 1] = v1 * inv_K;
|
||||
}
|
||||
|
||||
// CDF 9/7 inverse lifting steps.
|
||||
for (int i = 2; i < PADDED_SIZE - 1; i += 2)
|
||||
values[i] -= DELTA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 3; i < PADDED_SIZE - 2; i += 2)
|
||||
values[i] -= GAMMA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 4; i < PADDED_SIZE - 3; i += 2)
|
||||
values[i] -= BETA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 5; i < PADDED_SIZE - 4; i += 2)
|
||||
values[i] -= ALPHA * (values[i - 1] + values[i + 1]);
|
||||
|
||||
// Avoid WAR hazard.
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
for (int i = APRON_HALF; i < PADDED_SIZE_HALF - APRON_HALF; i++)
|
||||
{
|
||||
float2 a = values[2 * i + 0];
|
||||
float2 b = values[2 * i + 1];
|
||||
|
||||
// Transpose the 2x2 block, transpose write.
|
||||
float2 t0 = float2(a.x, b.x);
|
||||
float2 t1 = float2(a.y, b.y);
|
||||
|
||||
int y_coord = (local_coord.x >> 1) + (i - APRON_HALF);
|
||||
store_shared(blk, y_coord, 2 * local_coord.y + 0, t0);
|
||||
store_shared(blk, y_coord, 2 * local_coord.y + 1, t1);
|
||||
}
|
||||
}
|
||||
|
||||
static void inverse_transform4x2(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||
uint local_index, bool active_lane, int y_offset)
|
||||
{
|
||||
const int SIZE = 4;
|
||||
const int PADDED_SIZE = SIZE + 2 * APRON;
|
||||
const int PADDED_SIZE_HALF = PADDED_SIZE / 2;
|
||||
float2 values[PADDED_SIZE];
|
||||
|
||||
int2 local_coord = int2(4 * int(local_index % 8u), int(local_index / 8u) + y_offset);
|
||||
|
||||
if (active_lane)
|
||||
{
|
||||
for (int i = 0; i < PADDED_SIZE; i += 2)
|
||||
{
|
||||
float2 v0 = load_shared(blk, local_coord.y, local_coord.x + i + 0);
|
||||
float2 v1 = load_shared(blk, local_coord.y, local_coord.x + i + 1);
|
||||
values[i + 0] = v0 * K;
|
||||
values[i + 1] = v1 * inv_K;
|
||||
}
|
||||
|
||||
for (int i = 2; i < PADDED_SIZE - 1; i += 2)
|
||||
values[i] -= DELTA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 3; i < PADDED_SIZE - 2; i += 2)
|
||||
values[i] -= GAMMA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 4; i < PADDED_SIZE - 3; i += 2)
|
||||
values[i] -= BETA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 5; i < PADDED_SIZE - 4; i += 2)
|
||||
values[i] -= ALPHA * (values[i - 1] + values[i + 1]);
|
||||
}
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
if (active_lane)
|
||||
{
|
||||
for (int i = APRON_HALF; i < PADDED_SIZE_HALF - APRON_HALF; i++)
|
||||
{
|
||||
float2 a = values[2 * i + 0];
|
||||
float2 b = values[2 * i + 1];
|
||||
|
||||
float2 t0 = float2(a.x, b.x);
|
||||
float2 t1 = float2(a.y, b.y);
|
||||
|
||||
int y_coord = (local_coord.x >> 1) + (i - APRON_HALF);
|
||||
store_shared(blk, y_coord, 2 * local_coord.y + 0, t0);
|
||||
store_shared(blk, y_coord, 2 * local_coord.y + 1, t1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kernel void idwt(
|
||||
texture2d_array<float, access::sample> uTexture [[texture(0)]],
|
||||
texture2d<float, access::write> uOutput [[texture(1)]],
|
||||
sampler uSampler [[sampler(0)]],
|
||||
constant IdwtRegisters ®isters [[buffer(0)]],
|
||||
uint3 wg_id [[threadgroup_position_in_grid]],
|
||||
uint local_index [[thread_index_in_threadgroup]])
|
||||
{
|
||||
threadgroup half2 shared_block[SHARED_ROWS][SHARED_COLS];
|
||||
|
||||
load_image_with_apron(uTexture, uSampler, shared_block, local_index, wg_id.xy,
|
||||
registers.resolution, registers.inv_resolution);
|
||||
|
||||
// Horizontal transform.
|
||||
inverse_transform8x2(shared_block, local_index);
|
||||
|
||||
// Also need to transform the apron.
|
||||
inverse_transform4x2(shared_block, local_index, local_index < 32, BLOCK_SIZE_HALF);
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
// Vertical transform.
|
||||
inverse_transform8x2(shared_block, local_index);
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
int2 local_coord = unswizzle8x8(local_index);
|
||||
|
||||
for (int y = local_coord.y; y < BLOCK_SIZE_HALF; y += 8)
|
||||
{
|
||||
for (int x = local_coord.x; x < BLOCK_SIZE; x += 8)
|
||||
{
|
||||
float2 v = load_shared(shared_block, y, x);
|
||||
if (DCShift)
|
||||
v += 0.5f;
|
||||
// Transposed store (wg_id.yx) — undoes the transpose-on-load; out-of-range writes
|
||||
// at the aligned-size overhang are dropped by Metal (matching the Vulkan behavior).
|
||||
int2 out0 = int2(2 * y + 0, x) + BLOCK_SIZE * int2(int(wg_id.y), int(wg_id.x));
|
||||
int2 out1 = int2(2 * y + 1, x) + BLOCK_SIZE * int2(int(wg_id.y), int(wg_id.x));
|
||||
uOutput.write(float4(v.x), uint2(out0));
|
||||
uOutput.write(float4(v.y), uint2(out1));
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -37,6 +37,7 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Metal
|
||||
import QuartzCore
|
||||
|
||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||
@@ -249,6 +250,28 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's
|
||||
/// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in
|
||||
/// `init`, before the connection exists) can capture it, then `start` binds the live connection +
|
||||
/// the arming flag once known — the same "reference captured in init, configured in start" shape as
|
||||
/// `recovery`/`gate`. `record` runs on VideoToolbox's callback thread; `bind` runs once on the main
|
||||
/// thread before the pump feeds the first AU, so the plain fields are safe (set-once, then read).
|
||||
private final class DecodeReport: @unchecked Sendable {
|
||||
private weak var connection: PunktfunkConnection?
|
||||
private var enabled = false
|
||||
func bind(_ connection: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
self.enabled = connection.wantsDecodeLatency()
|
||||
}
|
||||
/// Report received→decoded for one frame, in µs. Both stamps are client `CLOCK_REALTIME`
|
||||
/// (no skew). Skips when the controller isn't armed, so it's free to call on every decode.
|
||||
func record(receivedNs: Int64, decodedNs: Int64) {
|
||||
guard enabled, let c = connection else { return }
|
||||
let us = (decodedNs - receivedNs) / 1000
|
||||
if us > 0 { c.reportDecodeUs(UInt32(min(us, Int64(UInt32.max)))) }
|
||||
}
|
||||
}
|
||||
|
||||
public final class Stage2Pipeline {
|
||||
private let ring = ReadyRing()
|
||||
private let presenter: MetalVideoPresenter
|
||||
@@ -257,8 +280,12 @@ public final class Stage2Pipeline {
|
||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||
private let pacing: PresentPacing
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let recovery = KeyframeRecovery()
|
||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||
/// binds the live connection + arming flag (see DecodeReport).
|
||||
private let decodeReport = DecodeReport()
|
||||
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
|
||||
/// `start` reseeds it to the live connection's drop count. Captured by the decoder callbacks
|
||||
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||
@@ -306,11 +333,13 @@ public final class Stage2Pipeline {
|
||||
self.presenter = presenter
|
||||
self.pacing = pacing
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
let renderSignal = renderSignal
|
||||
let gate = gate
|
||||
let decodeReport = decodeReport
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { frame in
|
||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||
@@ -318,6 +347,10 @@ public final class Stage2Pipeline {
|
||||
// including ones the re-anchor gate withholds or the newest-wins ring drops.
|
||||
decodeMeter?.record(
|
||||
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
||||
// Same interval, reported to the core bitrate controller so Automatic caps at this
|
||||
// device's real decode limit instead of the network link ceiling. Every decoded
|
||||
// frame (not just presented ones), so a newest-wins drop can't hide the backlog.
|
||||
decodeReport.record(receivedNs: frame.receivedNs, decodedNs: frame.decodedNs)
|
||||
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
|
||||
// garbage VideoToolbox returns Ok for a reference-missing delta) — don't submit it,
|
||||
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
|
||||
@@ -346,6 +379,7 @@ public final class Stage2Pipeline {
|
||||
) {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
|
||||
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
|
||||
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
||||
|
||||
@@ -362,7 +396,21 @@ public final class Stage2Pipeline {
|
||||
let presenter = presenter
|
||||
let pumpStopped = pumpStopped
|
||||
let reanchorGate = gate
|
||||
let thread = Thread {
|
||||
// PyroWave rides a different decode half: no CMFormatDescription/VideoToolbox machinery
|
||||
// (a wavelet AU has no parameter sets), no keyframe recovery or re-anchor freeze (the
|
||||
// stream is all-intra and Phase 4's partial delivery WANTS lossy frames on glass as
|
||||
// localized blur, not a freeze). The ready ring, render thread, pacing and meters are
|
||||
// shared unchanged.
|
||||
let thread: Thread
|
||||
if connection.videoCodec == .pyrowave {
|
||||
thread = Self.makePyroWavePump(
|
||||
connection: connection, token: token, pumpStopped: pumpStopped,
|
||||
ring: ring, renderSignal: renderSignal,
|
||||
device: presenter.metalDevice, queue: presenter.metalQueue,
|
||||
decodeMeter: decodeMeter,
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||
} else {
|
||||
thread = Thread {
|
||||
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||
var format: CMVideoFormatDescription?
|
||||
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
|
||||
@@ -445,6 +493,7 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
thread.name = "punktfunk-stage2-pump"
|
||||
thread.qualityOfService = .userInteractive
|
||||
@@ -504,9 +553,7 @@ public final class Stage2Pipeline {
|
||||
let presentAt = vsyncEnabled
|
||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||
let renderStarted = CACurrentMediaTime()
|
||||
let rendered = presenter.render(
|
||||
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
||||
) { presentedNs in
|
||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||
if let gate {
|
||||
@@ -525,6 +572,18 @@ public final class Stage2Pipeline {
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
debugStats?.presented(atNs: presentedNs)
|
||||
}
|
||||
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
||||
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||
let rendered: Bool
|
||||
switch frame.image {
|
||||
case .video(let pixelBuffer, let isHDR):
|
||||
rendered = presenter.render(
|
||||
pixelBuffer, isHDR: isHDR, presentAtMediaTime: presentAt,
|
||||
onPresented: onGlass)
|
||||
case .planar(let planes):
|
||||
rendered = presenter.renderPlanar(
|
||||
planes, presentAtMediaTime: presentAt, onPresented: onGlass)
|
||||
}
|
||||
debugStats?.renderReturned(
|
||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||
if !rendered {
|
||||
@@ -592,6 +651,93 @@ public final class Stage2Pipeline {
|
||||
renderSignal.signal() // wake the render thread so it can observe the stop and exit
|
||||
}
|
||||
|
||||
/// The PyroWave pump: AUs go straight into the Metal wavelet decoder (no VideoToolbox, no
|
||||
/// format descriptions), decoded planes ride the same ready ring / render thread. All-intra
|
||||
/// stream, so none of the VT pump's recovery machinery applies: keyframe/RFI requests are
|
||||
/// silenced host-side for this codec, and a lossy (partial-delivery) frame is MEANT to
|
||||
/// present as localized blur — never a freeze. Static + capture-by-parameter for the same
|
||||
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||
private static func makePyroWavePump(
|
||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
||||
device: MTLDevice, queue: MTLCommandQueue,
|
||||
decodeMeter: LatencyMeter?,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
) -> Thread {
|
||||
// The chunk-aligned parse window = the session's negotiated shard payload (Welcome);
|
||||
// the 64-byte floor mirrors the Rust client's guard against a nonsense value.
|
||||
let windowSize = max(64, Int(connection.shardPayload))
|
||||
return Thread {
|
||||
defer { pumpStopped.signal() }
|
||||
// Compiles the two compute kernels on the session's first frames' thread — ~tens of
|
||||
// ms, once per session. Failure = this device can't run the negotiated codec (the
|
||||
// advertisement probe should have prevented this); end the session cleanly.
|
||||
guard let decoder = MetalWaveletDecoder(device: device, queue: queue) else {
|
||||
if !token.isStopped { onSessionEnd?() }
|
||||
return
|
||||
}
|
||||
// Newest decoded frame index — a late partial (the reassembler's 30 ms fuse can
|
||||
// deliver one behind a newer complete frame) must not travel back in time.
|
||||
var newestIndex: UInt32?
|
||||
var lastDims: (w: Int, h: Int)?
|
||||
var alive = true
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
onFrame?(au)
|
||||
if let newest = newestIndex,
|
||||
Int32(bitPattern: au.frameIndex &- newest) <= 0 {
|
||||
return true // stale (or duplicate) frame — skip
|
||||
}
|
||||
guard !token.isStopped else { return true }
|
||||
let chunkAligned =
|
||||
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||
let ptsNs = au.ptsNs
|
||||
let receivedNs = au.receivedNs
|
||||
let flags = au.flags
|
||||
let submitted = decoder.decode(
|
||||
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||
) { planes in
|
||||
// Metal completed-handler thread — stamp + enqueue, don't block
|
||||
// (the exact contract of the VT output callback).
|
||||
guard let planes else { return }
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let decodedNs =
|
||||
Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
decodeMeter?.record(
|
||||
ptsNs: UInt64(receivedNs), atNs: decodedNs, offsetNs: 0)
|
||||
ring.submit(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
image: .planar(planes), flags: flags))
|
||||
renderSignal.signal()
|
||||
}
|
||||
if submitted {
|
||||
newestIndex = au.frameIndex
|
||||
// Decoded-size changes come from the SOF dims (this is also how a
|
||||
// mid-stream Reconfigure lands here) — report like the VT pump.
|
||||
if let size = decoder.decodedSize,
|
||||
lastDims?.w != size.width || lastDims?.h != size.height {
|
||||
lastDims = (size.width, size.height)
|
||||
onDecodedSize?(size.width, size.height)
|
||||
}
|
||||
}
|
||||
// A dropped AU (malformed / SOF lost / too few blocks) is just skipped:
|
||||
// every PyroWave frame is independently decodable, the next one heals.
|
||||
return true
|
||||
} catch {
|
||||
if !token.isStopped { onSessionEnd?() }
|
||||
return false // session closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
||||
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
||||
/// present time (when the frame is actually on glass), not the moment we drew.
|
||||
|
||||
@@ -12,7 +12,23 @@ import CoreVideo
|
||||
import Foundation
|
||||
import VideoToolbox
|
||||
|
||||
/// One decoded frame waiting to be presented. Owns a retained `CVPixelBuffer` until shown.
|
||||
/// A decoded frame's pixels — which present path they take. VideoToolbox codecs deliver a
|
||||
/// biplanar `CVPixelBuffer` (NV12/P010/444v/x444); the PyroWave Metal decoder delivers three
|
||||
/// separate R8 plane textures straight off its compute pass (there is no CVPixelBuffer — the
|
||||
/// planes never leave the GPU).
|
||||
public enum ReadyImage: @unchecked Sendable {
|
||||
/// 8-bit NV12 / 4:4:4 biplanar (SDR) or 10-bit P010 / x444 (HDR), Metal-compatible.
|
||||
/// `isHDR` = the stream is BT.2020 PQ and the presenter must configure EDR output.
|
||||
case video(CVPixelBuffer, isHDR: Bool)
|
||||
#if canImport(Metal)
|
||||
/// PyroWave planar output (Y full-res + Cb/Cr half-res, 8-bit SDR) with its precomputed
|
||||
/// CSC rows — presented by `MetalVideoPresenter.renderPlanar`.
|
||||
case planar(WaveletPlanes)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One decoded frame waiting to be presented. Owns its image (a retained `CVPixelBuffer`, or
|
||||
/// the PyroWave ring textures) until shown.
|
||||
public struct ReadyFrame: @unchecked Sendable {
|
||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||
public let ptsNs: UInt64
|
||||
@@ -22,15 +38,26 @@ public struct ReadyFrame: @unchecked Sendable {
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||
public let decodedNs: Int64
|
||||
/// The decoded image — 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible.
|
||||
public let pixelBuffer: CVPixelBuffer
|
||||
/// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must
|
||||
/// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format.
|
||||
public let isHDR: Bool
|
||||
/// The decoded image and which present path it takes.
|
||||
public let image: ReadyImage
|
||||
/// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame
|
||||
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
|
||||
/// mark) at present time — the async decode callback has no other access to it. 0 when unknown.
|
||||
public let flags: UInt32
|
||||
|
||||
/// The VideoToolbox path's buffer; nil for a PyroWave planar frame. (Kept as the accessor
|
||||
/// the decode round-trip tests assert against.)
|
||||
public var pixelBuffer: CVPixelBuffer? {
|
||||
if case .video(let buffer, _) = image { return buffer }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Whether this frame presents on the HDR path. PyroWave planar frames are 8-bit SDR by
|
||||
/// contract.
|
||||
public var isHDR: Bool {
|
||||
if case .video(_, let hdr) = image { return hdr }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame context threaded through the VideoToolbox frame refcon: the AU's receipt instant (for
|
||||
@@ -286,6 +313,6 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
onDecoded(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
pixelBuffer: imageBuffer, isHDR: isHDR, flags: flags))
|
||||
image: .video(imageBuffer, isHDR: isHDR), flags: flags))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,6 +698,7 @@ final class StreamLayerUIView: UIView {
|
||||
let mouse = TouchMouse()
|
||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
|
||||
return mouse
|
||||
}()
|
||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||
@@ -708,6 +709,22 @@ final class StreamLayerUIView: UIView {
|
||||
func resetTouchInput() {
|
||||
touchMouse.reset()
|
||||
fingerRoute = nil
|
||||
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
|
||||
}
|
||||
|
||||
/// The soft keyboard is keyed off first-responder status: the three-finger swipe
|
||||
/// (TouchMouse) summons/dismisses it here, and the UIKeyInput conformance below turns
|
||||
/// what it types into wire key events. Also the reason `canBecomeFirstResponder` is true
|
||||
/// on iOS (tvOS anchors the responder chain on the CONTROLLER instead — see
|
||||
/// StreamViewController.viewDidAppear).
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
func setSoftKeyboardVisible(_ visible: Bool) {
|
||||
if visible {
|
||||
becomeFirstResponder()
|
||||
} else if isFirstResponder {
|
||||
resignFirstResponder()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -879,4 +896,46 @@ final class StreamLayerUIView: UIView {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// The soft keyboard's output → wire key events. UIKeyInput is deliberately minimal (no
|
||||
// UITextInput): the stream needs keystrokes, not an editing buffer — insertions map through
|
||||
// `SoftKeyMap` to US-positional VKs (with a VK_LSHIFT wrap for shifted characters) and
|
||||
// characters outside the map (emoji, non-Latin scripts) are dropped, matching the wire's VK
|
||||
// contract. Events ride the same `onTouchEvent` path as the touch-driven mouse, so they're
|
||||
// gated on captureEnabled with everything else and can't leak past a trust prompt.
|
||||
extension StreamLayerUIView: UIKeyInput {
|
||||
// Keep the IME literal — no autocorrect/smart substitutions; a remote desktop is not prose,
|
||||
// and the host does its own text handling.
|
||||
var autocorrectionType: UITextAutocorrectionType { get { .no } set {} }
|
||||
var autocapitalizationType: UITextAutocapitalizationType { get { .none } set {} }
|
||||
var spellCheckingType: UITextSpellCheckingType { get { .no } set {} }
|
||||
var smartQuotesType: UITextSmartQuotesType { get { .no } set {} }
|
||||
var smartDashesType: UITextSmartDashesType { get { .no } set {} }
|
||||
var smartInsertDeleteType: UITextSmartInsertDeleteType { get { .no } set {} }
|
||||
var keyboardType: UIKeyboardType { get { .asciiCapable } set {} }
|
||||
|
||||
var hasText: Bool { false }
|
||||
|
||||
func insertText(_ text: String) {
|
||||
// A hardware keyboard's presses reach the host through GCKeyboard AND arrive here as
|
||||
// UIKeyInput insertions while we're first responder — forwarding both would double
|
||||
// every character, so the HID path owns keys whenever a hardware keyboard is attached.
|
||||
guard GCKeyboard.coalesced == nil else { return }
|
||||
for ch in text {
|
||||
guard let key = SoftKeyMap.vk(for: ch) else { continue }
|
||||
if key.shift { onTouchEvent?(.key(0xA0, down: true)) } // VK_LSHIFT
|
||||
onTouchEvent?(.key(key.vk, down: true))
|
||||
onTouchEvent?(.key(key.vk, down: false))
|
||||
if key.shift { onTouchEvent?(.key(0xA0, down: false)) }
|
||||
}
|
||||
}
|
||||
|
||||
func deleteBackward() {
|
||||
guard GCKeyboard.coalesced == nil else { return } // see insertText
|
||||
onTouchEvent?(.key(0x08, down: true)) // VK_BACK
|
||||
onTouchEvent?(.key(0x08, down: false))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -237,10 +237,11 @@ final class AV1Tests: XCTestCase {
|
||||
let ready = try XCTUnwrap(frame)
|
||||
XCTAssertEqual(ready.ptsNs, 42_000_000)
|
||||
XCTAssertFalse(ready.isHDR)
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 320)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 180)
|
||||
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 320)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 180)
|
||||
XCTAssertEqual(
|
||||
CVPixelBufferGetPixelFormatType(ready.pixelBuffer),
|
||||
CVPixelBufferGetPixelFormatType(buffer),
|
||||
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12")
|
||||
decoder.reset()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// PyroWave Metal decoder tests — two layers:
|
||||
//
|
||||
// 1. Bitstream/window-walk parser tests (pure CPU): hand-crafted packet streams assert the
|
||||
// exact wire semantics of pyrowave_decoder.cpp's push_packet walk + the Phase-4
|
||||
// chunk-aligned framing (4-byte window prefix, FRAG chains, zeroed missing shards).
|
||||
//
|
||||
// 2. Golden-frame PSNR tests (Metal GPU): host-encoded fixtures (crates/punktfunk-host
|
||||
// encode/linux/pyrowave.rs `pyrowave_dump_golden`, run on a Vulkan box) decoded by the
|
||||
// Metal port and PSNR-matched against upstream's own decoder output. Float wavelet math is
|
||||
// not bit-exact across implementations (upstream ships precision variants), so the gate is
|
||||
// PSNR, not equality. This is the §4.7 validation oracle for the hand-ported kernels —
|
||||
// the gather/mirror addressing in idwt is the spot most likely to drift.
|
||||
|
||||
#if canImport(Metal)
|
||||
import Metal
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class PyroWaveParserTests: XCTestCase {
|
||||
// 256x144 → aligned 256x160; block space identical to the committed fixtures.
|
||||
private let width = 256
|
||||
private let height = 144
|
||||
|
||||
/// A BitstreamSequenceHeader (START_OF_FRAME) for `width`x`height`, 4:2:0 BT.709 limited.
|
||||
private func sof(totalBlocks: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||||
let word0 =
|
||||
UInt32(width - 1) | (UInt32(height - 1) << 14) | (sequence << 28) | (1 << 31)
|
||||
// code=0 (SOF), chroma=0 (420), primaries/trc/matrix=0 (BT.709), range=1 (LIMITED),
|
||||
// siting=0.
|
||||
let word1 = UInt32(totalBlocks) | (1 << 30)
|
||||
return le32(word0) + le32(word1)
|
||||
}
|
||||
|
||||
/// A minimal coefficient packet: ballot=0 (all 8x8 blocks empty — legal and decodable),
|
||||
/// payload_words=2 (header only).
|
||||
private func packet(blockIndex: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||||
let word0 = UInt32(0) | (2 << 16) | (sequence << 28)
|
||||
let word1 = UInt32(0) | (UInt32(blockIndex) << 8)
|
||||
return le32(word0) + le32(word1)
|
||||
}
|
||||
|
||||
private func le32(_ v: UInt32) -> [UInt8] {
|
||||
[UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8(v >> 24)]
|
||||
}
|
||||
|
||||
/// Wrap bodies into `windowSize`-sized windows with the 4-byte used/kind prefix.
|
||||
private func window(_ body: [UInt8], kind: UInt16, size: Int) -> [UInt8] {
|
||||
precondition(body.count + 4 <= size)
|
||||
var out = [UInt8(body.count & 0xff), UInt8(body.count >> 8)]
|
||||
out += [UInt8(kind & 0xff), UInt8(kind >> 8)]
|
||||
out += body
|
||||
out += [UInt8](repeating: 0, count: size - out.count)
|
||||
return out
|
||||
}
|
||||
|
||||
func testLayoutMatchesUpstreamBlockSpace() {
|
||||
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
||||
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
||||
let layout = WaveletLayout(width: width, height: height)
|
||||
XCTAssertEqual(layout.alignedWidth, 256)
|
||||
XCTAssertEqual(layout.alignedHeight, 160)
|
||||
XCTAssertEqual(layout.levelWidth(0), 128)
|
||||
XCTAssertEqual(layout.levelHeight(0), 80)
|
||||
XCTAssertEqual(layout.levelWidth(4), 8)
|
||||
XCTAssertEqual(layout.levelHeight(4), 5)
|
||||
// Hand-summed: L4 (8x5 → 1 block) × 3 comps × 4 bands = 12; L3 (16x10 → 1) × 9 = 9;
|
||||
// L2 (32x20 → 1) × 9 = 9; L1 (64x40 → 2x2=4... ) — trust the invariant instead:
|
||||
// every band's count is ceil(w8/4)*ceil(h8/4) and the total is their sum.
|
||||
var expected = 0
|
||||
for level in stride(from: 4, through: 0, by: -1) {
|
||||
let w8 = (layout.levelWidth(level) + 7) / 8
|
||||
let h8 = (layout.levelHeight(level) + 7) / 8
|
||||
let per = ((w8 + 3) / 4) * ((h8 + 3) / 4)
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 { continue }
|
||||
expected += per * (level == 4 ? 4 : 3)
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(layout.blockCount32, expected)
|
||||
// The finest luma level's stride is its 32-block row width.
|
||||
XCTAssertEqual(layout.blockMeta[0][0][1].stride, (128 + 31) / 32)
|
||||
// Level-0 chroma is not coded in 4:2:0.
|
||||
XCTAssertEqual(layout.blockMeta[1][0][1].offset, -1)
|
||||
}
|
||||
|
||||
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||||
let layout = WaveletLayout(width: width, height: height)
|
||||
var au = sof(totalBlocks: 4)
|
||||
au += packet(blockIndex: 0)
|
||||
au += packet(blockIndex: 3)
|
||||
au += packet(blockIndex: 3) // duplicate — first wins, not double-counted
|
||||
au += packet(blockIndex: layout.blockCount32 - 1)
|
||||
let frame = try XCTUnwrap(
|
||||
WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||
XCTAssertEqual(frame.layout.width, width)
|
||||
XCTAssertEqual(frame.totalBlocks, 4)
|
||||
XCTAssertEqual(frame.decodedBlocks, 3)
|
||||
XCTAssertEqual(frame.offsets[0], 0)
|
||||
XCTAssertEqual(frame.offsets[3], 2) // u32 words: each header-only packet is 2 words
|
||||
XCTAssertEqual(frame.offsets[1], UInt32.max)
|
||||
XCTAssertEqual(frame.payload.count, 6)
|
||||
XCTAssertFalse(frame.bt2020)
|
||||
XCTAssertFalse(frame.fullRange) // range bit 1 = LIMITED
|
||||
}
|
||||
|
||||
func testHalfOrFewerBlocksIsDropped() {
|
||||
var au = sof(totalBlocks: 4)
|
||||
au += packet(blockIndex: 0)
|
||||
au += packet(blockIndex: 1)
|
||||
// 2 of 4 decoded = exactly half — upstream requires MORE than half.
|
||||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||
}
|
||||
|
||||
func testMissingSOFIsDropped() {
|
||||
let au = packet(blockIndex: 0) + packet(blockIndex: 1)
|
||||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||
}
|
||||
|
||||
func testTruncatedPacketIsRejected() {
|
||||
var au = sof(totalBlocks: 1)
|
||||
// Claims 4 payload words but only the 8-byte header follows.
|
||||
let word0 = UInt32(0) | (4 << 16) | (1 << 28)
|
||||
au += le32(word0) + le32(0)
|
||||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||
}
|
||||
|
||||
func testWindowWalkPackedFragAndMissingShard() throws {
|
||||
let size = 64
|
||||
// Window 1: SOF + one packet, PACKED. Window 2: a FRAG chain carrying one packet split
|
||||
// across two windows. Window 3: all zeros (a lost shard of a partial frame). Window 4:
|
||||
// a PACKED packet — the chain break must not eat it.
|
||||
let fragPacket = packet(blockIndex: 2)
|
||||
var au = window(sof(totalBlocks: 3) + packet(blockIndex: 0), kind: 0, size: size)
|
||||
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||||
au += window(Array(fragPacket[5...]), kind: 3, size: size)
|
||||
au += [UInt8](repeating: 0, count: size) // missing shard
|
||||
au += window(packet(blockIndex: 1), kind: 0, size: size)
|
||||
let frame = try XCTUnwrap(
|
||||
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||||
XCTAssertEqual(frame.decodedBlocks, 3)
|
||||
XCTAssertEqual(frame.offsets[0], 0)
|
||||
XCTAssertEqual(frame.offsets[2], 2)
|
||||
XCTAssertEqual(frame.offsets[1], 4)
|
||||
}
|
||||
|
||||
func testBrokenFragChainIsDiscarded() throws {
|
||||
let size = 64
|
||||
let fragPacket = packet(blockIndex: 2)
|
||||
var au = window(sof(totalBlocks: 1) + packet(blockIndex: 0), kind: 0, size: size)
|
||||
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||||
au += [UInt8](repeating: 0, count: size) // the chain's middle shard was lost
|
||||
au += window(Array(fragPacket[5...]), kind: 3, size: size) // dangling LAST — dropped
|
||||
let frame = try XCTUnwrap(
|
||||
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||||
XCTAssertEqual(frame.decodedBlocks, 1)
|
||||
XCTAssertEqual(frame.offsets[2], UInt32.max)
|
||||
}
|
||||
}
|
||||
|
||||
/// Golden-frame decode against the committed host-encoder fixtures. Skipped when the machine
|
||||
/// has no Metal device (headless CI) — everywhere else this is the hand-ported kernels' guard.
|
||||
final class PyroWaveGoldenTests: XCTestCase {
|
||||
private static let fixtureDir = "PyroWaveFixtures"
|
||||
|
||||
private func fixture(_ name: String) throws -> Data {
|
||||
let url = try XCTUnwrap(
|
||||
Bundle.module.url(
|
||||
forResource: name, withExtension: "bin", subdirectory: Self.fixtureDir),
|
||||
"missing fixture \(name).bin — regenerate with pyrowave_dump_golden")
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
/// Completion box — the decode callback lands on a Metal thread.
|
||||
private final class ResultBox: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var planes: WaveletPlanes?
|
||||
}
|
||||
|
||||
/// Decode `au` synchronously and read all three planes back to CPU bytes.
|
||||
private func decode(
|
||||
au: Data, chunkAligned: Bool, windowSize: Int
|
||||
) throws -> (y: [UInt8], cb: [UInt8], cr: [UInt8]) {
|
||||
let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
|
||||
let queue = try XCTUnwrap(device.makeCommandQueue())
|
||||
let decoder = try XCTUnwrap(MetalWaveletDecoder(device: device, queue: queue))
|
||||
let done = expectation(description: "decode completes")
|
||||
let box = ResultBox()
|
||||
let submitted = decoder.decode(
|
||||
au: au, chunkAligned: chunkAligned, windowSize: windowSize
|
||||
) { planes in
|
||||
box.lock.lock()
|
||||
box.planes = planes
|
||||
box.lock.unlock()
|
||||
done.fulfill()
|
||||
}
|
||||
XCTAssertTrue(submitted, "the fixture AU must parse")
|
||||
wait(for: [done], timeout: 10)
|
||||
box.lock.lock()
|
||||
let result = box.planes
|
||||
box.lock.unlock()
|
||||
let planes = try XCTUnwrap(result, "the GPU pass must complete without error")
|
||||
return (
|
||||
try readback(planes.y, device: device, queue: queue),
|
||||
try readback(planes.cb, device: device, queue: queue),
|
||||
try readback(planes.cr, device: device, queue: queue)
|
||||
)
|
||||
}
|
||||
|
||||
private func readback(
|
||||
_ texture: MTLTexture, device: MTLDevice, queue: MTLCommandQueue
|
||||
) throws -> [UInt8] {
|
||||
let bytesPerRow = texture.width
|
||||
let length = bytesPerRow * texture.height
|
||||
let buffer = try XCTUnwrap(device.makeBuffer(length: length, options: .storageModeShared))
|
||||
let cmd = try XCTUnwrap(queue.makeCommandBuffer())
|
||||
let blit = try XCTUnwrap(cmd.makeBlitCommandEncoder())
|
||||
blit.copy(
|
||||
from: texture, sourceSlice: 0, sourceLevel: 0,
|
||||
sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0),
|
||||
sourceSize: MTLSize(width: texture.width, height: texture.height, depth: 1),
|
||||
to: buffer, destinationOffset: 0, destinationBytesPerRow: bytesPerRow,
|
||||
destinationBytesPerImage: length)
|
||||
blit.endEncoding()
|
||||
cmd.commit()
|
||||
cmd.waitUntilCompleted()
|
||||
return [UInt8](UnsafeRawBufferPointer(start: buffer.contents(), count: length))
|
||||
}
|
||||
|
||||
private func psnr(_ a: [UInt8], _ b: [UInt8]) -> Double {
|
||||
precondition(a.count == b.count)
|
||||
var sse = 0.0
|
||||
for i in 0..<a.count {
|
||||
let d = Double(a[i]) - Double(b[i])
|
||||
sse += d * d
|
||||
}
|
||||
if sse == 0 { return .infinity }
|
||||
let mse = sse / Double(a.count)
|
||||
return 10 * log10(255.0 * 255.0 / mse)
|
||||
}
|
||||
|
||||
private func assertMatchesReference(
|
||||
_ decoded: (y: [UInt8], cb: [UInt8], cr: [UInt8]), prefix: String,
|
||||
file: StaticString = #filePath, line: UInt = #line
|
||||
) throws {
|
||||
for (name, plane, ref) in [
|
||||
("y", decoded.y, try fixture("\(prefix)-y")),
|
||||
("cb", decoded.cb, try fixture("\(prefix)-cb")),
|
||||
("cr", decoded.cr, try fixture("\(prefix)-cr")),
|
||||
] {
|
||||
XCTAssertEqual(plane.count, ref.count, file: file, line: line)
|
||||
let db = psnr(plane, [UInt8](ref))
|
||||
print("pyrowave golden \(prefix) \(name): \(db) dB")
|
||||
// The Metal port and upstream's decoder run the same math at the same precision
|
||||
// tier; residual differences are float rounding + the gather/mirror edge handling.
|
||||
// Well-matched ports measure ≫50 dB; 45 catches a real divergence long before it
|
||||
// is visible.
|
||||
XCTAssertGreaterThan(db, 45.0, "plane PSNR \(db) dB", file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
func testDenseGoldenFrame() throws {
|
||||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||
let au = try fixture("au-dense")
|
||||
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||||
try assertMatchesReference(decoded, prefix: "ref-dense")
|
||||
}
|
||||
|
||||
func testChunkAlignedGoldenFrame() throws {
|
||||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||
let au = try fixture("au-chunked")
|
||||
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||||
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
||||
}
|
||||
|
||||
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
||||
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||||
/// localized blur, not garbage).
|
||||
func testPartialFrameStillDecodes() throws {
|
||||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||
var au = try fixture("au-chunked")
|
||||
let windows = au.count / 1408
|
||||
try XCTSkipIf(windows < 3, "fixture too small to punch a hole in")
|
||||
let hole = (windows / 2) * 1408
|
||||
au.replaceSubrange(hole..<(hole + 1408), with: [UInt8](repeating: 0, count: 1408))
|
||||
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||||
let ref = try fixture("ref-chunked-y")
|
||||
let db = psnr(decoded.y, [UInt8](ref))
|
||||
XCTAssertGreaterThan(db, 25.0, "lossy frame should still resemble the source (\(db) dB)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -47,18 +47,21 @@ final class Stage444Tests: XCTestCase {
|
||||
box.lock.lock(); let frame = box.frame; let error = box.error; box.lock.unlock()
|
||||
XCTAssertNil(error.map { "decode error \($0)" })
|
||||
let ready = try XCTUnwrap(frame, "a 4:4:4 ReadyFrame must be delivered")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 256)
|
||||
let pf = CVPixelBufferGetPixelFormatType(ready.pixelBuffer)
|
||||
guard case .video(let buffer, let isHDR) = ready.image else {
|
||||
return XCTFail("a VideoToolbox decode must deliver a .video frame")
|
||||
}
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 256)
|
||||
let pf = CVPixelBufferGetPixelFormatType(buffer)
|
||||
XCTAssertTrue(
|
||||
pf == kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange,
|
||||
"expected a biplanar 4:4:4 8-bit buffer, got \(fourCCString(pf))")
|
||||
XCTAssertFalse(ready.isHDR, "an 8-bit BT.709 4:4:4 stream is SDR")
|
||||
XCTAssertFalse(isHDR, "an 8-bit BT.709 4:4:4 stream is SDR")
|
||||
// The chroma plane (plane 1) must be FULL resolution for 4:4:4 (vs half for 4:2:0) — this is
|
||||
// what lets the unchanged shader sample chroma at the luma UV.
|
||||
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(ready.pixelBuffer, 1), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(ready.pixelBuffer, 1), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(buffer, 1), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(buffer, 1), 256)
|
||||
}
|
||||
|
||||
private func fourCCString(_ t: OSType) -> String {
|
||||
|
||||
@@ -99,8 +99,9 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
||||
box.lock.unlock()
|
||||
XCTAssertNil(error.map { "decode error \($0)" })
|
||||
let ready = try XCTUnwrap(frame, "the async output callback must deliver a ReadyFrame")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height)
|
||||
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(buffer), width)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(buffer), height)
|
||||
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
|
||||
XCTAssertEqual(
|
||||
ready.receivedNs, 41_000_000, "receivedNs round-trips through the frame refcon")
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
@@ -12,7 +12,7 @@
|
||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
||||
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||
# every host (and every pinned game):
|
||||
# PF_HOST host[:port] to connect to (required)
|
||||
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
|
||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||
@@ -36,24 +36,31 @@ set -u
|
||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
|
||||
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
||||
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||
if [ -n "${PF_BROWSE:-}" ]; then
|
||||
# The gamepad library launcher: browse the host's games on-screen, A streams one,
|
||||
# session end returns to the launcher, B quits back to Gaming Mode.
|
||||
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
||||
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
|
||||
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
|
||||
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: gamepad UI $APPID --browse (console home)" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
|
||||
if [ -n "${PF_MGMT:-}" ]; then
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
||||
fi
|
||||
|
||||
# Streaming modes need a host (browse above is the only host-less path).
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
"controller_mappings"
|
||||
{
|
||||
"version" "3"
|
||||
"revision" "2"
|
||||
"title" "Punktfunk"
|
||||
"description" "Native touchscreen + full gamepad passthrough for the Punktfunk streaming client."
|
||||
"creator" "0"
|
||||
"progenitor" "template://controller_neptune_gamepad_fps.vdf"
|
||||
"url" "template://controller_neptune_gamepad_fps.vdf"
|
||||
"export_type" "unknown"
|
||||
"controller_type" "controller_neptune"
|
||||
"controller_caps" "23117823"
|
||||
"major_revision" "0"
|
||||
"minor_revision" "0"
|
||||
"Timestamp" "0"
|
||||
"localization"
|
||||
{
|
||||
"english"
|
||||
{
|
||||
"title" "Punktfunk"
|
||||
"description" "Native touchscreen + full gamepad for Punktfunk streaming."
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "0"
|
||||
"mode" "four_buttons"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"button_a"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button A, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_b"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button B, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_x"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button X, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_y"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button Y, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "1"
|
||||
"mode" "dpad"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_up, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_south"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_down, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_east"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_right, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_west"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_left, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "2"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "3"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"deadzone_inner_radius" "7199"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "4"
|
||||
"mode" "trigger"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"output_trigger" "1"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "5"
|
||||
"mode" "trigger"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"output_trigger" "2"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "6"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "8"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "9"
|
||||
"mode" "dpad"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_UP, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_south"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_DOWN, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_east"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_RIGHT, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_west"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_LEFT, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"requires_click" "0"
|
||||
"haptic_intensity_override" "0"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "10"
|
||||
"mode" "single_button"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button START, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "11"
|
||||
"mode" "single_button"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button SELECT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "12"
|
||||
"mode" "mouse_joystick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "13"
|
||||
"mode" "flickstick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "14"
|
||||
"mode" "flickstick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "15"
|
||||
"mode" "flickstick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "16"
|
||||
"mode" "flickstick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "7"
|
||||
"mode" "switches"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"button_escape"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button start, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_menu"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button select, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"left_bumper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button shoulder_left, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"right_bumper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button shoulder_right, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_back_left"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_back_right"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_back_left_upper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_back_right_upper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"always_on_action"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "controller_action ts_n, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"preset"
|
||||
{
|
||||
"id" "0"
|
||||
"name" "Default"
|
||||
"group_source_bindings"
|
||||
{
|
||||
"7" "switch active"
|
||||
"0" "button_diamond active"
|
||||
"1" "left_trackpad active"
|
||||
"11" "left_trackpad inactive"
|
||||
"16" "left_trackpad inactive"
|
||||
"2" "right_trackpad inactive"
|
||||
"6" "right_trackpad inactive"
|
||||
"10" "right_trackpad inactive"
|
||||
"12" "right_trackpad active"
|
||||
"15" "right_trackpad inactive"
|
||||
"3" "joystick active"
|
||||
"14" "joystick inactive"
|
||||
"4" "left_trigger active"
|
||||
"5" "right_trigger active"
|
||||
"8" "right_joystick active"
|
||||
"13" "right_joystick inactive"
|
||||
"9" "dpad active"
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"left_trackpad_mode" "0"
|
||||
"right_trackpad_mode" "0"
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,93 @@ def _pins_path() -> Path:
|
||||
return _client_config_dir() / "decky-pinned.json"
|
||||
|
||||
|
||||
# --- Steam Input controller config injection (native touchscreen via the ts_n command) --------
|
||||
# The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with
|
||||
# the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We
|
||||
# ship that layout (controller_config/punktfunk.vdf, built on Steam's gamepad-fps template) and
|
||||
# point our shortcuts at it, EmuDeck-style: drop it in controller_base/templates/ (so it is also
|
||||
# a selectable "Punktfunk" template) AND set each account's configset entry for our shortcut's
|
||||
# game key to that template. Steam keys non-Steam games by their LOWERCASE NAME (verified on the
|
||||
# Deck: our "Punktfunk" shortcut → the "punktfunk" configset key), so both our shortcuts (same
|
||||
# name) share one entry. controller_neptune = the Deck's built-in controller type.
|
||||
CONTROLLER_TEMPLATE = "punktfunk.vdf"
|
||||
|
||||
|
||||
def _steam_root() -> Path:
|
||||
"""Steam's base dir on SteamOS (~/.steam/steam symlinks here)."""
|
||||
return Path(decky.DECKY_USER_HOME) / ".local" / "share" / "Steam"
|
||||
|
||||
|
||||
def _controller_template_src() -> Path:
|
||||
return Path(decky.DECKY_PLUGIN_DIR) / "controller_config" / CONTROLLER_TEMPLATE
|
||||
|
||||
|
||||
def _chown_like_parent(path: Path) -> None:
|
||||
"""The Decky backend runs as root, so files it CREATES in the deck-owned Steam tree land
|
||||
root-owned — which would stop Steam (running as the user) from rewriting them. Match the
|
||||
parent dir's owner so Steam retains write access. Best-effort."""
|
||||
try:
|
||||
st = path.parent.stat()
|
||||
os.chown(path, st.st_uid, st.st_gid)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _configset_dirs() -> list[Path]:
|
||||
"""Every Steam account's controller-config dir holding configset_controller_neptune.vdf."""
|
||||
base = _steam_root() / "steamapps" / "common" / "Steam Controller Configs"
|
||||
return [p / "config" for p in sorted(base.glob("*")) if (p / "config").is_dir()]
|
||||
|
||||
|
||||
def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: str) -> str:
|
||||
"""Set the top-level ``"<key>" { "<source_type>" "<source_val>" }`` block in a
|
||||
configset_controller_neptune.vdf, replacing any existing block for that key (case-insensitive)
|
||||
or inserting one before the file's final closing brace. Targeted (only our key is touched) so
|
||||
the hundreds of other game entries stay byte-for-byte intact. Creates the wrapping
|
||||
``"controller_config" { }`` skeleton when the file is empty/new."""
|
||||
block = f'\t"{key}"\n\t{{\n\t\t"{source_type}"\t\t"{source_val}"\n\t}}\n'
|
||||
if '"controller_config"' not in text:
|
||||
return '"controller_config"\n{\n' + block + "}\n"
|
||||
|
||||
lower = text.lower()
|
||||
needle = f'"{key.lower()}"'
|
||||
# Find the key token that begins a top-level entry (its own line), then its "{ … }" block.
|
||||
search_from = 0
|
||||
while True:
|
||||
idx = lower.find(needle, search_from)
|
||||
if idx == -1:
|
||||
break
|
||||
# Must be a standalone key line (preceded only by whitespace back to a newline).
|
||||
line_start = text.rfind("\n", 0, idx) + 1
|
||||
if text[line_start:idx].strip() != "":
|
||||
search_from = idx + len(needle)
|
||||
continue
|
||||
brace = text.find("{", idx)
|
||||
if brace == -1:
|
||||
break
|
||||
depth = 0
|
||||
i = brace
|
||||
while i < len(text):
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
i += 1
|
||||
end = i + 1
|
||||
# Consume the trailing newline after the block so we don't accumulate blank lines.
|
||||
if end < len(text) and text[end] == "\n":
|
||||
end += 1
|
||||
return text[:line_start] + block + text[end:]
|
||||
|
||||
# Not present — insert before the last closing brace (the controller_config block's end).
|
||||
last_close = text.rstrip().rfind("}")
|
||||
if last_close == -1:
|
||||
return text.rstrip() + "\n" + block
|
||||
return text[:last_close] + block + text[last_close:]
|
||||
|
||||
|
||||
def _parse_library_tsv(stdout: str) -> list[dict]:
|
||||
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
|
||||
game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title
|
||||
@@ -726,10 +813,10 @@ class Plugin:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
async def shortcut_art(self) -> dict:
|
||||
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
|
||||
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the
|
||||
icon's absolute path for SetShortcutIcon (which wants a file, not bytes). Missing
|
||||
files are simply omitted — artwork is cosmetic and must never block a launch."""
|
||||
"""The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``):
|
||||
base64 PNGs (grid/gridwide/hero/logo) for SetCustomArtworkForApp plus the icon's
|
||||
absolute path for SetShortcutIcon (which wants a file, not bytes). Missing files are
|
||||
simply omitted — artwork is cosmetic and must never block a launch."""
|
||||
art: dict = {}
|
||||
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
|
||||
for key, fname in (
|
||||
@@ -746,6 +833,54 @@ class Plugin:
|
||||
art["icon_path"] = str(icon) if icon.exists() else ""
|
||||
return art
|
||||
|
||||
async def apply_controller_config(self, name: str = "Punktfunk") -> dict:
|
||||
"""Install our Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and
|
||||
point the shortcut(s) at it, so the Deck touchscreen reaches the client as native touch
|
||||
with zero manual controller setup. Best-effort + idempotent — a controller tweak must
|
||||
never block a launch, so failures are reported, not raised. Both shortcuts share the same
|
||||
name → the same lowercase configset key, so one entry per account covers both."""
|
||||
src = _controller_template_src()
|
||||
if not src.exists():
|
||||
return {"ok": False, "error": "template-missing", "detail": str(src)}
|
||||
key = name.strip().lower()
|
||||
applied: list[str] = []
|
||||
errors: list[str] = []
|
||||
# 1) Ship it as a selectable template (also the safe fallback if Steam clobbers the
|
||||
# configset write on exit): controller_base/templates/punktfunk.vdf.
|
||||
try:
|
||||
tdir = _steam_root() / "controller_base" / "templates"
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
dst = tdir / CONTROLLER_TEMPLATE
|
||||
shutil.copyfile(src, dst)
|
||||
_chown_like_parent(dst)
|
||||
applied.append("template")
|
||||
except OSError as e:
|
||||
errors.append(f"template: {e}")
|
||||
# 2) Point each Steam account's configset at that template for our game key.
|
||||
dirs = _configset_dirs()
|
||||
for d in dirs:
|
||||
f = d / "configset_controller_neptune.vdf"
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8") if f.exists() else ""
|
||||
new = _upsert_configset_entry(text, key, "template", CONTROLLER_TEMPLATE)
|
||||
if new != text:
|
||||
if f.exists(): # keep one recoverable backup before our first edit
|
||||
bak = f.with_name(f.name + ".pf-bak")
|
||||
if not bak.exists():
|
||||
shutil.copyfile(f, bak)
|
||||
_chown_like_parent(bak)
|
||||
existed = f.exists()
|
||||
f.write_text(new, encoding="utf-8")
|
||||
if not existed: # a freshly-created file is root-owned — hand it to the user
|
||||
_chown_like_parent(f)
|
||||
applied.append(f"configset:{d.parent.name}")
|
||||
except OSError as e:
|
||||
errors.append(f"{d.parent.name}: {e}")
|
||||
decky.logger.info(
|
||||
"apply_controller_config key=%s applied=%s errors=%s", key, applied, errors
|
||||
)
|
||||
return {"ok": not errors, "applied": applied, "errors": errors, "accounts": len(dirs)}
|
||||
|
||||
async def runner_info(self) -> dict:
|
||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
||||
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons).
|
||||
|
||||
The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam
|
||||
would otherwise render as a gray placeholder tile. These assets brand it: the lens mark
|
||||
(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy
|
||||
gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk"
|
||||
needs only p·u·n·k·t·f). The frontend applies them via
|
||||
SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts).
|
||||
|
||||
Outputs (checked in; re-run only when the brand changes):
|
||||
clients/decky/assets/grid.png 600 x 900 library capsule (portrait)
|
||||
clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search)
|
||||
clients/decky/assets/hero.png 1920 x 620 game-page banner
|
||||
clients/decky/assets/logo.png transparent overlaid on the hero by Steam
|
||||
clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon)
|
||||
|
||||
Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are
|
||||
antialiased analytically from signed distances (one sample per pixel) instead of 4x4
|
||||
supersampling.
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent # clients/decky
|
||||
OUT = HERE / "assets"
|
||||
|
||||
# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py).
|
||||
R = 194.41
|
||||
C1 = (403.037, 597.262) # light circle, behind
|
||||
C2 = (597.8075, 402.8525) # deep circle, in front
|
||||
BB_MIN = (C1[0] - R, C2[1] - R)
|
||||
BB_MAX = (C2[0] + R, C1[1] + R)
|
||||
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
|
||||
MARK_SPAN = BB_MAX[0] - BB_MIN[0]
|
||||
|
||||
COL_LIGHT = (0xA7, 0x9F, 0xF8)
|
||||
COL_DEEP = (0x6C, 0x5B, 0xF3)
|
||||
COL_HI = (0xD2, 0xC9, 0xFB)
|
||||
WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender
|
||||
BG_TOP = (0x28, 0x1E, 0x46)
|
||||
BG_BOT = (0x12, 0x0D, 0x22)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline
|
||||
# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the
|
||||
# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that.
|
||||
# ------------------------------------------------------------------------------------------
|
||||
def _arc(cx, cy, r, a0, a1, n=24):
|
||||
"""Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen."""
|
||||
pts = []
|
||||
for i in range(n + 1):
|
||||
a = math.radians(a0 + (a1 - a0) * i / n)
|
||||
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
||||
return pts
|
||||
|
||||
|
||||
GLYPHS = {
|
||||
# letter: (advance, [polyline, ...])
|
||||
"p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]),
|
||||
"u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]),
|
||||
"n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]),
|
||||
"k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]),
|
||||
"t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]),
|
||||
"f": (
|
||||
0.85,
|
||||
[[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]],
|
||||
),
|
||||
}
|
||||
GAP = 0.34 # inter-letter gap, in glyph units
|
||||
STROKE = 0.26 # stroke thickness, in glyph units
|
||||
ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent
|
||||
|
||||
|
||||
def word_segments(text):
|
||||
"""The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width."""
|
||||
segs = []
|
||||
x = 0.0
|
||||
for ch in text:
|
||||
adv, lines = GLYPHS[ch]
|
||||
for line in lines:
|
||||
for (x1, y1), (x2, y2) in zip(line, line[1:]):
|
||||
segs.append((x + x1, y1, x + x2, y2))
|
||||
x += adv + GAP
|
||||
return segs, x - GAP
|
||||
|
||||
|
||||
def render_word_alpha(text, unit_px):
|
||||
"""Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit."""
|
||||
segs, width_u = word_segments(text)
|
||||
half = STROKE / 2 * unit_px
|
||||
pad = half + 1.5
|
||||
w = math.ceil(width_u * unit_px + 2 * pad)
|
||||
h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad)
|
||||
ox, oy = pad, pad - ASCENT * unit_px
|
||||
px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs]
|
||||
# Bucket segments per pixel column range so each pixel tests only nearby strokes.
|
||||
buf = bytearray(w * h)
|
||||
for x1, y1, x2, y2 in px_segs:
|
||||
lo_x = max(0, math.floor(min(x1, x2) - pad))
|
||||
hi_x = min(w, math.ceil(max(x1, x2) + pad))
|
||||
lo_y = max(0, math.floor(min(y1, y2) - pad))
|
||||
hi_y = min(h, math.ceil(max(y1, y2) + pad))
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
len2 = dx * dx + dy * dy
|
||||
for py in range(lo_y, hi_y):
|
||||
row = py * w
|
||||
fy = py + 0.5
|
||||
for px in range(lo_x, hi_x):
|
||||
fx = px + 0.5
|
||||
if len2 > 0:
|
||||
t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2))
|
||||
else:
|
||||
t = 0.0
|
||||
d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy))
|
||||
cov = 0.5 + (half - d)
|
||||
if cov > 0:
|
||||
v = min(255, round(min(1.0, cov) * 255))
|
||||
if v > buf[row + px]:
|
||||
buf[row + px] = v
|
||||
return buf, w, h
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# Canvas: RGBA bytearray, straight alpha, painted back to front.
|
||||
# ------------------------------------------------------------------------------------------
|
||||
class Canvas:
|
||||
def __init__(self, w, h):
|
||||
self.w, self.h = w, h
|
||||
self.buf = bytearray(w * h * 4)
|
||||
|
||||
def fill_gradient(self, top, bottom):
|
||||
for y in range(self.h):
|
||||
t = y / max(1, self.h - 1)
|
||||
c = bytes(
|
||||
(
|
||||
round(top[0] + (bottom[0] - top[0]) * t),
|
||||
round(top[1] + (bottom[1] - top[1]) * t),
|
||||
round(top[2] + (bottom[2] - top[2]) * t),
|
||||
255,
|
||||
)
|
||||
)
|
||||
self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w
|
||||
|
||||
def _blend(self, i, rgb, a):
|
||||
"""`rgb` over the pixel at byte offset i with coverage a (0..1)."""
|
||||
if a <= 0:
|
||||
return
|
||||
b = self.buf
|
||||
ia = 1.0 - a
|
||||
da = b[i + 3] / 255.0
|
||||
oa = a + da * ia
|
||||
if oa <= 0:
|
||||
return
|
||||
for k in range(3):
|
||||
b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa)
|
||||
b[i + 3] = round(oa * 255)
|
||||
|
||||
def glow(self, cx, cy, radius, rgb, strength):
|
||||
"""Soft gaussian-ish radial glow (for the mark's halo on the big surfaces)."""
|
||||
lo_x = max(0, math.floor(cx - 2.2 * radius))
|
||||
hi_x = min(self.w, math.ceil(cx + 2.2 * radius))
|
||||
lo_y = max(0, math.floor(cy - 2.2 * radius))
|
||||
hi_y = min(self.h, math.ceil(cy + 2.2 * radius))
|
||||
for y in range(lo_y, hi_y):
|
||||
for x in range(lo_x, hi_x):
|
||||
d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius)
|
||||
a = strength * math.exp(-2.5 * d2)
|
||||
if a > 1 / 255:
|
||||
self._blend((y * self.w + x) * 4, rgb, a)
|
||||
|
||||
def mark(self, cx, cy, span):
|
||||
"""The lens mark centered at (cx, cy) with the given pixel span."""
|
||||
scale = span / MARK_SPAN
|
||||
c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale)
|
||||
c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale)
|
||||
r = R * scale
|
||||
lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2))
|
||||
hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2))
|
||||
lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2))
|
||||
hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2))
|
||||
for y in range(lo_y, hi_y):
|
||||
for x in range(lo_x, hi_x):
|
||||
fx, fy = x + 0.5, y + 0.5
|
||||
cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1])))
|
||||
cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1])))
|
||||
if cov1 <= 0 and cov2 <= 0:
|
||||
continue
|
||||
i = (y * self.w + x) * 4
|
||||
self._blend(i, COL_LIGHT, cov1)
|
||||
self._blend(i, COL_DEEP, cov2)
|
||||
self._blend(i, COL_HI, min(cov1, cov2))
|
||||
|
||||
def word(self, text, unit_px, cx, cy):
|
||||
"""The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit."""
|
||||
alpha, w, h = render_word_alpha(text, unit_px)
|
||||
ox = round(cx - w / 2)
|
||||
# Optical vertical centering on the x-height band (0..1 in glyph units), not the
|
||||
# ascender/descender box — the word reads centered that way.
|
||||
pad = STROKE / 2 * unit_px + 1.5
|
||||
band_mid = pad - ASCENT * unit_px + 0.5 * unit_px
|
||||
oy = round(cy - band_mid)
|
||||
for y in range(h):
|
||||
ty = y + oy
|
||||
if not 0 <= ty < self.h:
|
||||
continue
|
||||
for x in range(w):
|
||||
a = alpha[y * w + x]
|
||||
if a:
|
||||
tx = x + ox
|
||||
if 0 <= tx < self.w:
|
||||
self._blend((ty * self.w + tx) * 4, WORD, a / 255.0)
|
||||
|
||||
def round_corners(self, radius):
|
||||
"""Multiply alpha with a rounded-rect mask (icon)."""
|
||||
for y in range(self.h):
|
||||
for x in range(self.w):
|
||||
dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius)))
|
||||
dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius)))
|
||||
if dx > 0 and dy > 0:
|
||||
cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy)))
|
||||
i = (y * self.w + x) * 4
|
||||
self.buf[i + 3] = round(self.buf[i + 3] * cov)
|
||||
|
||||
def png(self):
|
||||
def chunk(tag, data):
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ tag
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)
|
||||
raw = b"".join(
|
||||
b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h)
|
||||
)
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", ihdr)
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
def save(name, canvas):
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
out = OUT / name
|
||||
out.write_bytes(canvas.png())
|
||||
print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})")
|
||||
|
||||
|
||||
def main():
|
||||
# Portrait capsule: mark in the upper half, wordmark beneath.
|
||||
c = Canvas(600, 900)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(300, 340, 260, COL_DEEP, 0.35)
|
||||
c.mark(300, 340, 320)
|
||||
c.word("punktfunk", 44, 300, 640)
|
||||
save("grid.png", c)
|
||||
|
||||
# Wide capsule: mark left, wordmark right of it.
|
||||
c = Canvas(920, 430)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(230, 215, 200, COL_DEEP, 0.35)
|
||||
c.mark(230, 215, 240)
|
||||
c.word("punktfunk", 40, 620, 220)
|
||||
save("gridwide.png", c)
|
||||
|
||||
# Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself.
|
||||
c = Canvas(1920, 620)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(1500, 310, 330, COL_DEEP, 0.4)
|
||||
c.mark(1500, 310, 400)
|
||||
save("hero.png", c)
|
||||
|
||||
# Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam.
|
||||
c = Canvas(1120, 300)
|
||||
c.mark(150, 150, 240)
|
||||
c.word("punktfunk", 62, 660, 155)
|
||||
save("logo.png", c)
|
||||
|
||||
# Icon: brand tile, rounded corners, mark only.
|
||||
c = Canvas(256, 256)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(128, 128, 110, COL_DEEP, 0.3)
|
||||
c.mark(128, 128, 190)
|
||||
c.round_corners(36)
|
||||
save("icon.png", c)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -26,8 +26,12 @@ cp main.py plugin.json package.json LICENSE "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
||||
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed).
|
||||
# Steam-shortcut artwork (grid/gridwide/hero/logo/icon — committed under assets/).
|
||||
cp assets/*.png "$DEST/assets/"
|
||||
# The Steam Input controller layout (native touchscreen `ts_n` + gamepad passthrough) the
|
||||
# backend installs (apply_controller_config → controller_base/templates + the shortcut config).
|
||||
mkdir -p "$DEST/controller_config"
|
||||
cp controller_config/punktfunk.vdf "$DEST/controller_config/punktfunk.vdf"
|
||||
[ -f decky.pyi ] && cp decky.pyi "$DEST/"
|
||||
[ -f README.md ] && cp README.md "$DEST/"
|
||||
|
||||
|
||||
@@ -150,6 +150,14 @@ export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: str
|
||||
);
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
|
||||
// shortcut(s) at it, so the Deck touchscreen reaches the client as native touch with no manual
|
||||
// controller setup. Best-effort + idempotent; keyed by the shared shortcut NAME (both shortcuts
|
||||
// use the same name → the same lowercase configset key), so one call covers both.
|
||||
export const applyControllerConfig = callable<
|
||||
[name: string],
|
||||
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
|
||||
>("apply_controller_config");
|
||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { streamPin } from "./library";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
import { ensureGamepadUiShortcut } from "./steam";
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||
@@ -196,6 +197,10 @@ const QamPanel: FC = () => {
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
|
||||
// home) exists and is repointed to the current plugin dir — also installs the native-touch
|
||||
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||
void ensureGamepadUiShortcut();
|
||||
return {
|
||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
||||
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
// Launch the stream as a Steam game so gamescope focuses + fullscreens it.
|
||||
// Launch Punktfunk as Steam games so gamescope focuses + fullscreens them.
|
||||
//
|
||||
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
|
||||
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
|
||||
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
|
||||
// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
|
||||
// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
|
||||
// and start it with RunGame. The wrapper then execs
|
||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register non-Steam
|
||||
// shortcuts whose exe is `/bin/sh` running our wrapper script (bin/punktfunkrun.sh), and start
|
||||
// them with RunGame. The wrapper then execs the flatpak client as a reaper descendant.
|
||||
//
|
||||
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
|
||||
// see applyControllerConfig):
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
|
||||
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
|
||||
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
|
||||
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
|
||||
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
|
||||
// This is the library-visible "Punktfunk" app the user opens directly.
|
||||
//
|
||||
// Both get the shipped artwork and the native-touch controller config.
|
||||
|
||||
import { runnerInfo, shortcutArt, wake } from "./backend";
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
|
||||
|
||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||
@@ -46,32 +55,33 @@ declare const collectionStore:
|
||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||
| undefined;
|
||||
|
||||
// The shortcut used to be hidden ("implementation detail"); it is user-visible now — it
|
||||
// carries proper artwork and living in the library is how users relaunch their last host.
|
||||
// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent).
|
||||
function unhideShortcut(appId: number): void {
|
||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
||||
function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||
const attempt = () => {
|
||||
try {
|
||||
collectionStore?.SetAppsAsHidden?.([appId], false);
|
||||
collectionStore?.SetAppsAsHidden?.([appId], hidden);
|
||||
} catch {
|
||||
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
||||
}
|
||||
};
|
||||
attempt(); // succeeds immediately for an already-registered (reused) shortcut
|
||||
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
||||
};
|
||||
|
||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
||||
const ART_VERSION = 2;
|
||||
function artKey(appId: number): string {
|
||||
return `punktfunk:shortcutArt:${appId}`;
|
||||
}
|
||||
|
||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once.
|
||||
const ART_VERSION = 1;
|
||||
const ART_KEY = "punktfunk:shortcutArt";
|
||||
|
||||
/**
|
||||
* Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION).
|
||||
* Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch.
|
||||
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
||||
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
||||
*/
|
||||
async function applyArtwork(appId: number): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) {
|
||||
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
||||
return;
|
||||
}
|
||||
const art = await shortcutArt();
|
||||
@@ -89,13 +99,14 @@ async function applyArtwork(appId: number): Promise<void> {
|
||||
if (art.icon_path) {
|
||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||
}
|
||||
localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`);
|
||||
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||
}
|
||||
}
|
||||
|
||||
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
|
||||
// The shortcut name is user-visible (Steam overlay + library) — brand-case it. BOTH shortcuts
|
||||
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
@@ -111,76 +122,128 @@ function gameIdFromAppId(appId: number): string {
|
||||
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
|
||||
}
|
||||
|
||||
// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the
|
||||
// library (the appId is stable for the life of the shortcut).
|
||||
const STORAGE_KEY = "punktfunk:shortcutAppId";
|
||||
// Persist each shortcut's appId across reloads so we reuse ONE per role instead of churning the
|
||||
// library (an appId is stable for the life of the shortcut). The STREAM key is the historical
|
||||
// one, so existing single-shortcut installs migrate into the (now hidden) stream role, and the
|
||||
// visible gamepad-UI shortcut is created alongside.
|
||||
const STORAGE_KEY_STREAM = "punktfunk:shortcutAppId";
|
||||
const STORAGE_KEY_UI = "punktfunk:uiAppId";
|
||||
|
||||
function rememberAppId(appId: number) {
|
||||
function remember(key: string, appId: number) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(appId));
|
||||
localStorage.setItem(key, String(appId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
function recallAppId(): number | null {
|
||||
function recall(key: string): number | null {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
const v = localStorage.getItem(key);
|
||||
return v ? Number(v) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Install the native-touch controller config once per plugin session (idempotent file writes in
|
||||
// the root backend). Keyed by the shared shortcut NAME, so this single call covers both
|
||||
// shortcuts. Gated in localStorage so we don't rewrite Steam's config dir on every launch; bump
|
||||
// CONFIG_VERSION to force a reinstall after the shipped .vdf changes.
|
||||
const CONFIG_KEY = "punktfunk:controllerConfig";
|
||||
const CONFIG_VERSION = 1;
|
||||
async function ensureControllerConfig(): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(CONFIG_KEY) === `${CONFIG_VERSION}`) {
|
||||
return;
|
||||
}
|
||||
const r = await applyControllerConfig(SHORTCUT_NAME);
|
||||
if (r?.ok) {
|
||||
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
||||
} else {
|
||||
console.warn("punktfunk: controller config not fully applied", r);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: controller config not applied", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
|
||||
* appended per-launch via the launch options), branded and visible in the library, and
|
||||
* return its appId + the current runner path. Reuses the remembered shortcut, re-pointing
|
||||
* it each time — the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at
|
||||
* the script directly, and pre-0.7 shortcuts were hidden and artless.
|
||||
* Ensure the STREAM shortcut (hidden, stateful) — the per-session launcher whose launch options
|
||||
* are rewritten per stream. Branded, artworked, native-touch config applied, and HIDDEN (it is
|
||||
* an implementation detail; the visible entry is the gamepad-UI shortcut). Returns its appId +
|
||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||
*/
|
||||
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||
|
||||
const remembered = recallAppId();
|
||||
const remembered = recall(STORAGE_KEY_STREAM);
|
||||
if (remembered != null) {
|
||||
// Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
unhideShortcut(remembered); // pre-0.7 installs hid it
|
||||
void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch
|
||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(remembered);
|
||||
return { appId: remembered, runner: info.runner };
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
unhideShortcut(appId);
|
||||
void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch
|
||||
rememberAppId(appId);
|
||||
setShortcutHidden(appId, true);
|
||||
void applyArtwork(appId);
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
return { appId, runner: info.runner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the
|
||||
* Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient
|
||||
* API for this, so it is feature-detected and MUST never block or throw into the launch — the manual
|
||||
* toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is
|
||||
* the documented source of truth. No-op when the optional API is absent.
|
||||
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
||||
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
||||
* Fixed launch options (no per-session state), branded, artworked, native-touch config applied,
|
||||
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
||||
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
||||
*/
|
||||
function disableSteamInputForShortcut(appId: number): void {
|
||||
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
try {
|
||||
const input = (
|
||||
SteamClient as unknown as {
|
||||
Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void };
|
||||
}
|
||||
).Input;
|
||||
input?.SetSteamInputEnabledForApp?.(appId, false);
|
||||
} catch {
|
||||
/* a controller tweak must never break the launch */
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
return null;
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, "");
|
||||
void ensureControllerConfig();
|
||||
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
let appId = recall(STORAGE_KEY_UI);
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
} else {
|
||||
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
remember(STORAGE_KEY_UI, appId);
|
||||
}
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
|
||||
setShortcutHidden(appId, false); // the visible library entry
|
||||
void applyArtwork(appId);
|
||||
return appId;
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: gamepad-UI shortcut not ensured", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||
export async function launchGamepadUi(): Promise<void> {
|
||||
const appId = await ensureGamepadUiShortcut();
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,9 +273,9 @@ export function isSafeLaunchId(id: string): boolean {
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||
* library title, or into the gamepad library launcher). Encodes the target into the
|
||||
* shortcut's launch options (so one generic shortcut serves every host and every pinned
|
||||
* game), then RunGame.
|
||||
* library title, or into a host's gamepad library). Encodes the target into the STREAM
|
||||
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
|
||||
* then RunGame.
|
||||
*/
|
||||
export async function launchStream(
|
||||
host: string,
|
||||
@@ -224,10 +287,7 @@ export async function launchStream(
|
||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const { appId, runner } = await ensureShortcut();
|
||||
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
||||
// disables Steam Input manually — see the Settings instruction).
|
||||
disableSteamInputForShortcut(appId);
|
||||
const { appId, runner } = await ensureStreamShortcut();
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
if (opts.browse) {
|
||||
@@ -251,7 +311,7 @@ export async function launchStream(
|
||||
|
||||
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
|
||||
export function stopStream(): void {
|
||||
const appId = recallAppId();
|
||||
const appId = recall(STORAGE_KEY_STREAM);
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,10 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
|
||||
eprintln!(
|
||||
"pairing failed: {} ({e:?})",
|
||||
crate::trust::pair_error_message(&e)
|
||||
);
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]
|
||||
/// Codec setting values (persisted) paired with their display labels below.
|
||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
|
||||
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||
@@ -324,10 +324,12 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Video decoder",
|
||||
"Automatic tries VAAPI hardware decode, then software",
|
||||
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
|
||||
Vulkan Video on NVIDIA), falling back to software",
|
||||
&[
|
||||
"Automatic (VAAPI → software)",
|
||||
"Hardware (VAAPI)",
|
||||
"Automatic (hardware → software)",
|
||||
"Vulkan Video",
|
||||
"VAAPI",
|
||||
"Software",
|
||||
],
|
||||
);
|
||||
|
||||
@@ -214,8 +214,10 @@ pub fn pin_dialog(
|
||||
};
|
||||
let (host, port) = (req.addr.clone(), req.port);
|
||||
std::thread::spawn(move || {
|
||||
// Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host
|
||||
// rejection) — never blame the PIN for a dead network path.
|
||||
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
|
||||
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
|
||||
.map_err(|e| trust::pair_error_message(&e));
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
glib::spawn_future_local(async move {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
//! exits without connecting.
|
||||
//!
|
||||
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--remode WxHxFPS:SECS]
|
||||
//! [--rebitrate KBPS:SECS]
|
||||
//! [--out FILE] [--bitrate KBPS] [--codec auto|h264|hevc|av1] [--audio-channels 2|6|8]
|
||||
//! [--launch APP] [--name NAME] [--speed-test KBPS:MS]
|
||||
//! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test]
|
||||
@@ -51,8 +52,8 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, window_loss_ppm, Hello, LossReport, ProbeRequest, ProbeResult, Reconfigure,
|
||||
Reconfigured, RequestKeyframe, Start, Welcome,
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, Hello, LossReport, ProbeRequest, ProbeResult,
|
||||
Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -84,6 +85,11 @@ struct Args {
|
||||
pin: Option<[u8; 32]>,
|
||||
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
||||
remode: Option<(Mode, u32)>,
|
||||
/// `--rebitrate KBPS:SECS` — send a mid-stream [`SetBitrate`] (the adaptive-bitrate control
|
||||
/// message) SECS seconds into the stream: the headless validator for the host's in-place
|
||||
/// encoder rate retarget (Phase 3.2) / rebuild fallback. Wiggles the cursor around the switch
|
||||
/// so a damage-driven idle desktop actually publishes frames through it.
|
||||
rebitrate: Option<(u32, u32)>,
|
||||
/// `--pair PIN` — run the pairing ceremony instead of a session.
|
||||
pair: Option<String>,
|
||||
/// `--name LABEL` — how the host labels this client when pairing.
|
||||
@@ -201,6 +207,10 @@ fn parse_args() -> Args {
|
||||
let (m, secs) = s.split_once(':')?;
|
||||
Some((parse_mode(m)?, secs.parse().ok()?))
|
||||
});
|
||||
let rebitrate = get("--rebitrate").and_then(|s| {
|
||||
let (kbps, secs) = s.split_once(':')?;
|
||||
Some((kbps.parse().ok()?, secs.parse().ok()?))
|
||||
});
|
||||
// A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use
|
||||
// (the user asked for verification; fail closed).
|
||||
let pin = match get("--pin") {
|
||||
@@ -252,6 +262,7 @@ fn parse_args() -> Args {
|
||||
seconds: get("--seconds").and_then(|s| s.parse().ok()),
|
||||
pin,
|
||||
remode,
|
||||
rebitrate,
|
||||
pair: get("--pair").map(String::from),
|
||||
name: get("--name").unwrap_or("punktfunk-probe").to_string(),
|
||||
compositor,
|
||||
@@ -470,7 +481,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
video_caps: {
|
||||
// Always ask for per-AU host timings (0xCF) — this is a measurement tool, and the
|
||||
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING;
|
||||
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
||||
// qualifies for `--speed-test` bursts; without the bit the host declines them.
|
||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
|
||||
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ;
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||
}
|
||||
@@ -626,6 +640,64 @@ async fn session(args: Args) -> Result<()> {
|
||||
other => tracing::error!(?other, "bad Reconfigured"),
|
||||
}
|
||||
});
|
||||
} else if let Some((new_kbps, after_secs)) = args.rebitrate {
|
||||
// Mid-stream adaptive-bitrate test: after a delay, send the SetBitrate the Automatic
|
||||
// controller would and await the host's BitrateChanged ack. Host-side this exercises the
|
||||
// in-place `reconfigure_bitrate` (no IDR) or the rebuild fallback — the host log says
|
||||
// which. The cursor wiggle keeps a damage-driven idle desktop publishing frames through
|
||||
// the whole window: the encode loop only drains bitrate requests between frames, and the
|
||||
// post-switch AUs are what prove the stream carried on.
|
||||
let mut rs = send;
|
||||
let mut rr = recv;
|
||||
let conn2 = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
let wiggle = |i: u32| InputEvent {
|
||||
kind: InputKind::MouseMove,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x: if i % 2 == 0 { 2 } else { -2 },
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
let end =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(after_secs as u64 + 6);
|
||||
let switch_at =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(after_secs as u64);
|
||||
let mut sent = false;
|
||||
let mut i = 0u32;
|
||||
while std::time::Instant::now() < end {
|
||||
let _ = conn2.send_datagram(wiggle(i).encode().to_vec().into());
|
||||
i += 1;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if !sent && std::time::Instant::now() >= switch_at {
|
||||
sent = true;
|
||||
tracing::info!(new_kbps, "requesting mid-stream bitrate change");
|
||||
if io::write_msg(
|
||||
&mut rs,
|
||||
&SetBitrate {
|
||||
bitrate_kbps: new_kbps,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::error!("SetBitrate write failed");
|
||||
return;
|
||||
}
|
||||
match io::read_msg(&mut rr)
|
||||
.await
|
||||
.map(|b| BitrateChanged::decode(&b))
|
||||
{
|
||||
Ok(Ok(ack)) => tracing::info!(
|
||||
applied_kbps = ack.bitrate_kbps,
|
||||
"BITRATE CHANGE acked by host"
|
||||
),
|
||||
other => tracing::error!(?other, "bad BitrateChanged"),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if let Some((target_kbps, duration_ms)) = args.speed_test {
|
||||
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler; measure
|
||||
// delivered WIRE packets (session-stat delta) vs. what the host reports putting on the wire.
|
||||
@@ -639,11 +711,28 @@ async fn session(args: Args) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let conn2 = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
|
||||
// Baseline the packet-level counters right before the burst (video is paused during it,
|
||||
// so the delta is pure probe traffic plus a sliver of resumed video in the settle).
|
||||
// Warm up the stream — and generate desktop activity while doing so. Damage-driven
|
||||
// capture paths (Windows IDD-push, a static headless desktop anywhere) publish NO
|
||||
// frame until something composes, and the host's pipeline build waits for a first
|
||||
// frame — so an idle virtual display would time the whole speed test out. A ±2 px
|
||||
// cursor wiggle over the wire is injected host-side into the right session/desktop.
|
||||
for i in 0..20u32 {
|
||||
let mv = InputEvent {
|
||||
kind: InputKind::MouseMove,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x: if i % 2 == 0 { 2 } else { -2 },
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
let _ = conn2.send_datagram(mv.encode().to_vec().into());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
// Baseline the packet-level counters right before the burst (video is paused during it,
|
||||
// so the delta is pure probe traffic plus a sliver of resumed video in the settle).
|
||||
let base_pkts = rxp.load(Relaxed);
|
||||
let base_bytes = rxb.load(Relaxed);
|
||||
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
|
||||
@@ -668,6 +757,15 @@ async fn session(args: Args) -> Result<()> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// A declined burst comes back all-zero (duration_ms = 0) — e.g. the host predates
|
||||
// speed tests. Say so instead of dividing a settle-window sliver by 1 ms.
|
||||
if res.duration_ms == 0 {
|
||||
tracing::error!(
|
||||
"SPEED TEST declined by host (all-zero ProbeResult) — host too old, or it \
|
||||
rejected the request; check the host log"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The reliable result can beat the last UDP shards — let the tail arrive before reading.
|
||||
// Keep this short: video resumes the instant the burst ends, so a long settle counts
|
||||
// resumed-video packets against the probe (inflating recv past the host's wire count).
|
||||
@@ -1150,7 +1248,8 @@ async fn session(args: Args) -> Result<()> {
|
||||
let cap_secs = args.seconds.unwrap_or(120);
|
||||
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
|
||||
let mut last_loss_report = std::time::Instant::now();
|
||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||
let (mut last_recovered, mut last_late, mut last_received, mut last_dropped) =
|
||||
(0u64, 0u64, 0u64, 0u64);
|
||||
loop {
|
||||
// Mirror packet-level receive counters for the speed-test reporter (reads their delta),
|
||||
// and publish a windowed loss estimate for the adaptive-FEC LossReport task.
|
||||
@@ -1164,6 +1263,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
lp_dt.store(
|
||||
window_loss_ppm(
|
||||
s.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||
s.fec_late_shards.wrapping_sub(last_late),
|
||||
s.packets_received.wrapping_sub(last_received),
|
||||
s.frames_dropped.wrapping_sub(last_dropped),
|
||||
),
|
||||
@@ -1171,6 +1271,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
);
|
||||
last_loss_report = std::time::Instant::now();
|
||||
last_recovered = s.fec_recovered_shards;
|
||||
last_late = s.fec_late_shards;
|
||||
last_received = s.packets_received;
|
||||
last_dropped = s.frames_dropped;
|
||||
}
|
||||
@@ -1242,6 +1343,23 @@ async fn session(args: Args) -> Result<()> {
|
||||
s.flush().ok();
|
||||
}
|
||||
|
||||
// PUNKTFUNK_PERF: cumulative receive-path stage split for the whole run — where the
|
||||
// receive core's time went (kernel drain vs AES-GCM open vs reassembly+FEC). This is
|
||||
// the measurement tool's view of the client-pump wall the 2026-07-14 sweeps pinned.
|
||||
if let Some(p) = session.take_pump_perf() {
|
||||
let per_pkt = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
|
||||
tracing::info!(
|
||||
recv_ms = p.recv_ns / 1_000_000,
|
||||
decrypt_ms = p.decrypt_ns / 1_000_000,
|
||||
reasm_ms = p.reasm_ns / 1_000_000,
|
||||
packets = p.packets,
|
||||
pkts_per_batch = p.packets.checked_div(p.batches.max(1)).unwrap_or(0),
|
||||
decrypt_ns_pkt = per_pkt(p.decrypt_ns),
|
||||
reasm_ns_pkt = per_pkt(p.reasm_ns),
|
||||
"receive stage split (whole run, PUNKTFUNK_PERF)"
|
||||
);
|
||||
}
|
||||
|
||||
latencies_us.sort_unstable();
|
||||
let pct = |p: f64| -> u64 {
|
||||
if latencies_us.is_empty() {
|
||||
|
||||
@@ -13,7 +13,12 @@ name = "punktfunk-session"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["ui"]
|
||||
default = ["ui", "pyrowave"]
|
||||
# PyroWave client decode (the wired-LAN wavelet codec) — enables the decode backend + the
|
||||
# planar present path. ON by default; each session still opts in explicitly (the Settings
|
||||
# codec pick, or PUNKTFUNK_PREFER_PYROWAVE=1). The Windows ARM64 leg builds
|
||||
# --no-default-features and so skips it (video decode is Linux-only anyway).
|
||||
pyrowave = ["pf-client-core/pyrowave", "pf-presenter/pyrowave"]
|
||||
# The Skia console UI (stats OSD, capture HUD, later the gamepad library). Dropping it
|
||||
# (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming,
|
||||
# stats on stdout only.
|
||||
|
||||
@@ -29,6 +29,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A request-access connect awaiting the operator's approval on the host: stamped by the
|
||||
/// launch handler and consumed by `on_connected`, which persists the host as paired.
|
||||
struct PendingApproval {
|
||||
name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp_hex: String,
|
||||
}
|
||||
|
||||
pub fn run(target: Option<&str>) -> u8 {
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
@@ -128,6 +137,14 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
||||
let json_status = arg_flag("--json-status");
|
||||
let settings_at_start = trust::Settings::load();
|
||||
|
||||
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
||||
// connect; `on_connected` reads it once the host lets us in and persists the host as PAIRED,
|
||||
// so the next connect is an ordinary one. `None` for every normal launch, so `on_connected`
|
||||
// then only touches last-used.
|
||||
let pending_approval: Arc<Mutex<Option<PendingApproval>>> = Arc::new(Mutex::new(None));
|
||||
let pending_cb = pending_approval.clone();
|
||||
|
||||
let opts = pf_presenter::SessionOpts {
|
||||
window_title: window_label.map_or_else(
|
||||
|| "Punktfunk".to_string(),
|
||||
@@ -142,8 +159,16 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
},
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
json_status,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
trust::touch_last_used(&fp_hex);
|
||||
// A request-access connect just succeeded → the operator approved us. Save the
|
||||
// host as paired (it was unsaved/discovered), keyed to the fingerprint we pinned.
|
||||
if let Some(p) = pending_cb.lock().unwrap().take() {
|
||||
if p.fp_hex == fp_hex {
|
||||
trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true);
|
||||
}
|
||||
}
|
||||
})),
|
||||
overlay: Some(Box::new(overlay)),
|
||||
window_size: crate::session_main::window_size(&settings_at_start),
|
||||
@@ -161,21 +186,23 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
fp_hex,
|
||||
launch,
|
||||
title,
|
||||
request_access,
|
||||
} => {
|
||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||
// The console only offers Connect on paired rows; a pinless
|
||||
// launch is a logic slip, never a silent TOFU.
|
||||
// Connect (and request-access) pin the host's advertised fingerprint;
|
||||
// a pinless launch is a logic slip, never a silent TOFU.
|
||||
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
||||
return ActionOutcome::Handled;
|
||||
};
|
||||
tracing::info!(%addr, %title, launch = launch.as_deref().unwrap_or("desktop"),
|
||||
tracing::info!(%addr, %title, request_access,
|
||||
launch = launch.as_deref().unwrap_or("desktop"),
|
||||
"launching from the console");
|
||||
// Settings re-load per launch: the console's own settings screen
|
||||
// may have changed them since the last stream.
|
||||
let settings = trust::Settings::load();
|
||||
ActionOutcome::Start(Box::new(session_params(
|
||||
let mut params = session_params(
|
||||
&settings,
|
||||
addr,
|
||||
addr.clone(),
|
||||
port,
|
||||
pin,
|
||||
identity.clone(),
|
||||
@@ -184,7 +211,20 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
native,
|
||||
force_software,
|
||||
vulkan,
|
||||
)))
|
||||
);
|
||||
if request_access {
|
||||
// The host PARKS the connect until the operator approves — outlast its
|
||||
// approval window (host `PENDING_APPROVAL_WAIT`), matching the desktop
|
||||
// shells' 185 s. On success `on_connected` persists the host as paired.
|
||||
params.connect_timeout = Duration::from_secs(185);
|
||||
*pending_approval.lock().unwrap() = Some(PendingApproval {
|
||||
name: title.clone(),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
});
|
||||
}
|
||||
ActionOutcome::Start(Box::new(params))
|
||||
}
|
||||
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
||||
OverlayAction::Quit => ActionOutcome::Quit,
|
||||
@@ -381,18 +421,9 @@ impl ServiceState {
|
||||
console.set_pair(PairPhase::Paired { key: fp_hex });
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = match e {
|
||||
punktfunk_core::PunktfunkError::Crypto => {
|
||||
"Wrong PIN — check the host's Pairing page and try again."
|
||||
.to_string()
|
||||
}
|
||||
punktfunk_core::PunktfunkError::Timeout => {
|
||||
"The host didn't answer. Is it running and reachable?"
|
||||
.to_string()
|
||||
}
|
||||
other => format!("Pairing failed: {other:?}"),
|
||||
};
|
||||
console.set_pair(PairPhase::Failed(msg));
|
||||
// Cause-specific wording (wrong PIN vs not-armed vs unreachable
|
||||
// vs a typed host rejection) — shared with every other surface.
|
||||
console.set_pair(PairPhase::Failed(trust::pair_error_message(&e)));
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,7 +64,9 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
}
|
||||
Err(e) => {
|
||||
st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)"));
|
||||
// Cause-specific: wrong PIN vs pairing-not-armed vs unreachable —
|
||||
// never blame the PIN for a dead network path (shared wording).
|
||||
st.call(trust::pair_error_message(&e));
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, load_or_create_identity, parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
hex, learn_mac, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -40,6 +40,11 @@ tracing = "0.1"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pipewire = "0.9"
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
# PyroWave decode (the opt-in wired-LAN wavelet codec, design/pyrowave-codec-plan.md
|
||||
# §4.5) — pure Vulkan compute on the presenter's shared device. `ash` only wraps the
|
||||
# presenter's existing raw handles (same pinned version as pf-presenter).
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
ash = { version = "0.38", optional = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
@@ -57,3 +62,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
||||
# method itself is feature-gated behind this.
|
||||
"Win32_Security",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# PyroWave client decode ships in every default build (flatpak included; pyrowave-sys is a
|
||||
# vendored in-repo tree, offline-safe, and an empty stub off Linux/Windows). The codec is
|
||||
# still strictly per-session opt-in (Settings codec pick / PUNKTFUNK_PREFER_PYROWAVE=1).
|
||||
default = ["pyrowave"]
|
||||
pyrowave = ["dep:pyrowave-sys", "dep:ash"]
|
||||
|
||||
@@ -264,9 +264,14 @@ impl PadInfo {
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self.pref {
|
||||
GamepadPref::DualSense => "DualSense",
|
||||
GamepadPref::DualSenseEdge => "DualSense Edge",
|
||||
GamepadPref::DualShock4 => "DualShock 4",
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
GamepadPref::SteamDeck => "Steam Deck",
|
||||
GamepadPref::SteamController => "Steam Controller",
|
||||
GamepadPref::SteamController2 => "Steam Controller 2",
|
||||
GamepadPref::SteamController2Puck => "Steam Controller 2 Puck",
|
||||
GamepadPref::SwitchPro => "Switch Pro",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -297,6 +302,9 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
T::PS5 => GamepadPref::DualSense,
|
||||
T::PS4 => GamepadPref::DualShock4,
|
||||
T::XboxOne => GamepadPref::XboxOne,
|
||||
// A paired Joy-Con set exposes the full Pro button surface through SDL, so it rides
|
||||
// the same virtual pad; single Joy-Cons stay on the Xbox 360 fallback (half a pad).
|
||||
T::NintendoSwitchPro | T::NintendoSwitchJoyconPair => GamepadPref::SwitchPro,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
@@ -778,11 +786,20 @@ impl Worker {
|
||||
self.subsystem.product_for_id(jid).unwrap_or(0),
|
||||
);
|
||||
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
|
||||
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
|
||||
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
|
||||
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
||||
// VID/PID — the host then builds the matching virtual hid-steam pad (grips + trackpads +
|
||||
// the right glyph identity): Deck 0x1205; classic SC wired 0x1102 / dongle 0x1142.
|
||||
if vid == 0x28DE && pid == 0x1205 {
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
if vid == 0x28DE && matches!(pid, 0x1102 | 0x1142) {
|
||||
pref = GamepadPref::SteamController;
|
||||
}
|
||||
// The DualSense Edge has no distinct SDL gamepad type either (it reports PS5) — detect by
|
||||
// VID/PID so the host builds the virtual Edge and this pad's back paddles land on native
|
||||
// slots instead of the fold/drop policy.
|
||||
if vid == 0x054C && pid == 0x0DF2 {
|
||||
pref = GamepadPref::DualSenseEdge;
|
||||
}
|
||||
let name = self
|
||||
.subsystem
|
||||
.name_for_id(jid)
|
||||
@@ -1556,7 +1573,12 @@ impl Worker {
|
||||
let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else {
|
||||
continue;
|
||||
};
|
||||
let is_ds = slot.pref == GamepadPref::DualSense;
|
||||
// A physical Edge takes the same raw DS5 effects packets (SDL's DS5EffectsState_t
|
||||
// layout is shared; SDL keys the enhanced path off the Edge PID itself).
|
||||
let is_ds = matches!(
|
||||
slot.pref,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
);
|
||||
match hid {
|
||||
HidOutput::Led { r, g, b, .. } if is_ds => {
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
||||
@@ -1586,7 +1608,8 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
HidOutput::Led { pad, .. }
|
||||
| HidOutput::PlayerLeds { pad, .. }
|
||||
| HidOutput::Trigger { pad, .. }
|
||||
| HidOutput::TrackpadHaptic { pad, .. } => *pad,
|
||||
| HidOutput::TrackpadHaptic { pad, .. }
|
||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1904,5 +1927,13 @@ mod slot_tests {
|
||||
}),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
hidout_pad(&HidOutput::HidRaw {
|
||||
pad: 6,
|
||||
kind: 0,
|
||||
data: vec![0x80, 0, 0]
|
||||
}),
|
||||
6
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,11 @@ pub mod session;
|
||||
pub mod trust;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod video;
|
||||
// PyroWave decode — Linux + `pyrowave` feature only (plan §4.5; the Windows client's
|
||||
// present-path decision and the Apple Metal port are their own phases).
|
||||
#[cfg(windows)]
|
||||
pub mod video_d3d11;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pub mod video_pyrowave;
|
||||
|
||||
pub mod wol;
|
||||
|
||||
@@ -211,6 +211,21 @@ fn pump(
|
||||
frame_tx: async_channel::Sender<DecodedFrame>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
// PUNKTFUNK_PREFER_PYROWAVE=1 — the Phase-2 lab opt-in for the wired-LAN wavelet codec
|
||||
// (a Settings toggle is the Phase-3 productization). Riding `preferred_codec` is exactly
|
||||
// the plan-§3 contract: the host only ever picks PyroWave when the client names it.
|
||||
#[allow(unused_mut)]
|
||||
let mut preferred = params.preferred_codec;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if std::env::var("PUNKTFUNK_PREFER_PYROWAVE").as_deref() == Ok("1") {
|
||||
if params.vulkan.as_ref().is_some_and(|v| v.pyrowave_decode) {
|
||||
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"PUNKTFUNK_PREFER_PYROWAVE=1 but the presenter device failed the pyrowave probe — keeping the normal codec preference"
|
||||
);
|
||||
}
|
||||
}
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
@@ -220,8 +235,9 @@ fn pump(
|
||||
params.bitrate_kbps,
|
||||
params.video_caps,
|
||||
params.audio_channels,
|
||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||
// FFmpeg's codecs plus CODEC_PYROWAVE when the presenter device passed the probe.
|
||||
crate::video::decodable_codecs_for(params.vulkan.as_ref()),
|
||||
preferred, // the user's soft codec preference (0 = auto; see the pyrowave opt-in above)
|
||||
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
||||
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
||||
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
||||
@@ -239,6 +255,10 @@ fn pump(
|
||||
.to_string()
|
||||
}
|
||||
PunktfunkError::Timeout => "Connection timed out".to_string(),
|
||||
// The host said WHY it turned us away (typed application close) — show that
|
||||
// verbatim instead of a generic failure: "the request was denied on the host"
|
||||
// and "connection timed out" call for very different next steps.
|
||||
PunktfunkError::Rejected(reason) => crate::trust::connect_reject_message(reason),
|
||||
other => format!("Connect failed: {other:?}"),
|
||||
};
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Failed {
|
||||
@@ -262,7 +282,29 @@ fn pump(
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
let mut decoder = match Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref()) {
|
||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||
// reachable only through the explicit preference above (resolve_codec never
|
||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
let mode = connector.mode();
|
||||
match params.vulkan.as_ref() {
|
||||
Some(vk) => Decoder::new_pyrowave(
|
||||
vk,
|
||||
mode.width,
|
||||
mode.height,
|
||||
connector.shard_payload as usize,
|
||||
),
|
||||
None => Err(anyhow::anyhow!(
|
||||
"pyrowave session without a presenter device"
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref())
|
||||
};
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
let built = Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref());
|
||||
let mut decoder = match built {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||
@@ -286,7 +328,23 @@ fn pump(
|
||||
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
|
||||
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||
let clock_offset_live = connector.clock_offset_shared();
|
||||
// PUNKTFUNK_DEBUG_RECONFIGURE=WxH@HZ:SECS — lab lever: request ONE mid-stream mode
|
||||
// switch N seconds in, so a headless session (no window manager to drag a window in)
|
||||
// can exercise the resize path deterministically — host pipeline rebuild, decoder
|
||||
// follow-through (e.g. the PyroWave in-place rebuild), overlay/aspect handling.
|
||||
let pump_start = Instant::now();
|
||||
let mut debug_reconfig = std::env::var("PUNKTFUNK_DEBUG_RECONFIGURE")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
let parsed = parse_debug_reconfigure(&s);
|
||||
if parsed.is_none() {
|
||||
tracing::warn!(value = %s, "PUNKTFUNK_DEBUG_RECONFIGURE not understood (want WxH@HZ:SECS) — ignored");
|
||||
}
|
||||
parsed
|
||||
});
|
||||
let mut total_frames = 0u64;
|
||||
// Newest frame index handed to the decoder — the staleness bar for late partials.
|
||||
let mut newest_decoded_idx: Option<u32> = None;
|
||||
let mut window_start = Instant::now();
|
||||
let mut frames_n = 0u32;
|
||||
let mut bytes_n = 0u64;
|
||||
@@ -294,6 +352,9 @@ fn pump(
|
||||
// corrected), `decode` = received→decoded (client-local). p50 per 1 s window.
|
||||
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
|
||||
// Adaptive bitrate: report the decode stage back to the core controller only when it's armed
|
||||
// (Automatic, non-PyroWave). Constant for the session — resolve once, gate the per-frame call.
|
||||
let wants_decode = connector.wants_decode_latency();
|
||||
// Host/network split (Phase 2): frames awaiting their per-AU 0xCF host timing,
|
||||
// correlated by pts_ns. Bounded — an old host never sends any, so entries just age out.
|
||||
let mut pending_split: std::collections::VecDeque<(u64, u64)> =
|
||||
@@ -324,6 +385,18 @@ fn pump(
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break None;
|
||||
}
|
||||
if let Some((mode, delay)) = debug_reconfig {
|
||||
if pump_start.elapsed() >= delay {
|
||||
tracing::info!(
|
||||
?mode,
|
||||
"PUNKTFUNK_DEBUG_RECONFIGURE: requesting mid-stream mode switch"
|
||||
);
|
||||
if let Err(e) = connector.request_mode(mode) {
|
||||
tracing::warn!(error = ?e, "debug mode switch request failed");
|
||||
}
|
||||
debug_reconfig = None;
|
||||
}
|
||||
}
|
||||
// 20 ms wait: audio has its own thread now, so this only bounds stop-flag
|
||||
// responsiveness and the per-iteration keyframe-recovery check (a frame arrives
|
||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||
@@ -395,7 +468,21 @@ fn pump(
|
||||
}
|
||||
None => next_expected_index = Some(frame.frame_index.wrapping_add(1)),
|
||||
}
|
||||
match decoder.decode(&frame.data) {
|
||||
// A PARTIAL that lost the race (a newer frame already decoded) is pure
|
||||
// time travel — skip it; each PyroWave frame is independent, so nothing
|
||||
// downstream needs it. Completes keep the normal path (reorder is handled
|
||||
// by the continuity gate).
|
||||
if !frame.complete
|
||||
&& newest_decoded_idx
|
||||
.is_some_and(|n: u32| n.wrapping_sub(frame.frame_index) <= u32::MAX / 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
newest_decoded_idx = Some(match newest_decoded_idx {
|
||||
Some(n) if frame.frame_index.wrapping_sub(n) > u32::MAX / 2 => n,
|
||||
_ => frame.frame_index,
|
||||
});
|
||||
match decoder.decode_frame(&frame.data, frame.flags, frame.complete) {
|
||||
Ok(Some(image)) => {
|
||||
// Fold this decoded frame through the shared freeze gate: it reads the AU's
|
||||
// re-anchor wire flags (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT),
|
||||
@@ -413,6 +500,8 @@ fn pump(
|
||||
DecodedImage::VkFrame(_) => "vulkan",
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(_) => "d3d11va",
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(_) => "pyrowave",
|
||||
};
|
||||
if total_frames == 1 {
|
||||
let (w, h, path) = match &image {
|
||||
@@ -422,6 +511,8 @@ fn pump(
|
||||
DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(d) => (d.width, d.height, "d3d11va"),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => (f.width, f.height, "pyrowave"),
|
||||
};
|
||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||
}
|
||||
@@ -486,6 +577,15 @@ fn pump(
|
||||
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
|
||||
}
|
||||
}
|
||||
// Adaptive bitrate: feed the decoder-backlog signal every frame (the network
|
||||
// signals can't see the client's decoder). Uses the CPU-side decoded stamp:
|
||||
// exact for the synchronous D3D11VA/software path; received→submit for the
|
||||
// async Vulkan-Video path — still the decoder-input backpressure the rate
|
||||
// controller needs, without the per-frame fence wait the HUD stat avoids.
|
||||
if wants_decode {
|
||||
let us = decoded_ns.saturating_sub(received_ns) / 1000;
|
||||
connector.report_decode_us(us.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
}
|
||||
// The decoder produced nothing — under zero-reorder LOW_DELAY (one-in/one-out) that
|
||||
// means it's wedged on missing references with no reassembler drop to trigger
|
||||
@@ -703,3 +803,43 @@ fn spawn_audio(
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio thread failed to start — audio disabled"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Parse the `PUNKTFUNK_DEBUG_RECONFIGURE` lab lever: `WxH@HZ:SECS` → request that mode
|
||||
/// SECS seconds into the stream (e.g. `1280x720@60:5`).
|
||||
fn parse_debug_reconfigure(s: &str) -> Option<(Mode, Duration)> {
|
||||
let (mode_s, secs_s) = s.split_once(':')?;
|
||||
let (res, hz) = mode_s.split_once('@')?;
|
||||
let (w, h) = res.split_once('x')?;
|
||||
let mode = Mode {
|
||||
width: w.trim().parse().ok()?,
|
||||
height: h.trim().parse().ok()?,
|
||||
refresh_hz: hz.trim().parse().ok()?,
|
||||
};
|
||||
Some((mode, Duration::from_secs(secs_s.trim().parse().ok()?)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_reconfigure_parses_the_documented_shape() {
|
||||
let (mode, delay) = parse_debug_reconfigure("1280x720@60:5").unwrap();
|
||||
assert_eq!((mode.width, mode.height, mode.refresh_hz), (1280, 720, 60));
|
||||
assert_eq!(delay, Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_reconfigure_rejects_garbage() {
|
||||
for bad in [
|
||||
"",
|
||||
"1280x720",
|
||||
"1280x720@60",
|
||||
"x@:",
|
||||
"ax b@c:d",
|
||||
"1280x720@60:x",
|
||||
] {
|
||||
assert!(parse_debug_reconfigure(bad).is_none(), "{bad:?} parsed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,65 @@ pub fn pair_with_host(
|
||||
)
|
||||
}
|
||||
|
||||
/// User-facing sentence for a failed connect / request-access, keyed on the actual cause —
|
||||
/// shared by every desktop/console surface so "the host declined this device" never renders
|
||||
/// as "connection timed out". Reason-specific text for a typed host rejection
|
||||
/// ([`punktfunk_core::reject::RejectReason`]); the caller keeps its own wording for
|
||||
/// non-rejection errors.
|
||||
pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> String {
|
||||
use punktfunk_core::reject::RejectReason as R;
|
||||
match reason {
|
||||
R::Denied => "The host declined this device's request.".into(),
|
||||
R::ApprovalTimeout => {
|
||||
"Nobody approved the request on the host in time — approve this device in the \
|
||||
host's console or web UI, then request access again."
|
||||
.into()
|
||||
}
|
||||
R::Superseded => {
|
||||
"A newer request from this device replaced this one — approve the latest request \
|
||||
on the host."
|
||||
.into()
|
||||
}
|
||||
R::IdentityRequired => {
|
||||
"The host requires pairing — pair this device (PIN or request access) first.".into()
|
||||
}
|
||||
R::PairingNotArmed => {
|
||||
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try \
|
||||
again."
|
||||
.into()
|
||||
}
|
||||
R::PairingBoundToOtherDevice => {
|
||||
"The host's pairing window is armed for a different device — arm it for this one."
|
||||
.into()
|
||||
}
|
||||
R::PairingRateLimited => {
|
||||
"Too many pairing attempts — wait a couple of seconds and try again.".into()
|
||||
}
|
||||
R::WireVersionMismatch => {
|
||||
"Client and host versions don't match — update both to the same release.".into()
|
||||
}
|
||||
R::Busy => "The host is busy with another session.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing sentence for a failed PIN pairing ceremony ([`pair_with_host`]) — distinguishes
|
||||
/// a wrong PIN (the SPAKE2 proof failed) from an unreachable host and from the host's typed
|
||||
/// rejections, so a dead network path or a disarmed host is never reported as a bad PIN.
|
||||
pub fn pair_error_message(err: &punktfunk_core::PunktfunkError) -> String {
|
||||
use punktfunk_core::PunktfunkError as E;
|
||||
match err {
|
||||
E::Crypto => "Wrong PIN — check the PIN on the host's Pairing page and try again.".into(),
|
||||
E::Rejected(reason) => connect_reject_message(*reason),
|
||||
E::Timeout => "The host didn't answer. Is it running and reachable?".into(),
|
||||
E::Io(_) => {
|
||||
"Couldn't reach the host — check that this device and the host are on the same \
|
||||
network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
||||
.into()
|
||||
}
|
||||
other => format!("Pairing failed: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is
|
||||
/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to
|
||||
/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic,
|
||||
@@ -515,6 +574,10 @@ impl Settings {
|
||||
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
|
||||
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
|
||||
"av1" => punktfunk_core::quic::CODEC_AV1,
|
||||
// The wired-LAN wavelet codec: preference-only by design (resolve_codec never
|
||||
// auto-picks it), and harmless on a build/device that doesn't advertise the
|
||||
// bit — the ladder falls back to HEVC.
|
||||
"pyrowave" => punktfunk_core::quic::CODEC_PYROWAVE,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -631,6 +694,9 @@ mod tests {
|
||||
assert!(s.mic_enabled);
|
||||
assert_eq!(s.decoder, "hardware");
|
||||
assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1);
|
||||
let mut pw = s.clone();
|
||||
pw.codec = "pyrowave".into();
|
||||
assert_eq!(pw.preferred_codec(), punktfunk_core::quic::CODEC_PYROWAVE);
|
||||
assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080");
|
||||
assert!(s.hdr_enabled);
|
||||
// The old shell's `show_hud` lands on `show_stats` (the user's preference survives).
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
||||
//!
|
||||
//! Three backends, picked at session start (auto: vulkan → vaapi → software;
|
||||
//! Three backends, picked at session start (auto on Linux: vaapi → vulkan → software on
|
||||
//! desktop Mesa, vulkan first on NVIDIA/VanGogh — see
|
||||
//! [`VulkanDecodeDevice::prefer_vulkan_over_vaapi`];
|
||||
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
|
||||
//!
|
||||
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
||||
@@ -66,6 +68,11 @@ pub enum DecodedImage {
|
||||
/// (Intel's Windows driver foremost). See `crate::video_d3d11`.
|
||||
#[cfg(windows)]
|
||||
D3d11(crate::video_d3d11::D3d11Frame),
|
||||
/// PyroWave planar output: three R8 plane views on the presenter's own device,
|
||||
/// decode already fence-complete, GENERAL layout — the presenter's planar CSC
|
||||
/// samples them directly (BT.709 limited, the codec's fixed colour contract).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
PyroWave(crate::video_pyrowave::PyroWavePlanarFrame),
|
||||
}
|
||||
|
||||
/// One Vulkan-decoded frame. The image lives on the presenter's own VkDevice (the
|
||||
@@ -181,6 +188,8 @@ impl DecodedImage {
|
||||
DecodedImage::VkFrame(f) => f.keyframe,
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => f.keyframe,
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => f.keyframe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +204,8 @@ impl DecodedImage {
|
||||
DecodedImage::VkFrame(f) => (f.width, f.height),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => (f.width, f.height),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => (f.width, f.height),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +321,11 @@ enum Backend {
|
||||
Vaapi(VaapiDecoder),
|
||||
#[cfg(windows)]
|
||||
D3d11va(crate::video_d3d11::D3d11vaDecoder),
|
||||
/// PyroWave (wired-LAN wavelet codec): pyrowave compute on the presenter's device,
|
||||
/// no FFmpeg involvement. No demotion rung — there is no other decoder for it.
|
||||
/// Boxed: the decoder (pinned create-info hold + plane ring) dwarfs the other variants.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
PyroWave(Box<crate::video_pyrowave::PyroWaveDecoder>),
|
||||
Software(SoftwareDecoder),
|
||||
}
|
||||
|
||||
@@ -358,6 +374,21 @@ pub fn decodable_codecs() -> u8 {
|
||||
bits
|
||||
}
|
||||
|
||||
/// [`decodable_codecs`] plus the PyroWave bit when the presenter's device passed the
|
||||
/// compute-feature probe. Advertisement-only: `resolve_codec` never auto-picks PyroWave —
|
||||
/// the session must also name it `preferred_codec` (plan §3), which the client does only
|
||||
/// under its explicit opt-in.
|
||||
pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>) -> u8 {
|
||||
let bits = decodable_codecs();
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if vk.map(|v| v.pyrowave_decode).unwrap_or(false) {
|
||||
return bits | punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
}
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
let _ = vk;
|
||||
bits
|
||||
}
|
||||
|
||||
/// libavcodec logs reference-frame recovery to the process stderr very verbosely
|
||||
/// (`First slice in a frame missing`, `Could not find ref with POC …`, `Error
|
||||
/// constructing the frame RPS`) — normal chatter while the decoder waits for a keyframe
|
||||
@@ -384,8 +415,11 @@ impl Decoder {
|
||||
/// `vk` is the presenter's shared Vulkan device when its stack can run FFmpeg's
|
||||
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
||||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||
/// hatch, and the documented knob), then the setting; both default to auto
|
||||
/// (Vulkan → VAAPI → software; no VAAPI on Windows).
|
||||
/// hatch, and the documented knob), then the setting; both default to auto.
|
||||
/// Auto's hardware order on Linux depends on the device
|
||||
/// ([`VulkanDecodeDevice::prefer_vulkan_over_vaapi`]): VAAPI → Vulkan → software on
|
||||
/// desktop Mesa (AMD/Intel), Vulkan → VAAPI → software on NVIDIA and the Deck's
|
||||
/// VanGogh. Windows is Vulkan → D3D11VA → software (no VAAPI there).
|
||||
pub fn new(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
pref: &str,
|
||||
@@ -405,6 +439,31 @@ impl Decoder {
|
||||
want_keyframe: false,
|
||||
})
|
||||
};
|
||||
// Linux `auto`: try VAAPI FIRST unless this device is one where Vulkan Video is
|
||||
// the established right answer (NVIDIA — no usable VAAPI; VanGogh — VAAPI
|
||||
// chroma-fringes). Mesa now exposes decode queues by default (and the session
|
||||
// binary opts RADV in for the Deck's sake), which silently moved every desktop
|
||||
// AMD/Intel box onto FFmpeg-Vulkan-on-Mesa — user-reported to judder/error-streak
|
||||
// (then demote to software) where explicit VAAPI streams perfectly.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut vaapi_tried = false;
|
||||
#[cfg(target_os = "linux")]
|
||||
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||||
&& !vk
|
||||
.filter(|v| v.video_decode)
|
||||
.is_some_and(|v| v.prefer_vulkan_over_vaapi())
|
||||
{
|
||||
vaapi_tried = true;
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(reason = %e, "VAAPI unavailable — trying Vulkan Video");
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(choice.as_str(), "auto" | "" | "vulkan" | "hardware") {
|
||||
// `video_decode` gates the Vulkan Video attempt: the presenter now exports its
|
||||
// handle bundle even when the device has no decode queue (Windows D3D11 interop
|
||||
@@ -423,7 +482,7 @@ impl Decoder {
|
||||
return Err(e.context("PUNKTFUNK_DECODER=vulkan but it failed"));
|
||||
}
|
||||
tracing::info!(reason = %format!("{e:#}"),
|
||||
"Vulkan Video unavailable — trying VAAPI");
|
||||
"Vulkan Video unavailable — falling back");
|
||||
}
|
||||
},
|
||||
None if choice == "vulkan" => {
|
||||
@@ -435,12 +494,13 @@ impl Decoder {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Deck note: `auto` reaches VAAPI when Vulkan Video isn't available. A presenter
|
||||
// that can't display the dmabufs demotes this decoder to software mid-session
|
||||
// via [`Decoder::force_software`]. Windows has no VAAPI — auto falls straight
|
||||
// through to software there.
|
||||
// Deck/NVIDIA note: `auto` reaches VAAPI here when Vulkan Video isn't available
|
||||
// (on desktop Mesa it was already tried above — `vaapi_tried` skips the repeat).
|
||||
// A presenter that can't display the dmabufs demotes this decoder to software
|
||||
// mid-session via [`Decoder::force_software`]. Windows has no VAAPI — auto falls
|
||||
// straight through to software there.
|
||||
#[cfg(target_os = "linux")]
|
||||
if choice != "software" && choice != "vulkan" {
|
||||
if choice != "software" && choice != "vulkan" && !vaapi_tried {
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
@@ -508,6 +568,29 @@ impl Decoder {
|
||||
|
||||
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
|
||||
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
|
||||
/// Open a PyroWave decoder for a `CODEC_PYROWAVE` session (plan §4.5): pyrowave
|
||||
/// compute on the presenter's device, no FFmpeg. `codec_id` is irrelevant (kept as
|
||||
/// HEVC so an — impossible — demotion path stays well-formed).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pub fn new_pyrowave(
|
||||
vk: &VulkanDecodeDevice,
|
||||
width: u32,
|
||||
height: u32,
|
||||
shard_payload: usize,
|
||||
) -> Result<Decoder> {
|
||||
Ok(Decoder {
|
||||
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
||||
vk,
|
||||
width,
|
||||
height,
|
||||
shard_payload,
|
||||
)?)),
|
||||
codec_id: ffmpeg::codec::Id::HEVC,
|
||||
vaapi_fails: 0,
|
||||
want_keyframe: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn take_keyframe_request(&mut self) -> bool {
|
||||
std::mem::take(&mut self.want_keyframe)
|
||||
}
|
||||
@@ -535,12 +618,43 @@ impl Decoder {
|
||||
/// pump asks the host for a fresh IDR — under the infinite GOP nothing else resyncs a
|
||||
/// rebuilt/erroring decoder, so skipping this leaves the picture gray/frozen for good.
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||||
self.decode_frame(au, 0, true)
|
||||
}
|
||||
|
||||
/// [`decode`](Self::decode) with the AU's wire facts: `user_flags` (chunk-aligned AUs
|
||||
/// are parsed in shard windows — [`punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED`])
|
||||
/// and completeness (`false` = a partial delivery; only the PyroWave backend decodes
|
||||
/// those — as one frame of localized blur, plan §4.4).
|
||||
pub fn decode_frame(
|
||||
&mut self,
|
||||
au: &[u8],
|
||||
// Only the PyroWave backend reads the flags; without that feature the param is unused.
|
||||
#[cfg_attr(
|
||||
not(all(target_os = "linux", feature = "pyrowave")),
|
||||
allow(unused_variables)
|
||||
)]
|
||||
user_flags: u32,
|
||||
complete: bool,
|
||||
) -> Result<Option<DecodedImage>> {
|
||||
let result = match &mut self.backend {
|
||||
Backend::Vulkan(v) => v.decode(au).map(|f| f.map(DecodedImage::VkFrame)),
|
||||
Backend::Vulkan(v) => {
|
||||
debug_assert!(complete, "partial AUs are pyrowave-only");
|
||||
v.decode(au).map(|f| f.map(DecodedImage::VkFrame))
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Backend::Vaapi(v) => v.decode(au).map(|f| f.map(DecodedImage::Dmabuf)),
|
||||
#[cfg(windows)]
|
||||
Backend::D3d11va(d) => d.decode(au).map(|f| f.map(DecodedImage::D3d11)),
|
||||
// No demote ladder below PyroWave (nothing else decodes it): propagate the
|
||||
// error; the pump surfaces it and the session falls back to HEVC by
|
||||
// renegotiation (plan §4.6), not by decoder swap.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
Backend::PyroWave(p) => {
|
||||
let aligned = user_flags & punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED != 0;
|
||||
return Ok(p
|
||||
.decode_frame(au, aligned, complete)?
|
||||
.map(DecodedImage::PyroWave));
|
||||
}
|
||||
Backend::Software(s) => return Ok(s.decode(au)?.map(DecodedImage::Cpu)),
|
||||
};
|
||||
match result {
|
||||
@@ -558,6 +672,24 @@ impl Decoder {
|
||||
self.vaapi_fails += 1;
|
||||
self.want_keyframe = true;
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||
// A failing Vulkan backend still has a hardware rung below it on
|
||||
// Linux — demote to VAAPI first (user-reported: FFmpeg-Vulkan-on-Mesa
|
||||
// error-streaking where VAAPI streams perfectly); only when that
|
||||
// can't be built either does the session land on software.
|
||||
#[cfg(target_os = "linux")]
|
||||
if matches!(self.backend, Backend::Vulkan(_)) {
|
||||
match VaapiDecoder::new(self.codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
||||
self.backend = Backend::Vaapi(v);
|
||||
self.vaapi_fails = 0;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(va) => tracing::info!(reason = %va,
|
||||
"VAAPI unavailable for demotion — software decode"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"{which} decode failing repeatedly — demoting to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
@@ -1002,6 +1134,12 @@ pub struct VulkanDecodeDevice {
|
||||
pub instance: usize,
|
||||
pub physical_device: usize,
|
||||
pub device: usize,
|
||||
/// PCI vendor of the presenter's physical device (0x10DE NVIDIA, 0x1002 AMD,
|
||||
/// 0x8086 Intel) — drives [`Self::prefer_vulkan_over_vaapi`].
|
||||
pub vendor_id: u32,
|
||||
/// The driver's device-name string (e.g. "AMD RADV VANGOGH") — the VanGogh/Deck
|
||||
/// detection for [`Self::prefer_vulkan_over_vaapi`].
|
||||
pub device_name: String,
|
||||
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
||||
pub graphics_qf: u32,
|
||||
/// Raw `VkQueueFlags` of that family (the qf[] entry wants the real capabilities).
|
||||
@@ -1022,6 +1160,24 @@ pub struct VulkanDecodeDevice {
|
||||
/// features). The bundle now exists even without it — Windows D3D11 interop rides the
|
||||
/// same struct — so consumers gate the FFmpeg-Vulkan decoder on THIS, not on `Some`.
|
||||
pub video_decode: bool,
|
||||
/// PyroWave decode (the wired-LAN wavelet codec) is usable: Vulkan 1.3 + the compute
|
||||
/// features its kernels need were present AND enabled at device creation
|
||||
/// (`shaderInt16`, `storageBuffer8BitAccess`, subgroup size control). Gates the
|
||||
/// `CODEC_PYROWAVE` advertisement and the pyrowave decoder backend.
|
||||
pub pyrowave_decode: bool,
|
||||
/// The feature facts + creation shape the pyrowave decoder's pinned create-info
|
||||
/// reconstruction mirrors (pyrowave 0.4.0 requires the instance/device create infos —
|
||||
/// content-accurate, kept alive — to share our VkDevice).
|
||||
pub f_shader_int16: bool,
|
||||
pub f_storage_buffer8: bool,
|
||||
pub f_subgroup_size_control: bool,
|
||||
pub f_compute_full_subgroups: bool,
|
||||
pub f_shader_float16: bool,
|
||||
/// `VkPhysicalDeviceProperties::apiVersion` of the presenter's device.
|
||||
pub api_version: u32,
|
||||
/// The queue families the device was created with (one `VkDeviceQueueCreateInfo` each,
|
||||
/// one queue per family, priority 1.0) — mirrored by the reconstruction.
|
||||
pub queue_families: Vec<u32>,
|
||||
/// The presenter enabled `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`:
|
||||
/// D3D11 shared-texture frames can reach the screen. Always `false` off Windows.
|
||||
pub d3d11_import: bool,
|
||||
@@ -1035,6 +1191,27 @@ pub struct VulkanDecodeDevice {
|
||||
pub queue_lock: std::sync::Arc<QueueLock>,
|
||||
}
|
||||
|
||||
impl VulkanDecodeDevice {
|
||||
/// Should `auto` try Vulkan Video BEFORE VAAPI on this device?
|
||||
/// * **NVIDIA** — Vulkan is its only hardware path (no usable VAAPI; the
|
||||
/// nvidia-vaapi-driver is broken for this, Moonlight blacklists it).
|
||||
/// * **AMD (RADV, VanGogh included)** — Vulkan decode outperforms VAAPI on RADV
|
||||
/// (on-glass verdict), and on VanGogh VAAPI's separate-plane dmabuf import
|
||||
/// additionally shows chroma fringing; the session binary opts RADV into
|
||||
/// `video_decode` precisely to get the Vulkan path. Vulkan-first is safe here
|
||||
/// because a mid-session Vulkan failure streak demotes to VAAPI (not software),
|
||||
/// so a broken Mesa Vulkan path still lands on the working driver.
|
||||
///
|
||||
/// Intel (ANV) and unknown vendors keep the battle-tested zero-copy VAAPI first —
|
||||
/// ANV's Vulkan Video is the least-proven Mesa path and VAAPI is what every other
|
||||
/// Linux client uses there.
|
||||
pub fn prefer_vulkan_over_vaapi(&self) -> bool {
|
||||
const VENDOR_NVIDIA: u32 = 0x10DE;
|
||||
const VENDOR_AMD: u32 = 0x1002;
|
||||
self.vendor_id == VENDOR_NVIDIA || self.vendor_id == VENDOR_AMD
|
||||
}
|
||||
}
|
||||
|
||||
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`).
|
||||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||
@@ -1505,6 +1682,59 @@ unsafe extern "C" fn pick_vulkan(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decode_device(vendor_id: u32, device_name: &str) -> VulkanDecodeDevice {
|
||||
VulkanDecodeDevice {
|
||||
get_instance_proc_addr: 0,
|
||||
instance: 0,
|
||||
physical_device: 0,
|
||||
device: 0,
|
||||
vendor_id,
|
||||
device_name: device_name.into(),
|
||||
graphics_qf: 0,
|
||||
graphics_queue_flags: 0,
|
||||
decode_qf: 0,
|
||||
decode_video_caps: 0,
|
||||
instance_extensions: Vec::new(),
|
||||
device_extensions: Vec::new(),
|
||||
f_sampler_ycbcr: true,
|
||||
f_timeline_semaphore: true,
|
||||
f_synchronization2: true,
|
||||
f_shader_int16: false,
|
||||
f_storage_buffer8: false,
|
||||
f_subgroup_size_control: false,
|
||||
f_compute_full_subgroups: false,
|
||||
f_shader_float16: false,
|
||||
api_version: 0,
|
||||
queue_families: Vec::new(),
|
||||
pyrowave_decode: false,
|
||||
video_decode: true,
|
||||
d3d11_import: false,
|
||||
adapter_luid: None,
|
||||
queue_lock: std::sync::Arc::new(QueueLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto's Linux hardware order: Vulkan-first on NVIDIA (no usable VAAPI) and ALL AMD
|
||||
/// (Vulkan decode outperforms VAAPI on RADV — on-glass verdict; VanGogh additionally
|
||||
/// chroma-fringes over VAAPI); Intel/unknown keep VAAPI first (ANV's Vulkan Video is
|
||||
/// the least-proven Mesa path). A Vulkan failure streak still demotes to VAAPI, so
|
||||
/// Vulkan-first can never strand a box on software decode.
|
||||
#[test]
|
||||
fn vulkan_over_vaapi_on_nvidia_and_amd() {
|
||||
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_over_vaapi());
|
||||
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_over_vaapi());
|
||||
assert!(
|
||||
decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_over_vaapi()
|
||||
);
|
||||
assert!(
|
||||
decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)").prefer_vulkan_over_vaapi()
|
||||
);
|
||||
assert!(
|
||||
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)")
|
||||
.prefer_vulkan_over_vaapi()
|
||||
);
|
||||
}
|
||||
|
||||
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||
ColorDesc {
|
||||
primaries: 1,
|
||||
|
||||
@@ -22,7 +22,9 @@ pub(crate) enum GlyphStyle {
|
||||
impl GlyphStyle {
|
||||
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
||||
match pref {
|
||||
Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes,
|
||||
Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
|
||||
GlyphStyle::Shapes
|
||||
}
|
||||
Some(_) => GlyphStyle::Letters,
|
||||
None => GlyphStyle::Keyboard,
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ pub(crate) struct ConnectIntent {
|
||||
pub launch: Option<String>,
|
||||
/// What the connecting card says (host or game title).
|
||||
pub title: String,
|
||||
/// The no-PIN delegated-approval connect (the pair screen's "Request access"): the
|
||||
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
|
||||
/// binary parks on a long budget and persists the host as paired once let in.
|
||||
pub request_access: bool,
|
||||
}
|
||||
|
||||
pub(crate) enum Nav {
|
||||
|
||||
@@ -100,6 +100,7 @@ impl HomeScreen {
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
request_access: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||