Compare commits
164 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 | |||
| 49533ff90a | |||
| 1b890ae919 | |||
| f88d0ae4dc | |||
| 94802795e7 | |||
| 764b5d938b | |||
| 1af11cc64d | |||
| 2f214532d9 | |||
| 31bc863084 | |||
| 60af4de3ba | |||
| aedffc69dd | |||
| 26cac9ce20 | |||
| 48933dc405 | |||
| e5166c6e6e | |||
| 59fc820226 | |||
| d611645ffc | |||
| 17457cf4ba | |||
| 6263108e15 | |||
| 0c427cb3f1 | |||
| 5109a4c80a | |||
| 43e52437c0 | |||
| 2642ba6ad0 | |||
| 236c59754b | |||
| 68b9f108ab | |||
| 5cd66eca59 | |||
| a9dc6efe55 | |||
| 9822fc3b1c | |||
| cdb43f00fe | |||
| 644c035a34 | |||
| 3c16c1dd30 | |||
| c0fc2d8ee8 | |||
| ef5808254a | |||
| 8a18e130a2 | |||
| cd701a9594 | |||
| 05868ef634 | |||
| 2d37835545 | |||
| 9514a8c0e2 | |||
| 97c67b2692 | |||
| 0ad4e6eff7 | |||
| 76be4c3e12 | |||
| 84329205eb | |||
| e9d8f2bc04 | |||
| bbbb7f5723 | |||
| 5ab6daa694 | |||
| 76594f27c1 | |||
| e89b2f60eb | |||
| 63bc2bb10f | |||
| ad532b08a0 | |||
| 93093f3cf9 | |||
| fdda7144ed | |||
| 0dc414f197 | |||
| a95b518ef3 | |||
| f77fdee3e9 | |||
| a85be8e467 | |||
| 1dfcb0b2f6 | |||
| e87fd42cee | |||
| dd02e1f402 | |||
| 2271f67202 | |||
| 89aa6767f9 | |||
| 7cea893db5 | |||
| e55ff1bb28 | |||
| 890c7531d8 | |||
| e6fbcecdb9 | |||
| 64b9d11ee6 |
@@ -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"]
|
||||||
@@ -90,7 +90,14 @@ jobs:
|
|||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||||
# both client binaries must ship (build-client-deb.sh installs both).
|
# both client binaries must ship (build-client-deb.sh installs both).
|
||||||
cargo build --release --locked \
|
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||||
|
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||||
|
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
||||||
|
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
||||||
|
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
||||||
|
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
||||||
|
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
||||||
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (bun preset)
|
- name: Build + smoke-boot web console (bun preset)
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
|||||||
|
|
||||||
## Before you push
|
## 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
|
```sh
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|||||||
@@ -870,15 +870,6 @@ dependencies = [
|
|||||||
"itertools 0.10.5",
|
"itertools 0.10.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossbeam-channel"
|
|
||||||
version = "0.5.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -2154,7 +2145,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2286,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2765,9 +2756,10 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"ash",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -2775,6 +2767,7 @@ dependencies = [
|
|||||||
"pf-ffvk",
|
"pf-ffvk",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
"pyrowave-sys",
|
||||||
"rustls",
|
"rustls",
|
||||||
"sdl3",
|
"sdl3",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2787,7 +2780,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2808,7 +2801,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2817,7 +2810,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3001,7 +2994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3017,7 +3010,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3033,7 +3026,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3048,22 +3041,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"crossbeam-channel",
|
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"opus",
|
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"sdl3",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"wasapi",
|
|
||||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||||
"windows-reactor",
|
"windows-reactor",
|
||||||
"windows-reactor-setup",
|
"windows-reactor-setup",
|
||||||
@@ -3072,7 +3060,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3103,7 +3091,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3131,9 +3119,11 @@ dependencies = [
|
|||||||
"nvidia-video-codec-sdk",
|
"nvidia-video-codec-sdk",
|
||||||
"openh264",
|
"openh264",
|
||||||
"opus",
|
"opus",
|
||||||
|
"parking_lot",
|
||||||
"pf-driver-proto",
|
"pf-driver-proto",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
"pyrowave-sys",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@@ -3175,7 +3165,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3189,7 +3179,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3204,6 +3194,14 @@ dependencies = [
|
|||||||
"winresource",
|
"winresource",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyrowave-sys"
|
||||||
|
version = "0.12.0"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"cmake",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ members = [
|
|||||||
"crates/pf-console-ui",
|
"crates/pf-console-ui",
|
||||||
"crates/pf-ffvk",
|
"crates/pf-ffvk",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
|
"crates/pyrowave-sys",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
"clients/session",
|
"clients/session",
|
||||||
@@ -35,7 +36,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.9.1"
|
"version": "0.11.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -2043,11 +2043,12 @@
|
|||||||
},
|
},
|
||||||
"ApiCodec": {
|
"ApiCodec": {
|
||||||
"type": "string",
|
"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": [
|
"enum": [
|
||||||
"h264",
|
"h264",
|
||||||
"h265",
|
"hevc",
|
||||||
"av1"
|
"av1",
|
||||||
|
"pyrowave"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ApiDisplayInfo": {
|
"ApiDisplayInfo": {
|
||||||
@@ -2811,6 +2812,7 @@
|
|||||||
"app_version",
|
"app_version",
|
||||||
"gfe_version",
|
"gfe_version",
|
||||||
"codecs",
|
"codecs",
|
||||||
|
"gamestream",
|
||||||
"ports"
|
"ports"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2831,6 +2833,10 @@
|
|||||||
},
|
},
|
||||||
"description": "Codecs the host can encode (NVENC)."
|
"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": {
|
"gfe_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "GFE version advertised to Moonlight clients."
|
"description": "GFE version advertised to Moonlight clients."
|
||||||
@@ -3393,9 +3399,16 @@
|
|||||||
"video_streaming",
|
"video_streaming",
|
||||||
"audio_streaming",
|
"audio_streaming",
|
||||||
"pin_pending",
|
"pin_pending",
|
||||||
"paired_clients"
|
"paired_clients",
|
||||||
|
"active_sessions"
|
||||||
],
|
],
|
||||||
"properties": {
|
"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": {
|
"audio_streaming": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True while the audio stream thread is running."
|
"description": "True while the audio stream thread is running."
|
||||||
@@ -3417,7 +3430,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SessionInfo",
|
"$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",
|
"$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",
|
"armed",
|
||||||
"sample_count",
|
"sample_count",
|
||||||
"started_unix_ms",
|
"started_unix_ms",
|
||||||
|
"elapsed_ms",
|
||||||
"kind"
|
"kind"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3606,6 +3620,12 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Capture currently running."
|
"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": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
||||||
|
|||||||
@@ -27,12 +27,27 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<!-- Gamepad rumble feedback. -->
|
<!-- Gamepad rumble feedback. -->
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<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
|
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
|
||||||
devices without a gamepad. -->
|
devices without a gamepad. -->
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
<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.software.leanback" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.gamepad" 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.
|
<!-- 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
|
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:name="android.game_mode_config"
|
||||||
android:resource="@xml/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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
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">
|
android:theme="@style/Theme.PunktfunkAndroid">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -303,7 +303,8 @@ internal fun PairPinDialog(
|
|||||||
if (fp.isNotEmpty()) {
|
if (fp.isNotEmpty()) {
|
||||||
onPaired(fp) // verified host fp — caller saves + connects
|
onPaired(fp) // verified host fp — caller saves + connects
|
||||||
} else {
|
} 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)."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which phase of the connect flow to draw — the pure view model [ConnectOverlay] resolves from the
|
||||||
|
* live dial/wake state, so [ConnectTakeover] / [ConnectModal] can render (and be screenshot-tested)
|
||||||
|
* statelessly.
|
||||||
|
*/
|
||||||
|
internal sealed interface ConnectPhase {
|
||||||
|
val hostName: String
|
||||||
|
|
||||||
|
/** The dial is in flight (shown the instant a host is picked). */
|
||||||
|
data class Connecting(override val hostName: String) : ConnectPhase
|
||||||
|
|
||||||
|
/** A sleeping host is being Wake-on-LAN'd and we're waiting for it to advertise again. */
|
||||||
|
data class Waking(override val hostName: String, val seconds: Int, val connectsAfter: Boolean) : ConnectPhase
|
||||||
|
|
||||||
|
/** The wake wait ran out — offer retry / cancel. */
|
||||||
|
data class WakeTimedOut(override val hostName: String) : ConnectPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-phase copy, shared by the console takeover and the touch modal so both read identically. */
|
||||||
|
private data class ConnectCopy(
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
/** Monospace the subtitle so a ticking seconds counter doesn't jitter its width. */
|
||||||
|
val monoSubtitle: Boolean,
|
||||||
|
val cancelLabel: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) {
|
||||||
|
is ConnectPhase.Connecting -> ConnectCopy(
|
||||||
|
"Connecting to ${phase.hostName}", "Establishing a secure connection…", false, "Cancel",
|
||||||
|
)
|
||||||
|
is ConnectPhase.Waking -> ConnectCopy(
|
||||||
|
"Waking ${phase.hostName}…",
|
||||||
|
"Waiting for it to come online · ${phase.seconds}s",
|
||||||
|
true,
|
||||||
|
// A wake-only wait (no dial after) says "Stop Waiting"; a wake that will connect says "Cancel".
|
||||||
|
if (!phase.connectsAfter) "Stop Waiting" else "Cancel",
|
||||||
|
)
|
||||||
|
is ConnectPhase.WakeTimedOut -> ConnectCopy(
|
||||||
|
"${phase.hostName} didn't wake",
|
||||||
|
"It may still be booting, or it's powered off / off this network.",
|
||||||
|
false,
|
||||||
|
"Cancel",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unified "getting you connected" feedback — one flow for BOTH phases of reaching a host, so the
|
||||||
|
* user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
||||||
|
* out to be asleep:
|
||||||
|
*
|
||||||
|
* - **Connecting** ([connectingHostName] non-null): the dial is in flight. Shown immediately on tap,
|
||||||
|
* so a host that takes a beat to answer no longer looks like nothing happened.
|
||||||
|
* - **Waking** ([WakeController.waking] non-null): the dial failed on a sleeping host, so we're firing
|
||||||
|
* Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on timeout.
|
||||||
|
*
|
||||||
|
* Presentation is mode-aware (mirrors the Apple client): in the **console / gamepad** UI it's a
|
||||||
|
* full-screen aurora [ConnectTakeover] — the same signature backdrop the console home uses, driven by
|
||||||
|
* the pad (B cancels, A retries once timed out) with a hint bar. In the **default touch** UI it's a
|
||||||
|
* Material [ConnectModal] over the host grid, matching the app's other dialogs — the aurora takeover
|
||||||
|
* looked out of place there.
|
||||||
|
*
|
||||||
|
* The two phases hand off within a single Compose frame (see ConnectScreen's `doConnectDirect` →
|
||||||
|
* `waker.start` → redial), so nothing blinks between them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConnectOverlay(
|
||||||
|
connectingHostName: String?,
|
||||||
|
waker: WakeController,
|
||||||
|
gamepadUi: Boolean,
|
||||||
|
onCancelConnect: () -> Unit,
|
||||||
|
) {
|
||||||
|
val waking = waker.waking
|
||||||
|
// Waking takes precedence (it only exists after a dial has failed) so a stray overlap can't strand
|
||||||
|
// the "Connecting…" phase over a wake in progress.
|
||||||
|
val phase = when {
|
||||||
|
waking != null && waking.timedOut -> ConnectPhase.WakeTimedOut(waking.hostName)
|
||||||
|
waking != null -> ConnectPhase.Waking(waking.hostName, waking.seconds, waking.connectsAfter)
|
||||||
|
connectingHostName != null -> ConnectPhase.Connecting(connectingHostName)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
// System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait.
|
||||||
|
val cancel = { if (waking != null) waker.cancel() else onCancelConnect() }
|
||||||
|
|
||||||
|
if (gamepadUi) {
|
||||||
|
BackHandler { cancel() }
|
||||||
|
// A retries once a wake has timed out; B falls through to the BackHandler above.
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = {},
|
||||||
|
onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() },
|
||||||
|
)
|
||||||
|
ConnectTakeover(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||||
|
} else {
|
||||||
|
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
|
||||||
|
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default-UI presentation: a Material dialog over the host grid, matching the app's other touch
|
||||||
|
* dialogs. A spinner (or the sleep glyph once timed out) sits above the title; the scrim is inert so a
|
||||||
|
* stray tap can't drop a connect in flight — only the buttons or system Back cancel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ConnectModal(
|
||||||
|
phase: ConnectPhase,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
val copy = connectCopy(phase)
|
||||||
|
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
properties = DialogProperties(dismissOnClickOutside = false),
|
||||||
|
icon = {
|
||||||
|
if (timedOut) {
|
||||||
|
Icon(Icons.Filled.Bedtime, contentDescription = null)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text(copy.title, textAlign = TextAlign.Center) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
copy.subtitle,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// No confirm action until the wake times out; then "Try Again" is the primary button.
|
||||||
|
confirmButton = {
|
||||||
|
if (timedOut) TextButton(onClick = onRetry) { Text("Try Again") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onCancel) { Text(copy.cancelLabel) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console / gamepad presentation: an opaque aurora backdrop with a centred spinner/title/subtitle
|
||||||
|
* for [phase], plus a bottom hint bar spelling out the pad actions (B cancels, A retries once timed
|
||||||
|
* out) — glyph-driven like every other console screen. onClick keeps the hints tappable too, so a
|
||||||
|
* user without a working pad can still get out.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ConnectTakeover(
|
||||||
|
phase: ConnectPhase,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
val copy = connectCopy(phase)
|
||||||
|
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Swallow taps so the screen behind can't be touched through the takeover.
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
if (timedOut) {
|
||||||
|
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.9f),
|
||||||
|
modifier = Modifier.size(46.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PulsingSpinner()
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
copy.title,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
copy.subtitle,
|
||||||
|
color = Color.White.copy(alpha = 0.65f),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val hints = buildList {
|
||||||
|
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
|
||||||
|
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
|
||||||
|
}
|
||||||
|
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connecting/waking indicator: a white progress ring inside two brand-violet halo rings that
|
||||||
|
* expand and fade on a staggered loop — a small sign of life so the takeover reads as working, not
|
||||||
|
* stalled.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PulsingSpinner() {
|
||||||
|
val transition = rememberInfiniteTransition(label = "connectPulse")
|
||||||
|
val pulse by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Restart),
|
||||||
|
label = "pulse",
|
||||||
|
)
|
||||||
|
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
val maxR = size.minDimension / 2f
|
||||||
|
for (i in 0..1) {
|
||||||
|
val p = (pulse + i * 0.5f) % 1f
|
||||||
|
drawCircle(
|
||||||
|
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
|
||||||
|
radius = maxR * (0.42f + p * 0.58f),
|
||||||
|
style = Stroke(width = 2.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
modifier = Modifier.size(54.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,16 @@ private class RequestAccessState(val target: PendingTrust) {
|
|||||||
val cancelled = AtomicBoolean(false)
|
val cancelled = AtomicBoolean(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain dial in flight — [hostName] labels the unified [ConnectOverlay]'s "Connecting…" phase, and
|
||||||
|
* [cancelled] lets its Cancel abort. The native connect is a blocking call with no abort, so Cancel
|
||||||
|
* returns the UI immediately and a late-arriving handle is torn down silently rather than navigating
|
||||||
|
* into a session the user already backed out of. Mirrors [RequestAccessState]'s late-result handling.
|
||||||
|
*/
|
||||||
|
private class ConnectAttempt(val hostName: String) {
|
||||||
|
val cancelled = AtomicBoolean(false)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectScreen(
|
fun ConnectScreen(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -107,6 +117,9 @@ fun ConnectScreen(
|
|||||||
var port by remember { mutableStateOf("9777") }
|
var port by remember { mutableStateOf("9777") }
|
||||||
var connecting by remember { mutableStateOf(false) }
|
var connecting by remember { mutableStateOf(false) }
|
||||||
var status by remember { mutableStateOf<String?>(null) }
|
var status by remember { mutableStateOf<String?>(null) }
|
||||||
|
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
|
||||||
|
// when idle or when the request-access / wake flows own the screen instead.
|
||||||
|
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
|
||||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||||
val (w, h, hz) = settings.effectiveMode(context)
|
val (w, h, hz) = settings.effectiveMode(context)
|
||||||
|
|
||||||
@@ -267,11 +280,20 @@ fun ConnectScreen(
|
|||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val thisAttempt = ConnectAttempt(name)
|
||||||
|
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
|
||||||
connecting = true
|
connecting = true
|
||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = null
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||||
|
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
|
||||||
|
// cancelConnect — drop the just-opened session silently rather than navigating into it.
|
||||||
|
if (thisAttempt.cancelled.get()) {
|
||||||
|
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
attempt = null
|
||||||
connecting = false
|
connecting = false
|
||||||
if (handle != 0L) {
|
if (handle != 0L) {
|
||||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||||
@@ -283,16 +305,32 @@ fun ConnectScreen(
|
|||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} else {
|
||||||
discovery.start()
|
discovery.start()
|
||||||
if (onFailure != null) {
|
val token = NativeBridge.nativeTakeLastError()
|
||||||
status = ""
|
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()
|
onFailure()
|
||||||
} else {
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel a plain dial in flight (the overlay's "Connecting…" phase, B / Cancel). The native
|
||||||
|
// connect can't be aborted, so flag this attempt (a late handle is closed silently in
|
||||||
|
// doConnectDirect) and return the UI now, resuming the discovery we paused for the dial.
|
||||||
|
fun cancelConnect() {
|
||||||
|
attempt?.cancelled?.set(true)
|
||||||
|
attempt = null
|
||||||
|
connecting = false
|
||||||
|
discovery.start()
|
||||||
|
}
|
||||||
|
|
||||||
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||||
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||||
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||||
@@ -382,7 +420,12 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} 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()
|
discovery.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,40 +549,21 @@ fun ConnectScreen(
|
|||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
status?.let {
|
status?.let {
|
||||||
// While connecting it's progress (spinner, neutral); otherwise it's a
|
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||||
// result/error (red). Previously every status showed in error-red, so a
|
// job now, so `status` only ever carries a result/error here — a filled error
|
||||||
// normal "Connecting…" looked like a failure.
|
// container reads as a real failure banner, not just red text lost in the layout.
|
||||||
if (connecting) {
|
Surface(
|
||||||
Row(
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
shape = MaterialTheme.shapes.medium,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
Text(
|
||||||
modifier = Modifier.size(16.dp),
|
it,
|
||||||
strokeWidth = 2.dp,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
Text(
|
textAlign = TextAlign.Center,
|
||||||
it,
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Result/error: a filled error container reads as a real failure banner,
|
|
||||||
// not just red text lost in the layout.
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
@@ -837,8 +861,15 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||||
WakeOverlay(waker, gamepadUi)
|
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||||
|
// grid and the console home.
|
||||||
|
ConnectOverlay(
|
||||||
|
connectingHostName = attempt?.hostName,
|
||||||
|
waker = waker,
|
||||||
|
gamepadUi = gamepadUi,
|
||||||
|
onCancelConnect = { cancelConnect() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.hardware.input.InputManager
|
import android.hardware.input.InputManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CombinedVibration
|
import android.os.CombinedVibration
|
||||||
@@ -44,6 +45,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
import io.unom.punktfunk.kit.Sc2Capture
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,8 +149,38 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
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") {
|
Group("Gamepads") {
|
||||||
if (pads.isEmpty()) {
|
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||||
|
if (pads.isEmpty() && !sc2Present) {
|
||||||
Text(
|
Text(
|
||||||
"No controller detected. punktfunk can only forward devices Android " +
|
"No controller detected. punktfunk can only forward devices Android " +
|
||||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||||
@@ -158,8 +190,11 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
pads.forEachIndexed { i, dev ->
|
// Every real controller is forwarded now (Automatic forwards them all, each on its own
|
||||||
PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting)
|
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
|
||||||
|
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
|
||||||
|
pads.forEach { dev ->
|
||||||
|
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,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. */
|
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||||
@@ -222,8 +330,12 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
|||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
if (forwarded) {
|
if (forwarded) {
|
||||||
|
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
|
||||||
|
// user can tell which physical pad is which. The stream's wire pad index is
|
||||||
|
// assigned separately (lowest-free per device) once streaming starts.
|
||||||
|
val number = dev.controllerNumber
|
||||||
Text(
|
Text(
|
||||||
"forwarded to host",
|
if (number > 0) "forwarded · player $number" else "forwarded to host",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
@@ -319,6 +431,15 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this device is actually forwarded to the host — the same rule the stream's [GamepadRouter]
|
||||||
|
* applies: a real, non-virtual controller whose source classes include GAMEPAD. A joystick-only node
|
||||||
|
* (e.g. a DualSense motion-sensor sibling, or an adapter that enumerates as bare joystick) shows in
|
||||||
|
* the list but isn't forwarded.
|
||||||
|
*/
|
||||||
|
private fun isForwarded(dev: InputDevice): Boolean =
|
||||||
|
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||||
|
|
||||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||||
if (Build.VERSION.SDK_INT >= 31) {
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
@@ -371,6 +492,10 @@ private fun prefLabel(pref: Int): String = when (pref) {
|
|||||||
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
||||||
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
||||||
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
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"
|
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)
|
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||||
}
|
}
|
||||||
pairing = false
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,10 @@ private fun resolveDir(s: NavInputState): NavDir? {
|
|||||||
if (s.hatY >= 0.5f) return NavDir.DOWN
|
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||||
if (s.hatX <= -0.5f) return NavDir.LEFT
|
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||||
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||||
return if (abs(s.stickY) >= abs(s.stickX)) {
|
// Horizontal wins an exact |x| == |y| diagonal tie (Y must be strictly greater to take the
|
||||||
|
// vertical branch), matching the SDL core and Apple nav so a perfect 45° push resolves the
|
||||||
|
// same on every client.
|
||||||
|
return if (abs(s.stickY) > abs(s.stickX)) {
|
||||||
when {
|
when {
|
||||||
s.stickY <= -STICK_HIGH -> NavDir.UP
|
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||||
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeSource
|
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 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
|
// 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) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
fun update(next: Settings) { s = next; onChange(next) }
|
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) }
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
// 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]. */
|
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||||
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
* [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(
|
fun <T> choice(
|
||||||
id: String, header: String?, label: String, detail: String,
|
id: String, header: String?, label: String, detail: String,
|
||||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
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.",
|
"The virtual pad the host creates — Automatic matches this controller.",
|
||||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
) { update(s.copy(gamepad = it)) },
|
) { 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(
|
choice(
|
||||||
"hud", "Interface", "Statistics overlay",
|
"hud", "Interface", "Statistics overlay",
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
"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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -46,6 +47,10 @@ fun isTvDevice(context: Context): Boolean {
|
|||||||
@Composable
|
@Composable
|
||||||
fun rememberControllerConnected(): State<Boolean> {
|
fun rememberControllerConnected(): State<Boolean> {
|
||||||
val context = LocalContext.current
|
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) }
|
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
@@ -59,5 +64,7 @@ fun rememberControllerConnected(): State<Boolean> {
|
|||||||
connected.value = Gamepad.firstPad() != null
|
connected.value = Gamepad.firstPad() != null
|
||||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||||
}
|
}
|
||||||
return connected
|
return remember {
|
||||||
|
derivedStateOf { connected.value || activity?.sc2MenuActive == true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
package io.unom.punktfunk
|
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.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyCharacterMap
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -16,9 +24,13 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
/**
|
/**
|
||||||
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
||||||
@@ -27,8 +39,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
var streamHandle: Long = 0L
|
var streamHandle: Long = 0L
|
||||||
|
|
||||||
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
|
/**
|
||||||
var axisMapper: Gamepad.AxisMapper? = null
|
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
|
||||||
|
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
|
||||||
|
* hot-plug, and routes rumble/HID feedback back by pad index. Null while not streaming.
|
||||||
|
*/
|
||||||
|
var gamepadRouter: GamepadRouter? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||||
@@ -44,9 +60,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
var requestStreamExit: (() -> Unit)? = null
|
var requestStreamExit: (() -> Unit)? = null
|
||||||
|
|
||||||
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
|
||||||
private var heldPadButtons = 0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||||
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||||
@@ -71,6 +84,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
private var highRefreshModeId = 0
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
lastPadIsGamepad = !isTvDevice(this)
|
lastPadIsGamepad = !isTvDevice(this)
|
||||||
@@ -88,13 +125,166 @@ class MainActivity : ComponentActivity() {
|
|||||||
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
// 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.
|
// 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
|
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 {
|
setContent {
|
||||||
PunktfunkTheme {
|
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) }
|
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]. */
|
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||||
private fun resolveHighRefreshMode() {
|
private fun resolveHighRefreshMode() {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -125,23 +315,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||||
val bit = Gamepad.buttonBit(event.keyCode)
|
val bit = Gamepad.buttonBit(event.keyCode)
|
||||||
if (bit != 0) {
|
if (bit != 0) {
|
||||||
when (event.action) {
|
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||||
KeyEvent.ACTION_DOWN -> {
|
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
|
||||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
|
||||||
heldPadButtons = heldPadButtons or bit
|
// can still leave — but an accidental brush of the four buttons no longer quits.
|
||||||
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
gamepadRouter?.onButton(event, bit)
|
||||||
// (a couch user has no keyboard/Back). Fired once per full chord.
|
|
||||||
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
|
||||||
heldPadButtons = 0
|
|
||||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyEvent.ACTION_UP -> {
|
|
||||||
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
|
||||||
heldPadButtons = heldPadButtons and bit.inv()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +341,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||||
val vk = Keymap.toVk(event)
|
val vk = Keymap.toVk(event)
|
||||||
if (vk != 0) {
|
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)
|
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
|
return true // consumed — don't let the system also act on it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +393,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||||
if (streamHandle != 0L) {
|
if (streamHandle != 0L) {
|
||||||
if (axisMapper?.onMotion(event) == true) return true
|
if (gamepadRouter?.onMotion(event) == true) return true
|
||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||||
@@ -248,9 +438,4 @@ class MainActivity : ComponentActivity() {
|
|||||||
-> true
|
-> true
|
||||||
else -> KeyEvent.isGamepadButton(kc)
|
else -> KeyEvent.isGamepadButton(kc)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
|
||||||
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,23 @@ data class Settings(
|
|||||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||||
*/
|
*/
|
||||||
val autoWakeEnabled: Boolean = true,
|
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. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -142,6 +159,8 @@ class SettingsStore(context: Context) {
|
|||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, 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) {
|
fun save(s: Settings) {
|
||||||
@@ -162,6 +181,8 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||||
|
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||||
|
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +218,8 @@ class SettingsStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
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. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
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.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
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
|
* 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",
|
subtitle = "What the app detects, with a live input test",
|
||||||
onClick = onOpenControllers,
|
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
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.text.InputType
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
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 android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -24,17 +39,21 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
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.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.Sc2Capture
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -149,6 +168,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
}.onEach { it.setReferenceCounted(false) }
|
}.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) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
wifiLocks.forEach { lock ->
|
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.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
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
|
// 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
|
// 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
|
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||||
@@ -174,21 +203,99 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
val priorOrientation = activity?.requestedOrientation
|
val priorOrientation = activity?.requestedOrientation
|
||||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
activity?.streamHandle = handle // route hardware keys to this session
|
activity?.streamHandle = handle // route hardware keys to this session
|
||||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||||
|
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||||
|
// controller (Automatic). Built here, released on dispose.
|
||||||
|
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||||
|
activity?.gamepadRouter = router
|
||||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||||
// the keep-alive linger), unlike a host-ended / backgrounded drop.
|
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||||
|
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
|
||||||
|
// the same way the Back gesture does.
|
||||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
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
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
// index via the router; poll threads stopped + joined before the router is released and the
|
||||||
|
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||||
|
// 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 {
|
onDispose {
|
||||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||||
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
|
feedback.onHidRaw = null
|
||||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||||
activity?.axisMapper = null
|
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?.streamHandle = 0L
|
||||||
activity?.requestStreamExit = null
|
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
|
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())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
@@ -209,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).
|
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
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()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -259,8 +369,22 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
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
|
// 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(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||||
when (touchMode) {
|
when (touchMode) {
|
||||||
@@ -269,9 +393,63 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
handle,
|
handle,
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
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 TAP_DRAG_MS = 250L
|
||||||
private const val SCROLL_DIV = 4f
|
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 →
|
// 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
|
// 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
|
// 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;
|
* 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
|
* 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
|
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||||
@@ -94,6 +100,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
handle: Long,
|
handle: Long,
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
onCycleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
|
onKeyboard: (show: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
@@ -128,6 +135,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
var maxFingers = 1
|
var maxFingers = 1
|
||||||
var scrolling = false
|
var scrolling = false
|
||||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
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 prevCx = startX
|
||||||
var prevCy = startY
|
var prevCy = startY
|
||||||
var upTime = down.uptimeMillis
|
var upTime = down.uptimeMillis
|
||||||
@@ -148,9 +161,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
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) {
|
if (pressed.size == 2) {
|
||||||
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
val cy = (pressed.sumOf { it.position.y.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
|
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||||
@@ -177,6 +193,36 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
prevCx = cx
|
prevCx = cx
|
||||||
moved = true
|
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) {
|
} else if (!scrolling) {
|
||||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||||
// back to one finger doesn't jerk the cursor).
|
// back to one finger doesn't jerk the cursor).
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
|
|||||||
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||||
*/
|
*/
|
||||||
class WakeController(private val scope: CoroutineScope) {
|
class WakeController(private val scope: CoroutineScope) {
|
||||||
/** null = idle; non-null drives [WakeOverlay]. */
|
/** null = idle; non-null drives the "Waking…" phase of [ConnectOverlay]. */
|
||||||
data class Waking(
|
data class Waking(
|
||||||
val hostName: String,
|
val hostName: String,
|
||||||
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Bedtime
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
|
||||||
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
|
||||||
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
|
||||||
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
|
||||||
* timed out) while the touch buttons work for a pointer.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
|
||||||
val w = waker.waking ?: return
|
|
||||||
|
|
||||||
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
|
||||||
if (gamepadUi) {
|
|
||||||
// A retries once timed out; B falls through to the BackHandler above.
|
|
||||||
GamepadNavEffect2D(
|
|
||||||
active = true,
|
|
||||||
onDirection = {},
|
|
||||||
onActivate = { if (w.timedOut) waker.retry() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.6f))
|
|
||||||
// Swallow taps so the home behind can't be touched while waking.
|
|
||||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.padding(40.dp)
|
|
||||||
.widthIn(max = 380.dp)
|
|
||||||
.clip(RoundedCornerShape(22.dp))
|
|
||||||
.background(Color(0xF01A1730))
|
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
|
||||||
.padding(28.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
) {
|
|
||||||
if (w.timedOut) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Bedtime,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White.copy(alpha = 0.85f),
|
|
||||||
modifier = Modifier.size(34.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${w.hostName} didn't wake",
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 19.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"It may still be booting, or it's powered off / off this network.",
|
|
||||||
color = Color.White.copy(alpha = 0.6f),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
|
||||||
) {
|
|
||||||
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
|
||||||
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
CircularProgressIndicator(color = Color.White)
|
|
||||||
Text(
|
|
||||||
"Waking ${w.hostName}…",
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 19.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Waiting for it to come online · ${w.seconds}s",
|
|
||||||
color = Color.White.copy(alpha = 0.6f),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
)
|
|
||||||
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
|
||||||
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -68,6 +68,29 @@ class ScreenshotTest {
|
|||||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||||
|
|
||||||
|
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||||
|
@Test
|
||||||
|
fun connecting() = shootScreen("connecting") {
|
||||||
|
HostsScene()
|
||||||
|
ConnectingScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun waking() = shootScreen("waking") {
|
||||||
|
HostsScene()
|
||||||
|
WakingScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wakeTimedOut() = shootScreen("wake-timed-out") {
|
||||||
|
HostsScene()
|
||||||
|
WakeTimedOutScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The console flow is the full-screen aurora takeover (a root capture).
|
||||||
|
@Test
|
||||||
|
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun trust() = shootScreen("trust") {
|
fun trust() = shootScreen("trust") {
|
||||||
HostsScene()
|
HostsScene()
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.BrandDark
|
import io.unom.punktfunk.BrandDark
|
||||||
|
import io.unom.punktfunk.ConnectModal
|
||||||
|
import io.unom.punktfunk.ConnectPhase
|
||||||
|
import io.unom.punktfunk.ConnectTakeover
|
||||||
import io.unom.punktfunk.Settings
|
import io.unom.punktfunk.Settings
|
||||||
import io.unom.punktfunk.TouchMode
|
import io.unom.punktfunk.TouchMode
|
||||||
import io.unom.punktfunk.SettingsScreen
|
import io.unom.punktfunk.SettingsScreen
|
||||||
@@ -215,3 +218,31 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default-UI connect flow (the real [ConnectModal]) in each phase — instant "Connecting…"
|
||||||
|
* feedback, the "Waking…" wait, and the wake-timed-out prompt. These render as a Material dialog over
|
||||||
|
* the host grid, so the test composes [HostsScene] behind them and captures the whole screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ConnectingScene() =
|
||||||
|
ConnectModal(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun WakingScene() =
|
||||||
|
ConnectModal(
|
||||||
|
ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true),
|
||||||
|
onCancel = {}, onRetry = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun WakeTimedOutScene() =
|
||||||
|
ConnectModal(ConnectPhase.WakeTimedOut("Living Room PC"), onCancel = {}, onRetry = {})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console / gamepad connect flow (the real full-screen [ConnectTakeover]) — the aurora backdrop
|
||||||
|
* with a bottom hint bar, the same signature look the console home uses.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ConnectConsoleScene() =
|
||||||
|
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ object Gamepad {
|
|||||||
const val BTN_X = 0x4000
|
const val BTN_X = 0x4000
|
||||||
const val BTN_Y = 0x8000
|
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_*`.
|
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
|
||||||
const val AXIS_LS_X = 0
|
const val AXIS_LS_X = 0
|
||||||
const val AXIS_LS_Y = 1
|
const val AXIS_LS_Y = 1
|
||||||
@@ -52,6 +62,10 @@ object Gamepad {
|
|||||||
const val PREF_DUALSHOCK4 = 4
|
const val PREF_DUALSHOCK4 = 4
|
||||||
const val PREF_STEAMCONTROLLER = 5
|
const val PREF_STEAMCONTROLLER = 5
|
||||||
const val PREF_STEAMDECK = 6
|
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.
|
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
@@ -59,10 +73,19 @@ object Gamepad {
|
|||||||
private const val VID_VALVE = 0x28DE
|
private const val VID_VALVE = 0x28DE
|
||||||
private const val VID_NINTENDO = 0x057E
|
private const val VID_NINTENDO = 0x057E
|
||||||
|
|
||||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
// Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct
|
||||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
// 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)
|
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) /
|
// 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 /
|
// 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
|
// 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_STEAMDECK = setOf(0x1205)
|
||||||
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
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
|
// 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.
|
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||||
private val PID_XBOXONE = setOf(
|
private val PID_XBOXONE = setOf(
|
||||||
@@ -91,10 +120,15 @@ object Gamepad {
|
|||||||
val pid = dev.productId
|
val pid = dev.productId
|
||||||
return when {
|
return when {
|
||||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
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_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
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
|
else -> PREF_XBOX360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,47 +205,26 @@ object Gamepad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps joystick MotionEvents to axis (+ HAT→dpad) sends for one session, **on change only**.
|
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||||
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
|
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||||
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
|
* instance per forwarded controller (owned by [GamepadRouter], which routes each device's events
|
||||||
* sticks on the host (which has no client-side held-state knowledge).
|
* to its own mapper so a second pad can't clobber the first); call [reset] on that slot closing
|
||||||
|
* (disconnect / session stop) so nothing sticks on the host (which has no client-side held-state
|
||||||
|
* knowledge).
|
||||||
*
|
*
|
||||||
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
|
* The router only ever feeds this a qualifying event from the mapper's own device — a real
|
||||||
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
|
* gamepad (its source classes include GAMEPAD), never a controller's joystick-classified sibling
|
||||||
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
|
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||||
* sensors) and any second pad report every axis as 0, and folding them into the same state
|
* folds the event straight in without re-qualifying it.
|
||||||
* flapped a held trigger/stick between its value and 0 on every event interleave.
|
|
||||||
*/
|
*/
|
||||||
class AxisMapper(private val handle: Long) {
|
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||||
private var hatX = 0 // -1 / 0 / +1
|
private var hatX = 0 // -1 / 0 / +1
|
||||||
private var hatY = 0
|
private var hatY = 0
|
||||||
|
|
||||||
/** deviceId of the controller pad 0 is pinned to; −1 until the first qualifying event. */
|
/** Fold one joystick ACTION_MOVE from this mapper's controller onto its pad index. */
|
||||||
private var deviceId = -1
|
fun onMotion(event: MotionEvent) {
|
||||||
|
|
||||||
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
|
|
||||||
fun onMotion(event: MotionEvent): Boolean {
|
|
||||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
|
||||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
|
||||||
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
|
|
||||||
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
|
|
||||||
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
|
|
||||||
// joystick-class remotes don't — and those report every pad axis as 0 (see the
|
|
||||||
// class doc for the held-trigger flap this caused).
|
|
||||||
val dev = event.device ?: return false
|
|
||||||
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
|
|
||||||
// Single-pad model: pin to the first qualifying controller so a second pad (or its
|
|
||||||
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
|
|
||||||
if (deviceId != event.deviceId) {
|
|
||||||
if (deviceId != -1) {
|
|
||||||
if (InputDevice.getDevice(deviceId) != null) return false
|
|
||||||
reset() // the pinned pad is gone — lift its held state before adopting
|
|
||||||
}
|
|
||||||
deviceId = event.deviceId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||||
@@ -240,23 +253,39 @@ object Gamepad {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// HAT → dpad button transitions (track previous, emit only the deltas).
|
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||||
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||||
|
// final getAxisValue would show the HAT already back at rest and miss the tap entirely.
|
||||||
|
// Feed every historical HAT sample (oldest→newest) through the same transition logic
|
||||||
|
// before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins:
|
||||||
|
// only the final value matters for an analog axis.)
|
||||||
|
for (h in 0 until event.historySize) {
|
||||||
|
applyHat(
|
||||||
|
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)),
|
||||||
|
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
applyHat(
|
||||||
|
sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)),
|
||||||
|
sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit dpad button deltas for one HAT sample (`hx`/`hy` each −1/0/+1), tracking held state. */
|
||||||
|
private fun applyHat(hx: Int, hy: Int) {
|
||||||
if (hx != hatX) {
|
if (hx != hatX) {
|
||||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||||
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
|
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
|
||||||
hatX = hx
|
hatX = hx
|
||||||
}
|
}
|
||||||
val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y))
|
|
||||||
if (hy != hatY) {
|
if (hy != hatY) {
|
||||||
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
|
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
|
||||||
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
||||||
hatY = hy
|
hatY = hy
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release-all: zero every axis and clear the held dpad. */
|
/** Release-all: zero every axis and clear the held dpad (all on this mapper's pad index). */
|
||||||
fun reset() {
|
fun reset() {
|
||||||
for (id in 0..5) sendAxis(id, 0)
|
for (id in 0..5) sendAxis(id, 0)
|
||||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||||
@@ -268,10 +297,10 @@ object Gamepad {
|
|||||||
private fun sendAxis(id: Int, v: Int) {
|
private fun sendAxis(id: Int, v: Int) {
|
||||||
if (last[id] == v) return
|
if (last[id] == v) return
|
||||||
last[id] = v
|
last[id] = v
|
||||||
NativeBridge.nativeSendGamepadAxis(handle, id, v)
|
NativeBridge.nativeSendGamepadAxis(handle, id, v, pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down)
|
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)
|
||||||
|
|
||||||
// −1..1 float → ±32767 i16 (matches the Apple client's 32767 scale).
|
// −1..1 float → ±32767 i16 (matches the Apple client's 32767 scale).
|
||||||
private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt()
|
private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.unom.punktfunk.kit
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.lights.Light
|
import android.hardware.lights.Light
|
||||||
import android.hardware.lights.LightState
|
import android.hardware.lights.LightState
|
||||||
@@ -15,67 +16,95 @@ import android.view.InputDevice
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
* Host→client gamepad feedback for one session, routed per controller by wire pad index. Two daemon
|
||||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
* poll threads drain the blocking native pulls and render in Kotlin: rumble → the addressed
|
||||||
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
* controller's `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output
|
||||||
* player-LED via `LightsManager` (API 33+); adaptive
|
* → that controller's lightbar / player-LED via `LightsManager` (API 33+); adaptive triggers are
|
||||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
* parse-validated and logged (Android has no public adaptive-trigger API).
|
||||||
|
*
|
||||||
|
* Each pull carries the wire pad index it is addressed to; [GamepadRouter.deviceForPad] resolves it
|
||||||
|
* to the physical controller currently holding that index — so a rumble the host aimed at pad 1
|
||||||
|
* drives pad 1's motors, and an update for an index with no live controller (a pad that just
|
||||||
|
* unplugged) is dropped. Per-controller rumble/light bindings are built lazily and cached by device
|
||||||
|
* id (bounded — at most 16 pads).
|
||||||
*
|
*
|
||||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||||
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
|
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
|
||||||
* this MUST run before `nativeClose` frees the session handle.
|
* this MUST run before the router is released and `nativeClose` frees the session handle.
|
||||||
*
|
*
|
||||||
* The active pad is resolved from the connected input devices (first gamepad/joystick). With none
|
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||||
* connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the
|
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
||||||
* `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) {
|
class GamepadFeedback(
|
||||||
|
private val handle: Long,
|
||||||
|
private val router: GamepadRouter?,
|
||||||
|
private val deviceVibrator: Vibrator? = null,
|
||||||
|
) {
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "pf.feedback"
|
const val TAG = "pf.feedback"
|
||||||
const val TAG_LED: Byte = 0x01
|
const val TAG_LED: Byte = 0x01
|
||||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||||
const val TAG_TRIGGER: Byte = 0x03
|
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.
|
// 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.
|
// A new host renews far below this, so it never actually holds this long there.
|
||||||
const val LEGACY_RUMBLE_MS = 60_000L
|
const val LEGACY_RUMBLE_MS = 60_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||||
|
private class RumbleBind(
|
||||||
|
val vm: VibratorManager?,
|
||||||
|
val legacy: Vibrator?,
|
||||||
|
val ids: IntArray,
|
||||||
|
val amplitudeControlled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One controller's lights binding (API 33+): its open session + the RGB / player-id lights it exposes. */
|
||||||
|
private class LightBind(
|
||||||
|
val session: LightsManager.LightsSession,
|
||||||
|
val rgb: Light?,
|
||||||
|
val player: Light?,
|
||||||
|
)
|
||||||
|
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
private var rumbleThread: Thread? = null
|
private var rumbleThread: Thread? = null
|
||||||
private var hidoutThread: Thread? = null
|
private var hidoutThread: Thread? = null
|
||||||
|
|
||||||
private var vm: VibratorManager? = null
|
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble
|
||||||
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
// thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes
|
||||||
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
// from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining
|
||||||
private var legacy: Vibrator? = null
|
// the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain
|
||||||
private var vibratorIds: IntArray = IntArray(0)
|
// HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the
|
||||||
private var amplitudeControlled = false
|
// null value that caches "this controller has no vibrator / no controllable lights"). The lock
|
||||||
|
// guards only the map ops — rendering runs on the returned reference outside it; a stale reference
|
||||||
private var lightsSession: LightsManager.LightsSession? = null
|
// is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd
|
||||||
private var rgbLight: Light? = null
|
// no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed.
|
||||||
private var playerLight: Light? = null
|
private val bindsLock = Any()
|
||||||
|
private val rumbleBinds = HashMap<Int, RumbleBind?>()
|
||||||
|
private val lightBinds = HashMap<Int, LightBind?>()
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
val dev = resolvePad()
|
|
||||||
bindRumble(dev)
|
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
bindLights(dev)
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "lights need API 33 (have ${Build.VERSION.SDK_INT}) — lightbar/playerLed no-op")
|
|
||||||
}
|
|
||||||
|
|
||||||
running = true
|
running = true
|
||||||
rumbleThread = Thread({
|
rumbleThread = Thread({
|
||||||
while (running) {
|
while (running) {
|
||||||
val ev = NativeBridge.nativeNextRumble(handle)
|
val ev = NativeBridge.nativeNextRumble(handle)
|
||||||
if (ev < 0L) continue // timeout / closed
|
if (ev < 0L) continue // timeout / closed
|
||||||
// ev bit 48 = has a v2 lease; bits 32..47 = ttl_ms; 16..31 = low; 0..15 = high. The
|
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
||||||
// lease flag is out-of-band, so any ttl_ms (incl. 0xFFFF) is a real lease — no
|
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
||||||
// in-band sentinel. No lease (legacy host) → the prior long one-shot.
|
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
||||||
|
// long one-shot.
|
||||||
|
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||||
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
||||||
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
||||||
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
||||||
renderRumble(
|
renderRumble(
|
||||||
|
pad,
|
||||||
((ev ushr 16) and 0xFFFF).toInt(),
|
((ev ushr 16) and 0xFFFF).toInt(),
|
||||||
(ev and 0xFFFF).toInt(),
|
(ev and 0xFFFF).toInt(),
|
||||||
durationMs,
|
durationMs,
|
||||||
@@ -84,7 +113,8 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
hidoutThread = Thread({
|
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) {
|
while (running) {
|
||||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||||
if (n < 0) continue // timeout / closed
|
if (n < 0) continue // timeout / closed
|
||||||
@@ -93,100 +123,136 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
rumbleThread?.interrupt()
|
rumbleThread?.interrupt()
|
||||||
hidoutThread?.interrupt()
|
hidoutThread?.interrupt()
|
||||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
|
||||||
runCatching { legacy?.cancel() }
|
|
||||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
// pull (nativeNextRumble/nativeNextHidout) and read the router, so they MUST be dead before
|
||||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
// StreamScreen's onDispose reaches router.release() / nativeClose, which free that state. A
|
||||||
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
// *bounded* join that times out would let a thread survive into the freed handle → use-after-
|
||||||
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
// free SIGSEGV (the back-while-streaming crash, on the one path the main-thread `closed` guard
|
||||||
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
// can't cover). Safe to block unbounded: the native pulls are internally time-bounded
|
||||||
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
// (PULL_TIMEOUT ~100 ms) and rendering is a quick best-effort binder call, so each thread
|
||||||
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
// observes running=false and exits within ~one timeout — the join returns promptly.
|
||||||
runCatching { rumbleThread?.join() }
|
runCatching { rumbleThread?.join() }
|
||||||
runCatching { hidoutThread?.join() }
|
runCatching { hidoutThread?.join() }
|
||||||
rumbleThread = null
|
rumbleThread = null
|
||||||
hidoutThread = null
|
hidoutThread = null
|
||||||
runCatching { lightsSession?.close() }
|
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
|
||||||
lightsSession = null
|
// lights session.
|
||||||
rgbLight = null
|
runCatching { deviceVibrator?.cancel() }
|
||||||
playerLight = null
|
synchronized(bindsLock) {
|
||||||
vm = null
|
for (b in rumbleBinds.values) b?.let {
|
||||||
legacy = null
|
runCatching { it.vm?.cancel() }
|
||||||
vibratorIds = IntArray(0)
|
runCatching { it.legacy?.cancel() }
|
||||||
|
}
|
||||||
|
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||||
|
rumbleBinds.clear()
|
||||||
|
lightBinds.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
/**
|
||||||
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
* Evict and release the bindings for a controller that just disconnected — invoked from
|
||||||
|
* [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its
|
||||||
|
* `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session
|
||||||
|
* immediately instead of leaking it until [stop]. A no-op for a device with no cached binding.
|
||||||
|
* The next feedback for that pad index rebinds against whatever controller now holds it.
|
||||||
|
*/
|
||||||
|
// Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists
|
||||||
|
// under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers.
|
||||||
|
@Suppress("MissingPermission", "NewApi")
|
||||||
|
fun onDeviceRemoved(deviceId: Int) {
|
||||||
|
synchronized(bindsLock) {
|
||||||
|
rumbleBinds.remove(deviceId)?.let {
|
||||||
|
runCatching { it.vm?.cancel() }
|
||||||
|
runCatching { it.legacy?.cancel() }
|
||||||
|
}
|
||||||
|
lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Rumble ----
|
// ---- Rumble ----
|
||||||
|
|
||||||
private fun bindRumble(dev: InputDevice?) {
|
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
|
||||||
if (dev == null) {
|
private fun rumbleBindFor(pad: Int): RumbleBind? {
|
||||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
val dev = router?.deviceForPad(pad) ?: return null
|
||||||
return
|
synchronized(bindsLock) {
|
||||||
|
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||||
|
val bind = bindRumble(dev)
|
||||||
|
rumbleBinds[dev.id] = bind
|
||||||
|
return bind
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindRumble(dev: InputDevice): RumbleBind? {
|
||||||
if (Build.VERSION.SDK_INT >= 31) {
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
val m = dev.vibratorManager
|
val m = dev.vibratorManager
|
||||||
val ids = m.vibratorIds
|
val ids = m.vibratorIds
|
||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
vm = m
|
val amp = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||||
vibratorIds = ids
|
Log.i(TAG, "rumble: bound ${ids.size} vibrators for '${dev.name}' amplitudeControl=$amp")
|
||||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
return RumbleBind(m, null, ids, amp)
|
||||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
|
||||||
} else {
|
|
||||||
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val v = dev.vibrator
|
|
||||||
if (!v.hasVibrator()) {
|
|
||||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
legacy = v
|
|
||||||
amplitudeControlled = v.hasAmplitudeControl()
|
|
||||||
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
|
||||||
}
|
}
|
||||||
|
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val v = dev.vibrator
|
||||||
|
if (!v.hasVibrator()) {
|
||||||
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
Log.i(TAG, "rumble: bound legacy vibrator for '${dev.name}' amplitudeControl=${v.hasAmplitudeControl()}")
|
||||||
|
return RumbleBind(null, v, IntArray(0), v.hasAmplitudeControl())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes).
|
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
||||||
* `durationMs` is the host's v2 envelope TTL — the one-shot self-terminates after it unless the
|
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
|
||||||
* host renews, so a lost stop (or a dead host) silences at the lease instead of the old fixed
|
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
||||||
* 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS] (the prior fixed duration).
|
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
||||||
*/
|
*/
|
||||||
private fun renderRumble(low: Int, high: Int, durationMs: Long) {
|
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||||
Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
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 lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
val m = vm
|
val m = bind.vm
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
if (lo == 0 && hi == 0) {
|
if (lo == 0 && hi == 0) {
|
||||||
m.cancel() // (0,0) = stop
|
m.cancel() // (0,0) = stop
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val combo = CombinedVibration.startParallel()
|
val combo = CombinedVibration.startParallel()
|
||||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
if (bind.amplitudeControlled && bind.ids.size >= 2) {
|
||||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
// Two-motor split — ASSUMPTION: ids[0] = light/right, ids[1] = heavy/left
|
||||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs))
|
// (XInput/Moonlight convention). Android does not guarantee the order of
|
||||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs))
|
// VibratorManager.getVibratorIds(), so a pad that enumerates heavy-first would
|
||||||
|
// invert the feel: the stronger amplitude drives the physically-lighter motor.
|
||||||
|
// Failure mode is tactile only — both motors still fire, nothing silences or
|
||||||
|
// crashes — so this stays the default pending per-pad on-glass verification (G20).
|
||||||
|
// ids beyond the first two (rare) are left alone here.
|
||||||
|
if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
|
||||||
|
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
|
||||||
} else {
|
} else {
|
||||||
// Single motor or no amplitude control: blend both into one effect.
|
// Single motor or no amplitude control: blend both into one effect.
|
||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a, durationMs))
|
for (id in bind.ids) combo.addVibrator(id, oneShot(a, durationMs))
|
||||||
}
|
}
|
||||||
runCatching { m.vibrate(combo.combine()) }
|
runCatching { m.vibrate(combo.combine()) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||||
val lv = legacy ?: return
|
val lv = bind.legacy ?: return
|
||||||
if (lo == 0 && hi == 0) {
|
if (lo == 0 && hi == 0) {
|
||||||
lv.cancel() // (0,0) = stop
|
lv.cancel() // (0,0) = stop
|
||||||
return
|
return
|
||||||
@@ -194,7 +260,30 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
runCatching {
|
runCatching {
|
||||||
lv.vibrate(
|
lv.vibrate(
|
||||||
if (amplitudeControlled) oneShot(a, durationMs)
|
if (bind.amplitudeControlled) oneShot(a, durationMs)
|
||||||
|
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -207,42 +296,69 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||||
// self-terminates on a lost stop; cancel on zero.
|
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||||
|
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||||
|
// (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0)
|
||||||
|
// stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching,
|
||||||
|
// so an uncaught throw here would kill the whole rumble poll thread.
|
||||||
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
||||||
VibrationEffect.createOneShot(durationMs, amp)
|
VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
|
||||||
|
|
||||||
// ---- HID output ----
|
// ---- HID output ----
|
||||||
|
|
||||||
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
|
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
|
||||||
buf.rewind()
|
buf.rewind()
|
||||||
|
val pad = buf.get().toInt() and 0xFF // wire pad index the event is addressed to
|
||||||
when (buf.get()) { // kind tag
|
when (buf.get()) { // kind tag
|
||||||
TAG_LED -> {
|
TAG_LED -> {
|
||||||
val r = buf.get().toInt() and 0xFF
|
val r = buf.get().toInt() and 0xFF
|
||||||
val g = buf.get().toInt() and 0xFF
|
val g = buf.get().toInt() and 0xFF
|
||||||
val b = buf.get().toInt() and 0xFF
|
val b = buf.get().toInt() and 0xFF
|
||||||
Log.i(TAG, "hidout Led r=$r g=$g b=$b") // verification line
|
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
|
||||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b))
|
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||||
}
|
}
|
||||||
TAG_PLAYER_LEDS -> {
|
TAG_PLAYER_LEDS -> {
|
||||||
val bits = buf.get().toInt() and 0x1F
|
val bits = buf.get().toInt() and 0x1F
|
||||||
val player = playerIndexForBits(bits)
|
val player = playerIndexForBits(bits)
|
||||||
Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line
|
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
|
||||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(player)
|
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||||
}
|
}
|
||||||
TAG_TRIGGER -> {
|
TAG_TRIGGER -> {
|
||||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||||
val effLen = n - 2
|
val effLen = n - 3 // [pad][kind][which] header, then the effect block
|
||||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"hidout Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
"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")
|
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. */
|
/** 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) {
|
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||||
0b00000 -> 0
|
0b00000 -> 0
|
||||||
@@ -253,37 +369,63 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
|
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindLights(dev: InputDevice?) {
|
/** The lights binding for the controller on wire pad [pad], or null (no live pad / no lights / < API 33). Cached by device id. */
|
||||||
if (dev == null) {
|
private fun lightBindFor(pad: Int): LightBind? {
|
||||||
Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)")
|
if (Build.VERSION.SDK_INT < 33) return null
|
||||||
return
|
val dev = router?.deviceForPad(pad) ?: return null
|
||||||
|
synchronized(bindsLock) {
|
||||||
|
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||||
|
val bind = bindLights(dev)
|
||||||
|
lightBinds[dev.id] = bind
|
||||||
|
return bind
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindLights(dev: InputDevice): LightBind? {
|
||||||
val lm = dev.lightsManager
|
val lm = dev.lightsManager
|
||||||
|
var rgb: Light? = null
|
||||||
|
var player: Light? = null
|
||||||
for (l in lm.lights) {
|
for (l in lm.lights) {
|
||||||
if (rgbLight == null && l.hasRgbControl()) rgbLight = l
|
if (rgb == null && l.hasRgbControl()) rgb = l
|
||||||
if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l
|
if (player == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) player = l
|
||||||
}
|
}
|
||||||
if (rgbLight == null && playerLight == null) {
|
if (rgb == null && player == null) {
|
||||||
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
|
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
lightsSession = lm.openSession()
|
val session = lm.openSession()
|
||||||
Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}")
|
Log.i(TAG, "lights: bound rgb=${rgb != null} playerLed=${player != null} for '${dev.name}'")
|
||||||
|
return LightBind(session, rgb, player)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setLightbar(argb: Int) {
|
private fun setLightbar(pad: Int, argb: Int) {
|
||||||
val s = lightsSession ?: return
|
val bind = lightBindFor(pad) ?: return
|
||||||
val l = rgbLight ?: return
|
val l = bind.rgb ?: return
|
||||||
runCatching {
|
runCatching {
|
||||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPlayerId(player: Int) {
|
private fun setPlayerId(pad: Int, player: Int) {
|
||||||
val s = lightsSession ?: return
|
val bind = lightBindFor(pad) ?: return
|
||||||
val l = playerLight ?: return
|
val l = bind.player ?: return
|
||||||
runCatching {
|
runCatching {
|
||||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-controller router for one stream session — the Android analogue of the Linux client's gamepad
|
||||||
|
* `Worker`/`Slot` model (`pf-client-core/src/gamepad.rs`) over the shared native-plane wire contract
|
||||||
|
* (`punktfunk-core/src/input.rs`). Each physical controller (Android `deviceId`) gets a STABLE
|
||||||
|
* lowest-free wire pad index (0..15) held for its lifetime and freed only on disconnect, so a pad
|
||||||
|
* dropping never renumbers the others (a game must not see its players shuffle). Every forwarded event
|
||||||
|
* carries that pad index; a [NativeBridge.nativeSendGamepadArrival] declaring the pad's type is sent
|
||||||
|
* once BEFORE its first input, a [NativeBridge.nativeSendGamepadRemove] on disconnect. Per-device axis
|
||||||
|
* state lives in each slot's [Gamepad.AxisMapper] so a second controller can't clobber the first.
|
||||||
|
* Feedback (rumble / HID) is routed BACK to the originating device by pad index via [deviceForPad].
|
||||||
|
*
|
||||||
|
* Selection: forward EVERY real controller (the Linux client's single-player pin has no Android UI
|
||||||
|
* surface yet — Automatic is the only mode). Lifetime matches the session: constructed on stream
|
||||||
|
* attach (opening a slot for every already-connected pad, so its Arrival lands before any input),
|
||||||
|
* released on detach.
|
||||||
|
*
|
||||||
|
* A single controller lands on wire index 0, so its per-transition button/axis wire is byte-identical
|
||||||
|
* to the old single-pad path (plus the Arrival/Remove declarations the contract requires — which an
|
||||||
|
* older host simply ignores).
|
||||||
|
*
|
||||||
|
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
|
||||||
|
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||||
|
* threads, so the slot table is a [ConcurrentHashMap].
|
||||||
|
*/
|
||||||
|
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||||
|
|
||||||
|
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||||
|
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||||
|
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||||
|
var held = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||||
|
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||||
|
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||||
|
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||||
|
*/
|
||||||
|
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||||
|
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
|
private val inputManager = context.getSystemService(InputManager::class.java)
|
||||||
|
private val listener = object : InputManager.InputDeviceListener {
|
||||||
|
override fun onInputDeviceAdded(deviceId: Int) {
|
||||||
|
InputDevice.getDevice(deviceId)?.let { if (isForwardable(it)) openSlot(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInputDeviceRemoved(deviceId: Int) = closeSlot(deviceId)
|
||||||
|
override fun onInputDeviceChanged(deviceId: Int) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
inputManager?.registerInputDeviceListener(listener, mainHandler)
|
||||||
|
// Open a slot for every controller already connected when the session starts — the pads that
|
||||||
|
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
|
||||||
|
for (id in InputDevice.getDeviceIds()) {
|
||||||
|
InputDevice.getDevice(id)?.let { if (isForwardable(it)) openSlot(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* ([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) {
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||||
|
private fun armExit() {
|
||||||
|
if (pendingExit != null) return // already counting down
|
||||||
|
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()) {
|
||||||
|
// Release the held buttons + zero the axes on every triggering pad so nothing sticks
|
||||||
|
// host-side once we leave, then signal the deliberate exit.
|
||||||
|
for (s in held) releaseHeld(s)
|
||||||
|
onExitChord?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One joystick MotionEvent — routed to the producing device's own [Gamepad.AxisMapper] (per-device
|
||||||
|
* state). Returns true if consumed. Only a real gamepad drives a pad: a DualSense/DS4 motion-sensor
|
||||||
|
* sibling node classifies as bare joystick (no GAMEPAD source class) and reports every pad axis as
|
||||||
|
* 0, so [isForwardable] filters it out before it can open a slot or clobber axes.
|
||||||
|
*/
|
||||||
|
fun onMotion(event: MotionEvent): Boolean {
|
||||||
|
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||||
|
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||||
|
val dev = event.device ?: return false
|
||||||
|
if (!isForwardable(dev)) return false
|
||||||
|
val slot = slotFor(dev) ?: return false
|
||||||
|
slot.mapper.onMotion(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) 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) {
|
||||||
|
if (slot.index == pad) return InputDevice.getDevice(deviceId)
|
||||||
|
}
|
||||||
|
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]).
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
inputManager?.unregisterInputDeviceListener(listener)
|
||||||
|
disarmExit() // drop any pending exit-chord timer so it can't fire after teardown
|
||||||
|
// Snapshot the ids first — closeSlot mutates the map.
|
||||||
|
for (id in slots.keys.toList()) closeSlot(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- slots ----
|
||||||
|
|
||||||
|
/** A real, non-virtual controller we forward — its source classes include GAMEPAD (excludes a pad's bare-joystick sensor node). */
|
||||||
|
private fun isForwardable(dev: InputDevice): Boolean =
|
||||||
|
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slot for [dev], opening one (and declaring the pad) if this device is unseen; null when [dev]
|
||||||
|
* isn't a forwardable controller or every wire index is taken. The [isForwardable] gate lives here —
|
||||||
|
* the single lazy-open chokepoint both [onButton] and [onMotion] funnel through — so no entry point
|
||||||
|
* can open a phantom slot for a virtual/non-gamepad source (the hot-plug listener and init loop
|
||||||
|
* pre-filter and call [openSlot] directly).
|
||||||
|
*/
|
||||||
|
private fun slotFor(dev: InputDevice?): Slot? {
|
||||||
|
if (dev == null) return null
|
||||||
|
slots[dev.id]?.let { return it }
|
||||||
|
if (!isForwardable(dev)) return null
|
||||||
|
return openSlot(dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a slot for [dev] on the lowest free wire index, declaring its kind ([NativeBridge.nativeSendGamepadArrival])
|
||||||
|
* before any input so the host builds a matching virtual device (mixed types across pads).
|
||||||
|
* Idempotent; null when all 16 wire indices are already forwarded.
|
||||||
|
*/
|
||||||
|
private fun openSlot(dev: InputDevice): Slot? {
|
||||||
|
slots[dev.id]?.let { return it }
|
||||||
|
val index = lowestFreeIndex() ?: return null // 16 pads already forwarded — drop this one
|
||||||
|
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||||
|
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||||
|
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||||
|
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||||
|
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||||
|
slots[dev.id] = slot
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush a slot's held wire state (so nothing sticks host-side), signal the removal, and free its
|
||||||
|
* index. Safe against an already-gone device — the flush emits wire events only, no device access.
|
||||||
|
*/
|
||||||
|
private fun closeSlot(deviceId: Int) {
|
||||||
|
val slot = slots.remove(deviceId) ?: return
|
||||||
|
releaseHeld(slot)
|
||||||
|
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||||
|
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||||
|
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||||
|
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||||
|
onSlotClosed?.invoke(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||||
|
private fun releaseHeld(slot: Slot) {
|
||||||
|
var bits = slot.held
|
||||||
|
while (bits != 0) {
|
||||||
|
val bit = bits and -bits // lowest set bit
|
||||||
|
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||||
|
bits = bits and bit.inv()
|
||||||
|
}
|
||||||
|
slot.held = 0
|
||||||
|
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||||
|
private fun lowestFreeIndex(): Int? {
|
||||||
|
val taken = slots.values.mapTo(HashSet()) { it.index }
|
||||||
|
for (i in 0 until MAX_PADS) if (i !in taken) return i
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
|
||||||
|
const val MAX_PADS = 16
|
||||||
|
|
||||||
|
/** 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 — 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,
|
name: String,
|
||||||
): 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
|
* 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
|
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
||||||
@@ -269,26 +279,52 @@ object NativeBridge {
|
|||||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||||
|
|
||||||
// ---- Gamepad: one pad forwarded as pad 0 (Rust hardcodes flags=0) ----
|
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
||||||
|
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
||||||
|
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||||
|
// events into seq'd GamepadState snapshots keyed on this index and owns the per-pad seq.
|
||||||
|
|
||||||
/** One gamepad button transition. bit: a [Gamepad].BTN_* bit. down: press/release. */
|
/** One gamepad button transition on wire pad [pad] (0..15). bit: a [Gamepad].BTN_* bit. down: press/release. */
|
||||||
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean)
|
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean, pad: Int)
|
||||||
|
|
||||||
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
/** One gamepad axis update on wire pad [pad] (0..15). axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
||||||
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int)
|
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int, pad: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare the controller KIND presented on wire pad [pad] (0..15) so the host builds a matching
|
||||||
|
* virtual device (mixed types across pads). pref: a [Gamepad].PREF_* wire byte. Send ONCE when a
|
||||||
|
* pad opens, BEFORE any of its input; an older host ignores it (that pad then uses the handshake's
|
||||||
|
* session-default kind — the pre-existing single-pad behaviour on pad 0).
|
||||||
|
*/
|
||||||
|
external fun nativeSendGamepadArrival(handle: Long, pref: Int, pad: Int)
|
||||||
|
|
||||||
|
/** 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) ----
|
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each
|
* Block up to ~100 ms for the next rumble update. Returns a packed positive long: bits 49..52 =
|
||||||
* 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread.
|
* wire pad index (0..15), bit 48 = has a v2 lease, bits 32..47 = ttl_ms, bits 16..31 = low, bits
|
||||||
|
* 0..15 = high (each amplitude 0..0xFFFF; 0/0 = stop), or -1 on timeout / session closed. Kotlin
|
||||||
|
* routes the update to the controller holding that pad index. Call from a dedicated poll thread.
|
||||||
*/
|
*/
|
||||||
external fun nativeNextRumble(handle: Long): Long
|
external fun nativeNextRumble(handle: Long): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
|
||||||
* ByteBuffer, capacity >= 64) as `[kind][fields…]`: Led=01 r g b, PlayerLeds=02 bits,
|
* capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
|
||||||
* Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed.
|
* 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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use ndk::media::media_format::MediaFormat;
|
|||||||
use ndk::native_window::NativeWindow;
|
use ndk::native_window::NativeWindow;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::error::PunktfunkError;
|
use punktfunk_core::error::PunktfunkError;
|
||||||
|
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
||||||
use punktfunk_core::session::Frame;
|
use punktfunk_core::session::Frame;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
@@ -208,9 +209,15 @@ fn run_sync(
|
|||||||
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
||||||
// round-trip) and we only pop the next one once it's queued.
|
// round-trip) and we only pop the next one once it's queued.
|
||||||
let mut pending: Option<Frame> = None;
|
let mut pending: Option<Frame> = None;
|
||||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
|
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
|
||||||
// climbs.
|
// Armed on a frame-index gap or a dropped-count climb, it withholds the decoder's concealed output
|
||||||
let mut last_dropped = client.frames_dropped();
|
// (released WITHOUT rendering — the SurfaceView keeps the last rendered frame on glass) until a
|
||||||
|
// proven clean re-anchor lifts it: an IDR (wire FLAG_SOF), an RFI anchor, or the 2nd recovery mark.
|
||||||
|
// `last_kf_req` throttles the keyframe intents it emits; `recovery_flags` carries each AU's
|
||||||
|
// user_flags from feed to present (keyed by the codec-echoed pts) so `on_decoded` reads the
|
||||||
|
// re-anchor signalling the platform decoder doesn't expose.
|
||||||
|
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||||
|
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||||
@@ -222,9 +229,11 @@ fn run_sync(
|
|||||||
// reclaimed after the codec is dropped below.
|
// reclaimed after the codec is dropped below.
|
||||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||||
let render_cb = install_render_callback(&codec, &tracker);
|
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
|
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||||
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
// 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();
|
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||||
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
// 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
|
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||||
@@ -243,6 +252,20 @@ fn run_sync(
|
|||||||
if pending.is_none() {
|
if pending.is_none() {
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
|
// Loss recovery (RFI): feed the frame index so a forward gap fires a throttled
|
||||||
|
// reference-frame-invalidation request — an RFI-capable host (AMD LTR / NVENC)
|
||||||
|
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||||
|
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||||
|
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||||
|
if client.note_frame_index(frame.frame_index) {
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
}
|
||||||
|
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||||
|
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||||
|
recovery_flags.push_back((frame.pts_ns / 1000, frame.flags));
|
||||||
|
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||||
|
recovery_flags.pop_front();
|
||||||
|
}
|
||||||
if fed == 0 {
|
if fed == 0 {
|
||||||
let p = &frame.data;
|
let p = &frame.data;
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -251,40 +274,45 @@ fn run_sync(
|
|||||||
&p[..p.len().min(6)]
|
&p[..p.len().min(6)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
// Receipt stamp for the `decode` stage pairing, parked in `in_flight` (keyed by
|
||||||
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
// the pts the codec echoes on its output buffer) whenever it's needed: the HUD
|
||||||
// steady state skips the wall-clock read and the lock entirely. The receipt
|
// being visible, or the ABR decode signal (`measure_decode`). The HUD-only
|
||||||
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
|
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||||
// the output buffer) for the decoded-point pairing in `drain`.
|
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||||
if stats.enabled() {
|
if stats.enabled() || measure_decode {
|
||||||
let received_ns = now_realtime_ns();
|
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));
|
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||||
if in_flight.len() > IN_FLIGHT_CAP {
|
if in_flight.len() > IN_FLIGHT_CAP {
|
||||||
in_flight.pop_front(); // stale — codec never echoed it back
|
in_flight.pop_front(); // stale — codec never echoed it back
|
||||||
}
|
}
|
||||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||||
// 0xCF host timings that have arrived — host = the host's own
|
// capture_pts.
|
||||||
// capture→sent, network = our capture→received minus it (per-frame
|
if stats.enabled() {
|
||||||
// tiling; saturating in case of clock jitter).
|
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||||
if let Some(hostnet_us) = lat_us {
|
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
.then_some((lat_ns / 1000) as u64);
|
||||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
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) {
|
||||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
if let Some(i) =
|
||||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||||
{
|
{
|
||||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||||
stats.note_host_split(
|
stats.note_host_split(
|
||||||
t.host_us as u64,
|
t.host_us as u64,
|
||||||
hostnet_us.saturating_sub(t.host_us as u64),
|
hostnet_us.saturating_sub(t.host_us as u64),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +352,8 @@ fn run_sync(
|
|||||||
};
|
};
|
||||||
let (r, d) = drain(
|
let (r, d) = drain(
|
||||||
&codec,
|
&codec,
|
||||||
|
&client,
|
||||||
|
measure_decode,
|
||||||
&window,
|
&window,
|
||||||
&mut applied_ds,
|
&mut applied_ds,
|
||||||
wait,
|
wait,
|
||||||
@@ -331,6 +361,8 @@ fn run_sync(
|
|||||||
&mut in_flight,
|
&mut in_flight,
|
||||||
clock_offset.load(Ordering::Relaxed),
|
clock_offset.load(Ordering::Relaxed),
|
||||||
&tracker,
|
&tracker,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
);
|
);
|
||||||
rendered += r;
|
rendered += r;
|
||||||
discarded += d;
|
discarded += d;
|
||||||
@@ -370,21 +402,19 @@ fn run_sync(
|
|||||||
work_accum_ns = 0;
|
work_accum_ns = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
// Loss recovery + overdue backstop, folded through the gate. Under infinite GOP the only
|
||||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
// recovery keyframe is one we request; the reassembler drops unrecoverable AUs (frames_dropped)
|
||||||
// reference-missing delta frames that follow and renders them without error, so keying off
|
// and the decoder then conceals the reference-missing deltas and renders them without error, so
|
||||||
// a decode error rarely fires. Request an IDR when the drop count climbs, throttled — the
|
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
|
||||||
// decode stays wedged for several frames until the IDR lands, so requesting every frame
|
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
|
||||||
// would flood the control stream.
|
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
|
||||||
let dropped = client.frames_dropped();
|
let now = Instant::now();
|
||||||
if dropped > last_dropped {
|
if gate.poll(client.frames_dropped(), now)
|
||||||
last_dropped = dropped;
|
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
let now = Instant::now();
|
{
|
||||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
last_kf_req = Some(now);
|
||||||
last_kf_req = Some(now);
|
let _ = client.request_keyframe();
|
||||||
let _ = client.request_keyframe();
|
log::debug!("decode: requested keyframe (loss recovery / overdue re-anchor)");
|
||||||
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,8 +732,10 @@ struct OutputReady {
|
|||||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||||
enum DecodeEvent {
|
enum DecodeEvent {
|
||||||
/// A received access unit from the feeder, ready to queue into the decoder.
|
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||||
Au(Frame),
|
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||||
|
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||||
|
Au(Frame, bool),
|
||||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||||
InputAvailable(usize),
|
InputAvailable(usize),
|
||||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||||
@@ -843,6 +875,9 @@ fn run_async(
|
|||||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||||
// HUD is visible.
|
// HUD is visible.
|
||||||
let clock_offset = client.clock_offset_shared();
|
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()));
|
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||||
@@ -863,7 +898,15 @@ fn run_async(
|
|||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pf-decode-feed".into())
|
.name("pf-decode-feed".into())
|
||||||
.spawn(move || {
|
.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()
|
.ok()
|
||||||
};
|
};
|
||||||
@@ -889,7 +932,12 @@ fn run_async(
|
|||||||
let mut discarded: u64 = 0;
|
let mut discarded: u64 = 0;
|
||||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||||
let mut oversized_dropped: u64 = 0;
|
let mut oversized_dropped: u64 = 0;
|
||||||
let mut last_dropped = client.frames_dropped();
|
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
||||||
|
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
||||||
|
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||||
|
// `present_ready` (present), keyed by the codec-echoed pts.
|
||||||
|
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||||
|
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||||
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
||||||
@@ -915,6 +963,8 @@ fn run_async(
|
|||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||||
@@ -927,6 +977,8 @@ fn run_async(
|
|||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||||
@@ -944,6 +996,8 @@ fn run_async(
|
|||||||
let had_output = !ready.is_empty();
|
let had_output = !ready.is_empty();
|
||||||
present_ready(
|
present_ready(
|
||||||
&codec,
|
&codec,
|
||||||
|
&client,
|
||||||
|
measure_decode,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&stats,
|
&stats,
|
||||||
&in_flight,
|
&in_flight,
|
||||||
@@ -951,6 +1005,8 @@ fn run_async(
|
|||||||
&tracker,
|
&tracker,
|
||||||
&mut rendered,
|
&mut rendered,
|
||||||
&mut discarded,
|
&mut discarded,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||||
@@ -982,17 +1038,19 @@ fn run_async(
|
|||||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Loss recovery: request an IDR when the reassembler's unrecoverable-drop count climbs (or we
|
// Loss recovery + overdue backstop, folded through the gate. A parked-AU overflow drop is itself
|
||||||
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
// a loss, so it arms the freeze directly; the gate's `poll` then arms on a dropped-count climb
|
||||||
// control stream.
|
// and re-asks on an overdue freeze. All keyframe intents route through the shared 100 ms
|
||||||
let dropped = client.frames_dropped();
|
// throttle so a multi-frame recovery gap can't flood the control stream.
|
||||||
if dropped > last_dropped || aus_dropped > 0 {
|
let now = Instant::now();
|
||||||
last_dropped = dropped;
|
if aus_dropped > 0 {
|
||||||
let now = Instant::now();
|
gate.arm(now);
|
||||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
}
|
||||||
last_kf_req = Some(now);
|
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
||||||
let _ = client.request_keyframe();
|
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
}
|
{
|
||||||
|
last_kf_req = Some(now);
|
||||||
|
let _ = client.request_keyframe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,6 +1074,7 @@ fn run_async(
|
|||||||
fn feeder_loop(
|
fn feeder_loop(
|
||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
measure_decode: bool,
|
||||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||||
clock_offset: Arc<AtomicI64>,
|
clock_offset: Arc<AtomicI64>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
@@ -1026,13 +1085,16 @@ fn feeder_loop(
|
|||||||
while !shutdown.load(Ordering::Relaxed) {
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
if stats.enabled() {
|
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
||||||
|
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
||||||
|
// 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);
|
||||||
|
// 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 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
|
let mut g = in_flight
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1042,23 +1104,31 @@ fn feeder_loop(
|
|||||||
g.pop_front(); // stale — codec never echoed it back
|
g.pop_front(); // stale — codec never echoed it back
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(hostnet_us) = lat_us {
|
if stats.enabled() {
|
||||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||||
pending_split.pop_front();
|
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) {
|
||||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
|
{
|
||||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||||
stats.note_host_split(
|
stats.note_host_split(
|
||||||
t.host_us as u64,
|
t.host_us as u64,
|
||||||
hostnet_us.saturating_sub(t.host_us as u64),
|
hostnet_us.saturating_sub(t.host_us as u64),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ev_tx.send(DecodeEvent::Au(frame)).is_err() {
|
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
|
||||||
break; // the decode loop is gone
|
break; // the decode loop is gone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1140,7 @@ fn feeder_loop(
|
|||||||
|
|
||||||
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
||||||
/// dropped on overflow (the caller then requests a keyframe).
|
/// dropped on overflow (the caller then requests a keyframe).
|
||||||
|
#[allow(clippy::too_many_arguments)] // two call sites; the freeze gate + flag map are threaded in
|
||||||
fn dispatch_event(
|
fn dispatch_event(
|
||||||
ev: DecodeEvent,
|
ev: DecodeEvent,
|
||||||
pending_aus: &mut VecDeque<Frame>,
|
pending_aus: &mut VecDeque<Frame>,
|
||||||
@@ -1077,9 +1148,20 @@ fn dispatch_event(
|
|||||||
ready: &mut Vec<OutputReady>,
|
ready: &mut Vec<OutputReady>,
|
||||||
fmt_dirty: &mut bool,
|
fmt_dirty: &mut bool,
|
||||||
fatal: &mut bool,
|
fatal: &mut bool,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
match ev {
|
match ev {
|
||||||
DecodeEvent::Au(f) => {
|
DecodeEvent::Au(f, gap) => {
|
||||||
|
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||||
|
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||||
|
if gap {
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
}
|
||||||
|
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||||
|
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||||
|
recovery_flags.pop_front();
|
||||||
|
}
|
||||||
pending_aus.push_back(f);
|
pending_aus.push_back(f);
|
||||||
if pending_aus.len() > FRAME_PARK_CAP {
|
if pending_aus.len() > FRAME_PARK_CAP {
|
||||||
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
||||||
@@ -1100,6 +1182,10 @@ fn dispatch_event(
|
|||||||
DecodeEvent::Error { fatal: f } => {
|
DecodeEvent::Error { fatal: f } => {
|
||||||
if f {
|
if f {
|
||||||
*fatal = true;
|
*fatal = true;
|
||||||
|
} else {
|
||||||
|
// A recoverable/transient codec error is a decode hiccup on a broken reference chain —
|
||||||
|
// arm the freeze so the concealed output it recovers into is held off the screen.
|
||||||
|
gate.arm(Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1250,8 @@ fn feed_ready(
|
|||||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||||
fn present_ready(
|
fn present_ready(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
ready: &mut Vec<OutputReady>,
|
ready: &mut Vec<OutputReady>,
|
||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||||
@@ -1171,22 +1259,40 @@ fn present_ready(
|
|||||||
tracker: &DisplayTracker,
|
tracker: &DisplayTracker,
|
||||||
rendered: &mut u64,
|
rendered: &mut u64,
|
||||||
discarded: &mut u64,
|
discarded: &mut u64,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
) {
|
) {
|
||||||
if ready.is_empty() {
|
if ready.is_empty() {
|
||||||
return;
|
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
|
let mut g = in_flight
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
for o in ready.iter() {
|
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 —
|
||||||
|
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||||
|
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||||
|
let now = Instant::now();
|
||||||
let last = ready.len() - 1;
|
let last = ready.len() - 1;
|
||||||
let mut skipped: u64 = 0;
|
let mut skipped: u64 = 0;
|
||||||
for (i, o) in ready.drain(..).enumerate() {
|
for (i, o) in ready.drain(..).enumerate() {
|
||||||
let render = i == last;
|
let flags = take_flags(recovery_flags, o.pts_us);
|
||||||
|
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||||
|
let render = i == last && present;
|
||||||
match codec.release_output_buffer_by_index(o.index, render) {
|
match codec.release_output_buffer_by_index(o.index, render) {
|
||||||
Ok(()) if render => {
|
Ok(()) if render => {
|
||||||
*rendered += 1;
|
*rendered += 1;
|
||||||
@@ -1206,7 +1312,7 @@ fn present_ready(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
|
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||||
@@ -1395,6 +1501,8 @@ fn feed(
|
|||||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
||||||
fn drain(
|
fn drain(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
window: &NativeWindow,
|
window: &NativeWindow,
|
||||||
applied_ds: &mut Option<DataSpace>,
|
applied_ds: &mut Option<DataSpace>,
|
||||||
first_wait: Duration,
|
first_wait: Duration,
|
||||||
@@ -1402,22 +1510,42 @@ fn drain(
|
|||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
tracker: &DisplayTracker,
|
tracker: &DisplayTracker,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
) -> (u64, u64) {
|
) -> (u64, u64) {
|
||||||
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
||||||
// `Some((pts_us, decoded_ns))` only while the HUD is visible (the stamp read is gated).
|
// `Some((pts_us, decoded_ns))` only while the HUD is visible. `held_present` is the freeze gate's
|
||||||
|
// verdict for that newest buffer (`false` = a post-loss concealment to withhold).
|
||||||
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
||||||
|
let mut held_present = true;
|
||||||
let mut discarded: u64 = 0;
|
let mut discarded: u64 = 0;
|
||||||
let mut wait = first_wait;
|
let mut wait = first_wait;
|
||||||
loop {
|
loop {
|
||||||
match codec.dequeue_output_buffer(wait) {
|
match codec.dequeue_output_buffer(wait) {
|
||||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||||
wait = Duration::ZERO; // only the first dequeue may block
|
// Only the first dequeue may block; later ones poll (wait == ZERO).
|
||||||
let meta = if stats.enabled() {
|
wait = Duration::ZERO;
|
||||||
|
// Fold every dequeued frame through the gate in pts (== decode) order — even the ones
|
||||||
|
// the newest-wins policy discards — so the two-mark re-anchor count stays correct; the
|
||||||
|
// verdict of the newest (last folded) buffer decides whether it reaches glass.
|
||||||
|
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||||
|
let flags = take_flags(recovery_flags, pts_us);
|
||||||
|
held_present =
|
||||||
|
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||||
|
let meta = if stats.enabled() || measure_decode {
|
||||||
// The dequeue IS the sync loop's decoded-availability instant.
|
// The dequeue IS the sync loop's decoded-availability instant.
|
||||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
|
||||||
let decoded_ns = now_realtime_ns();
|
let decoded_ns = now_realtime_ns();
|
||||||
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
|
note_decoded_pts(
|
||||||
Some((pts_us, decoded_ns))
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -1460,16 +1588,19 @@ fn drain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Present the newest ready frame, if any, and park its metadata for the render callback.
|
// Present the newest ready frame — UNLESS the gate is withholding it as a post-loss concealment,
|
||||||
|
// in which case release it without rendering (the SurfaceView keeps the last rendered frame frozen
|
||||||
|
// on glass) and count it as a discard rather than a display.
|
||||||
let mut rendered = 0;
|
let mut rendered = 0;
|
||||||
if let Some((buf, meta)) = held {
|
if let Some((buf, meta)) = held {
|
||||||
match codec.release_output_buffer(buf, true) {
|
match codec.release_output_buffer(buf, held_present) {
|
||||||
Ok(()) => {
|
Ok(()) if held_present => {
|
||||||
rendered = 1;
|
rendered = 1;
|
||||||
if let Some((pts_us, decoded_ns)) = meta {
|
if let Some((pts_us, decoded_ns)) = meta {
|
||||||
tracker.note_rendered(pts_us, decoded_ns);
|
tracker.note_rendered(pts_us, decoded_ns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||||
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1485,6 +1616,8 @@ fn drain(
|
|||||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||||
/// stamp (async loop).
|
/// stamp (async loop).
|
||||||
fn note_decoded_pts(
|
fn note_decoded_pts(
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
@@ -1503,12 +1636,44 @@ fn note_decoded_pts(
|
|||||||
break;
|
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);
|
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
|
||||||
|
/// signalling (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT) rides the AU's flags, which are
|
||||||
|
/// only in scope at feed time — so the feed side parks `(pts_us, flags)` here and the present side
|
||||||
|
/// looks them up to fold [`ReanchorGate::on_decoded`]. Decode order == input order (low-latency, no
|
||||||
|
/// B-frames), so this evicts entries older than `pts_us` as it goes; a miss (probe filler, or an entry
|
||||||
|
/// aged past the cap) reads `0` — no recovery flags, decoded normally.
|
||||||
|
fn take_flags(map: &mut VecDeque<(u64, u32)>, pts_us: u64) -> u32 {
|
||||||
|
while let Some(&(p, f)) = map.front() {
|
||||||
|
if p > pts_us {
|
||||||
|
break; // future frame — leave it for its own output buffer
|
||||||
|
}
|
||||||
|
map.pop_front();
|
||||||
|
if p == pts_us {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
|||||||
const TAG_LED: u8 = 0x01;
|
const TAG_LED: u8 = 0x01;
|
||||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||||
const TAG_TRIGGER: u8 = 0x03;
|
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.
|
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||||
/// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 =
|
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||||
/// `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = stop). The lease flag is
|
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
||||||
/// out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no in-band sentinel to
|
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
||||||
/// collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and Kotlin falls
|
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
||||||
/// back to its long one-shot. `-1` on timeout / session closed (all packed values are positive, so
|
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
||||||
/// `-1` stays unambiguous). Pad index is dropped (single-pad model). Run from a Kotlin poll thread.
|
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
||||||
|
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -46,14 +48,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
||||||
Ok((_pad, low, high, ttl)) => {
|
Ok((pad, low, high, ttl)) => {
|
||||||
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
||||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim.
|
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
||||||
|
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
||||||
let (lease_flag, ttl_bits) = match ttl {
|
let (lease_flag, ttl_bits) = match ttl {
|
||||||
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
||||||
None => (0, 0),
|
None => (0, 0),
|
||||||
};
|
};
|
||||||
lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high)
|
(jlong::from(pad & 0xF) << 49)
|
||||||
|
| lease_flag
|
||||||
|
| ttl_bits
|
||||||
|
| (jlong::from(low) << 16)
|
||||||
|
| jlong::from(high)
|
||||||
}
|
}
|
||||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||||
}
|
}
|
||||||
@@ -61,10 +68,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||||
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
|
/// HID-output event, written into the caller's direct ByteBuffer as `[pad][kind][fields…]` (the
|
||||||
/// Led → `[0x01][r][g][b]` (len 4)
|
/// leading `pad` is the wire pad index the event is addressed to, so Kotlin routes it to that
|
||||||
/// PlayerLeds → `[0x02][bits]` (len 2)
|
/// controller — multi-pad HID feedback):
|
||||||
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
|
/// Led → `[pad][0x01][r][g][b]` (len 5)
|
||||||
|
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
|
||||||
|
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
|
||||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||||
@@ -97,33 +106,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||||
|
|
||||||
|
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
|
||||||
let n = match ev {
|
let n = match ev {
|
||||||
HidOutput::Led { r, g, b, .. } => {
|
HidOutput::Led { pad, r, g, b } => {
|
||||||
if cap < 4 {
|
if cap < 5 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
out[0] = TAG_LED;
|
out[0] = pad;
|
||||||
out[1] = r;
|
out[1] = TAG_LED;
|
||||||
out[2] = g;
|
out[2] = r;
|
||||||
out[3] = b;
|
out[3] = g;
|
||||||
4
|
out[4] = b;
|
||||||
|
5
|
||||||
}
|
}
|
||||||
HidOutput::PlayerLeds { bits, .. } => {
|
HidOutput::PlayerLeds { pad, bits } => {
|
||||||
if cap < 2 {
|
if cap < 3 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
out[0] = TAG_PLAYER_LEDS;
|
out[0] = pad;
|
||||||
out[1] = bits;
|
out[1] = TAG_PLAYER_LEDS;
|
||||||
2
|
out[2] = bits;
|
||||||
|
3
|
||||||
}
|
}
|
||||||
HidOutput::Trigger { which, effect, .. } => {
|
HidOutput::Trigger { pad, which, effect } => {
|
||||||
let n = 2 + effect.len();
|
let n = 3 + effect.len();
|
||||||
if cap < n {
|
if cap < n {
|
||||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||||
}
|
}
|
||||||
out[0] = TAG_TRIGGER;
|
out[0] = pad;
|
||||||
out[1] = which;
|
out[1] = TAG_TRIGGER;
|
||||||
out[2..n].copy_from_slice(&effect);
|
out[2] = which;
|
||||||
|
out[3..n].copy_from_slice(&effect);
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
HidOutput::TrackpadHaptic { .. } => {
|
HidOutput::TrackpadHaptic { .. } => {
|
||||||
@@ -131,6 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
// rumble already rides the universal 0xCA plane).
|
// rumble already rides the universal 0xCA plane).
|
||||||
return -1;
|
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
|
n as jint
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,43 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
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.
|
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
/// 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.
|
/// 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) => {
|
Err(e) => {
|
||||||
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
||||||
|
note_error(&e);
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +356,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
|
|||||||
Ok(host_fp) => hex32(&host_fp),
|
Ok(host_fp) => hex32(&host_fp),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
// 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}");
|
log::error!("nativePair to {host}:{port} failed: {e}");
|
||||||
|
note_error(&e);
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
//! 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).
|
//! 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::sys::{jboolean, jint, jlong};
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
|
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||||
|
|
||||||
use super::SessionHandle;
|
use super::SessionHandle;
|
||||||
|
|
||||||
@@ -145,13 +146,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||||
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the
|
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
||||||
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id
|
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
||||||
// in `code` and the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host
|
// stable lowest-free index per Android device and threads it here. Buttons carry the gamepad::BTN_*
|
||||||
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad.
|
// bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id in `code` and
|
||||||
|
// the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host accumulates the
|
||||||
|
// incremental events per pad into a matching virtual device. The core input task folds these into
|
||||||
|
// the seq'd GamepadState snapshots (keyed on this same `flags` index) and owns the per-pad seq — so
|
||||||
|
// the only thing this layer must get right is the index. Wire contract: input.rs::gamepad. A single
|
||||||
|
// controller lands on index 0, so its wire is byte-identical to the old single-pad path.
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition.
|
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
|
||||||
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release.
|
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
|
||||||
|
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -159,21 +166,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
|||||||
handle: jlong,
|
handle: jlong,
|
||||||
bit: jint,
|
bit: jint,
|
||||||
down: jboolean,
|
down: jboolean,
|
||||||
|
pad: jint,
|
||||||
) {
|
) {
|
||||||
// flags = 0: pad index 0 — single-pad model.
|
|
||||||
send_event(
|
send_event(
|
||||||
handle,
|
handle,
|
||||||
InputKind::GamepadButton,
|
InputKind::GamepadButton,
|
||||||
bit as u32,
|
bit as u32,
|
||||||
i32::from(down != 0),
|
i32::from(down != 0),
|
||||||
0,
|
0,
|
||||||
0,
|
pad as u32,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update.
|
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
|
||||||
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (−32768..32767, +y=up) or
|
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
|
||||||
/// trigger 0..255.
|
/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -181,7 +188,92 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
|||||||
handle: jlong,
|
handle: jlong,
|
||||||
axis_id: jint,
|
axis_id: jint,
|
||||||
value: jint,
|
value: jint,
|
||||||
|
pad: jint,
|
||||||
) {
|
) {
|
||||||
// flags = 0: pad index 0 — single-pad model.
|
send_event(
|
||||||
send_event(handle, InputKind::GamepadAxis, axis_id as u32, value, 0, 0);
|
handle,
|
||||||
|
InputKind::GamepadAxis,
|
||||||
|
axis_id as u32,
|
||||||
|
value,
|
||||||
|
0,
|
||||||
|
pad as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendGamepadArrival(handle, pref, pad)` — declare the controller KIND presented
|
||||||
|
/// on wire pad index `pad` so the host builds a matching virtual device (mixed types — pad 0 a
|
||||||
|
/// DualSense, pad 1 an Xbox pad). `pref`: the `GamepadPref` wire byte (rides `code`). `pad`: wire pad
|
||||||
|
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
|
||||||
|
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
|
||||||
|
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pref: jint,
|
||||||
|
pad: jint,
|
||||||
|
) {
|
||||||
|
send_event(
|
||||||
|
handle,
|
||||||
|
InputKind::GamepadArrival,
|
||||||
|
pref as u32,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
pad as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
|
||||||
|
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||||
|
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||||
|
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pad: jint,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,22 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Custom keys merged into the auto-generated Info.plist (GENERATE_INFOPLIST_FILE=YES
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
supplies the rest). NSBonjourServices is required for NWBrowser to browse this
|
<true/>
|
||||||
service type on iOS/tvOS — without it the system blocks the browse and discovery
|
<key>GCSupportedGameControllers</key>
|
||||||
returns nothing. Kept OUT of the synchronized App/ + Sources/ groups so it isn't
|
<array>
|
||||||
auto-added as a bundle resource (which collides with Info.plist processing). -->
|
<dict>
|
||||||
|
<key>ProfileName</key>
|
||||||
|
<string>ExtendedGamepad</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>ProfileName</key>
|
||||||
|
<string>MicroGamepad</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_punktfunk._udp</string>
|
<string>_punktfunk._udp</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- Standard-algorithm crypto only (AES-GCM via the Rust core) — exempt from export
|
|
||||||
compliance, but the key must be declared or every TestFlight build stalls on the
|
|
||||||
compliance question. -->
|
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
<!-- Allow CADisplayLink above 60 Hz on ProMotion iPhones: without this key the system
|
|
||||||
silently caps the link at 60 even when SessionPresenter asks for the stream's rate
|
|
||||||
via preferredFrameRateRange, so a 120 fps stream would present at half rate. -->
|
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,19 +14,11 @@
|
|||||||
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||||
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||||
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||||
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
be approved by Apple for the App ID and enabled in the provisioning profile. macOS is not
|
||||||
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
gated by this (its App Sandbox network.client/server cover it), hence its separate file.
|
||||||
|
Approved and provisioned, so it's enabled here and PunktfunkConnection.wakeOnLANAvailable
|
||||||
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
is true on iOS/tvOS too. -->
|
||||||
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
|
||||||
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
|
||||||
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
|
||||||
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
|
||||||
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
|
||||||
file, no multicast entitlement needed). -->
|
|
||||||
<!--
|
|
||||||
<key>com.apple.developer.networking.multicast</key>
|
<key>com.apple.developer.networking.multicast</key>
|
||||||
<true/>
|
<true/>
|
||||||
-->
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ let package = Package(
|
|||||||
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
||||||
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
||||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||||
.testTarget(name: "PunktfunkKitTests", 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"],
|
||||||
|
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")
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,13 @@
|
|||||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "PUNKTFUNK_BILINEAR_LUMA"
|
||||||
|
value = "1"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ struct ContentView: View {
|
|||||||
case "h264": return PunktfunkConnection.codecH264
|
case "h264": return PunktfunkConnection.codecH264
|
||||||
case "hevc": return PunktfunkConnection.codecHEVC
|
case "hevc": return PunktfunkConnection.codecHEVC
|
||||||
case "av1": return PunktfunkConnection.codecAV1
|
case "av1": return PunktfunkConnection.codecAV1
|
||||||
|
case "pyrowave": return PunktfunkConnection.codecPyroWave
|
||||||
default: return 0
|
default: return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,8 @@ struct ContentView: View {
|
|||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@State private var libraryTarget: StoredHost?
|
@State private var libraryTarget: StoredHost?
|
||||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||||
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
/// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS
|
||||||
|
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
|
||||||
@StateObject private var waker = HostWaker()
|
@StateObject private var waker = HostWaker()
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Whether the hosting window is native-fullscreen right now (reported by
|
/// Whether the hosting window is native-fullscreen right now (reported by
|
||||||
@@ -86,6 +88,10 @@ struct ContentView: View {
|
|||||||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||||||
|
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||||
|
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||||
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||||
@@ -259,9 +265,26 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||||
// `home`, so it covers the whole wake→online→connect sequence).
|
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||||
homeBase.overlay { WakeOverlay(waker: waker) }
|
// "Connecting…" feedback on any dial, flowing seamlessly into the "Waking…" wait if the host
|
||||||
|
// turns out to be asleep.
|
||||||
|
homeBase.overlay {
|
||||||
|
ConnectOverlay(
|
||||||
|
connectingHostName: connectingOverlayName,
|
||||||
|
waker: waker,
|
||||||
|
gamepadUI: gamepadUIActive,
|
||||||
|
onCancelConnect: { model.disconnect() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host label for the connect takeover's "Connecting…" phase — a plain dial in flight. Nil
|
||||||
|
/// during the delegated-approval wait (that has its own "Waiting for approval" prompt, so the
|
||||||
|
/// takeover must not stack over it) and, of course, when idle or streaming.
|
||||||
|
private var connectingOverlayName: String? {
|
||||||
|
guard awaitingApproval == nil, model.phase == .connecting, let host = model.activeHost
|
||||||
|
else { return nil }
|
||||||
|
return host.displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var homeBase: some View {
|
@ViewBuilder private var homeBase: some View {
|
||||||
@@ -567,7 +590,8 @@ struct ContentView: View {
|
|||||||
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
||||||
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
||||||
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
||||||
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable,
|
||||||
|
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||||
discovery.start() // so the wake-wait can observe it reappear
|
discovery.start() // so the wake-wait can observe it reappear
|
||||||
startSessionDirect(
|
startSessionDirect(
|
||||||
host, launchID: launchID, allowTofu: allowTofu,
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
@@ -624,7 +648,9 @@ struct ContentView: View {
|
|||||||
private func prepareWake(for host: StoredHost) {
|
private func prepareWake(for host: StoredHost) {
|
||||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||||
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||||
|
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
|
||||||
|
// dial times out. With auto-wake off, connects go straight through (no packet).
|
||||||
let macs = host.wakeMacs
|
let macs = host.wakeMacs
|
||||||
let ip = host.address
|
let ip = host.address
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// The unified "getting you connected" overlay — one look for BOTH phases of reaching a host, so the
|
||||||
|
// user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
||||||
|
// out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the shared console
|
||||||
|
// UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`.
|
||||||
|
//
|
||||||
|
// - Connecting (`connectingHostName` non-nil): the dial is in flight. Shown immediately on activate
|
||||||
|
// so a host that takes a beat to answer no longer looks like nothing happened.
|
||||||
|
// - Waking (`waker.waking` non-nil): the dial failed on a sleeping host, so we're firing
|
||||||
|
// Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on
|
||||||
|
// timeout.
|
||||||
|
//
|
||||||
|
// Presentation is mode-aware: the gamepad ("console") UI gets a full-screen aurora takeover — the
|
||||||
|
// same living backdrop the console home wears, so it reads as a deliberate 10-foot moment; the
|
||||||
|
// default touch/desktop UI gets a Liquid Glass modal over a dim scrim, which sits right at home among
|
||||||
|
// the app's other floating surfaces (the trust card, the HUD) instead of a full-screen aurora that
|
||||||
|
// looked out of place there.
|
||||||
|
//
|
||||||
|
// The two phases hand off within a single view update (HostWaker clears `waking` and starts the
|
||||||
|
// connect in the same MainActor step), so the overlay never blinks between them. It swallows input to
|
||||||
|
// the screen behind it, and on iOS/macOS the pad drives it (B cancels, A retries once timed out).
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConnectOverlay: View {
|
||||||
|
/// Non-nil while a plain dial is in flight (the delegated-approval wait has its own prompt, so it
|
||||||
|
/// passes nil here). Drives the "Connecting…" phase.
|
||||||
|
let connectingHostName: String?
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
|
/// The console launcher is up → full-screen aurora takeover; otherwise the default UI's Liquid
|
||||||
|
/// Glass modal.
|
||||||
|
var gamepadUI: Bool
|
||||||
|
/// Cancel a dial in flight — tears down the (uncancelable) connect and returns the UI; the late
|
||||||
|
/// result is discarded by SessionModel's connect guard.
|
||||||
|
var onCancelConnect: () -> Void
|
||||||
|
|
||||||
|
private enum Phase {
|
||||||
|
case connecting(name: String)
|
||||||
|
case waking(HostWaker.Waking)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waking takes precedence — it only ever exists after a dial has already failed, so a stray
|
||||||
|
/// overlap can't strand the "Connecting…" phase over a wake in progress.
|
||||||
|
private var phase: Phase? {
|
||||||
|
if let w = waker.waking { return .waking(w) }
|
||||||
|
if let name = connectingHostName { return .connecting(name: name) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let phase {
|
||||||
|
ZStack {
|
||||||
|
if gamepadUI {
|
||||||
|
// Console: an opaque, living aurora over everything.
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
GamepadScreenBackground().ignoresSafeArea()
|
||||||
|
Color.clear.contentShape(Rectangle()).onTapGesture {}
|
||||||
|
content(phase).padding(40).frame(maxWidth: 460)
|
||||||
|
} else {
|
||||||
|
// Default UI: a Liquid Glass modal over a dim scrim.
|
||||||
|
Rectangle().fill(.black.opacity(0.5)).ignoresSafeArea()
|
||||||
|
.contentShape(Rectangle()).onTapGesture {}
|
||||||
|
content(phase)
|
||||||
|
.padding(28)
|
||||||
|
.frame(maxWidth: 380)
|
||||||
|
.glassBackground(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
.transition(.opacity)
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func content(_ phase: Phase) -> some View {
|
||||||
|
// The takeover carries larger type than the compact modal.
|
||||||
|
let titleSize: CGFloat = gamepadUI ? 24 : 19
|
||||||
|
let bodySize: CGFloat = gamepadUI ? 14 : 13
|
||||||
|
VStack(spacing: gamepadUI ? 16 : 14) {
|
||||||
|
switch phase {
|
||||||
|
case .connecting(let name):
|
||||||
|
ProgressView().controlSize(.large).tint(.white)
|
||||||
|
Text("Connecting to \(name)")
|
||||||
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text("Establishing a secure connection…")
|
||||||
|
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||||
|
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
|
||||||
|
case .waking(let w) where w.timedOut:
|
||||||
|
Image(systemName: "moon.zzz.fill")
|
||||||
|
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
|
||||||
|
Text("\(w.hostName) didn't wake")
|
||||||
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text("It may still be booting, or it's powered off / off this network.")
|
||||||
|
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||||
|
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
case .waking(let w):
|
||||||
|
ProgressView().controlSize(.large).tint(.white)
|
||||||
|
Text("Waking \(w.hostName)…")
|
||||||
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text("Waiting for it to come online · \(w.seconds)s")
|
||||||
|
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
|
||||||
|
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
|
||||||
|
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||||
|
.buttonStyle(.bordered).padding(.top, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// Controller binding for the overlay: B cancels whatever's in flight (a dial or the wake wait); A
|
||||||
|
/// retries once a wake has timed out. The closures read the live state on each press, so they stay
|
||||||
|
/// correct across the Connecting ↔ Waking handoff without the view re-mounting. A zero-size backing
|
||||||
|
/// view owning a `GamepadMenuInput` for the overlay's lifetime (the home is gated inactive while the
|
||||||
|
/// overlay is up, so nothing else is consuming the pad).
|
||||||
|
private struct ConnectControllerInput: View {
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
|
var onCancelConnect: () -> Void
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.onAppear {
|
||||||
|
input.onBack = { if waker.waking != nil { waker.cancel() } else { onCancelConnect() } }
|
||||||
|
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||||
|
input.start()
|
||||||
|
}
|
||||||
|
.onDisappear { input.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -65,6 +65,9 @@ struct GamepadHomeView: View {
|
|||||||
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
||||||
/// Settings "Game library" toggle opts out).
|
/// Settings "Game library" toggle opts out).
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
|
/// Auto-wake on connect (default ON) — when off, activating an offline host just dials (no wake),
|
||||||
|
/// so the tile drops its "Wake & Connect" affordance for a plain "Connect".
|
||||||
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
@@ -192,9 +195,12 @@ struct GamepadHomeView: View {
|
|||||||
onActivate: { $0.activate() },
|
onActivate: { $0.activate() },
|
||||||
onSecondary: { openLibraryForSelected() },
|
onSecondary: { openLibraryForSelected() },
|
||||||
onTertiary: { showSettings = true },
|
onTertiary: { showSettings = true },
|
||||||
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||||
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||||
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
|
||||||
|
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
|
||||||
|
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||||
|
&& waker.waking == nil && model.phase != .connecting
|
||||||
) { tile in
|
) { tile in
|
||||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||||
}
|
}
|
||||||
@@ -256,7 +262,7 @@ struct GamepadHomeView: View {
|
|||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
filled: true,
|
filled: true,
|
||||||
hasLibrary: true,
|
hasLibrary: true,
|
||||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||||
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
||||||
&& !host.wakeMacs.isEmpty,
|
&& !host.wakeMacs.isEmpty,
|
||||||
activate: { connect(host) })
|
activate: { connect(host) })
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
// The "Waking <host>…" modal shown while HostWaker brings a sleeping host back — a spinner + a
|
|
||||||
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
|
|
||||||
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
|
|
||||||
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
|
|
||||||
|
|
||||||
import PunktfunkKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct WakeOverlay: View {
|
|
||||||
@ObservedObject var waker: HostWaker
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let w = waker.waking {
|
|
||||||
ZStack {
|
|
||||||
// Dim + swallow input to the home behind it.
|
|
||||||
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {}
|
|
||||||
card(w)
|
|
||||||
.frame(maxWidth: 380)
|
|
||||||
.padding(28)
|
|
||||||
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
||||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
|
||||||
.padding(40)
|
|
||||||
}
|
|
||||||
.environment(\.colorScheme, .dark)
|
|
||||||
.transition(.opacity)
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
.background { WakeControllerInput(waker: waker) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
|
|
||||||
VStack(spacing: 14) {
|
|
||||||
if w.timedOut {
|
|
||||||
Image(systemName: "moon.zzz.fill")
|
|
||||||
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
|
|
||||||
Text("\(w.hostName) didn't wake")
|
|
||||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
|
||||||
Text("It may still be booting, or it's powered off / off this network.")
|
|
||||||
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
|
||||||
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
|
||||||
}
|
|
||||||
.padding(.top, 6)
|
|
||||||
} else {
|
|
||||||
ProgressView().controlSize(.large).tint(.white)
|
|
||||||
Text("Waking \(w.hostName)…")
|
|
||||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
|
||||||
Text("Waiting for it to come online · \(w.seconds)s")
|
|
||||||
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
|
|
||||||
.monospacedDigit()
|
|
||||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
|
|
||||||
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
|
|
||||||
/// gated inactive while a wake is up, so nothing else is consuming the pad).
|
|
||||||
private struct WakeControllerInput: View {
|
|
||||||
@ObservedObject var waker: HostWaker
|
|
||||||
@State private var input = GamepadMenuInput(manager: .shared)
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Color.clear
|
|
||||||
.onAppear {
|
|
||||||
input.onBack = { waker.cancel() }
|
|
||||||
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
|
||||||
input.start()
|
|
||||||
}
|
|
||||||
.onDisappear { input.stop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -49,8 +49,24 @@ enum ShotScenes {
|
|||||||
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||||
AnyView(ShotGamepadAddHost())
|
AnyView(ShotGamepadAddHost())
|
||||||
},
|
},
|
||||||
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
|
||||||
AnyView(ShotWaking())
|
AnyView(ShotConnect(kind: .connecting))
|
||||||
|
},
|
||||||
|
ShotScene(name: "09b-waking", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .waking))
|
||||||
|
},
|
||||||
|
ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .timedOut))
|
||||||
|
},
|
||||||
|
// The default-UI presentation (Liquid Glass modal over the touch grid) of the same phases.
|
||||||
|
ShotScene(name: "09d-connecting-modal", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .connecting, gamepadUI: false))
|
||||||
|
},
|
||||||
|
ShotScene(name: "09e-waking-modal", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .waking, gamepadUI: false))
|
||||||
|
},
|
||||||
|
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
#endif
|
#endif
|
||||||
@@ -137,23 +153,53 @@ private struct ShotGamepadAddHost: View {
|
|||||||
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ShotWaking: View {
|
/// The unified connect overlay (the real `ConnectOverlay`) in each phase — instant "Connecting…"
|
||||||
|
/// feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUI` picks the presentation:
|
||||||
|
/// the console's full-screen aurora takeover over the gamepad home, or the default UI's Liquid Glass
|
||||||
|
/// modal over the touch host grid.
|
||||||
|
private struct ShotConnect: View {
|
||||||
|
enum Kind { case connecting, waking, timedOut }
|
||||||
|
let kind: Kind
|
||||||
|
var gamepadUI = true
|
||||||
|
|
||||||
@StateObject private var store = ShotMock.hostStore()
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
@StateObject private var model = SessionModel()
|
@StateObject private var model = SessionModel()
|
||||||
@StateObject private var discovery = HostDiscovery()
|
@StateObject private var discovery = HostDiscovery()
|
||||||
@StateObject private var waker = HostWaker()
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GamepadHomeView(
|
backdrop
|
||||||
store: store, model: model, discovery: discovery,
|
.overlay {
|
||||||
libraryTarget: .constant(nil), waker: waker,
|
ConnectOverlay(
|
||||||
connect: { _ in }, connectDiscovered: { _ in }
|
connectingHostName: kind == .connecting ? "Battlestation" : nil,
|
||||||
)
|
waker: waker,
|
||||||
.overlay { WakeOverlay(waker: waker) }
|
gamepadUI: gamepadUI,
|
||||||
.onAppear {
|
onCancelConnect: {})
|
||||||
waker.debugSet(.init(
|
}
|
||||||
hostID: store.hosts.first?.id ?? UUID(),
|
.onAppear {
|
||||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
switch kind {
|
||||||
|
case .connecting:
|
||||||
|
break
|
||||||
|
case .waking:
|
||||||
|
waker.debugSet(.init(
|
||||||
|
hostID: store.hosts.first?.id ?? UUID(),
|
||||||
|
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||||
|
case .timedOut:
|
||||||
|
waker.debugSet(.init(
|
||||||
|
hostID: store.hosts.first?.id ?? UUID(),
|
||||||
|
hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var backdrop: some View {
|
||||||
|
if gamepadUI {
|
||||||
|
GamepadHomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
libraryTarget: .constant(nil), waker: waker,
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in })
|
||||||
|
} else {
|
||||||
|
ShotHome()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class HostWaker: ObservableObject {
|
|||||||
var timedOut = false
|
var timedOut = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// nil = idle; non-nil drives `WakeOverlay`.
|
/// nil = idle; non-nil drives the "Waking…" phase of `ConnectOverlay`.
|
||||||
@Published private(set) var waking: Waking?
|
@Published private(set) var waking: Waking?
|
||||||
|
|
||||||
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
||||||
|
|||||||
@@ -239,6 +239,18 @@ final class SessionModel: ObservableObject {
|
|||||||
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||||
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||||
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
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(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
@@ -284,10 +296,15 @@ final class SessionModel: ObservableObject {
|
|||||||
self.errorMessage = "\(host.displayName) is not paired yet. "
|
self.errorMessage = "\(host.displayName) is not paired yet. "
|
||||||
+ "Pair with its PIN before streaming."
|
+ "Pair with its PIN before streaming."
|
||||||
}
|
}
|
||||||
case .failure:
|
case .failure(let error):
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
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
|
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||||||
// own overlay explains what's happening.
|
// own overlay explains what's happening.
|
||||||
onUnreachable()
|
onUnreachable()
|
||||||
@@ -419,9 +436,10 @@ final class SessionModel: ObservableObject {
|
|||||||
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
||||||
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||||
self.audio = audio
|
self.audio = audio
|
||||||
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
// Gamepads: forward every controller GamepadManager selected — each on its own wire pad
|
||||||
// host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the
|
// index (a pin forwards only one, Automatic forwards all) — and render the host's feedback
|
||||||
// session's virtual pad is a DualSense). Same trust gate as audio — nothing is
|
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||||||
|
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||||||
// forwarded during the trust prompt.
|
// forwarded during the trust prompt.
|
||||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS) || os(tvOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
#if os(iOS)
|
||||||
|
import CoreHaptics
|
||||||
|
#endif
|
||||||
|
|
||||||
struct GamepadSettingsView: View {
|
struct GamepadSettingsView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -36,7 +39,11 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
@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
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -229,7 +236,7 @@ struct GamepadSettingsView: View {
|
|||||||
.map { (label: "\($0) Hz", tag: $0) }
|
.map { (label: "\($0) Hz", tag: $0) }
|
||||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||||
return [
|
var list: [Row] = [
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||||
label: "Resolution",
|
label: "Resolution",
|
||||||
@@ -258,6 +265,11 @@ struct GamepadSettingsView: View {
|
|||||||
+ "available on the host.",
|
+ "available on the host.",
|
||||||
options: SettingsOptions.compositors, current: compositor
|
options: SettingsOptions.compositors, current: compositor
|
||||||
) { compositor = $0 },
|
) { compositor = $0 },
|
||||||
|
toggleRow(
|
||||||
|
id: "autoWake", icon: "power", label: "Auto-wake on connect",
|
||||||
|
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||||
|
+ "streaming. Off connects straight through.",
|
||||||
|
value: $autoWakeEnabled),
|
||||||
|
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||||
@@ -323,6 +335,23 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||||
value: $gamepadUIEnabled),
|
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
|
/// 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 {
|
if AV1.hardwareDecodeSupported {
|
||||||
options.insert(("AV1", "av1"), at: 2)
|
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
|
return options
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||||
// composed by the per-platform bodies in SettingsView.swift.
|
// composed by the per-platform bodies in SettingsView.swift.
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import CoreHaptics
|
||||||
|
#endif
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -42,9 +45,10 @@ extension SettingsView {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text(matchWindow
|
Text(matchWindow
|
||||||
? "The stream follows this window — the host resizes its virtual output to match "
|
? "The stream follows this window — the host resizes its virtual output to match "
|
||||||
+ "as you resize, no scaling. \(Self.bitrateFooter)"
|
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
|
||||||
: "The host creates a virtual output at exactly this mode — "
|
+ "\(Self.bitrateFooter)"
|
||||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
: "The host creates a virtual output at exactly this mode — native resolution, but "
|
||||||
|
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -294,6 +298,24 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
||||||
|
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
||||||
|
@ViewBuilder var wakeSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Wake-on-LAN")
|
||||||
|
} footer: {
|
||||||
|
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
||||||
|
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
||||||
|
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
||||||
|
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
||||||
|
+ "way.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder var windowSection: some View {
|
@ViewBuilder var windowSection: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Section {
|
Section {
|
||||||
@@ -452,6 +474,12 @@ extension SettingsView {
|
|||||||
Text(option.label).tag(option.tag)
|
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)
|
#if !os(tvOS)
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
#endif
|
#endif
|
||||||
@@ -468,6 +496,11 @@ extension SettingsView {
|
|||||||
// for its own footer and has no such toggle to describe.
|
// for its own footer and has no such toggle to describe.
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
|
#if os(iOS)
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
Text(Self.deviceRumbleFooter)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Text(Self.gamepadUIFooter)
|
Text(Self.gamepadUIFooter)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ extension SettingsView {
|
|||||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "Applies from the next session."
|
+ "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)
|
#if !os(tvOS)
|
||||||
static let gamepadUIFooter =
|
static let gamepadUIFooter =
|
||||||
"When a controller connects, the host list and library switch to a controller-"
|
"When a controller connects, the host list and library switch to a controller-"
|
||||||
@@ -133,8 +140,10 @@ extension SettingsView {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if gamepads.active?.id == controller.id {
|
// Every forwarded controller is surfaced (not just the primary `active`) with its
|
||||||
Text("In use")
|
// wire pad index as a player number — a pin forwards only one, Automatic forwards all.
|
||||||
|
if let pad = gamepads.padIndex(for: controller) {
|
||||||
|
Text("Player \(pad + 1)")
|
||||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||||
|
// Opt-in (default OFF): the explicit mode below is used and never auto-resized. When ON, a
|
||||||
|
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||||
|
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@@ -45,12 +48,14 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State var showControllerTest = false
|
@State var showControllerTest = false
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
@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.
|
// 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),
|
// 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).
|
// General on iPad (a two-column layout should never open with an empty detail).
|
||||||
@@ -106,6 +111,7 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
streamModeSection
|
streamModeSection
|
||||||
compositorSection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
@@ -235,6 +241,7 @@ struct SettingsView: View {
|
|||||||
streamModeSection
|
streamModeSection
|
||||||
pointerSection
|
pointerSection
|
||||||
compositorSection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
@@ -305,6 +312,10 @@ struct SettingsView: View {
|
|||||||
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
|
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var autoWakeEnabledTag: Binding<String> {
|
||||||
|
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||||||
|
}
|
||||||
|
|
||||||
private var tvBody: some View {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -344,9 +355,13 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Auto-wake on connect",
|
||||||
|
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||||
Text("The host creates a virtual output at exactly this mode — native "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
+ "is honored only if available on the host.")
|
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
||||||
|
+ "sleeping saved host and waits for it before streaming.")
|
||||||
.font(.geist(20, relativeTo: .caption))
|
.font(.geist(20, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -212,14 +212,18 @@ struct PairSheet: View {
|
|||||||
case .failure(PunktfunkClientError.wrongPIN):
|
case .failure(PunktfunkClientError.wrongPIN):
|
||||||
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||||
+ "and try again."
|
+ "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):
|
case .failure(is ClientIdentityStore.IdentityError):
|
||||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||||
+ "pairing would not survive a relaunch. Unlock the login "
|
+ "pairing would not survive a relaunch. Unlock the login "
|
||||||
+ "keychain and try again."
|
+ "keychain and try again."
|
||||||
case .failure:
|
case .failure:
|
||||||
errorText = "Pairing failed. Is the host reachable, pairing armed "
|
errorText = "Pairing failed — the host didn't answer. Is it running, "
|
||||||
+ "(web console → Pairing), and not mid-session? Retries are "
|
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
||||||
+ "rate-limited to one per 2 seconds."
|
+ "isolation)?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ public func pair(
|
|||||||
switch rc {
|
switch rc {
|
||||||
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||||
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
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,26 @@ public extension PunktfunkInputEvent {
|
|||||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
|
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Declare a pad's controller KIND (`InputKind::GamepadArrival`): `pref` is the
|
||||||
|
/// `GamepadType` wire byte (Auto=0, Xbox360=1, DualSense=2, XboxOne=3, DualShock4=4,
|
||||||
|
/// SteamController=5, SteamDeck=6), `pad` the wire index. Sent once when a controller slot
|
||||||
|
/// opens — BEFORE that pad's first input — so the host builds a matching virtual device and a
|
||||||
|
/// session can mix types (pad 0 a DualSense, pad 1 an Xbox pad). The core re-sends it a few
|
||||||
|
/// times against datagram loss and folds per-pad state behind it; a host that predates the tag
|
||||||
|
/// ignores it and uses the session-default kind from the handshake. Idempotent on the host.
|
||||||
|
static func gamepadArrival(pref: UInt32, pad: UInt32) -> PunktfunkInputEvent {
|
||||||
|
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue, code: pref, x: 0, y: 0, flags: pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pad disconnected (`InputKind::GamepadRemove`): `flags` = pad index. The client sends the
|
||||||
|
/// bare index; the core stamps the per-pad removal seq (`encode_gamepad_remove`) in the shared
|
||||||
|
/// snapshot seq space and arms a loss-resistant re-send burst, so the host tears the pad's
|
||||||
|
/// virtual device down and no reordered snapshot can resurrect it. A host that predates the tag
|
||||||
|
/// ignores it (the pad then lingers until session end — the pre-existing behaviour).
|
||||||
|
static func gamepadRemove(pad: UInt32) -> PunktfunkInputEvent {
|
||||||
|
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue, code: 0, x: 0, y: 0, flags: pad)
|
||||||
|
}
|
||||||
|
|
||||||
// Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes
|
// Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes
|
||||||
// fingers and is reusable after touchUp; coordinates are absolute pixels on the
|
// fingers and is reusable after touchUp; coordinates are absolute pixels on the
|
||||||
// client's touch surface, whose size rides in `flags` so the host can rescale —
|
// client's touch surface, whose size rides in `flags` so the host can rescale —
|
||||||
|
|||||||
@@ -59,6 +59,68 @@ public enum PunktfunkClientError: Error {
|
|||||||
case wrongPIN
|
case wrongPIN
|
||||||
case closed
|
case closed
|
||||||
case status(Int32)
|
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.
|
/// `withCString` over an optional — nil maps to a NULL C pointer.
|
||||||
@@ -70,19 +132,10 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
|||||||
public extension PunktfunkConnection {
|
public extension PunktfunkConnection {
|
||||||
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
||||||
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
||||||
/// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's
|
/// `com.apple.developer.networking.multicast` entitlement — now approved and enabled (see
|
||||||
/// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is
|
/// `Config/Punktfunk.entitlements`), so wake is available on every platform. Kept as the single
|
||||||
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
|
/// switch every call site gates on, should a future build ever need to disable it.
|
||||||
/// The MAC-learning path stays active on every platform, so flipping this on once the
|
static var wakeOnLANAvailable: Bool { true }
|
||||||
/// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to
|
|
||||||
/// `true` for iOS/tvOS too (and uncomment the entitlement).
|
|
||||||
static var wakeOnLANAvailable: Bool {
|
|
||||||
#if os(macOS)
|
|
||||||
return true
|
|
||||||
#else
|
|
||||||
return false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
||||||
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
||||||
@@ -197,6 +250,19 @@ public final class PunktfunkConnection {
|
|||||||
// exist so the resolved type round-trips and name parsing matches the host.
|
// exist so the resolved type round-trips and name parsing matches the host.
|
||||||
case steamController = 5
|
case steamController = 5
|
||||||
case steamDeck = 6
|
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
|
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||||
/// `GamepadPref::from_name`.
|
/// `GamepadPref::from_name`.
|
||||||
@@ -209,6 +275,11 @@ public final class PunktfunkConnection {
|
|||||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
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
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,9 +337,15 @@ public final class PunktfunkConnection {
|
|||||||
public private(set) var resolvedAudioChannels: UInt8 = 2
|
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||||
|
|
||||||
/// The video codec the host resolved for this session (`Welcome.codec`, `PUNKTFUNK_CODEC_*`):
|
/// 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
|
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1, `8` = PyroWave (only when this
|
||||||
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
/// 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
|
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
|
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||||
@@ -310,6 +387,10 @@ public final class PunktfunkConnection {
|
|||||||
) throws {
|
) throws {
|
||||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||||
var observed = [UInt8](repeating: 0, count: 32)
|
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
|
// `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.
|
// 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
|
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||||
@@ -320,24 +401,29 @@ public final class PunktfunkConnection {
|
|||||||
withOptionalCString(launchID) { launch in
|
withOptionalCString(launchID) { launch in
|
||||||
if let pin = pinSHA256 {
|
if let pin = pinSHA256 {
|
||||||
return pin.withUnsafeBytes { p in
|
return pin.withUnsafeBytes { p in
|
||||||
punktfunk_connect_ex7(
|
punktfunk_connect_ex8(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||||
videoCodecs, preferredCodec, launch,
|
videoCodecs, preferredCodec, launch,
|
||||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
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,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||||
videoCodecs, preferredCodec, launch,
|
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)
|
hostFingerprint = Data(observed)
|
||||||
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||||
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
||||||
@@ -372,6 +458,9 @@ public final class PunktfunkConnection {
|
|||||||
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||||
_ = punktfunk_connection_codec(handle, &codec)
|
_ = punktfunk_connection_codec(handle, &codec)
|
||||||
resolvedCodec = 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`.
|
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||||
@@ -445,6 +534,35 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_request_keyframe(h)
|
_ = punktfunk_connection_request_keyframe(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
|
||||||
|
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap —
|
||||||
|
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
||||||
|
/// picture that never arrived — the core fires a THROTTLED RFI request for the lost range, and an
|
||||||
|
/// RFI-capable host (AMD LTR / NVENC) recovers with a clean P-frame rather than a 20-40× IDR
|
||||||
|
/// spike. Call it for every received AU; the `framesDropped`-driven `requestKeyframe()` path stays
|
||||||
|
/// the backstop for when the recovery frame itself is lost. Cheap; silently dropped after close.
|
||||||
|
public func noteFrameIndex(_ frameIndex: UInt32) {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return }
|
||||||
|
_ = punktfunk_connection_note_frame_index(h, frameIndex, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `noteFrameIndex`, but also reports whether the core saw a FORWARD frame-index gap — the
|
||||||
|
/// signal that intervening frames were lost and the following AUs reference a picture that never
|
||||||
|
/// arrived. The post-loss re-anchor gate arms its display freeze on a gap (the earliest, most
|
||||||
|
/// precise loss trigger — ahead of the `framesDropped` climb). Same core side effect as
|
||||||
|
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
|
||||||
|
/// after close.
|
||||||
|
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return false }
|
||||||
|
var gap = false
|
||||||
|
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
|
||||||
|
return gap
|
||||||
|
}
|
||||||
|
|
||||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||||
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
||||||
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
||||||
@@ -460,6 +578,30 @@ public final class PunktfunkConnection {
|
|||||||
return out
|
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).
|
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||||
abiLock.lock()
|
abiLock.lock()
|
||||||
@@ -681,6 +823,15 @@ public final class PunktfunkConnection {
|
|||||||
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
||||||
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
||||||
public static let codecAV1: UInt8 = UInt8(PUNKTFUNK_CODEC_AV1)
|
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
|
/// 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,
|
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
// Gamepad capture → punktfunk/1 datagrams. Forwards exactly ONE controller — whatever
|
// Gamepad capture → punktfunk/1 datagrams. Forwards EVERY controller GamepadManager selected —
|
||||||
// GamepadManager selected — as pad 0, for the lifetime of a streaming session.
|
// each on its own stable wire pad index (pf-client-core's slot model) — for the lifetime of a
|
||||||
|
// streaming session. One physical controller with no pin is player 0 (byte-identical to the old
|
||||||
|
// single-pad path); a pin forwards only that one, also as pad 0.
|
||||||
//
|
//
|
||||||
// The wire is incremental (one button/axis transition per 18-byte event, accumulated
|
// Each forwarded controller gets a `Slot`: its open GC handlers plus the wire state (buttons,
|
||||||
// host-side into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the
|
// axes, touchpad fingers, motion throttle) for its pad index — isolated per device so two
|
||||||
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
// controllers never clobber each other. On connect a slot opens (GamepadArrival declares its
|
||||||
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
// kind, then input flows); on disconnect / pin change / stop it closes (held state flushed to
|
||||||
|
// rest on the wire, then GamepadRemove tells the host to tear the pad's virtual device down).
|
||||||
|
//
|
||||||
|
// The wire is incremental (one button/axis transition per 18-byte event, accumulated host-side
|
||||||
|
// into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the full
|
||||||
|
// GCExtendedGamepad state on every valueChanged and diff against the previous snapshot. Sticks
|
||||||
|
// are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. The core folds these
|
||||||
|
// per-pad transitions into idempotent, sequence-numbered snapshots keyed on the same pad index,
|
||||||
|
// so all this layer must get right is the index — one controller per slot, one slot per index.
|
||||||
//
|
//
|
||||||
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion samples in
|
||||||
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
// raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g — derived from the
|
||||||
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
// host's fixed calibration blob; the conversion lives in ONE place, `Wire`, so a live sign/scale
|
||||||
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
// correction is a one-line change). The host ignores both unless a pad's virtual device is a
|
||||||
// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad
|
// DualSense or DualShock 4 — both carry a touchpad and motion, so the capture below covers either
|
||||||
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
|
// (`GCDualShockGamepad` exposes the same `touchpad*` surface as `GCDualSenseGamepad`).
|
||||||
// `touchpad*` surface as `GCDualSenseGamepad`).
|
|
||||||
//
|
//
|
||||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture toggle — a
|
||||||
// toggle — a controller can't click local UI, so it always drives the host while the app
|
// controller can't click local UI, so it always drives the host while the app is active. On
|
||||||
// is active. On deactivation, controller switch, or stop, every held control is released
|
// deactivation, controller switch, or stop, every held control is released on the wire (the host
|
||||||
// on the wire (the host pad would otherwise stay stuck on the last state).
|
// pad would otherwise stay stuck on the last state).
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -33,17 +42,35 @@ import GameController
|
|||||||
public final class GamepadCapture {
|
public final class GamepadCapture {
|
||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
private let manager: GamepadManager
|
private let manager: GamepadManager
|
||||||
private var activeSub: AnyCancellable?
|
private var forwardedSub: AnyCancellable?
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
private var bound: GCController?
|
|
||||||
/// App inactive → GC stops delivering; everything is released and stays silent.
|
/// App inactive → GC stops delivering; everything is released and stays silent.
|
||||||
private var suspended = false
|
private var suspended = false
|
||||||
|
|
||||||
// Last wire state (the diff base — also what releaseAll() unwinds).
|
/// One forwarded controller: the open device plus the last wire state for its pad index (the
|
||||||
private var buttons: UInt32 = 0
|
/// diff base — also what `flush` unwinds). Held per Slot so two controllers never clobber each
|
||||||
private var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
/// other's held buttons/axes/fingers. Mirrors pf-client-core's `Slot`.
|
||||||
private var fingerActive: [Bool] = [false, false]
|
private final class Slot {
|
||||||
private var lastMotionNs: UInt64 = 0
|
let controller: GCController
|
||||||
|
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
|
||||||
|
/// event this controller sends — the low byte of `flags`.
|
||||||
|
let pad: UInt32
|
||||||
|
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
|
||||||
|
let pref: PunktfunkConnection.GamepadType
|
||||||
|
var buttons: UInt32 = 0
|
||||||
|
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||||
|
var fingerActive: [Bool] = [false, false]
|
||||||
|
var lastMotionNs: UInt64 = 0
|
||||||
|
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
|
||||||
|
self.controller = controller
|
||||||
|
self.pad = pad
|
||||||
|
self.pref = pref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open forwarded controllers, one Slot per physical pad on its own wire index. Reconciled
|
||||||
|
/// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`).
|
||||||
|
private var slots: [Slot] = []
|
||||||
|
|
||||||
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
||||||
private static let motionIntervalNs: UInt64 = 4_000_000
|
private static let motionIntervalNs: UInt64 = 4_000_000
|
||||||
@@ -71,10 +98,14 @@ public final class GamepadCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func start() {
|
public func start() {
|
||||||
// Fires immediately with the current selection, then on every change — a switch
|
// Session-scoped index assignment: a controller pinned before the session forwards as
|
||||||
// releases the old controller's wire state before the new one takes over.
|
// pad 0 (pf-client-core assigns indices at slot-open time, not app-launch time).
|
||||||
activeSub = manager.$active.sink { [weak self] dc in
|
manager.resetForwardingAssignment()
|
||||||
MainActor.assumeIsolated { self?.rebind(to: dc?.controller) }
|
// Fires immediately with the current forwarded set, then on every change — a connect,
|
||||||
|
// disconnect, or pin change reconciles the open slots against it (opening/closing devices
|
||||||
|
// and flushing wire state so nothing sticks down).
|
||||||
|
forwardedSub = manager.$forwarded.sink { [weak self] list in
|
||||||
|
MainActor.assumeIsolated { self?.reconcile(list) }
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
let resign = NSApplication.willResignActiveNotification
|
let resign = NSApplication.willResignActiveNotification
|
||||||
@@ -97,53 +128,56 @@ public final class GamepadCapture {
|
|||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.suspended = false
|
self.suspended = false
|
||||||
if let ext = self.bound?.extendedGamepad { self.sync(ext) }
|
// Re-send every open pad's current state (GC delivered nothing while inactive).
|
||||||
|
for slot in self.slots {
|
||||||
|
if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public func stop() {
|
public func stop() {
|
||||||
releaseAll()
|
closeAllSlots()
|
||||||
rebind(to: nil)
|
forwardedSub = nil
|
||||||
activeSub = nil
|
|
||||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
observers.removeAll()
|
observers.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rebind(to controller: GCController?) {
|
/// Bring `slots` in line with the forwarded set: close any slot no longer wanted (flushing its
|
||||||
guard controller !== bound else { return }
|
/// held wire state and sending GamepadRemove first) and open any newly-forwarded controller into
|
||||||
releaseAll()
|
/// its assigned wire index. A controller that stays forwarded keeps its slot untouched, so a
|
||||||
if let ext = bound?.extendedGamepad {
|
/// second pad connecting never disturbs the first. Mirrors pf-client-core's `reconcile_slots`.
|
||||||
ext.valueChangedHandler = nil
|
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
|
||||||
let tp = Self.touchpad(ext)
|
let wantIDs = Set(forwarded.map { ObjectIdentifier($0.controller) })
|
||||||
tp?.primary.valueChangedHandler = nil
|
for slot in slots where !wantIDs.contains(ObjectIdentifier(slot.controller)) {
|
||||||
tp?.secondary.valueChangedHandler = nil
|
closeSlot(slot)
|
||||||
}
|
}
|
||||||
// Hand the system gestures back to the OS before letting the old pad go — outside a
|
for dc in forwarded where !slots.contains(where: { $0.controller === dc.controller }) {
|
||||||
// stream the share button's screenshot and the Home overlay are the user's, not ours.
|
openSlot(dc)
|
||||||
if let old = bound {
|
|
||||||
for element in old.physicalInputProfile.elements.values {
|
|
||||||
element.preferredSystemGestureState = .enabled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let motion = bound?.motion {
|
// A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms.
|
||||||
motion.valueChangedHandler = nil
|
updateEscapeChord()
|
||||||
// Power the sensors back down — left active they keep the pad streaming
|
}
|
||||||
// gyro/accel over Bluetooth (battery drain) long after the session.
|
|
||||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
|
|
||||||
}
|
|
||||||
bound = controller
|
|
||||||
guard let c = controller, let ext = c.extendedGamepad else { return }
|
|
||||||
|
|
||||||
ext.valueChangedHandler = { [weak self] g, _ in
|
/// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its
|
||||||
MainActor.assumeIsolated { self?.sync(g) }
|
/// system gestures, declare its kind (GamepadArrival — before any input), then wake the host
|
||||||
|
/// pad and send its initial state. Skipped when the pad has no wire index (every slot taken)
|
||||||
|
/// or exposes no extended profile.
|
||||||
|
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
|
||||||
|
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
|
||||||
|
let c = dc.controller
|
||||||
|
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
|
||||||
|
slots.append(slot)
|
||||||
|
|
||||||
|
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
|
||||||
|
MainActor.assumeIsolated { if let self, let slot { self.sync(slot, g) } }
|
||||||
}
|
}
|
||||||
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
|
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
|
||||||
// gestures to several controller buttons — share/create → local screenshot/recording,
|
// gestures to several controller buttons — share/create → local screenshot/recording,
|
||||||
// Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a
|
// Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a
|
||||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||||
// the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
|
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||||
for element in c.physicalInputProfile.elements.values {
|
for element in c.physicalInputProfile.elements.values {
|
||||||
element.preferredSystemGestureState = .disabled
|
element.preferredSystemGestureState = .disabled
|
||||||
}
|
}
|
||||||
@@ -153,67 +187,114 @@ public final class GamepadCapture {
|
|||||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||||
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
|
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
|
||||||
home.pressedChangedHandler = { [weak self] _, _, pressed in
|
home.pressedChangedHandler = { [weak self, weak slot] _, _, pressed in
|
||||||
MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
|
MainActor.assumeIsolated { if let self, let slot { self.sendGuide(slot, down: pressed) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Wake the host pad immediately (pads are created lazily from the first event;
|
// Declare this pad's controller KIND before any of its input, so the host builds a
|
||||||
// a DualSense's UHID handshake + initial lightbar write only start then).
|
// matching virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
// re-sends it a few times against datagram loss; an older host ignores it and uses the
|
||||||
sync(ext)
|
// session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||||
|
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||||
|
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||||
|
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||||
|
sync(slot, ext)
|
||||||
|
|
||||||
if let tp = Self.touchpad(ext) {
|
if let tp = Self.touchpad(ext) {
|
||||||
tp.primary.valueChangedHandler = { [weak self] _, x, y in
|
tp.primary.valueChangedHandler = { [weak self, weak slot] _, x, y in
|
||||||
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 0, x: x, y: y) } }
|
||||||
}
|
}
|
||||||
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
|
tp.secondary.valueChangedHandler = { [weak self, weak slot] _, x, y in
|
||||||
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let motion = c.motion {
|
if let motion = c.motion {
|
||||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||||
motion.valueChangedHandler = { [weak self] m in
|
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||||
MainActor.assumeIsolated { self?.forwardMotion(m) }
|
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the profile into wire state and send every transition since the last one.
|
/// Flush a slot's held wire state (so nothing sticks down host-side) and signal the host to tear
|
||||||
private func sync(_ g: GCExtendedGamepad) {
|
/// its virtual device down (GamepadRemove), then detach GC handlers, hand the system gestures
|
||||||
|
/// back, and power the sensors down. Wire-only until the GC cleanup, so it is safe even when the
|
||||||
|
/// device already physically unplugged. Mirrors pf-client-core's `close_slot_at`.
|
||||||
|
private func closeSlot(_ slot: Slot) {
|
||||||
|
flush(slot)
|
||||||
|
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
|
||||||
|
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
|
||||||
|
connection.send(.gamepadRemove(pad: slot.pad))
|
||||||
|
let c = slot.controller
|
||||||
|
if let ext = c.extendedGamepad {
|
||||||
|
ext.valueChangedHandler = nil
|
||||||
|
let tp = Self.touchpad(ext)
|
||||||
|
tp?.primary.valueChangedHandler = nil
|
||||||
|
tp?.secondary.valueChangedHandler = nil
|
||||||
|
}
|
||||||
|
c.physicalInputProfile.buttons[GCInputButtonHome]?.pressedChangedHandler = nil
|
||||||
|
// Hand the system gestures back to the OS before letting the pad go — outside a stream the
|
||||||
|
// share button's screenshot and the Home overlay are the user's, not ours.
|
||||||
|
for element in c.physicalInputProfile.elements.values {
|
||||||
|
element.preferredSystemGestureState = .enabled
|
||||||
|
}
|
||||||
|
if let motion = c.motion {
|
||||||
|
motion.valueChangedHandler = nil
|
||||||
|
// Power the sensors back down — left active they keep the pad streaming gyro/accel
|
||||||
|
// over Bluetooth (battery drain) long after the session.
|
||||||
|
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
|
||||||
|
}
|
||||||
|
slots.removeAll { $0 === slot }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeAllSlots() {
|
||||||
|
while let slot = slots.first { closeSlot(slot) }
|
||||||
|
chordTimer?.invalidate()
|
||||||
|
chordTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the profile into a slot's wire state and send every transition since the last one,
|
||||||
|
/// tagged with the slot's wire pad index.
|
||||||
|
private func sync(_ slot: Slot, _ g: GCExtendedGamepad) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let newButtons = Self.buttonMask(g)
|
// guide is driven separately (`sendGuide`, off the Home handler) and deliberately kept out
|
||||||
updateEscapeChord(newButtons)
|
// of `buttonMask`. Preserve its current held state here so the XOR diff below never sees it
|
||||||
let changed = newButtons ^ buttons
|
// as "changed" — otherwise the first stick/button move after a guide press would emit a
|
||||||
|
// spurious guide-UP while the button is still physically held (and drop the bit from
|
||||||
|
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
|
||||||
|
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
|
||||||
|
let changed = newButtons ^ slot.buttons
|
||||||
if changed != 0 {
|
if changed != 0 {
|
||||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0))
|
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||||
}
|
}
|
||||||
buttons = newButtons
|
slot.buttons = newButtons
|
||||||
}
|
}
|
||||||
let newAxes: [Int32] = [
|
let newAxes: [Int32] = [
|
||||||
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()),
|
Int32(g.leftThumbstick.xAxis.value * 32767),
|
||||||
Int32((g.leftThumbstick.yAxis.value * 32767).rounded()),
|
Int32(g.leftThumbstick.yAxis.value * 32767),
|
||||||
Int32((g.rightThumbstick.xAxis.value * 32767).rounded()),
|
Int32(g.rightThumbstick.xAxis.value * 32767),
|
||||||
Int32((g.rightThumbstick.yAxis.value * 32767).rounded()),
|
Int32(g.rightThumbstick.yAxis.value * 32767),
|
||||||
Int32((g.leftTrigger.value * 255).rounded()),
|
Int32(g.leftTrigger.value * 255),
|
||||||
Int32((g.rightTrigger.value * 255).rounded()),
|
Int32(g.rightTrigger.value * 255),
|
||||||
]
|
]
|
||||||
for (i, v) in newAxes.enumerated() where v != axes[i] {
|
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: 0))
|
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||||
axes[i] = v
|
slot.axes[i] = v
|
||||||
}
|
}
|
||||||
|
updateEscapeChord()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
||||||
/// `buttonHome` element is unreliable). Folds into `buttons` so a held PS button is released by
|
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
|
||||||
/// `releaseAll` on focus loss just like the others.
|
/// released by `flush` on focus loss / close just like the others.
|
||||||
private func sendGuide(down: Bool) {
|
private func sendGuide(_ slot: Slot, down: Bool) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let bit = GamepadWire.guide
|
let bit = GamepadWire.guide
|
||||||
let now = down ? (buttons | bit) : (buttons & ~bit)
|
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||||
guard now != buttons else { return }
|
guard now != slot.buttons else { return }
|
||||||
connection.send(.gamepadButton(bit, down: down, pad: 0))
|
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||||
buttons = now
|
slot.buttons = now
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 {
|
private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 {
|
||||||
@@ -224,17 +305,21 @@ public final class GamepadCapture {
|
|||||||
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
|
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
|
||||||
if g.buttonMenu.isPressed { b |= GamepadWire.start }
|
if g.buttonMenu.isPressed { b |= GamepadWire.start }
|
||||||
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
|
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
|
||||||
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button —
|
// The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone
|
||||||
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
|
// pad's screenshot button — e.g. the GameSir G8's, below its d-pad) → the wire's capture
|
||||||
// the create button BOTH as buttonOptions and as the share element this OR is harmless —
|
// bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad
|
||||||
// same wire bit.
|
// this is a button physically DISTINCT from View (buttonOptions, above), so it must not
|
||||||
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
|
// collapse onto back — the host reads MISC1 as its own control (DualSense mute / Steam
|
||||||
|
// quick-access). Caveat: a pad that surfaces ONE physical button as both buttonOptions and
|
||||||
|
// this share element now emits back+misc1 for it — harmless on a plain xpad session (no
|
||||||
|
// misc button) and rare otherwise. NOTE: on-glass verify on a real Xbox-Series pad.
|
||||||
|
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.misc1 }
|
||||||
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
|
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
|
||||||
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
|
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
|
||||||
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
||||||
if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder }
|
if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder }
|
||||||
// guide (Home/PS) is NOT read here — it's forwarded directly by the Home button's
|
// guide (Home/PS) is NOT read here — it's forwarded directly by the Home button's
|
||||||
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `rebind`.
|
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `openSlot`.
|
||||||
if g.buttonA.isPressed { b |= GamepadWire.a }
|
if g.buttonA.isPressed { b |= GamepadWire.a }
|
||||||
if g.buttonB.isPressed { b |= GamepadWire.b }
|
if g.buttonB.isPressed { b |= GamepadWire.b }
|
||||||
if g.buttonX.isPressed { b |= GamepadWire.x }
|
if g.buttonX.isPressed { b |= GamepadWire.x }
|
||||||
@@ -262,29 +347,29 @@ public final class GamepadCapture {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
/// One touchpad finger moved on a slot's pad. GC reports ±1 positions and snaps to exactly
|
||||||
/// lift — treated as the lift signal (a real finger landing on the precise center
|
/// (0, 0) on lift — treated as the lift signal (a real finger landing on the precise center
|
||||||
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
||||||
private func touch(finger: Int, x: Float, y: Float) {
|
private func touch(_ slot: Slot, finger: Int, x: Float, y: Float) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let lifted = x == 0 && y == 0
|
let lifted = x == 0 && y == 0
|
||||||
if lifted {
|
if lifted {
|
||||||
if fingerActive[finger] {
|
if slot.fingerActive[finger] {
|
||||||
fingerActive[finger] = false
|
slot.fingerActive[finger] = false
|
||||||
connection.sendTouchpad(finger: UInt8(finger), active: false, x: 0, y: 0)
|
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fingerActive[finger] = true
|
slot.fingerActive[finger] = true
|
||||||
let w = GamepadWire.touchpad(x: x, y: y)
|
let w = GamepadWire.touchpad(x: x, y: y)
|
||||||
connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func forwardMotion(_ m: GCMotion) {
|
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let now = DispatchTime.now().uptimeNanoseconds
|
let now = DispatchTime.now().uptimeNanoseconds
|
||||||
guard now &- lastMotionNs >= Self.motionIntervalNs else { return }
|
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
|
||||||
lastMotionNs = now
|
slot.lastMotionNs = now
|
||||||
// Total acceleration in g: gravity + user when split, else the raw vector.
|
// Total acceleration in g: gravity + user when split, else the raw vector.
|
||||||
let ax: Float
|
let ax: Float
|
||||||
let ay: Float
|
let ay: Float
|
||||||
@@ -301,6 +386,7 @@ public final class GamepadCapture {
|
|||||||
let gs = GamepadWire.gyroLSBPerRadS
|
let gs = GamepadWire.gyroLSBPerRadS
|
||||||
let as_ = GamepadWire.accelLSBPerG
|
let as_ = GamepadWire.accelLSBPerG
|
||||||
connection.sendMotion(
|
connection.sendMotion(
|
||||||
|
pad: UInt8(slot.pad),
|
||||||
gyro: (
|
gyro: (
|
||||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||||
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
||||||
@@ -313,13 +399,12 @@ public final class GamepadCapture {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
|
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
|
||||||
/// host's virtual pad returns to rest instead of running with the last state.
|
/// moment none do — a release, or the holding pad unplugged (pf-client-core's `chord_held` is
|
||||||
/// Arm the disconnect timer when the full chord lands, disarm the moment any of the four
|
/// likewise any-slot). GC events only arrive on state CHANGES, so a held chord needs the timer:
|
||||||
/// releases. Events only arrive on state CHANGES, so a held chord needs the timer — the
|
/// the handler won't fire again until something moves.
|
||||||
/// handler won't fire again until something moves.
|
private func updateEscapeChord() {
|
||||||
private func updateEscapeChord(_ newButtons: UInt32) {
|
let held = slots.contains { $0.buttons & Self.escapeChord == Self.escapeChord }
|
||||||
let held = newButtons & Self.escapeChord == Self.escapeChord
|
|
||||||
if held, chordTimer == nil {
|
if held, chordTimer == nil {
|
||||||
let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in
|
let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in
|
||||||
Task { @MainActor in self?.onDisconnectRequest?() }
|
Task { @MainActor in self?.onDisconnectRequest?() }
|
||||||
@@ -332,20 +417,31 @@ public final class GamepadCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unwind everything a slot holds on the wire: button-ups, neutral axes, lifted fingers. The
|
||||||
|
/// host's virtual pad returns to rest instead of running with the last state. Wire events only
|
||||||
|
/// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send
|
||||||
|
/// GamepadRemove (that's `closeSlot`).
|
||||||
|
private func flush(_ slot: Slot) {
|
||||||
|
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||||
|
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||||
|
}
|
||||||
|
slot.buttons = 0
|
||||||
|
for (i, v) in slot.axes.enumerated() where v != 0 {
|
||||||
|
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||||
|
slot.axes[i] = 0
|
||||||
|
}
|
||||||
|
for (f, active) in slot.fingerActive.enumerated() where active {
|
||||||
|
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||||
|
slot.fingerActive[f] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops
|
||||||
|
/// delivering; resume re-syncs), disarms the escape chord. Distinct from `closeAllSlots`, which
|
||||||
|
/// also sends GamepadRemove and detaches handlers.
|
||||||
private func releaseAll() {
|
private func releaseAll() {
|
||||||
chordTimer?.invalidate()
|
chordTimer?.invalidate()
|
||||||
chordTimer = nil
|
chordTimer = nil
|
||||||
for bit in GamepadWire.allButtons where buttons & bit != 0 {
|
for slot in slots { flush(slot) }
|
||||||
connection.send(.gamepadButton(bit, down: false, pad: 0))
|
|
||||||
}
|
|
||||||
buttons = 0
|
|
||||||
for (i, v) in axes.enumerated() where v != 0 {
|
|
||||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: 0))
|
|
||||||
axes[i] = 0
|
|
||||||
}
|
|
||||||
for (f, active) in fingerActive.enumerated() where active {
|
|
||||||
connection.sendTouchpad(finger: UInt8(f), active: false, x: 0, y: 0)
|
|
||||||
fingerActive[f] = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,82 @@
|
|||||||
// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
|
// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
|
||||||
// HID-output (0xCD) planes and replays them on the active physical controller —
|
// HID-output (0xCD) planes and replays each update on the forwarded physical controller it is
|
||||||
|
// ADDRESSED TO by wire pad index —
|
||||||
//
|
//
|
||||||
// rumble → CHHapticEngine players (per-handle localities when the pad has them,
|
// rumble → CHHapticEngine players (per-handle localities when the pad has them,
|
||||||
// one combined engine otherwise),
|
// one combined engine otherwise), a RumbleRenderer per pad,
|
||||||
// lightbar → GCDeviceLight,
|
// lightbar → GCDeviceLight,
|
||||||
// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4),
|
// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4),
|
||||||
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
||||||
//
|
//
|
||||||
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
// Every forwarded controller gets a per-pad feedback slot (its RumbleRenderer + last light /
|
||||||
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
|
// player-LED / trigger state) keyed on the same wire index GamepadCapture streams it on, so a
|
||||||
// drain always polls both planes with short timeouts and never spins, so an Xbox session
|
// rumble the host aimed at pad 1 drives pad 1's actuator and nothing else. An update for a pad
|
||||||
// just renders rumble. GameController profile mutation
|
// with no live slot (one that just closed) is dropped. HID-output traffic exists only on
|
||||||
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
// PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only); the drain always
|
||||||
// touches neither. When GamepadManager switches the active controller mid-session, the
|
// polls both planes with short timeouts and never spins, so an Xbox pad just renders rumble.
|
||||||
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
// GameController profile mutation happens on main; CHHapticEngine work on the renderer's serial
|
||||||
// is replayed onto the new one.
|
// queue; the drain thread itself touches neither (it routes rumble to the pad's renderer under a
|
||||||
|
// lock and hops HID to main). When a controller leaves the forwarded set the old pad is reset
|
||||||
|
// (triggers off, player index unset) and its renderer silenced.
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreHaptics
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
public final class GamepadFeedback {
|
public final class GamepadFeedback {
|
||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
|
private let manager: GamepadManager
|
||||||
private let flag = StopFlag()
|
private let flag = StopFlag()
|
||||||
private let drainDone = DispatchSemaphore(value: 0)
|
private let drainDone = DispatchSemaphore(value: 0)
|
||||||
private var drainStarted = false
|
private var drainStarted = false
|
||||||
private let rumble = RumbleRenderer(policy: .session)
|
private var forwardedSub: AnyCancellable?
|
||||||
private var activeSub: AnyCancellable?
|
|
||||||
|
|
||||||
// Last applied feedback (main-actor) — replayed when the active controller changes.
|
/// One forwarded controller's non-rumble feedback state (main-actor) — the GC target plus the
|
||||||
@MainActor private var target: GCController?
|
/// last applied lightbar / player-LED / trigger, replayed if the controller on this pad swaps.
|
||||||
@MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
|
@MainActor private final class Slot {
|
||||||
@MainActor private var lastPlayerBits: UInt8?
|
var controller: GCController?
|
||||||
@MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
|
var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
|
||||||
|
var lastPlayerBits: UInt8?
|
||||||
|
var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
|
||||||
|
init(controller: GCController?) { self.controller = controller }
|
||||||
|
}
|
||||||
|
/// HID / lightbar / player-LED slots, keyed by wire pad index. Main-actor only.
|
||||||
|
@MainActor private var slots: [UInt8: Slot] = [:]
|
||||||
|
|
||||||
|
/// Rumble renderers keyed by wire pad index, guarded by `routingLock` so the background drain
|
||||||
|
/// thread can route an incoming envelope to the right pad's renderer while the main actor
|
||||||
|
/// reconciles the set. RumbleRenderer serializes on its own queue, so calling `apply` from the
|
||||||
|
/// drain thread is safe — only the map lookup needs the lock.
|
||||||
|
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) {
|
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||||
self.connection = connection
|
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
|
// 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.
|
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.activeSub = manager.$active.sink { [weak self] dc in
|
self.forwardedSub = manager.$forwarded.sink { [weak self] list in
|
||||||
MainActor.assumeIsolated { self?.retarget(dc?.controller) }
|
MainActor.assumeIsolated { self?.reconcile(list) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +102,38 @@ public final class GamepadFeedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bring the per-pad feedback slots in line with the forwarded set: drop pads no longer
|
||||||
|
/// forwarded (silence + release their renderer, reset their controller), add a slot +
|
||||||
|
/// renderer for each new pad, and retarget a pad whose controller changed (a re-plug into the
|
||||||
|
/// same freed index) — replaying its cached feedback onto the new device.
|
||||||
|
@MainActor
|
||||||
|
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
|
||||||
|
var want: [UInt8: GCController] = [:]
|
||||||
|
for dc in forwarded {
|
||||||
|
if let pad = manager.padIndex(for: dc) { want[pad] = dc.controller }
|
||||||
|
}
|
||||||
|
for (pad, slot) in slots where want[pad] == nil {
|
||||||
|
reset(slot.controller)
|
||||||
|
slots[pad] = nil
|
||||||
|
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||||
|
renderer?.stop()
|
||||||
|
}
|
||||||
|
for (pad, controller) in want {
|
||||||
|
if let slot = slots[pad] {
|
||||||
|
guard slot.controller !== controller else { continue }
|
||||||
|
reset(slot.controller)
|
||||||
|
slot.controller = controller
|
||||||
|
withRouting { rumbleByPad[pad]?.retarget(controller) }
|
||||||
|
replay(slot)
|
||||||
|
} else {
|
||||||
|
slots[pad] = Slot(controller: controller)
|
||||||
|
let renderer = RumbleRenderer(policy: .session)
|
||||||
|
renderer.retarget(controller)
|
||||||
|
withRouting { rumbleByPad[pad] = renderer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func start() {
|
public func start() {
|
||||||
guard !drainStarted else { return }
|
guard !drainStarted else { return }
|
||||||
drainStarted = true
|
drainStarted = true
|
||||||
@@ -88,19 +155,19 @@ public final class GamepadFeedback {
|
|||||||
// rumble/HID latency low while leaving the lock free between polls.
|
// rumble/HID latency low while leaving the lock free between polls.
|
||||||
//
|
//
|
||||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
||||||
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
|
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
|
||||||
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
|
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
|
||||||
// and its drop-newest overflow could shed a stop while stale nonzero states
|
// queue, and its drop-newest overflow could shed a stop while stale nonzero
|
||||||
// queued ahead of it — buzzing until the host's next 500 ms refresh.
|
// states queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||||
var newest: (low: UInt16, high: UInt16, ttl: UInt32)?
|
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
|
||||||
var rumbleBurst = 0
|
var rumbleBurst = 0
|
||||||
while rumbleBurst < 64, !flag.isStopped,
|
while rumbleBurst < 64, !flag.isStopped,
|
||||||
let r = try connection.nextRumble2(timeoutMs: 0) {
|
let r = try connection.nextRumble2(timeoutMs: 0) {
|
||||||
if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) }
|
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
|
||||||
rumbleBurst += 1
|
rumbleBurst += 1
|
||||||
}
|
}
|
||||||
if let n = newest {
|
for (pad, n) in newestByPad {
|
||||||
self?.rumble.apply(low: n.low, high: n.high, ttlMs: n.ttl)
|
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
|
||||||
}
|
}
|
||||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||||
@@ -126,7 +193,7 @@ public final class GamepadFeedback {
|
|||||||
thread.start()
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the drain and silence the motors. Blocks until the drain thread exits (≤ one
|
/// Stop the drain and silence every pad's motors. Blocks until the drain thread exits (≤ one
|
||||||
/// poll cycle) — call off the main actor, before `connection.close()`.
|
/// poll cycle) — call off the main actor, before `connection.close()`.
|
||||||
public func stop() {
|
public func stop() {
|
||||||
flag.stop()
|
flag.stop()
|
||||||
@@ -134,17 +201,37 @@ public final class GamepadFeedback {
|
|||||||
drainDone.wait()
|
drainDone.wait()
|
||||||
drainStarted = false
|
drainStarted = false
|
||||||
}
|
}
|
||||||
rumble.stop()
|
let renderers = withRouting { () -> [RumbleRenderer] in
|
||||||
// Drop the retarget subscription and the dead session's cached feedback — a
|
let r = Array(rumbleByPad.values)
|
||||||
// controller change after teardown must not replay this session's triggers/LEDs.
|
rumbleByPad.removeAll()
|
||||||
Task { @MainActor in
|
return r
|
||||||
self.activeSub = nil
|
|
||||||
self.lastLight = nil
|
|
||||||
self.lastPlayerBits = nil
|
|
||||||
self.lastTrigger = [nil, nil]
|
|
||||||
self.reset(self.target)
|
|
||||||
self.target = nil
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
self.forwardedSub = nil
|
||||||
|
for slot in self.slots.values { self.reset(slot.controller) }
|
||||||
|
self.slots.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
|
||||||
|
/// live renderer — one that just left the forwarded set — is dropped.
|
||||||
|
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 {
|
||||||
|
routingLock.lock()
|
||||||
|
defer { routingLock.unlock() }
|
||||||
|
return body()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
|
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||||
@@ -157,40 +244,37 @@ public final class GamepadFeedback {
|
|||||||
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
|
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||||
switch ev {
|
switch ev {
|
||||||
case let .led(pad, r, g, b):
|
case let .led(pad, r, g, b):
|
||||||
guard pad == 0 else { return }
|
guard let slot = slots[pad] else { return }
|
||||||
lastLight = (r, g, b)
|
slot.lastLight = (r, g, b)
|
||||||
target?.light?.color = GCColor(
|
slot.controller?.light?.color = GCColor(
|
||||||
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
||||||
case let .playerLEDs(pad, bits):
|
case let .playerLEDs(pad, bits):
|
||||||
guard pad == 0 else { return }
|
guard let slot = slots[pad] else { return }
|
||||||
lastPlayerBits = bits
|
slot.lastPlayerBits = bits
|
||||||
target?.playerIndex = Self.playerIndex(forBits: bits)
|
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||||
case let .triggerEffect(pad, which, effect):
|
case let .triggerEffect(pad, which, effect):
|
||||||
guard pad == 0, which < 2 else { return }
|
guard which < 2, let slot = slots[pad] else { return }
|
||||||
let parsed = DualSenseTriggerEffect.parse(effect)
|
let parsed = DualSenseTriggerEffect.parse(effect)
|
||||||
lastTrigger[Int(which)] = parsed
|
slot.lastTrigger[Int(which)] = parsed
|
||||||
if let trigger = adaptiveTrigger(which) {
|
if let trigger = adaptiveTrigger(slot.controller, which) {
|
||||||
parsed.apply(to: trigger)
|
parsed.apply(to: trigger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replay a pad's cached feedback onto its (swapped-in) controller so a re-plug looks the same.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func retarget(_ controller: GCController?) {
|
private func replay(_ slot: Slot) {
|
||||||
guard controller !== target else { return }
|
if let (r, g, b) = slot.lastLight {
|
||||||
reset(target)
|
slot.controller?.light?.color = GCColor(
|
||||||
target = controller
|
|
||||||
rumble.retarget(controller)
|
|
||||||
// Replay the session's feedback state so a swapped-in controller looks the same.
|
|
||||||
if let (r, g, b) = lastLight {
|
|
||||||
controller?.light?.color = GCColor(
|
|
||||||
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
||||||
}
|
}
|
||||||
if let bits = lastPlayerBits {
|
if let bits = slot.lastPlayerBits {
|
||||||
controller?.playerIndex = Self.playerIndex(forBits: bits)
|
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||||
}
|
}
|
||||||
for which in 0..<2 {
|
for which in 0..<2 {
|
||||||
if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
|
if let effect = slot.lastTrigger[which],
|
||||||
|
let trigger = adaptiveTrigger(slot.controller, UInt8(which)) {
|
||||||
effect.apply(to: trigger)
|
effect.apply(to: trigger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,8 +291,8 @@ public final class GamepadFeedback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
|
private func adaptiveTrigger(_ controller: GCController?, _ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
|
||||||
guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return nil }
|
||||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches
|
// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches
|
||||||
// GCController connect/disconnect from launch, so the Settings page shows live controller
|
// GCController connect/disconnect from launch, so the Settings page shows live controller
|
||||||
// state without a session, and the session components (GamepadCapture / GamepadFeedback)
|
// state without a session, and the session components (GamepadCapture / GamepadFeedback)
|
||||||
// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0.
|
// follow `forwarded` — every forwarded controller is streamed to the host, each on its own
|
||||||
|
// wire pad index (pf-client-core parity; up to `GamepadWire.maxPads`).
|
||||||
//
|
//
|
||||||
// Selection: the user can pin a controller in Settings (persisted under
|
// Selection (mirrors pf-client-core's `forwarded_ids` + slot model): with no pin, EVERY
|
||||||
// DefaultsKey.gamepadID); with no pin — or the pinned one absent — the most recently
|
// extended controller is forwarded — each assigned a stable lowest-free pad index held for
|
||||||
// connected extended gamepad wins. GCController has no stable hardware serial, so the pin
|
// its forwarded lifetime, so a disconnect frees only its own index and never renumbers the
|
||||||
// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins);
|
// others. A pin (Settings, persisted under DefaultsKey.gamepadID) forwards ONLY that one pad
|
||||||
// identical twin controllers may swap a pin across reconnects, which the Settings footer
|
// — an explicit single-player choice. `active` stays the single "primary" pad (the pinned
|
||||||
// documents.
|
// one, else the most recently connected extended gamepad) that the Settings / launcher / menu
|
||||||
|
// UI reads. GCController has no stable hardware serial, so the pin is a fingerprint of
|
||||||
|
// vendorName|productCategory (+ a connect-order suffix for twins); identical twin controllers
|
||||||
|
// may swap a pin across reconnects, which the Settings footer documents.
|
||||||
//
|
//
|
||||||
// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own
|
// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own
|
||||||
// `Settings{}` scene — there is no common ancestor view to inject from.
|
// `Settings{}` scene — there is no common ancestor view to inject from.
|
||||||
@@ -38,13 +42,14 @@ public final class GamepadManager: ObservableObject {
|
|||||||
public let hasHaptics: Bool
|
public let hasHaptics: Bool
|
||||||
public let hasMotion: Bool
|
public let hasMotion: Bool
|
||||||
public let hasAdaptiveTriggers: Bool
|
public let hasAdaptiveTriggers: Bool
|
||||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
/// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the
|
||||||
/// player LEDs) and the PlayStation glyph in Settings.
|
/// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph
|
||||||
public var isDualSense: Bool { kind == .dualSense }
|
/// in Settings.
|
||||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
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).
|
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||||
public var hasTouchpadAndMotion: Bool {
|
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).
|
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||||
public let batteryLevel: Float?
|
public let batteryLevel: Float?
|
||||||
@@ -60,9 +65,23 @@ public final class GamepadManager: ObservableObject {
|
|||||||
/// Every detected controller, in connect order (Settings lists these).
|
/// Every detected controller, in connect order (Settings lists these).
|
||||||
@Published public private(set) var controllers: [DiscoveredController] = []
|
@Published public private(set) var controllers: [DiscoveredController] = []
|
||||||
|
|
||||||
/// The one controller forwarded to the host (pad 0); nil when none qualifies.
|
/// The single "primary" controller — the pinned one, else the most recently connected
|
||||||
|
/// extended gamepad; nil when none qualifies. The Settings / launcher / menu UI and the
|
||||||
|
/// connect-time `resolveType` read this; the streaming input path uses `forwarded`.
|
||||||
@Published public private(set) var active: DiscoveredController?
|
@Published public private(set) var active: DiscoveredController?
|
||||||
|
|
||||||
|
/// The controllers forwarded to the host this session, in wire-pad-index preference order
|
||||||
|
/// (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad; Automatic forwards
|
||||||
|
/// every extended controller. GamepadCapture opens a slot per entry and GamepadFeedback routes
|
||||||
|
/// feedback back to it, each on the index from `padIndex(for:)`.
|
||||||
|
@Published public private(set) var forwarded: [DiscoveredController] = []
|
||||||
|
|
||||||
|
/// Stable wire pad index (0..<`GamepadWire.maxPads`) per forwarded controller, keyed by
|
||||||
|
/// GCController identity. Lowest-free, held while the controller stays forwarded — a
|
||||||
|
/// disconnect frees only its own index so the others never renumber (pf-client-core's
|
||||||
|
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
|
||||||
|
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
|
||||||
|
|
||||||
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
||||||
/// reselects immediately, so a Settings Picker can bind straight to this.
|
/// reselects immediately, so a Settings Picker can bind straight to this.
|
||||||
@Published public var preferredID: String {
|
@Published public var preferredID: String {
|
||||||
@@ -159,12 +178,57 @@ public final class GamepadManager: ObservableObject {
|
|||||||
let candidates = controllers.filter(\.isExtended)
|
let candidates = controllers.filter(\.isExtended)
|
||||||
// The pin wins when present; otherwise the most recently connected extended pad
|
// The pin wins when present; otherwise the most recently connected extended pad
|
||||||
// (list is in connect order). A stale pin falls back to automatic.
|
// (list is in connect order). A stale pin falls back to automatic.
|
||||||
active = candidates.last { $0.id == preferredID } ?? candidates.last
|
let pinned = candidates.last { $0.id == preferredID }
|
||||||
|
active = pinned ?? candidates.last
|
||||||
|
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
|
||||||
|
// (explicit single-player); Automatic forwards every extended controller in connect order
|
||||||
|
// (oldest→newest), so a game's player numbers are stable across hot-plug churn.
|
||||||
|
let next = pinned.map { [$0] } ?? candidates
|
||||||
|
// Update the pad-index assignment BEFORE publishing `forwarded`: @Published emits in
|
||||||
|
// `willSet`, so GamepadCapture/GamepadFeedback reconcile against `padIndex(for:)` the
|
||||||
|
// instant this assignment lands — a stale map here would skip a newly-forwarded pad.
|
||||||
|
assignPadIndices(for: next)
|
||||||
|
forwarded = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign each forwarded controller a stable wire pad index (lowest-free, held while it stays
|
||||||
|
/// forwarded) — mirrors pf-client-core's slot model, where a disconnect frees only its own
|
||||||
|
/// index and the others keep theirs. A controller already holding an index keeps it across the
|
||||||
|
/// churn; a slot beyond `GamepadWire.maxPads` goes unassigned (that pad is not forwarded).
|
||||||
|
private func assignPadIndices(for next: [DiscoveredController]) {
|
||||||
|
let live = Set(next.map { ObjectIdentifier($0.controller) })
|
||||||
|
padIndexByController = padIndexByController.filter { live.contains($0.key) }
|
||||||
|
for dc in next {
|
||||||
|
let key = ObjectIdentifier(dc.controller)
|
||||||
|
guard padIndexByController[key] == nil,
|
||||||
|
let free = Self.lowestFreeIndex(Set(padIndexByController.values)) else { continue }
|
||||||
|
padIndexByController[key] = free
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lowest wire pad index not already taken, or nil when all `GamepadWire.maxPads` are in
|
||||||
|
/// use (pf-client-core's `lowest_free_index`).
|
||||||
|
private static func lowestFreeIndex(_ taken: Set<UInt8>) -> UInt8? {
|
||||||
|
(0..<UInt8(GamepadWire.maxPads)).first { !taken.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire pad index a forwarded controller streams on, or nil when it isn't forwarded.
|
||||||
|
public func padIndex(for controller: DiscoveredController) -> UInt8? {
|
||||||
|
padIndexByController[ObjectIdentifier(controller.controller)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every pad-index assignment and recompute from the current forwarded set — called when
|
||||||
|
/// a streaming session begins so the assignment starts fresh (a controller pinned before the
|
||||||
|
/// session forwards as pad 0, not whatever index it held for the Settings list). pf-client-core
|
||||||
|
/// assigns indices at slot-open time; this reproduces that session-scoped start.
|
||||||
|
public func resetForwardingAssignment() {
|
||||||
|
padIndexByController.removeAll()
|
||||||
|
reselect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||||
let extended = c.extendedGamepad
|
let extended = c.extendedGamepad
|
||||||
let kind = padKind(extended)
|
let kind = padKind(extended, productCategory: c.productCategory)
|
||||||
return DiscoveredController(
|
return DiscoveredController(
|
||||||
id: id,
|
id: id,
|
||||||
name: c.vendorName ?? c.productCategory,
|
name: c.vendorName ?? c.productCategory,
|
||||||
@@ -174,28 +238,40 @@ public final class GamepadManager: ObservableObject {
|
|||||||
hasLight: c.light != nil,
|
hasLight: c.light != nil,
|
||||||
hasHaptics: c.haptics != nil,
|
hasHaptics: c.haptics != nil,
|
||||||
hasMotion: c.motion != nil,
|
hasMotion: c.motion != nil,
|
||||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
|
||||||
// DualShock 4 has none.
|
// Edge included); the DualShock 4 has none.
|
||||||
hasAdaptiveTriggers: kind == .dualSense,
|
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
|
||||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||||
isCharging: c.battery?.batteryState == .charging,
|
isCharging: c.battery?.batteryState == .charging,
|
||||||
controller: c)
|
controller: c)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
/// subclass (+ the product-category string where the subclass is shared). Detection order
|
||||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
/// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a
|
||||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
/// `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(
|
private static func padKind(
|
||||||
_ extended: GCExtendedGamepad?
|
_ extended: GCExtendedGamepad?,
|
||||||
|
productCategory: String
|
||||||
) -> PunktfunkConnection.GamepadType {
|
) -> PunktfunkConnection.GamepadType {
|
||||||
guard let extended else { return .xbox360 }
|
guard let extended else { return .xbox360 }
|
||||||
|
let category = productCategory.lowercased()
|
||||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||||
// here, so no `@available` guard is needed — matching the unguarded
|
// here, so no `@available` guard is needed — matching the unguarded
|
||||||
// `GCDualSenseGamepad` use elsewhere in the package.
|
// `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 GCDualShockGamepad { return .dualShock4 }
|
||||||
if extended is GCXboxGamepad { return .xboxOne }
|
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
|
return .xbox360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ public final class GamepadMenuInput {
|
|||||||
let stick = gamepad.leftThumbstick
|
let stick = gamepad.leftThumbstick
|
||||||
let x = stick.xAxis.value
|
let x = stick.xAxis.value
|
||||||
let y = stick.yAxis.value
|
let y = stick.yAxis.value
|
||||||
if abs(x) > abs(y), abs(x) > deadzone {
|
// Horizontal wins an exact |x| == |y| diagonal tie (>=), matching the SDL core and Android
|
||||||
|
// nav so a perfect 45° push resolves to the same direction on every client.
|
||||||
|
if abs(x) >= abs(y), abs(x) > deadzone {
|
||||||
return x > 0 ? .right : .left
|
return x > 0 ? .right : .left
|
||||||
} else if abs(y) > deadzone {
|
} else if abs(y) > deadzone {
|
||||||
return y > 0 ? .up : .down
|
return y > 0 ? .up : .down
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback),
|
// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback),
|
||||||
// and the tests — button bits, axis ids, and the touchpad/motion unit conversions.
|
// and the tests — the pad count, button bits, axis ids, and the touchpad/motion unit conversions.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`).
|
/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`).
|
||||||
public enum GamepadWire {
|
public enum GamepadWire {
|
||||||
|
/// Gamepads addressable on the wire — the pad index rides the low byte of `flags` on every
|
||||||
|
/// per-pad event, 0...15 (`punktfunk_core::input::MAX_PADS`).
|
||||||
|
public static let maxPads: Int = 16
|
||||||
|
|
||||||
public static let dpadUp: UInt32 = 0x0001
|
public static let dpadUp: UInt32 = 0x0001
|
||||||
public static let dpadDown: UInt32 = 0x0002
|
public static let dpadDown: UInt32 = 0x0002
|
||||||
public static let dpadLeft: UInt32 = 0x0004
|
public static let dpadLeft: UInt32 = 0x0004
|
||||||
@@ -22,11 +26,27 @@ public enum GamepadWire {
|
|||||||
public static let y: UInt32 = 0x8000
|
public static let y: UInt32 = 0x8000
|
||||||
/// DualSense touchpad click (Moonlight's extended-button bit position).
|
/// DualSense touchpad click (Moonlight's extended-button bit position).
|
||||||
public static let touchpadClick: UInt32 = 0x10_0000
|
public static let touchpadClick: UInt32 = 0x10_0000
|
||||||
|
/// Misc / capture button — Xbox-Series Share, DualSense Create, Steam-Deck quick-access
|
||||||
|
/// (Moonlight's extended-button namespace; `input::gamepad::BTN_MISC1`). The host routes it to
|
||||||
|
/// the DualSense mute / Steam quick-access menu; a plain virtual xpad has no such button.
|
||||||
|
public static let misc1: UInt32 = 0x0020_0000
|
||||||
|
/// Back-grip paddles (Xbox Elite P1–P4 / DualSense Edge / Steam-Deck L4-L5-R4-R5), in
|
||||||
|
/// Moonlight's extended-button namespace (`input::gamepad::BTN_PADDLE1..4`, R4/L4/R5/L5).
|
||||||
|
/// Defined for wire completeness and pinned by the tests; `GamepadCapture.buttonMask` does not
|
||||||
|
/// read them yet — the GameController `paddleButton1..4` ↔ BTN_PADDLE physical correspondence
|
||||||
|
/// needs confirming on a real Elite pad first (see the gamepad-review-cleanup plan, G22), so
|
||||||
|
/// they are intentionally absent from `allButtons` until that forwarding lands.
|
||||||
|
public static let paddle1: UInt32 = 0x0001_0000
|
||||||
|
public static let paddle2: UInt32 = 0x0002_0000
|
||||||
|
public static let paddle3: UInt32 = 0x0004_0000
|
||||||
|
public static let paddle4: UInt32 = 0x0008_0000
|
||||||
|
|
||||||
|
/// Every button `buttonMask`/`sendGuide` can set — walked by `sync`'s transition diff and by
|
||||||
|
/// `flush` on release. Paddles are excluded until their capture lands (see above).
|
||||||
public static let allButtons: [UInt32] = [
|
public static let allButtons: [UInt32] = [
|
||||||
dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
|
dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
|
||||||
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
|
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
|
||||||
a, b, x, y, touchpadClick,
|
a, b, x, y, touchpadClick, misc1,
|
||||||
]
|
]
|
||||||
|
|
||||||
public static let axisLSX: UInt32 = 0
|
public static let axisLSX: UInt32 = 0
|
||||||
|
|||||||
@@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
static let manual = Policy(staleAfter: nil)
|
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 queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||||
private let policy: Policy
|
private let policy: Policy
|
||||||
|
private let actuator: Actuator
|
||||||
|
|
||||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
/// 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)
|
/// 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))
|
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(policy: Policy = .session) {
|
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
|
self.actuator = actuator
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
/// `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
|
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||||
private func setup() {
|
private func setup() {
|
||||||
|
if actuator == .device {
|
||||||
|
setupDevice()
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let haptics = controller?.haptics else {
|
guard let haptics = controller?.haptics else {
|
||||||
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
// 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
|
// 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(
|
private func makeMotor(
|
||||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||||
) -> Motor? {
|
) -> Motor? {
|
||||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
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
|
// 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;
|
// (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
|
// 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()
|
try engine.start()
|
||||||
return Motor(engine: engine, sharpness: sharpness)
|
return Motor(engine: engine, sharpness: sharpness)
|
||||||
} catch {
|
} 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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ public final class InputCapture {
|
|||||||
/// its Esc suppression need it in both states).
|
/// its Esc suppression need it in both states).
|
||||||
private var cmdKeysDown: Set<UInt32> = []
|
private var cmdKeysDown: Set<UInt32> = []
|
||||||
|
|
||||||
|
/// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only:
|
||||||
|
/// the ⌃⌥⇧Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor,
|
||||||
|
/// like the ⌘⎋ toggle), so it needs the live modifier state — tracked in both forwarding states,
|
||||||
|
/// exactly like `cmdKeysDown`, and flushed by `releaseAll` when GC delivery stops.
|
||||||
|
private var chordModifiersDown: Set<UInt32> = []
|
||||||
|
|
||||||
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
||||||
/// locally; while false the user is interacting with the local UI (dragging the
|
/// locally; while false the user is interacting with the local UI (dragging the
|
||||||
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
||||||
@@ -119,6 +125,21 @@ public final class InputCapture {
|
|||||||
public var onDisconnect: (() -> Void)?
|
public var onDisconnect: (() -> Void)?
|
||||||
public var onCycleStats: (() -> Void)?
|
public var onCycleStats: (() -> Void)?
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||||
|
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||||
|
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
|
||||||
|
|
||||||
|
/// Whether Control AND Option AND Shift are all currently held (either side of each counts) —
|
||||||
|
/// the modifier precondition for the iPad ⌃⌥⇧Q release chord.
|
||||||
|
private var hasReleaseChordModifiers: Bool {
|
||||||
|
let m = chordModifiersDown
|
||||||
|
return (m.contains(0xA2) || m.contains(0xA3)) // control
|
||||||
|
&& (m.contains(0xA4) || m.contains(0xA5)) // option
|
||||||
|
&& (m.contains(0xA0) || m.contains(0xA1)) // shift
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
||||||
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
||||||
/// state — its handlers are gone, so it would otherwise sit "captured" with dead
|
/// state — its handlers are gone, so it would otherwise sit "captured" with dead
|
||||||
@@ -294,6 +315,7 @@ public final class InputCapture {
|
|||||||
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
||||||
private func releaseAll() {
|
private func releaseAll() {
|
||||||
cmdKeysDown.removeAll()
|
cmdKeysDown.removeAll()
|
||||||
|
chordModifiersDown.removeAll()
|
||||||
suppressedVK = nil
|
suppressedVK = nil
|
||||||
for vk in pressedVKs {
|
for vk in pressedVKs {
|
||||||
connection.send(.key(vk, down: false))
|
connection.send(.key(vk, down: false))
|
||||||
@@ -576,6 +598,13 @@ public final class InputCapture {
|
|||||||
self.cmdKeysDown.remove(vk)
|
self.cmdKeysDown.remove(vk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
// Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding
|
||||||
|
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
|
||||||
|
if Self.chordModifierVKs.contains(vk) {
|
||||||
|
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
// The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the
|
// The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the
|
||||||
// engage direction forwarding is already true when this fires.
|
// engage direction forwarding is already true when this fires.
|
||||||
if vk == self.suppressedVK {
|
if vk == self.suppressedVK {
|
||||||
@@ -592,6 +621,18 @@ public final class InputCapture {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
guard self.forwarding else { return }
|
guard self.forwarding else { return }
|
||||||
|
#if os(iOS)
|
||||||
|
// ⌃⌥⇧Q releases the captured mouse/keyboard (cross-client parity — the same combo the
|
||||||
|
// macOS keyDown monitor handles). Recognized only while forwarding (nothing to release
|
||||||
|
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
|
||||||
|
// the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release
|
||||||
|
// path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q).
|
||||||
|
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
|
||||||
|
self.suppressedVK = 0x51
|
||||||
|
self.onReleaseCapture?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
||||||
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
||||||
if vk == 0x1B, !self.cmdKeysDown.isEmpty {
|
if vk == 0x1B, !self.cmdKeysDown.isEmpty {
|
||||||
|
|||||||
@@ -118,3 +118,44 @@ extension InputCapture {
|
|||||||
]
|
]
|
||||||
#endif
|
#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
|
// 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
|
// 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
|
// (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
|
// * 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
|
// 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 accelGain: CGFloat = 0.6
|
||||||
static let accelSpeedFloor: CGFloat = 0.3
|
static let accelSpeedFloor: CGFloat = 0.3
|
||||||
static let accelMax: CGFloat = 3.0
|
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.
|
/// Acceleration multiplier for a finger speed in physical px per ms.
|
||||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||||
@@ -72,6 +76,9 @@ final class TouchMouse {
|
|||||||
var send: ((PunktfunkInputEvent) -> Void)?
|
var send: ((PunktfunkInputEvent) -> Void)?
|
||||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||||
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
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.
|
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
||||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||||
@@ -95,6 +102,11 @@ final class TouchMouse {
|
|||||||
private var carryY: CGFloat = 0
|
private var carryY: CGFloat = 0
|
||||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||||
private var scrollAnchor = CGPoint.zero
|
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.
|
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||||
private var lastTapUp: TimeInterval = 0
|
private var lastTapUp: TimeInterval = 0
|
||||||
private var lastTapPoint = CGPoint.zero
|
private var lastTapPoint = CGPoint.zero
|
||||||
@@ -114,6 +126,8 @@ final class TouchMouse {
|
|||||||
maxFingers = 0
|
maxFingers = 0
|
||||||
moved = false
|
moved = false
|
||||||
scrolling = false
|
scrolling = false
|
||||||
|
kbCount = 0
|
||||||
|
kbFired = false
|
||||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||||
// button for this whole gesture (laptop-trackpad convention).
|
// button for this whole gesture (laptop-trackpad convention).
|
||||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||||
@@ -140,8 +154,13 @@ final class TouchMouse {
|
|||||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
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()
|
scrollByCentroid()
|
||||||
|
} else if lastPos.count >= 3 {
|
||||||
|
keyboardSwipe(in: view)
|
||||||
} else if !scrolling, let touch = touches.first(where: {
|
} else if !scrolling, let touch = touches.first(where: {
|
||||||
lastPos[ObjectIdentifier($0)] != nil
|
lastPos[ObjectIdentifier($0)] != nil
|
||||||
}) {
|
}) {
|
||||||
@@ -208,9 +227,9 @@ final class TouchMouse {
|
|||||||
|
|
||||||
// MARK: - Per-event work
|
// MARK: - Per-event work
|
||||||
|
|
||||||
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
/// Two fingers → scroll by the centroid delta; never move the cursor. Fires a notch per
|
||||||
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
/// `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger right
|
||||||
/// right scrolls right (the host WHEEL(120) convention).
|
/// scrolls right (the host WHEEL(120) convention).
|
||||||
private func scrollByCentroid() {
|
private func scrollByCentroid() {
|
||||||
let n = CGFloat(lastPos.count)
|
let n = CGFloat(lastPos.count)
|
||||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
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 finger (and the gesture never became a scroll — dropping back from two fingers to
|
||||||
/// one must not jerk the cursor).
|
/// one must not jerk the cursor).
|
||||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
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
|
/// 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.
|
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||||
public static let audioChannels = "punktfunk.audioChannels"
|
public static let audioChannels = "punktfunk.audioChannels"
|
||||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference —
|
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, `"av1"`, or
|
||||||
/// the host emits it when it can, else falls back. Drives the decoder via `Welcome.codec`.
|
/// `"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 codec = "punktfunk.codec"
|
||||||
public static let micEnabled = "punktfunk.micEnabled"
|
public static let micEnabled = "punktfunk.micEnabled"
|
||||||
public static let speakerUID = "punktfunk.speakerUID"
|
public static let speakerUID = "punktfunk.speakerUID"
|
||||||
@@ -97,6 +99,18 @@ public enum DefaultsKey {
|
|||||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
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
|
||||||
|
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
||||||
|
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||||
|
public static let autoWake = "punktfunk.autoWake"
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
|
|||||||
@@ -543,19 +543,24 @@ public enum AV1 {
|
|||||||
|
|
||||||
extension VideoCodec {
|
extension VideoCodec {
|
||||||
/// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence
|
/// 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? {
|
public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
|
||||||
self == .av1
|
switch self {
|
||||||
? AV1.formatDescription(fromKeyframe: au)
|
case .av1: return AV1.formatDescription(fromKeyframe: au)
|
||||||
: AnnexB.formatDescription(fromIDR: au, codec: self)
|
case .pyrowave: return nil
|
||||||
|
default: return AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
|
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
|
||||||
public func sampleBuffer(
|
public func sampleBuffer(
|
||||||
au: AccessUnit, format: CMVideoFormatDescription
|
au: AccessUnit, format: CMVideoFormatDescription
|
||||||
) -> CMSampleBuffer? {
|
) -> CMSampleBuffer? {
|
||||||
self == .av1
|
switch self {
|
||||||
? AV1.sampleBuffer(au: au, format: format)
|
case .av1: return AV1.sampleBuffer(au: au, format: format)
|
||||||
: AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
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 h264
|
||||||
case hevc
|
case hevc
|
||||||
case av1
|
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).
|
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC).
|
||||||
public init(wire: UInt8) {
|
public init(wire: UInt8) {
|
||||||
switch wire {
|
switch wire {
|
||||||
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
|
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
|
||||||
case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1
|
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
|
default: self = .hevc // PUNKTFUNK_CODEC_HEVC — the default / older-host codec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,8 +153,8 @@ public enum AnnexB {
|
|||||||
sets = [vps, sps, pps]
|
sets = [vps, sps, pps]
|
||||||
case .h264:
|
case .h264:
|
||||||
sets = [sps, pps]
|
sets = [sps, pps]
|
||||||
case .av1:
|
case .av1, .pyrowave:
|
||||||
return nil // OBU stream, no parameter-set NALs — handled in AV1.swift, never here
|
return nil // no parameter-set NALs — dispatched in AV1.swift, never reaches here
|
||||||
}
|
}
|
||||||
|
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
@@ -184,8 +190,8 @@ public enum AnnexB {
|
|||||||
parameterSetSizes: sizes,
|
parameterSetSizes: sizes,
|
||||||
nalUnitHeaderLength: 4,
|
nalUnitHeaderLength: 4,
|
||||||
formatDescriptionOut: &format)
|
formatDescriptionOut: &format)
|
||||||
case .av1:
|
case .av1, .pyrowave:
|
||||||
break // unreachable — the .av1 arm above already returned
|
break // unreachable — the arm above already returned
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return status == noErr ? format : nil
|
return status == noErr ? format : nil
|
||||||
|
|||||||
@@ -124,7 +124,16 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
|
|||||||
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
|
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
|
||||||
constant CscUniform& csc) {
|
constant CscUniform& csc) {
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||||
float3 yuv = float3(catmullRomLuma(lumaTex, s, uv),
|
#ifdef PF_BILINEAR_LUMA
|
||||||
|
// DEBUG (PUNKTFUNK_BILINEAR_LUMA=1): plain bilinear luma — Catmull-Rom OFF. A/B lever to see if
|
||||||
|
// the bicubic overshoot contributes to edge fringing. NOTE: at a true 1:1 present both paths
|
||||||
|
// reduce to the identity texel, so if this toggle VISIBLY changes the picture, the present is
|
||||||
|
// NOT 1:1 (there's a resample); if it looks identical, the fringing is upstream (codec/source/OS).
|
||||||
|
float lumaY = lumaTex.sample(s, uv).r;
|
||||||
|
#else
|
||||||
|
float lumaY = catmullRomLuma(lumaTex, s, uv);
|
||||||
|
#endif
|
||||||
|
float3 yuv = float3(lumaY,
|
||||||
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
|
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
|
||||||
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||||
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||||
@@ -140,6 +149,28 @@ fragment float4 pf_frag(VOut in [[stage_in]],
|
|||||||
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
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 —
|
// 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
|
// 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
|
// PQ, so it does the PQ→display tone-map. No EOTF here. The rows fold in the exact 10-bit
|
||||||
@@ -206,8 +237,16 @@ public final class MetalVideoPresenter {
|
|||||||
/// tvOS only: the in-shader PQ→SDR tone-map fallback (pf_frag_hdr_tv → bgra8), used whenever
|
/// 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.
|
/// the display is composited without HDR headroom — see `setDisplayHeadroom`. nil elsewhere.
|
||||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
|
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||||
|
private let pipelinePlanar: MTLRenderPipelineState
|
||||||
private var textureCache: CVMetalTextureCache?
|
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.
|
/// 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
|
/// 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).
|
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||||
@@ -249,8 +288,18 @@ public final class MetalVideoPresenter {
|
|||||||
let pipelineSDR: MTLRenderPipelineState
|
let pipelineSDR: MTLRenderPipelineState
|
||||||
let pipelineHDR: MTLRenderPipelineState
|
let pipelineHDR: MTLRenderPipelineState
|
||||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
|
let pipelinePlanar: MTLRenderPipelineState
|
||||||
do {
|
do {
|
||||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
// 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
|
||||||
|
// the normal bicubic path. Read at presenter creation — set it in the environment and
|
||||||
|
// relaunch to flip; the log line confirms which path built.
|
||||||
|
let bilinearLuma = ProcessInfo.processInfo.environment["PUNKTFUNK_BILINEAR_LUMA"] == "1"
|
||||||
|
let source = (bilinearLuma ? "#define PF_BILINEAR_LUMA 1\n" : "") + shaderSource
|
||||||
|
if bilinearLuma {
|
||||||
|
presenterLog.info("stage2: PUNKTFUNK_BILINEAR_LUMA=1 — Catmull-Rom luma DISABLED (bilinear)")
|
||||||
|
}
|
||||||
|
let library = try device.makeLibrary(source: source, options: nil)
|
||||||
let vtx = library.makeFunction(name: "pf_vtx")
|
let vtx = library.makeFunction(name: "pf_vtx")
|
||||||
let sdr = MTLRenderPipelineDescriptor()
|
let sdr = MTLRenderPipelineDescriptor()
|
||||||
sdr.vertexFunction = vtx
|
sdr.vertexFunction = vtx
|
||||||
@@ -274,6 +323,11 @@ public final class MetalVideoPresenter {
|
|||||||
#else
|
#else
|
||||||
pipelineHDRToneMap = nil
|
pipelineHDRToneMap = nil
|
||||||
#endif
|
#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 {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -313,12 +367,14 @@ public final class MetalVideoPresenter {
|
|||||||
|
|
||||||
return MetalVideoPresenter(
|
return MetalVideoPresenter(
|
||||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||||
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
|
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||||
|
textureCache: textureCache, layer: layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(
|
private init(
|
||||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||||
|
pipelinePlanar: MTLRenderPipelineState,
|
||||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||||
) {
|
) {
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -326,6 +382,7 @@ public final class MetalVideoPresenter {
|
|||||||
self.pipelineSDR = pipelineSDR
|
self.pipelineSDR = pipelineSDR
|
||||||
self.pipelineHDR = pipelineHDR
|
self.pipelineHDR = pipelineHDR
|
||||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||||
|
self.pipelinePlanar = pipelinePlanar
|
||||||
self.textureCache = textureCache
|
self.textureCache = textureCache
|
||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
@@ -496,6 +553,67 @@ public final class MetalVideoPresenter {
|
|||||||
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
||||||
else { return false }
|
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
|
// 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
|
// 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:
|
// main thread owns) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass:
|
||||||
@@ -504,8 +622,6 @@ public final class MetalVideoPresenter {
|
|||||||
// Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT
|
// 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
|
// 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).
|
// (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)
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
? targetFromLayout : decodedSize
|
? targetFromLayout : decodedSize
|
||||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||||
@@ -524,17 +640,8 @@ public final class MetalVideoPresenter {
|
|||||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
encoder.setRenderPipelineState(pipeline)
|
||||||
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
bind(encoder)
|
||||||
// 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.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||||
encoder.endEncoding()
|
encoder.endEncoding()
|
||||||
if let onPresented {
|
if let onPresented {
|
||||||
@@ -562,9 +669,8 @@ public final class MetalVideoPresenter {
|
|||||||
} else {
|
} else {
|
||||||
commandBuffer.present(drawable)
|
commandBuffer.present(drawable)
|
||||||
}
|
}
|
||||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||||
// sampling — releasing them at scope exit could free the backing mid-read.
|
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
|
||||||
commandBuffer.commit()
|
commandBuffer.commit()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -590,8 +696,17 @@ public final class MetalVideoPresenter {
|
|||||||
let sig = "\(Int(decoded.width))x\(Int(decoded.height))→\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)"
|
let sig = "\(Int(decoded.width))x\(Int(decoded.height))→\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)"
|
||||||
if sig != lastSizeSig {
|
if sig != lastSizeSig {
|
||||||
lastSizeSig = sig
|
lastSizeSig = sig
|
||||||
|
// Explicit verdict: is the shader presenting 1:1 (decoded == drawable) or resampling? The
|
||||||
|
// scale ratio makes a residual match-window mismatch obvious. If this says 1:1 but the
|
||||||
|
// picture is still soft, the resample is downstream of us (macOS compositor — a scaled
|
||||||
|
// display mode, or a fractional-pixel window position), not the shader.
|
||||||
|
let sx = decoded.width > 0 ? drawable.width / decoded.width : 0
|
||||||
|
let sy = decoded.height > 0 ? drawable.height / decoded.height : 0
|
||||||
|
let verdict = decoded == drawable
|
||||||
|
? "1:1 (no resample)"
|
||||||
|
: String(format: "RESAMPLE scale=%.4fx%.4f", sx, sy)
|
||||||
let msg =
|
let msg =
|
||||||
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) hdr=\(hdrActive)"
|
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) [\(verdict)] hdr=\(hdrActive)"
|
||||||
presenterLog.info("\(msg, privacy: .public)")
|
presenterLog.info("\(msg, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Swift wrapper around the punktfunk-core C ABI's post-loss re-anchor gate
|
||||||
|
// (`punktfunk_reanchor_gate_*`, ABI v6). The shared Rust gate (crates/punktfunk-core/src/reanchor.rs)
|
||||||
|
// is what the Linux/Windows desktop pump and the Android client use directly; the Swift clients reach
|
||||||
|
// it across the C ABI so the freeze-until-reanchor policy is defined ONCE for every platform.
|
||||||
|
//
|
||||||
|
// Why a freeze at all: after unrecoverable loss the host keeps sending delta frames that reference a
|
||||||
|
// picture the client never got. Hardware decoders (VideoToolbox included) don't reliably error on
|
||||||
|
// that — they CONCEAL, returning a gray/garbage frame with a success status. Presenting those is the
|
||||||
|
// visible "gray flash with motion" of the loss reports. The gate withholds concealed frames and holds
|
||||||
|
// the last good picture on glass until a PROVEN clean re-anchor lands — an IDR (wire `FLAG_SOF`), an
|
||||||
|
// RFI recovery anchor (`USER_FLAG_RECOVERY_ANCHOR`), or the 2nd of two intra-refresh recovery marks
|
||||||
|
// (`USER_FLAG_RECOVERY_POINT`) — with a bounded backstop so a lost re-anchor can never freeze forever.
|
||||||
|
// See punktfunk-planning design/client-reanchor-freeze-parity.md.
|
||||||
|
//
|
||||||
|
// Threading: one gate per session. Its calls arrive from two threads — the pump thread (`arm` on a
|
||||||
|
// frame-index gap / a submit failure, `poll` per iteration) and a VideoToolbox decode thread
|
||||||
|
// (`onDecoded` per decoded frame, `onNoOutput` on a decode error). The raw Rust gate is a plain
|
||||||
|
// struct behind an opaque pointer with no internal synchronization, so every call is serialized under
|
||||||
|
// `lock` here — the calls are cheap field updates, so contention is negligible. `@unchecked Sendable`:
|
||||||
|
// the lock enforces the contract.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import PunktfunkCore
|
||||||
|
|
||||||
|
final class ReanchorGate: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
/// The opaque `ReanchorGate *`. `var` so `reseed` can swap it at session start. Never NULL
|
||||||
|
/// (`punktfunk_reanchor_gate_new` never returns NULL).
|
||||||
|
private var ptr: OpaquePointer
|
||||||
|
|
||||||
|
/// Seed the baseline with the connection's current `framesDropped` so the first `poll` doesn't
|
||||||
|
/// read the session's starting drop count as a fresh loss.
|
||||||
|
init(framesDropped: UInt64) {
|
||||||
|
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { punktfunk_reanchor_gate_free(ptr) }
|
||||||
|
|
||||||
|
/// Re-anchor the drop-count baseline to `framesDropped` for a (re)started session. The gate is
|
||||||
|
/// created in the pipeline's init (before a connection exists, seeded 0); `start` calls this once
|
||||||
|
/// the live connection's count is known so a mid-life connection's non-zero baseline isn't
|
||||||
|
/// mistaken for loss on the first poll.
|
||||||
|
func reseed(framesDropped: UInt64) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
punktfunk_reanchor_gate_free(ptr)
|
||||||
|
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge). Zeroes the
|
||||||
|
/// recovery-mark count and (re)sets the backstop deadline.
|
||||||
|
func arm() {
|
||||||
|
lock.lock()
|
||||||
|
punktfunk_reanchor_gate_arm(ptr)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
||||||
|
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
||||||
|
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
||||||
|
func onDecoded(flags: UInt32, decoderKeyframe: Bool = false) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var present = false
|
||||||
|
_ = punktfunk_reanchor_gate_on_decoded(ptr, flags, decoderKeyframe, &present)
|
||||||
|
return present
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A received AU produced no decoded frame (a VideoToolbox decode error). Returns true when the
|
||||||
|
/// no-output streak has tripped (the gate armed the freeze) and the caller should — throttled —
|
||||||
|
/// request a keyframe.
|
||||||
|
func onNoOutput() -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var requestKf = false
|
||||||
|
_ = punktfunk_reanchor_gate_on_no_output(ptr, &requestKf)
|
||||||
|
return requestKf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodic fold of the session's `framesDropped` plus the overdue backstop. Returns true when the
|
||||||
|
/// caller should — throttled — request a keyframe (a drop-count climb armed a fresh freeze, or the
|
||||||
|
/// freeze is overdue and re-asks while it keeps holding).
|
||||||
|
func poll(framesDropped: UInt64) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var requestKf = false
|
||||||
|
_ = punktfunk_reanchor_gate_poll(ptr, framesDropped, &requestKf)
|
||||||
|
return requestKf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
|
||||||
|
var isHolding: Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var holding = false
|
||||||
|
_ = punktfunk_reanchor_gate_is_holding(ptr, &holding)
|
||||||
|
return holding
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,15 @@ final class SessionPresenter {
|
|||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
private var metalLayer: CAMetalLayer?
|
private var metalLayer: CAMetalLayer?
|
||||||
private var connection: PunktfunkConnection?
|
private var connection: PunktfunkConnection?
|
||||||
|
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||||
|
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||||
|
/// `connection.currentMode()`, which (a) lags a mid-stream resize — it only updates on the
|
||||||
|
/// `Reconfigured` ack, and a resize-END produces no bounds change to re-run `layout` afterward —
|
||||||
|
/// and (b) can disagree with what the host actually DELIVERED (Windows corrective-ack falls back
|
||||||
|
/// to an advertised mode). The pixels we're drawing are the only correct aspect source; a wrong
|
||||||
|
/// one here is the "black bars + stretched" resize artifact. nil until the first frame → `layout`
|
||||||
|
/// falls back to `currentMode()`. Main-thread only.
|
||||||
|
private var contentSize: CGSize?
|
||||||
|
|
||||||
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
|
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
|
||||||
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
|
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
|
||||||
@@ -184,22 +193,45 @@ final class SessionPresenter {
|
|||||||
guard let metalLayer, let connection else { return }
|
guard let metalLayer, let connection else { return }
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
||||||
let fit: CGRect = (mode.width > 0 && mode.height > 0)
|
// Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a
|
||||||
? AVMakeRect(
|
// host that delivered a different size than requested), else the negotiated mode. The shader
|
||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
// stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that
|
||||||
insideRect: bounds)
|
// keeps the picture undistorted — a stale aspect here is the post-resize black-bars+stretch.
|
||||||
: bounds
|
let aspect: CGSize? = {
|
||||||
|
if let c = contentSize, c.width > 0, c.height > 0 { return c }
|
||||||
|
if mode.width > 0, mode.height > 0 {
|
||||||
|
return CGSize(width: Int(mode.width), height: Int(mode.height))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds
|
||||||
|
// Snap the sublayer frame to the BACKING PIXEL GRID. AVMakeRect centers the aspect-fit rect,
|
||||||
|
// so its origin/size are usually fractional points; a metal sublayer whose frame doesn't land
|
||||||
|
// on whole device pixels is RESAMPLED by the macOS/UIKit compositor during composite — a
|
||||||
|
// uniform "everything looks soft" blur — even when the drawable itself is pixel-exact 1:1
|
||||||
|
// (verified via the stage2 "[1:1 (no resample)]" log while the picture was still soft). Round
|
||||||
|
// origin AND size to device pixels so the composite is a true 1:1 blit. Idempotent when the
|
||||||
|
// frame is already aligned (e.g. fullscreen fit == integer bounds), so it's a no-op there.
|
||||||
|
let scale = contentsScale > 0 ? contentsScale : 1
|
||||||
|
let snapped = CGRect(
|
||||||
|
x: (fit.origin.x * scale).rounded() / scale,
|
||||||
|
y: (fit.origin.y * scale).rounded() / scale,
|
||||||
|
width: (fit.width * scale).rounded() / scale,
|
||||||
|
height: (fit.height * scale).rounded() / scale)
|
||||||
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
metalLayer.contentsScale = contentsScale
|
metalLayer.contentsScale = contentsScale
|
||||||
metalLayer.frame = fit
|
metalLayer.frame = snapped
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||||
// cross-thread) — this is what the presenter sizes its drawable to.
|
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||||
|
// the drawable's texel count equals the on-screen device-pixel count exactly (1 texel ↔ 1
|
||||||
|
// device pixel); with the frame snapped, this equals the pre-snap rounded value, so the
|
||||||
|
// decoded↔drawable 1:1 the log confirmed is preserved.
|
||||||
stage2?.setDrawableTarget(CGSize(
|
stage2?.setDrawableTarget(CGSize(
|
||||||
width: (fit.width * contentsScale).rounded(),
|
width: (snapped.width * scale).rounded(),
|
||||||
height: (fit.height * contentsScale).rounded()))
|
height: (snapped.height * scale).rounded()))
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
|
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
|
||||||
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
|
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
|
||||||
@@ -209,10 +241,20 @@ final class SessionPresenter {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record the decoded frame's real dimensions (the view hops the pump's `onDecodedSize` to main
|
||||||
|
/// and calls this) so `layout` aspect-fits to what's actually on screen instead of the possibly-
|
||||||
|
/// stale `currentMode()`. Only stores — the caller re-runs `layout` right after, because a
|
||||||
|
/// resize-END produces no bounds change to trigger one. No-op on a zero/unchanged size.
|
||||||
|
func setContentSize(_ size: CGSize) {
|
||||||
|
guard size.width > 0, size.height > 0, size != contentSize else { return }
|
||||||
|
contentSize = size
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
func stop() {
|
func stop() {
|
||||||
|
contentSize = nil // a new session re-derives it from its first frame
|
||||||
pump?.stop()
|
pump?.stop()
|
||||||
pump = nil
|
pump = nil
|
||||||
stage2Link?.invalidate()
|
stage2Link?.invalidate()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
/// 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 {
|
public final class Stage2Pipeline {
|
||||||
private let ring = ReadyRing()
|
private let ring = ReadyRing()
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
@@ -257,8 +280,16 @@ public final class Stage2Pipeline {
|
|||||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||||
private let pacing: PresentPacing
|
private let pacing: PresentPacing
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
private let recovery = KeyframeRecovery()
|
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).
|
||||||
|
private let gate = ReanchorGate(framesDropped: 0)
|
||||||
private var token = StopFlag()
|
private var token = StopFlag()
|
||||||
private var offsetNs: Int64 = 0
|
private var offsetNs: Int64 = 0
|
||||||
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
||||||
@@ -302,25 +333,39 @@ public final class Stage2Pipeline {
|
|||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.pacing = pacing
|
self.pacing = pacing
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let renderSignal = renderSignal
|
let renderSignal = renderSignal
|
||||||
|
let gate = gate
|
||||||
|
let decodeReport = decodeReport
|
||||||
self.decoder = VideoDecoder(
|
self.decoder = VideoDecoder(
|
||||||
onDecoded: { frame in
|
onDecoded: { frame in
|
||||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||||
// skew applies). Stamped at decode completion, so it covers every decoded frame,
|
// skew applies). Stamped at decode completion, so it covers every decoded frame,
|
||||||
// including ones the newest-wins ring drops before present.
|
// including ones the re-anchor gate withholds or the newest-wins ring drops.
|
||||||
decodeMeter?.record(
|
decodeMeter?.record(
|
||||||
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
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
|
||||||
|
// present) on a proven clean re-anchor (IDR / RFI anchor / 2nd recovery mark) or the
|
||||||
|
// bounded backstop. decoderKeyframe=false: VT doesn't flag IDRs, the wire FLAG_SOF does.
|
||||||
|
guard gate.onDecoded(flags: frame.flags) else { return }
|
||||||
ring.submit(frame)
|
ring.submit(frame)
|
||||||
// FRAME ARRIVAL is the render trigger (never the display link — see the header).
|
// FRAME ARRIVAL is the render trigger (never the display link — see the header).
|
||||||
renderSignal.signal()
|
renderSignal.signal()
|
||||||
},
|
},
|
||||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): fold it into the
|
||||||
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
// gate's no-output streak (which arms the freeze after a short run, matching the desktop),
|
||||||
|
// and when that trips ask the host for a fresh IDR now (infinite GOP — it wouldn't
|
||||||
// otherwise come soon). Throttled in KeyframeRecovery.
|
// otherwise come soon). Throttled in KeyframeRecovery.
|
||||||
onDecodeError: { _ in recovery.request() })
|
onDecodeError: { _ in if gate.onNoOutput() { recovery.request() } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
|
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
|
||||||
@@ -334,6 +379,8 @@ public final class Stage2Pipeline {
|
|||||||
) {
|
) {
|
||||||
offsetNs = connection.clockOffsetNs
|
offsetNs = connection.clockOffsetNs
|
||||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
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)
|
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
||||||
|
|
||||||
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
||||||
@@ -348,7 +395,22 @@ public final class Stage2Pipeline {
|
|||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let presenter = presenter
|
let presenter = presenter
|
||||||
let pumpStopped = pumpStopped
|
let pumpStopped = pumpStopped
|
||||||
let thread = Thread {
|
let reanchorGate = gate
|
||||||
|
// 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()
|
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
|
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
|
||||||
@@ -379,6 +441,9 @@ public final class Stage2Pipeline {
|
|||||||
awaitingIDR = true
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
if awaitingIDR { recovery.request() }
|
if awaitingIDR { recovery.request() }
|
||||||
|
// Freeze backstop: a drop-count climb arms the gate (in case the frame-index gap
|
||||||
|
// below was itself lost), and an overdue freeze re-asks for the re-anchor.
|
||||||
|
if reanchorGate.poll(framesDropped: dropped) { recovery.request() }
|
||||||
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
||||||
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
||||||
// host sends 0xCE only for HDR, INCLUDING a mid-session SDR→HDR transition (a game
|
// host sends 0xCE only for HDR, INCLUDING a mid-session SDR→HDR transition (a game
|
||||||
@@ -388,6 +453,13 @@ public final class Stage2Pipeline {
|
|||||||
presenter.setHdrMeta(meta)
|
presenter.setHdrMeta(meta)
|
||||||
}
|
}
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
|
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
|
||||||
|
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
|
||||||
|
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||||
|
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||||
|
// The same gap is the earliest, most precise signal to ARM the display freeze —
|
||||||
|
// the following concealed frames are withheld until a clean re-anchor.
|
||||||
|
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
@@ -421,6 +493,7 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
thread.name = "punktfunk-stage2-pump"
|
thread.name = "punktfunk-stage2-pump"
|
||||||
thread.qualityOfService = .userInteractive
|
thread.qualityOfService = .userInteractive
|
||||||
@@ -480,9 +553,7 @@ public final class Stage2Pipeline {
|
|||||||
let presentAt = vsyncEnabled
|
let presentAt = vsyncEnabled
|
||||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||||
let renderStarted = CACurrentMediaTime()
|
let renderStarted = CACurrentMediaTime()
|
||||||
let rendered = presenter.render(
|
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||||
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
|
||||||
) { presentedNs in
|
|
||||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
// 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.
|
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||||
if let gate {
|
if let gate {
|
||||||
@@ -501,6 +572,18 @@ public final class Stage2Pipeline {
|
|||||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||||
debugStats?.presented(atNs: presentedNs)
|
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(
|
debugStats?.renderReturned(
|
||||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||||
if !rendered {
|
if !rendered {
|
||||||
@@ -568,6 +651,93 @@ public final class Stage2Pipeline {
|
|||||||
renderSignal.signal() // wake the render thread so it can observe the stop and exit
|
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`
|
/// 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
|
/// 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.
|
/// present time (when the frame is actually on glass), not the moment we drew.
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ final class StreamPump {
|
|||||||
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
||||||
let recovery = KeyframeRecovery()
|
let recovery = KeyframeRecovery()
|
||||||
recovery.bind(connection)
|
recovery.bind(connection)
|
||||||
|
// Post-loss freeze-until-reanchor (shared core policy via the C ABI). Stage-1 has no per-frame
|
||||||
|
// decode callback, so the gate is folded at ENQUEUE (from the AU's wire flags): a withheld
|
||||||
|
// frame is still enqueued but flagged DoNotDisplay so the layer's decoder keeps the reference
|
||||||
|
// chain fed while the last GOOD picture stays on glass — until a clean re-anchor lifts it.
|
||||||
|
let gate = ReanchorGate(framesDropped: connection.framesDropped())
|
||||||
// The layer is non-Sendable but its enqueue/flush are documented thread-safe, and after
|
// The layer is non-Sendable but its enqueue/flush are documented thread-safe, and after
|
||||||
// this point only the pump thread drives it — assert that so the @Sendable Thread closure
|
// this point only the pump thread drives it — assert that so the @Sendable Thread closure
|
||||||
// may capture it.
|
// may capture it.
|
||||||
@@ -77,8 +82,17 @@ final class StreamPump {
|
|||||||
awaitingIDR = true
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
if awaitingIDR { recovery.request() }
|
if awaitingIDR { recovery.request() }
|
||||||
|
// Freeze backstop: a drop-count climb arms the gate (should the frame-index gap
|
||||||
|
// below be lost too), and an overdue freeze re-asks for the re-anchor.
|
||||||
|
if gate.poll(framesDropped: dropped) { recovery.request() }
|
||||||
|
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
|
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
|
||||||
|
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
|
||||||
|
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||||
|
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||||
|
// The same gap is the earliest, most precise signal to ARM the display freeze.
|
||||||
|
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||||
if let f = idrFormat {
|
if let f = idrFormat {
|
||||||
@@ -102,6 +116,7 @@ final class StreamPump {
|
|||||||
// delta into a failed layer can't recover it.
|
// delta into a failed layer can't recover it.
|
||||||
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||||
layer.flush()
|
layer.flush()
|
||||||
|
gate.arm() // a wedged decoder is a loss — freeze until the re-anchor
|
||||||
if idrFormat == nil {
|
if idrFormat == nil {
|
||||||
format = nil
|
format = nil
|
||||||
awaitingIDR = true
|
awaitingIDR = true
|
||||||
@@ -112,6 +127,13 @@ final class StreamPump {
|
|||||||
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
||||||
!token.isStopped // don't enqueue a stale frame after a restart
|
!token.isStopped // don't enqueue a stale frame after a restart
|
||||||
else { return true }
|
else { return true }
|
||||||
|
// Freeze-until-reanchor: while holding, WITHHOLD this concealed post-loss frame by
|
||||||
|
// flagging it DoNotDisplay — the layer still decodes it (keeping the reference
|
||||||
|
// chain fed) but shows the last GOOD picture until a clean re-anchor lifts the
|
||||||
|
// gate. Folded from the AU's wire flags (stage-1 has no decode callback).
|
||||||
|
if !gate.onDecoded(flags: au.flags) {
|
||||||
|
StreamPump.setDoNotDisplay(sample)
|
||||||
|
}
|
||||||
layer.enqueue(sample)
|
layer.enqueue(sample)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -128,6 +150,21 @@ final class StreamPump {
|
|||||||
thread.start()
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flag a sample decode-but-don't-display (`kCMSampleAttachmentKey_DoNotDisplay`). Used to
|
||||||
|
/// withhold decoder-concealed post-loss frames while the re-anchor gate holds: the layer keeps
|
||||||
|
/// its reference chain fed without flipping the frozen picture. No-op if the attachments array
|
||||||
|
/// can't be materialized (then the frame just displays — the freeze degrades to the old behavior).
|
||||||
|
private static func setDoNotDisplay(_ sample: CMSampleBuffer) {
|
||||||
|
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
||||||
|
sample, createIfNecessary: true), CFArrayGetCount(attachments) > 0
|
||||||
|
else { return }
|
||||||
|
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||||
|
CFDictionarySetValue(
|
||||||
|
dict,
|
||||||
|
Unmanaged.passUnretained(kCMSampleAttachmentKey_DoNotDisplay).toOpaque(),
|
||||||
|
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop pumping (≤ one poll timeout). Does not close the connection.
|
/// Stop pumping (≤ one poll timeout). Does not close the connection.
|
||||||
func stop() {
|
func stop() {
|
||||||
token.stop()
|
token.stop()
|
||||||
|
|||||||
@@ -12,7 +12,23 @@ import CoreVideo
|
|||||||
import Foundation
|
import Foundation
|
||||||
import VideoToolbox
|
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 {
|
public struct ReadyFrame: @unchecked Sendable {
|
||||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||||
public let ptsNs: UInt64
|
public let ptsNs: UInt64
|
||||||
@@ -22,24 +38,56 @@ public struct ReadyFrame: @unchecked Sendable {
|
|||||||
public let receivedNs: Int64
|
public let receivedNs: Int64
|
||||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||||
public let decodedNs: Int64
|
public let decodedNs: Int64
|
||||||
/// The decoded image — 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible.
|
/// The decoded image and which present path it takes.
|
||||||
public let pixelBuffer: CVPixelBuffer
|
public let image: ReadyImage
|
||||||
/// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must
|
/// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame
|
||||||
/// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format.
|
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
|
||||||
public let isHDR: Bool
|
/// 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
|
||||||
|
/// the decode-stage meter) and its wire `user_flags` (for the re-anchor gate). Retained across the
|
||||||
|
/// async decode and reclaimed exactly once — by the output callback for every frame VideoToolbox
|
||||||
|
/// accepts, or by `decode`'s error branch for a frame `DecodeFrame` rejected outright (the callback
|
||||||
|
/// then never fires). A tiny per-frame allocation, the price of smuggling two values (a 64-bit
|
||||||
|
/// instant plus the flags) through the single `void*` a bit-pattern scalar can't hold.
|
||||||
|
private final class FrameContext {
|
||||||
|
let receivedNs: Int64
|
||||||
|
let flags: UInt32
|
||||||
|
init(receivedNs: Int64, flags: UInt32) {
|
||||||
|
self.receivedNs = receivedNs
|
||||||
|
self.flags = flags
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
|
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
|
||||||
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon carries
|
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon is the
|
||||||
/// the AU's `receivedNs` as a pointer bit pattern (a scalar smuggled through the C void*, never
|
/// retained `FrameContext` set at submit; reclaim it here (balancing `passRetained`) and unpack the
|
||||||
/// dereferenced) so the decode stage can be computed against decode-completion.
|
/// AU's receipt instant (for the decode stage) and wire flags (for the re-anchor gate).
|
||||||
private let decoderOutputCallback: VTDecompressionOutputCallback = {
|
private let decoderOutputCallback: VTDecompressionOutputCallback = {
|
||||||
refcon, frameRefcon, status, _, imageBuffer, pts, _ in
|
refcon, frameRefcon, status, _, imageBuffer, pts, _ in
|
||||||
guard let refcon else { return }
|
guard let refcon else { return }
|
||||||
let receivedNs = frameRefcon.map { Int64(Int(bitPattern: $0)) } ?? 0
|
let ctx = frameRefcon.map { Unmanaged<FrameContext>.fromOpaque($0).takeRetainedValue() }
|
||||||
Unmanaged<VideoDecoder>.fromOpaque(refcon)
|
Unmanaged<VideoDecoder>.fromOpaque(refcon)
|
||||||
.takeUnretainedValue()
|
.takeUnretainedValue()
|
||||||
.handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts, receivedNs: receivedNs)
|
.handleDecoded(
|
||||||
|
status: status, imageBuffer: imageBuffer, pts: pts,
|
||||||
|
receivedNs: ctx?.receivedNs ?? 0, flags: ctx?.flags ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
|
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
|
||||||
@@ -117,16 +165,21 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
let sample = codec.sampleBuffer(au: au, format: newFormat)
|
let sample = codec.sampleBuffer(au: au, format: newFormat)
|
||||||
else { lock.unlock(); return false }
|
else { lock.unlock(); return false }
|
||||||
var infoOut = VTDecodeInfoFlags()
|
var infoOut = VTDecodeInfoFlags()
|
||||||
|
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
||||||
|
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
||||||
|
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||||
|
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
session,
|
session,
|
||||||
sampleBuffer: sample,
|
sampleBuffer: sample,
|
||||||
flags: [._EnableAsynchronousDecompression],
|
flags: [._EnableAsynchronousDecompression],
|
||||||
// The AU's receipt instant rides through as a bit pattern (nil for 0 — the output
|
frameRefcon: refcon,
|
||||||
// callback maps that back to 0); the callback needs it to stamp the decode stage.
|
|
||||||
frameRefcon: UnsafeMutableRawPointer(bitPattern: Int(au.receivedNs)),
|
|
||||||
infoFlagsOut: &infoOut)
|
infoFlagsOut: &infoOut)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
if status != noErr {
|
if status != noErr {
|
||||||
|
// DecodeFrame rejected the frame outright — the output callback will NOT fire, so
|
||||||
|
// reclaim the context here (balancing passRetained) to avoid leaking it.
|
||||||
|
Unmanaged<FrameContext>.fromOpaque(refcon).release()
|
||||||
onDecodeError(status)
|
onDecodeError(status)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -231,9 +284,10 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
|
/// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
|
||||||
/// AU's receipt instant threaded through the frame refcon (0 = unknown).
|
/// AU's receipt instant and `flags` its wire `user_flags`, both threaded through the frame refcon
|
||||||
|
/// (0 = unknown).
|
||||||
fileprivate func handleDecoded(
|
fileprivate func handleDecoded(
|
||||||
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64
|
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64, flags: UInt32
|
||||||
) {
|
) {
|
||||||
guard status == noErr, let imageBuffer else {
|
guard status == noErr, let imageBuffer else {
|
||||||
onDecodeError(status)
|
onDecodeError(status)
|
||||||
@@ -259,6 +313,6 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
onDecoded(
|
onDecoded(
|
||||||
ReadyFrame(
|
ReadyFrame(
|
||||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||||
pixelBuffer: imageBuffer, isHDR: isHDR))
|
image: .video(imageBuffer, isHDR: isHDR), flags: flags))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,8 +176,14 @@ public final class StreamLayerView: NSView {
|
|||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
public private(set) var connection: PunktfunkConnection?
|
public private(set) var connection: PunktfunkConnection?
|
||||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||||
/// setting is on; fed the view's physical-pixel size on every relayout.
|
/// setting is on (DEFAULT on, for pixel-exact windowed streaming); fed the view's physical-pixel
|
||||||
|
/// size on every relayout so the host mode tracks the window (1:1, no presenter resample).
|
||||||
private var matchFollower: MatchWindowFollower?
|
private var matchFollower: MatchWindowFollower?
|
||||||
|
/// Last decoded frame size fed into the presenter's aspect-fit. A new-mode IDR after a resize
|
||||||
|
/// re-fits the metal sublayer to the REAL content aspect here — `layout()` only re-runs on a
|
||||||
|
/// bounds change and a resize-END has none, so without this the layer keeps its pre-resize aspect
|
||||||
|
/// and the shader stretches the new frame into it (black bars + squish). Main-thread only.
|
||||||
|
private var lastDecodedContentSize: CGSize?
|
||||||
private let cursorCapture = CursorCapture()
|
private let cursorCapture = CursorCapture()
|
||||||
private var inputCapture: InputCapture?
|
private var inputCapture: InputCapture?
|
||||||
private var appObservers: [NSObjectProtocol] = []
|
private var appObservers: [NSObjectProtocol] = []
|
||||||
@@ -638,6 +644,10 @@ public final class StreamLayerView: NSView {
|
|||||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
||||||
// NSView.displayLink so it tracks the display this view is on.
|
// NSView.displayLink so it tracks the display this view is on.
|
||||||
|
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||||
|
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||||
|
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||||
|
let overlayDecodedSize = onDecodedSize
|
||||||
presenter.start(
|
presenter.start(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
baseLayer: displayLayer,
|
baseLayer: displayLayer,
|
||||||
@@ -647,13 +657,20 @@ public final class StreamLayerView: NSView {
|
|||||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
onDecodedSize: onDecodedSize) // resize overlay END signal (new-mode IDR dims)
|
onDecodedSize: { [weak self] w, h in // resize overlay END signal (new-mode IDR dims)
|
||||||
// Match-window (C3): follow the window's pixel size when the setting is on. Latched at
|
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||||
// session start (mirrors the other clients); the first real `layout()` feeds the initial
|
overlayDecodedSize?(w, h)
|
||||||
// size, so the stream converges to the window even if the connect used the explicit mode.
|
})
|
||||||
|
// Match-window (C3): when ON, follow the window's pixel size so a windowed session streams
|
||||||
|
// 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a
|
||||||
|
// non-matching window. The first real `layout()` feeds the initial size, so the stream
|
||||||
|
// converges to the window even though the connect used the explicit/display mode; entering
|
||||||
|
// fullscreen reports the full-display px, restoring a native-res 1:1 present there too.
|
||||||
|
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||||
|
// default keeps the explicit mode.
|
||||||
let follower = MatchWindowFollower(
|
let follower = MatchWindowFollower(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||||
matchFollower = follower
|
matchFollower = follower
|
||||||
layoutPresenter()
|
layoutPresenter()
|
||||||
@@ -679,6 +696,18 @@ public final class StreamLayerView: NSView {
|
|||||||
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new decoded size landed (a new-mode IDR after a resize, or the session's first frame): push
|
||||||
|
/// it to the presenter's aspect-fit and re-layout NOW. A resize-END triggers no `layout()`, so
|
||||||
|
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||||
|
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||||
|
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||||
|
let size = CGSize(width: width, height: height)
|
||||||
|
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||||
|
lastDecodedContentSize = size
|
||||||
|
presenter.setContentSize(size)
|
||||||
|
layoutPresenter()
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||||
public func stop() {
|
public func stop() {
|
||||||
@@ -688,6 +717,7 @@ public final class StreamLayerView: NSView {
|
|||||||
inputCapture = nil
|
inputCapture = nil
|
||||||
presenter.stop()
|
presenter.stop()
|
||||||
matchFollower = nil
|
matchFollower = nil
|
||||||
|
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
|
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
|
||||||
// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send.
|
// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send.
|
||||||
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
||||||
// starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here).
|
// starts, ⌘⎋ toggles and ⌃⌥⇧Q releases (both detected from the HID stream; there is no NSEvent
|
||||||
|
// monitor here). ⌃⌥⇧Q is the cross-client Ctrl+Alt+Shift+Q — it un-captures so the Magic Keyboard
|
||||||
|
// trackpad drives the local iPad UI again.
|
||||||
//
|
//
|
||||||
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
||||||
// the SwiftUI app layer is identical on both platforms.
|
// the SwiftUI app layer is identical on both platforms.
|
||||||
@@ -158,9 +160,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
||||||
private var wasCapturedOnResign = false
|
private var wasCapturedOnResign = false
|
||||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||||
/// setting is on; fed the view's physical-pixel size from `viewDidLayoutSubviews` so an iPad
|
/// setting is on (DEFAULT on, for pixel-exact scene streaming); fed the view's physical-pixel
|
||||||
/// Stage Manager / Split View scene resize renegotiates the host mode. iOS only (iPhone
|
/// size from `viewDidLayoutSubviews` so an iPad Stage Manager / Split View scene resize
|
||||||
/// naturally no-ops fullscreen; tvOS drives display modes via AVDisplayManager instead).
|
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||||
|
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||||
private var matchFollower: MatchWindowFollower?
|
private var matchFollower: MatchWindowFollower?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -183,6 +186,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
|
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
|
||||||
/// overlay clears when a frame at the requested size actually decodes.
|
/// overlay clears when a frame at the requested size actually decodes.
|
||||||
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||||
|
/// Last decoded size fed into the presenter's aspect-fit. A new-mode IDR (an iPad scene resize,
|
||||||
|
/// or a tvOS AVDisplayManager mode switch) re-fits the metal sublayer to the REAL content aspect
|
||||||
|
/// here — `viewDidLayoutSubviews` only re-runs on a bounds change, which a resize-END lacks, so
|
||||||
|
/// without this the layer keeps its pre-resize aspect and stretches the new frame into it. Main.
|
||||||
|
private var lastDecodedContentSize: CGSize?
|
||||||
|
|
||||||
var captureEnabled = true {
|
var captureEnabled = true {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -331,7 +339,19 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||||
}
|
}
|
||||||
streamView.onPointerButton = { [weak self] button, down in
|
streamView.onPointerButton = { [weak self] button, down in
|
||||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
guard let self else { return }
|
||||||
|
// Released → a trackpad/mouse click into the video RE-ENGAGES capture (the iPad
|
||||||
|
// analogue of macOS's `mouseDown → engageCapture(fromClick:)`, and the click-mirror of
|
||||||
|
// the ⌘⎋ / ⌃⌥⇧Q keyboard toggles). Only the button-DOWN engages; that click is the local
|
||||||
|
// engage gesture, so it's suppressed toward the host (`fromClick`) and never forwarded —
|
||||||
|
// its release is swallowed by InputCapture's suppress latch, whichever path delivers it.
|
||||||
|
// (Finger taps are untouched: touch always plays directly, so only the indirect pointer
|
||||||
|
// re-captures.) Captured already → the absolute path forwards the button as before.
|
||||||
|
if !self.captured {
|
||||||
|
if down, self.captureEnabled { self.setCaptured(true, fromClick: true) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||||
}
|
}
|
||||||
streamView.onScroll = { [weak self] dx, dy in
|
streamView.onScroll = { [weak self] dx, dy in
|
||||||
@@ -344,17 +364,27 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.setCaptured(!self.captured)
|
self.setCaptured(!self.captured)
|
||||||
}
|
}
|
||||||
|
// ⌃⌥⇧Q (cross-client parity with macOS/Windows/Linux) releases the captured pointer +
|
||||||
|
// keyboard so the Magic Keyboard trackpad returns to driving the local iPad UI. Detected
|
||||||
|
// from the HID stream in InputCapture (no NSEvent monitor on iOS); unlike the ⌘⎋ toggle it
|
||||||
|
// only ever RELEASES — re-pressing it while already released is a no-op (setCaptured guards).
|
||||||
|
capture.onReleaseCapture = { [weak self] in
|
||||||
|
self?.setCaptured(false)
|
||||||
|
}
|
||||||
capture.onPreempted = { [weak self] in
|
capture.onPreempted = { [weak self] in
|
||||||
self?.setCaptured(false)
|
self?.setCaptured(false)
|
||||||
}
|
}
|
||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
// Match-window (C3): follow the scene's pixel size when the setting is on. Latched at
|
// Match-window (C3): when ON, follow the scene's pixel size so a resizable iPad scene
|
||||||
// session start (mirrors the other clients); `viewDidLayoutSubviews` feeds it — covers
|
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it.
|
||||||
// Stage Manager / Split View resizes and rotation. iPhone fullscreen naturally no-ops.
|
// `viewDidLayoutSubviews` feeds it — covers Stage Manager / Split View resizes and rotation.
|
||||||
|
// iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode).
|
||||||
|
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||||
|
// default keeps the explicit mode.
|
||||||
let follower = MatchWindowFollower(
|
let follower = MatchWindowFollower(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||||
follower.onResizeTarget = onResizeTarget
|
follower.onResizeTarget = onResizeTarget
|
||||||
matchFollower = follower
|
matchFollower = follower
|
||||||
#endif
|
#endif
|
||||||
@@ -362,6 +392,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
||||||
|
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||||
|
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||||
|
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||||
|
let overlayDecodedSize = onDecodedSize
|
||||||
presenter.start(
|
presenter.start(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
baseLayer: streamView.displayLayer,
|
baseLayer: streamView.displayLayer,
|
||||||
@@ -371,7 +405,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
onDecodedSize: onDecodedSize)
|
onDecodedSize: { [weak self] w, h in
|
||||||
|
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||||
|
overlayDecodedSize?(w, h)
|
||||||
|
})
|
||||||
layoutMetalLayer()
|
layoutMetalLayer()
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -407,6 +444,19 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.syncPointerLock()
|
self?.syncPointerLock()
|
||||||
})
|
})
|
||||||
|
// The Stream menu's "Release Mouse" (⌃⌥⇧Q) posts this — the discoverable menu surface for
|
||||||
|
// the RELEASED state. While CAPTURED the combo is recognized from the HID stream in
|
||||||
|
// InputCapture (onReleaseCapture) before the menu sees it, so in practice this fires as a
|
||||||
|
// not-captured no-op (setCaptured guards it); wired for honesty + a non-GC fallback. Only the
|
||||||
|
// foreground-active scene's stream acts — the iPad analogue of macOS's key-window guard, so a
|
||||||
|
// second Stage Manager scene isn't released out from under the user.
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .punktfunkReleaseCapture, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self,
|
||||||
|
self.view.window?.windowScene?.activationState == .foregroundActive else { return }
|
||||||
|
self.setCaptured(false)
|
||||||
|
})
|
||||||
|
|
||||||
if captureEnabled {
|
if captureEnabled {
|
||||||
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||||
@@ -451,6 +501,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
sessionDisplayManager = nil
|
sessionDisplayManager = nil
|
||||||
#endif
|
#endif
|
||||||
presenter.stop()
|
presenter.stop()
|
||||||
|
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,12 +578,28 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new decoded size landed (a scene/mode resize's new IDR, or the first frame): push it to the
|
||||||
|
/// presenter's aspect-fit and re-layout NOW. A resize-END triggers no `viewDidLayoutSubviews`, so
|
||||||
|
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||||
|
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||||
|
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||||
|
let size = CGSize(width: width, height: height)
|
||||||
|
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||||
|
lastDecodedContentSize = size
|
||||||
|
presenter.setContentSize(size)
|
||||||
|
layoutMetalLayer()
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private func setCaptured(_ on: Bool) {
|
/// `fromClick` marks a click-driven engage (the released-state pointer click that re-captures):
|
||||||
|
/// that click's press/release are suppressed toward the host — it's the local engage gesture,
|
||||||
|
/// not a host click — exactly as macOS's `engageCapture(fromClick:)` does. Keyboard-driven
|
||||||
|
/// engages (⌘⎋) pass false so a normal click still reaches the host.
|
||||||
|
private func setCaptured(_ on: Bool, fromClick: Bool = false) {
|
||||||
if on {
|
if on {
|
||||||
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
||||||
guard captureEnabled, !captured, connection != nil else { return }
|
guard captureEnabled, !captured, connection != nil else { return }
|
||||||
inputCapture?.setForwarding(true)
|
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||||
captured = true
|
captured = true
|
||||||
} else {
|
} else {
|
||||||
guard captured else { return }
|
guard captured else { return }
|
||||||
@@ -631,6 +698,7 @@ final class StreamLayerUIView: UIView {
|
|||||||
let mouse = TouchMouse()
|
let mouse = TouchMouse()
|
||||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||||
|
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
|
||||||
return mouse
|
return mouse
|
||||||
}()
|
}()
|
||||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||||
@@ -641,6 +709,22 @@ final class StreamLayerUIView: UIView {
|
|||||||
func resetTouchInput() {
|
func resetTouchInput() {
|
||||||
touchMouse.reset()
|
touchMouse.reset()
|
||||||
fingerRoute = nil
|
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
|
#endif
|
||||||
|
|
||||||
@@ -812,4 +896,46 @@ final class StreamLayerUIView: UIView {
|
|||||||
}
|
}
|
||||||
#endif
|
#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
|
#endif
|
||||||
|
|||||||
@@ -237,10 +237,11 @@ final class AV1Tests: XCTestCase {
|
|||||||
let ready = try XCTUnwrap(frame)
|
let ready = try XCTUnwrap(frame)
|
||||||
XCTAssertEqual(ready.ptsNs, 42_000_000)
|
XCTAssertEqual(ready.ptsNs, 42_000_000)
|
||||||
XCTAssertFalse(ready.isHDR)
|
XCTAssertFalse(ready.isHDR)
|
||||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 320)
|
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 180)
|
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 320)
|
||||||
|
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 180)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
CVPixelBufferGetPixelFormatType(ready.pixelBuffer),
|
CVPixelBufferGetPixelFormatType(buffer),
|
||||||
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12")
|
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12")
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// player-LED-bits → GCControllerPlayerIndex map. All pure functions.
|
// player-LED-bits → GCControllerPlayerIndex map. All pure functions.
|
||||||
|
|
||||||
import GameController
|
import GameController
|
||||||
|
import PunktfunkCore
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import PunktfunkKit
|
@testable import PunktfunkKit
|
||||||
@@ -26,11 +27,16 @@ final class GamepadWireTests: XCTestCase {
|
|||||||
XCTAssertEqual(GamepadWire.x, 0x4000)
|
XCTAssertEqual(GamepadWire.x, 0x4000)
|
||||||
XCTAssertEqual(GamepadWire.y, 0x8000)
|
XCTAssertEqual(GamepadWire.y, 0x8000)
|
||||||
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
|
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
|
||||||
|
XCTAssertEqual(GamepadWire.misc1, 0x0020_0000)
|
||||||
// Every button is enumerated exactly once (releaseAll walks this list).
|
// Every button is enumerated exactly once (releaseAll walks this list).
|
||||||
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
|
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
|
||||||
XCTAssertEqual(combined, 0x0010_F7FF)
|
XCTAssertEqual(combined, 0x0030_F7FF)
|
||||||
XCTAssertEqual(GamepadWire.allButtons.count, 16)
|
XCTAssertEqual(GamepadWire.allButtons.count, 17)
|
||||||
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count)
|
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count)
|
||||||
|
// Paddles are defined but not yet forwarded, so they stay out of allButtons for now.
|
||||||
|
for paddle in [GamepadWire.paddle1, GamepadWire.paddle2, GamepadWire.paddle3, GamepadWire.paddle4] {
|
||||||
|
XCTAssertFalse(GamepadWire.allButtons.contains(paddle))
|
||||||
|
}
|
||||||
// Axis ids.
|
// Axis ids.
|
||||||
XCTAssertEqual(GamepadWire.axisLSX, 0)
|
XCTAssertEqual(GamepadWire.axisLSX, 0)
|
||||||
XCTAssertEqual(GamepadWire.axisLSY, 1)
|
XCTAssertEqual(GamepadWire.axisLSY, 1)
|
||||||
@@ -40,6 +46,79 @@ final class GamepadWireTests: XCTestCase {
|
|||||||
XCTAssertEqual(GamepadWire.axisRT, 5)
|
XCTAssertEqual(GamepadWire.axisRT, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testButtonBitsMatchTheCABIVerbatim() {
|
||||||
|
// Assert EVERY wire constant against the generated C ABI header (punktfunk_core.h, the same
|
||||||
|
// source `punktfunk_core::input::gamepad` emits), so a Swift-side edit that drifts from the
|
||||||
|
// Rust contract fails CI — not just the handful spot-checked above. (Cross-cutting review
|
||||||
|
// finding G15: the button values were re-declared per client with only a 3-of-19 check.)
|
||||||
|
XCTAssertEqual(GamepadWire.dpadUp, UInt32(PUNKTFUNK_BTN_DPAD_UP))
|
||||||
|
XCTAssertEqual(GamepadWire.dpadDown, UInt32(PUNKTFUNK_BTN_DPAD_DOWN))
|
||||||
|
XCTAssertEqual(GamepadWire.dpadLeft, UInt32(PUNKTFUNK_BTN_DPAD_LEFT))
|
||||||
|
XCTAssertEqual(GamepadWire.dpadRight, UInt32(PUNKTFUNK_BTN_DPAD_RIGHT))
|
||||||
|
XCTAssertEqual(GamepadWire.start, UInt32(PUNKTFUNK_BTN_START))
|
||||||
|
XCTAssertEqual(GamepadWire.back, UInt32(PUNKTFUNK_BTN_BACK))
|
||||||
|
XCTAssertEqual(GamepadWire.leftStickClick, UInt32(PUNKTFUNK_BTN_LS_CLICK))
|
||||||
|
XCTAssertEqual(GamepadWire.rightStickClick, UInt32(PUNKTFUNK_BTN_RS_CLICK))
|
||||||
|
XCTAssertEqual(GamepadWire.leftShoulder, UInt32(PUNKTFUNK_BTN_LB))
|
||||||
|
XCTAssertEqual(GamepadWire.rightShoulder, UInt32(PUNKTFUNK_BTN_RB))
|
||||||
|
XCTAssertEqual(GamepadWire.guide, UInt32(PUNKTFUNK_BTN_GUIDE))
|
||||||
|
XCTAssertEqual(GamepadWire.a, UInt32(PUNKTFUNK_BTN_A))
|
||||||
|
XCTAssertEqual(GamepadWire.b, UInt32(PUNKTFUNK_BTN_B))
|
||||||
|
XCTAssertEqual(GamepadWire.x, UInt32(PUNKTFUNK_BTN_X))
|
||||||
|
XCTAssertEqual(GamepadWire.y, UInt32(PUNKTFUNK_BTN_Y))
|
||||||
|
XCTAssertEqual(GamepadWire.touchpadClick, UInt32(PUNKTFUNK_BTN_TOUCHPAD))
|
||||||
|
XCTAssertEqual(GamepadWire.misc1, UInt32(PUNKTFUNK_GAMEPAD_BTN_MISC1))
|
||||||
|
XCTAssertEqual(GamepadWire.paddle1, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE1))
|
||||||
|
XCTAssertEqual(GamepadWire.paddle2, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE2))
|
||||||
|
XCTAssertEqual(GamepadWire.paddle3, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE3))
|
||||||
|
XCTAssertEqual(GamepadWire.paddle4, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE4))
|
||||||
|
// Axis ids and pad count share the same header.
|
||||||
|
XCTAssertEqual(GamepadWire.axisLSX, UInt32(PUNKTFUNK_AXIS_LS_X))
|
||||||
|
XCTAssertEqual(GamepadWire.axisLSY, UInt32(PUNKTFUNK_AXIS_LS_Y))
|
||||||
|
XCTAssertEqual(GamepadWire.axisRSX, UInt32(PUNKTFUNK_AXIS_RS_X))
|
||||||
|
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
|
||||||
|
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
|
||||||
|
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
|
||||||
|
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||||
|
// The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis.
|
||||||
|
let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3)
|
||||||
|
XCTAssertEqual(btn.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue))
|
||||||
|
XCTAssertEqual(btn.code, GamepadWire.a)
|
||||||
|
XCTAssertEqual(btn.x, 1)
|
||||||
|
XCTAssertEqual(btn.flags, 3)
|
||||||
|
let axis = PunktfunkInputEvent.gamepadAxis(GamepadWire.axisRT, value: 200, pad: 5)
|
||||||
|
XCTAssertEqual(axis.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue))
|
||||||
|
XCTAssertEqual(axis.code, GamepadWire.axisRT)
|
||||||
|
XCTAssertEqual(axis.x, 200)
|
||||||
|
XCTAssertEqual(axis.flags, 5)
|
||||||
|
// Single-controller path stays byte-identical: pad 0 ⇒ flags 0, exactly as before.
|
||||||
|
XCTAssertEqual(PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: false, pad: 0).flags, 0)
|
||||||
|
XCTAssertEqual(PunktfunkInputEvent.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0).flags, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testArrivalAndRemoveWireLayout() {
|
||||||
|
// GamepadArrival (kind 14): code = the GamepadType wire byte, flags = pad index.
|
||||||
|
let arrival = PunktfunkInputEvent.gamepadArrival(
|
||||||
|
pref: PunktfunkConnection.GamepadType.dualSense.rawValue, pad: 2)
|
||||||
|
XCTAssertEqual(arrival.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue))
|
||||||
|
XCTAssertEqual(arrival.code, PunktfunkConnection.GamepadType.dualSense.rawValue) // 2
|
||||||
|
XCTAssertEqual(arrival.flags, 2)
|
||||||
|
// The GamepadType raw values ARE the GamepadPref wire bytes the host resolves.
|
||||||
|
XCTAssertEqual(PunktfunkConnection.GamepadType.xbox360.rawValue, 1)
|
||||||
|
XCTAssertEqual(PunktfunkConnection.GamepadType.dualSense.rawValue, 2)
|
||||||
|
XCTAssertEqual(PunktfunkConnection.GamepadType.xboxOne.rawValue, 3)
|
||||||
|
XCTAssertEqual(PunktfunkConnection.GamepadType.dualShock4.rawValue, 4)
|
||||||
|
// GamepadRemove (kind 13): flags = pad index (the core stamps the per-pad seq).
|
||||||
|
let remove = PunktfunkInputEvent.gamepadRemove(pad: 7)
|
||||||
|
XCTAssertEqual(remove.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue))
|
||||||
|
XCTAssertEqual(remove.flags, 7)
|
||||||
|
// 16 addressable pads (punktfunk_core::input::MAX_PADS).
|
||||||
|
XCTAssertEqual(GamepadWire.maxPads, 16)
|
||||||
|
}
|
||||||
|
|
||||||
func testTouchpadConversionCorners() {
|
func testTouchpadConversionCorners() {
|
||||||
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
||||||
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
||||||
|
|||||||
@@ -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()
|
box.lock.lock(); let frame = box.frame; let error = box.error; box.lock.unlock()
|
||||||
XCTAssertNil(error.map { "decode error \($0)" })
|
XCTAssertNil(error.map { "decode error \($0)" })
|
||||||
let ready = try XCTUnwrap(frame, "a 4:4:4 ReadyFrame must be delivered")
|
let ready = try XCTUnwrap(frame, "a 4:4:4 ReadyFrame must be delivered")
|
||||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 256)
|
guard case .video(let buffer, let isHDR) = ready.image else {
|
||||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 256)
|
return XCTFail("a VideoToolbox decode must deliver a .video frame")
|
||||||
let pf = CVPixelBufferGetPixelFormatType(ready.pixelBuffer)
|
}
|
||||||
|
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 256)
|
||||||
|
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 256)
|
||||||
|
let pf = CVPixelBufferGetPixelFormatType(buffer)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
pf == kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange
|
pf == kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange
|
||||||
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange,
|
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange,
|
||||||
"expected a biplanar 4:4:4 8-bit buffer, got \(fourCCString(pf))")
|
"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
|
// 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.
|
// what lets the unchanged shader sample chroma at the luma UV.
|
||||||
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(ready.pixelBuffer, 1), 256)
|
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(buffer, 1), 256)
|
||||||
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(ready.pixelBuffer, 1), 256)
|
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(buffer, 1), 256)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fourCCString(_ t: OSType) -> String {
|
private func fourCCString(_ t: OSType) -> String {
|
||||||
|
|||||||
@@ -99,8 +99,9 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
|||||||
box.lock.unlock()
|
box.lock.unlock()
|
||||||
XCTAssertNil(error.map { "decode error \($0)" })
|
XCTAssertNil(error.map { "decode error \($0)" })
|
||||||
let ready = try XCTUnwrap(frame, "the async output callback must deliver a ReadyFrame")
|
let ready = try XCTUnwrap(frame, "the async output callback must deliver a ReadyFrame")
|
||||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width)
|
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height)
|
XCTAssertEqual(CVPixelBufferGetWidth(buffer), width)
|
||||||
|
XCTAssertEqual(CVPixelBufferGetHeight(buffer), height)
|
||||||
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
|
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
ready.receivedNs, 41_000_000, "receivedNs round-trips through the frame refcon")
|
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
|
# 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
|
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||||
# every host (and every pinned game):
|
# 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_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_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||||
@@ -36,24 +36,31 @@ set -u
|
|||||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
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
|
# 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).
|
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
# --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).
|
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||||
if [ -n "${PF_BROWSE:-}" ]; then
|
if [ -n "${PF_BROWSE:-}" ]; then
|
||||||
# The gamepad library launcher: browse the host's games on-screen, A streams one,
|
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
||||||
# session end returns to the launcher, B quits back to Gaming Mode.
|
# 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
|
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
|
||||||
if [ -n "${PF_MGMT:-}" ]; then
|
if [ -n "${PF_MGMT:-}" ]; then
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||||
fi
|
fi
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
||||||
fi
|
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
|
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
# 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
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||