Compare commits
@@ -210,6 +210,13 @@ jobs:
|
|||||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
|
||||||
|
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
|
||||||
|
# per-partition-set), which is the standing packaging verdict behind the on-device
|
||||||
|
# distrobox build (scripts/steamdeck/, see scripts/steamdeck/README.md). That flow builds
|
||||||
|
# its own HDR gamescope too. packaging/arch/build-sysext.sh remains a by-hand tool for the
|
||||||
|
# Deck CLIENT image and for operators who accept the prebuilt-host trade-off.
|
||||||
|
|
||||||
- name: Publish to the Gitea Arch registry
|
- name: Publish to the Gitea Arch registry
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|||||||
@@ -147,15 +147,16 @@ jobs:
|
|||||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||||
run: |
|
run: |
|
||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
# THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
|
# FOUR binaries ship in the client .deb, so all four are built here: the GTK shell,
|
||||||
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
|
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect),
|
||||||
# punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
|
# punktfunk-cli (the headless `punktfunk` front-end), and pf-update (the root helper
|
||||||
# three; leaving punktfunk-cli out here made it fall over on `install: No such file or
|
# behind `punktfunk-client --apply-update`). build-client-deb.sh installs all four;
|
||||||
|
# leaving punktfunk-cli out here made it fall over on `install: No such file or
|
||||||
# directory`, because its build-if-missing guard only tested the first two and so decided
|
# directory`, because its build-if-missing guard only tested the first two and so decided
|
||||||
# everything was already built. The HOST is built separately in the build-publish-host
|
# everything was already built. The HOST is built separately in the build-publish-host
|
||||||
# job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
# job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||||
cargo build --release --locked \
|
cargo build --release --locked \
|
||||||
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
|
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (bun preset)
|
- name: Build + smoke-boot web console (bun preset)
|
||||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Contributing to punktfunk
|
# Contributing to Punktfunk
|
||||||
|
|
||||||
Thanks for your interest in contributing!
|
Thanks for your interest in contributing!
|
||||||
|
|
||||||
## Licensing of contributions (inbound = outbound)
|
## Licensing of contributions (inbound = outbound)
|
||||||
|
|
||||||
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
Punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||||
|
|
||||||
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||||
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
|
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
|
||||||
@@ -28,6 +28,28 @@ If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0
|
|||||||
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
|
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
|
||||||
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
The Rust toolchain is **pinned exactly** in `rust-toolchain.toml`; rustup installs it for you the
|
||||||
|
first time you build, so don't override it — a different rustc reformats files nobody touched.
|
||||||
|
|
||||||
|
The workspace links real system libraries, so a bare `cargo build --workspace` fails on a stock
|
||||||
|
machine. The authoritative list is what CI installs, in `ci/rust-ci.Dockerfile` — on **Ubuntu 26.04**,
|
||||||
|
which is what gets you FFmpeg 8:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install build-essential clang libclang-dev pkg-config cmake \
|
||||||
|
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
|
||||||
|
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
|
||||||
|
libgl-dev libegl-dev libgbm-dev \
|
||||||
|
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||||
|
libvulkan-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
|
||||||
|
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
|
||||||
|
PipeWire — and is not a substitute for the list above.)
|
||||||
|
|
||||||
## Before you push
|
## Before you push
|
||||||
|
|
||||||
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
||||||
@@ -38,18 +60,40 @@ on formatting alone:
|
|||||||
git config core.hooksPath scripts/git-hooks
|
git config core.hooksPath scripts/git-hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
Then the usual full pass:
|
Then the usual full pass. Use `--locked` as CI does — otherwise a silent `Cargo.lock` update can pass
|
||||||
|
locally and fail CI:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
cargo test --workspace
|
cargo test --workspace --locked
|
||||||
```
|
```
|
||||||
|
|
||||||
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
|
Two more gates that only apply to some changes:
|
||||||
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
|
||||||
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
|
||||||
|
|
||||||
See the [README's Build & test section](README.md#build--test-from-source) and
|
- **Touched `web/` or `docs-site/`?** CI builds and typechecks both. Run, in that directory:
|
||||||
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
|
```sh
|
||||||
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
bun install && bun run build && bun run lint
|
||||||
|
```
|
||||||
|
Build first — it generates the API client / MDX typegen that the typecheck imports.
|
||||||
|
- **Touched Windows- or Linux-gated code from another OS?** `scripts/xcheck.sh windows` (or
|
||||||
|
`linux`) type-checks and lints that platform's `#[cfg(target_os = …)]` code in about a second,
|
||||||
|
instead of waiting for the CI job that compiles it.
|
||||||
|
|
||||||
|
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
|
||||||
|
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
|
||||||
|
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
|
||||||
|
and copy the snapshot the docs site serves:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p punktfunk-host -- openapi > api/openapi.json
|
||||||
|
cp api/openapi.json docs-site/public/openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Match the surrounding code's comment density and naming. Commit messages end with the
|
||||||
|
`Co-Authored-By` trailer (see `git log`).
|
||||||
|
|
||||||
|
See the [README's Build & test section](README.md#build--test-from-source) for the extra dev
|
||||||
|
commands (the FEC loss harness, the standalone C-ABI proof) and
|
||||||
|
[Design invariants](README.md#design-invariants) for the rules a change is expected to hold to, and
|
||||||
|
the [docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
||||||
|
|||||||
@@ -2880,6 +2880,7 @@ dependencies = [
|
|||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"opus",
|
"opus",
|
||||||
"pf-ffvk",
|
"pf-ffvk",
|
||||||
|
"pf-update-check",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"pyrowave-sys",
|
"pyrowave-sys",
|
||||||
@@ -3047,6 +3048,26 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-update"
|
||||||
|
version = "0.22.3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-update-check"
|
||||||
|
version = "0.22.3"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"base64",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"ureq",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.22.3"
|
version = "0.22.3"
|
||||||
@@ -3449,6 +3470,7 @@ dependencies = [
|
|||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
"pf-inject",
|
"pf-inject",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
|
"pf-update-check",
|
||||||
"pf-vdisplay",
|
"pf-vdisplay",
|
||||||
"pf-win-display",
|
"pf-win-display",
|
||||||
"pf-zerocopy",
|
"pf-zerocopy",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ members = [
|
|||||||
"crates/pf-ffvk",
|
"crates/pf-ffvk",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"crates/pf-paths",
|
"crates/pf-paths",
|
||||||
|
"crates/pf-update",
|
||||||
|
"crates/pf-update-check",
|
||||||
"crates/pf-host-config",
|
"crates/pf-host-config",
|
||||||
"crates/pf-gpu",
|
"crates/pf-gpu",
|
||||||
"crates/pf-zerocopy",
|
"crates/pf-zerocopy",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
|
<img src="assets/punktfunk-logo.svg" alt="Punktfunk" width="320" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
|
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
|
||||||
@@ -18,7 +18,7 @@ access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
|||||||
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
|
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
|
||||||
[SECURITY.md](SECURITY.md). Please don't open a public issue.
|
[SECURITY.md](SECURITY.md). Please don't open a public issue.
|
||||||
|
|
||||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
Punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||||
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
|
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
|
||||||
@@ -43,8 +43,13 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
|||||||
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
||||||
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
||||||
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on-box,
|
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on-box,
|
||||||
~1.3 ms cross-machine on a LAN. (AMD/Intel encode via VAAPI, and a GPU-less software H.264
|
~1.3 ms cross-machine on a LAN. (On Linux AMD/Intel, Vulkan Video for HEVC and AV1 with VAAPI for
|
||||||
encoder exists as a fallback.)
|
H.264 and as the fallback; a GPU-less software H.264 encoder exists as a last resort.)
|
||||||
|
- **A library that fills itself.** Steam and non-Steam titles show up as a grid on every client, and
|
||||||
|
plugins add their own sources — ROM Manager (your ROM collection, matched to installed emulators),
|
||||||
|
Playnite, VirtualHere. Install them from the console's **Plugins** page or with
|
||||||
|
`punktfunk-host plugins add`. See
|
||||||
|
[Plugins](https://docs.punktfunk.unom.io/docs/plugins).
|
||||||
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
|
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
|
||||||
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
|
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
|
||||||
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
|
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
|
||||||
@@ -58,12 +63,12 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
|||||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||||
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
| **Windows host** (Windows 11 22H2+, x64) | ✅ Beta — shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
|
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
|
||||||
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
|
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
|
||||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
|
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
|
||||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). Hardware decode and HDR10 present validated on glass on NVIDIA and Intel, including HDR pass-through on the Intel D3D11VA path |
|
||||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
|
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, game library, virtual-display presets, plugin store, GPU selection, performance capture graphs, live host logs, host updates |
|
||||||
|
|
||||||
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
|
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
|
||||||
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
|
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
|
||||||
@@ -81,36 +86,76 @@ Both run from **one process**: bare `punktfunk-host serve` is the **secure nativ
|
|||||||
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
||||||
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
||||||
|
|
||||||
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
|
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
|
||||||
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||||
|
|
||||||
## Install the host
|
## Install the host
|
||||||
|
|
||||||
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
||||||
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
|
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; on
|
||||||
Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
SteamOS the host is built on-device by a script instead, and a Windows host ships as a signed
|
||||||
|
installer (all-vendor: NVIDIA, AMD, Intel).
|
||||||
|
|
||||||
| Platform | Install | Guide |
|
| Platform | Install | Guide |
|
||||||
|--------|---------|-------|
|
|--------|---------|-------|
|
||||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
|
||||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
|
||||||
|
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
|
||||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||||
|
|
||||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
|
||||||
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
|
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
|
||||||
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
|
||||||
|
installed first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p ~/.config/punktfunk
|
||||||
|
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
|
||||||
|
# (on Bazzite take host.env.bazzite instead)
|
||||||
|
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
|
||||||
|
|
||||||
|
systemctl --user enable --now punktfunk-host # the streaming host
|
||||||
|
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
|
||||||
|
```
|
||||||
|
|
||||||
|
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
|
||||||
|
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
|
||||||
|
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
|
||||||
|
line before the replacement — the install guide has the snippet). Then open
|
||||||
|
`https://<host-ip>:47992` and pair.
|
||||||
|
|
||||||
|
How the virtual display and input are wired up depends on your desktop — see
|
||||||
|
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||||
|
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
|
||||||
|
[Sway](https://docs.punktfunk.unom.io/docs/sway).
|
||||||
|
|
||||||
|
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
|
||||||
|
nothing to run by hand — open the web console and pair. Use
|
||||||
|
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
|
||||||
|
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
|
||||||
|
`setup.exe` over the old install; uninstall from Add/Remove Programs.
|
||||||
|
|
||||||
|
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||||
|
|
||||||
|
The console's **Host** page also shows when a newer host is out, along with the exact command for
|
||||||
|
how *this* box was installed (or a one-click **Update now** on Windows) — see
|
||||||
|
[Updating the host](https://docs.punktfunk.unom.io/docs/updating). To remove it again, or to go back
|
||||||
|
to an earlier version, see [Uninstalling](https://docs.punktfunk.unom.io/docs/uninstall) and
|
||||||
|
[Release Channels](https://docs.punktfunk.unom.io/docs/channels#pin-a-version-or-roll-back).
|
||||||
|
|
||||||
## Connect a client
|
## Connect a client
|
||||||
|
|
||||||
| Streaming to… | Use |
|
| Streaming to… | Use |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
|
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
|
||||||
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
| Linux desktop / laptop | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
||||||
|
| Steam Deck | The **Decky plugin** in Gaming Mode — it launches the client for you ([Steam Deck](https://docs.punktfunk.unom.io/docs/steam-deck)); in Desktop Mode, the Flatpak directly |
|
||||||
| Android phone or TV | The **Android app** (`clients/android`) |
|
| Android phone or TV | The **Android app** (`clients/android`) |
|
||||||
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
||||||
|
| Scripts, automation, another launcher | **`punktfunk`** — the headless CLI shipped in the Linux client packages (`punktfunk pair`, `punktfunk hosts list --json`, `punktfunk launch <host>`) |
|
||||||
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
|
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
|
||||||
|
|
||||||
Each client discovers hosts on the network automatically and does a one-time
|
Each client discovers hosts on the network automatically and does a one-time
|
||||||
@@ -122,7 +167,7 @@ Each client discovers hosts on the network automatically and does a one-time
|
|||||||
For development, or as an install fallback where no package is available:
|
For development, or as an install fallback where no package is available:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
|
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, the `punktfunk` CLI, probe (Linux & macOS)
|
||||||
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
@@ -154,11 +199,14 @@ clients/
|
|||||||
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
|
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
|
||||||
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
||||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
||||||
|
cli/ punktfunk, the headless client CLI — pair · hosts · wake · library · launch · punktfunk:// links
|
||||||
probe/ headless reference / measurement client for punktfunk/1
|
probe/ headless reference / measurement client for punktfunk/1
|
||||||
decky/ Steam Deck Decky plugin
|
decky/ Steam Deck Decky plugin
|
||||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
web/ web console (TanStack) over the management API — status · devices · pairing · library · displays · plugins · GPUs · performance · logs · updates
|
||||||
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
|
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
|
||||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
sdk/ `@punktfunk/host` — TypeScript management-API client + event stream (Effect)
|
||||||
|
plugin-kit/ `@punktfunk/plugin-kit` — the plugin authoring kit (bun / TypeScript)
|
||||||
|
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite sysext + bootc · Windows installer + drivers · winget · Nix · gamescope
|
||||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||||
tools/ latency-probe · loss-harness (measurement)
|
tools/ latency-probe · loss-harness (measurement)
|
||||||
@@ -195,7 +243,7 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
|
|
||||||
### Third-party components
|
### Third-party components
|
||||||
|
|
||||||
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||||
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
||||||
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
||||||
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||||
@@ -203,7 +251,7 @@ notice ship in the installed `licenses/` folder).
|
|||||||
|
|
||||||
### Trademarks
|
### Trademarks
|
||||||
|
|
||||||
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
Punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
||||||
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
|
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
|
||||||
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
|
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
|
||||||
here only to describe interoperability.
|
here only to describe interoperability.
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
Punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||||
machine, so we take security reports seriously and appreciate responsible disclosure.
|
machine, so we take security reports seriously and appreciate responsible disclosure.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
|
||||||
|
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
|
||||||
|
we don't backport to older minor versions, so the supported versions are the latest stable release
|
||||||
|
and the current canary build. If you're on an older build, please check that the issue still
|
||||||
|
reproduces on the latest stable before reporting it. See
|
||||||
|
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
|
||||||
|
|
||||||
## Reporting a vulnerability
|
## Reporting a vulnerability
|
||||||
|
|
||||||
**Please report security issues privately by email to security@punktfunk.com.**
|
**Please report security issues privately by email to security@punktfunk.com.**
|
||||||
@@ -14,7 +23,7 @@ exposes other users before a fix exists.
|
|||||||
|
|
||||||
The more of this you can give us, the faster we can act:
|
The more of this you can give us, the faster we can act:
|
||||||
|
|
||||||
- The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
|
- The component and version (e.g. `punktfunk-host 0.22.3`, Windows or Linux, which client).
|
||||||
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||||
admin, a paired client, …).
|
admin, a paired client, …).
|
||||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||||
@@ -98,4 +107,4 @@ pursue legal action against researchers who:
|
|||||||
- give us reasonable time to remediate before public disclosure,
|
- give us reasonable time to remediate before public disclosure,
|
||||||
- don't exfiltrate more data than needed to demonstrate the issue.
|
- don't exfiltrate more data than needed to demonstrate the issue.
|
||||||
|
|
||||||
Thank you for helping keep punktfunk and its users safe.
|
Thank you for helping keep Punktfunk and its users safe.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ VENDORED THIRD-PARTY SOURCE (inside first-party crates)
|
|||||||
Vulkan-Headers (vendored, crates/pyrowave-sys) — https://github.com/KhronosGroup/Vulkan-Headers
|
Vulkan-Headers (vendored, crates/pyrowave-sys) — https://github.com/KhronosGroup/Vulkan-Headers
|
||||||
Font Awesome Free brand icons (vendored, assets/os-icons) — https://fontawesome.com
|
Font Awesome Free brand icons (vendored, assets/os-icons) — https://fontawesome.com
|
||||||
Simple Icons (vendored, assets/os-icons) — https://simpleicons.org
|
Simple Icons (vendored, assets/os-icons) — https://simpleicons.org
|
||||||
|
Bazzite logo (vendored, assets/os-icons) — https://github.com/ublue-os/bazzite
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
MANIFEST (crate version — SPDX license — source)
|
MANIFEST (crate version — SPDX license — source)
|
||||||
@@ -2930,6 +2931,25 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|||||||
DEALINGS IN THE SOFTWARE.
|
DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
The following license (bazzite.txt) applies to: Bazzite logo (vendored, assets/os-icons)
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
|
||||||
|
the Bazzite source repository (repo_content/Bazzite.svg).
|
||||||
|
|
||||||
|
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0,
|
||||||
|
https://www.apache.org/licenses/LICENSE-2.0.
|
||||||
|
|
||||||
|
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
|
||||||
|
gradient and decorative overlays were dropped, and the path was translated and scaled
|
||||||
|
into a 24x24 box with a monochrome fill (fill="currentColor").
|
||||||
|
|
||||||
|
Brand icons are trademarks of their respective owners and are used for identification
|
||||||
|
purposes only; their use does not imply endorsement.
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
The following license (LICENSE) applies to: bindgen 0.72.1
|
The following license (LICENSE) applies to: bindgen 0.72.1
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -6283,8 +6303,8 @@ the following restrictions:
|
|||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
The following license (font-awesome-brands.txt) applies to: Font Awesome Free brand icons (vendored, assets/os-icons)
|
The following license (font-awesome-brands.txt) applies to: Font Awesome Free brand icons (vendored, assets/os-icons)
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
|
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
|
||||||
opensuse in assets/os-icons/) are from Font Awesome Free.
|
assets/os-icons/) are from Font Awesome Free.
|
||||||
|
|
||||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||||
|
|
||||||
@@ -12997,8 +13017,9 @@ SOFTWARE.
|
|||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
The following license (simple-icons.txt) applies to: Simple Icons (vendored, assets/os-icons)
|
The following license (simple-icons.txt) applies to: Simple Icons (vendored, assets/os-icons)
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
|
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
|
||||||
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
are from Simple Icons
|
||||||
|
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||||
|
|
||||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||||
|
|||||||
@@ -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.22.2"
|
"version": "0.22.3"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -3433,6 +3433,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/update/apply": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"update"
|
||||||
|
],
|
||||||
|
"summary": "Apply the available update",
|
||||||
|
"description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.",
|
||||||
|
"operationId": "applyUpdate",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApplyRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Apply started — poll `GET /update/status`",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/update/check": {
|
"/api/v1/update/check": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3848,6 +3900,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ApplyRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"force": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ApprovePending": {
|
"ApprovePending": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
||||||
@@ -4913,6 +4974,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.",
|
||||||
|
"required": [
|
||||||
|
"from",
|
||||||
|
"to",
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"update.applied"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7155,6 +7239,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"UpdateJobInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
|
||||||
|
"required": [
|
||||||
|
"target_version",
|
||||||
|
"stage",
|
||||||
|
"received_bytes",
|
||||||
|
"started_unix"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"received_bytes": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"stage": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`downloading` | `verifying` | `applying` | `restarting`."
|
||||||
|
},
|
||||||
|
"started_unix": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"target_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The version being installed."
|
||||||
|
},
|
||||||
|
"total_bytes": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UpdateManifestInfo": {
|
"UpdateManifestInfo": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "One channel's manifest facts, as much as the console renders.",
|
"description": "One channel's manifest facts, as much as the console renders.",
|
||||||
@@ -7190,6 +7312,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"UpdateResultInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"from",
|
||||||
|
"to",
|
||||||
|
"finished_unix"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"error": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"finished_unix": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"log_path": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The installer's own log file on this host, for diagnosis."
|
||||||
|
},
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"stage": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The stage that failed; absent on success."
|
||||||
|
},
|
||||||
|
"staged": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Applied but activates on the next reboot (rpm-ostree)."
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UpdateStatus": {
|
"UpdateStatus": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The full update-check state for this host.",
|
"description": "The full update-check state for this host.",
|
||||||
@@ -7231,6 +7403,17 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
|
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
|
||||||
},
|
},
|
||||||
|
"job": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/UpdateJobInfo",
|
||||||
|
"description": "The apply in flight, if any."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"last_checked_unix": {
|
"last_checked_unix": {
|
||||||
"type": [
|
"type": [
|
||||||
"integer",
|
"integer",
|
||||||
@@ -7247,6 +7430,17 @@
|
|||||||
],
|
],
|
||||||
"description": "Why the last check failed, verbatim, if it did."
|
"description": "Why the last check failed, verbatim, if it did."
|
||||||
},
|
},
|
||||||
|
"last_result": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/UpdateResultInfo",
|
||||||
|
"description": "Outcome of the most recent apply attempt."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"manifest": {
|
"manifest": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -7257,6 +7451,13 @@
|
|||||||
"description": "The last verified manifest, if any check has succeeded."
|
"description": "The last verified manifest, if any check has succeeded."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"opt_in_hint": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
|
||||||
|
the Bazzite source repository (repo_content/Bazzite.svg).
|
||||||
|
|
||||||
|
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0,
|
||||||
|
https://www.apache.org/licenses/LICENSE-2.0.
|
||||||
|
|
||||||
|
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
|
||||||
|
gradient and decorative overlays were dropped, and the path was translated and scaled
|
||||||
|
into a 24x24 box with a monochrome fill (fill="currentColor").
|
||||||
|
|
||||||
|
Brand icons are trademarks of their respective owners and are used for identification
|
||||||
|
purposes only; their use does not imply endorsement.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
|
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
|
||||||
opensuse in assets/os-icons/) are from Font Awesome Free.
|
assets/os-icons/) are from Font Awesome Free.
|
||||||
|
|
||||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
|
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
|
||||||
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
are from Simple Icons
|
||||||
|
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||||
|
|
||||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
|
|||||||
|
|
||||||
| token | mark | source |
|
| token | mark | source |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `windows` | Windows | Font Awesome Free brands (CC BY 4.0) |
|
| `windows` | Windows (the current four-pane mark, no perspective skew) | own geometry |
|
||||||
| `apple` | Apple (also `macos` via alias) | Font Awesome Free brands (CC BY 4.0) |
|
| `apple` | Apple (also `macos` via alias) | Font Awesome Free brands (CC BY 4.0) |
|
||||||
| `linux` | Tux | Font Awesome Free brands (CC BY 4.0) |
|
| `linux` | Tux | Font Awesome Free brands (CC BY 4.0) |
|
||||||
| `steam` | Steam (also `steamos` via alias) | Font Awesome Free brands (CC BY 4.0) |
|
| `steam` | Steam (also `steamos` via alias) | Font Awesome Free brands (CC BY 4.0) |
|
||||||
@@ -17,14 +17,36 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
|
|||||||
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
|
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
|
||||||
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
|
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
|
||||||
| `debian` | Debian | Simple Icons (CC0 1.0) |
|
| `debian` | Debian | Simple Icons (CC0 1.0) |
|
||||||
|
| `bazzite` | Bazzite | ublue-os/bazzite (Apache-2.0) |
|
||||||
|
| `cachyos` | CachyOS | Simple Icons (CC0 1.0) |
|
||||||
|
| `nobara` | Nobara | Simple Icons (CC0 1.0, slug `nobaralinux`) |
|
||||||
|
|
||||||
Distros with no file here (Bazzite, CachyOS, Nobara, Pop!_OS, Mint, …) are intentional:
|
The last three are **distro leaves, not families**: a chain walks most-specific-first, so
|
||||||
the host advertises the full chain (`linux/fedora/bazzite`), and clients walk it
|
`linux/fedora/bazzite` would otherwise draw the Fedora mark. They earn their own art because
|
||||||
most-specific-first, so they degrade to the family mark and finally to Tux.
|
"a Bazzite box" and "a Fedora box" are different machines to the person reading the card, and
|
||||||
|
they are what this project's hosts actually run. Every other distro with no file here (Pop!_OS,
|
||||||
|
Mint, …) still degrades to its family's mark and finally to Tux — that fallback is the design,
|
||||||
|
not a gap.
|
||||||
|
|
||||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved.
|
Windows is the one mark drawn here rather than sourced: every icon set that ships a "Windows"
|
||||||
Licensing: attribution notices live in `LICENSES/` and are folded into
|
brand glyph still carries the **Windows 8/10 flag with the perspective skew**, which reads as
|
||||||
`THIRD-PARTY-NOTICES.txt` by `scripts/gen-third-party-notices.py`. The marks are
|
dated next to the flat four-pane mark Microsoft has used since Windows 11. Four equal squares
|
||||||
trademarks of their respective owners; they are used here nominatively — to *identify*
|
(11.377 + 1.246 gap) is the current proportion.
|
||||||
the operating system a host runs, the standard practice in this ecosystem — and imply no
|
|
||||||
affiliation or endorsement.
|
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Because
|
||||||
|
those viewBoxes are not all square, a client must letterbox rather than stretch — see the aspect
|
||||||
|
note in `clients/android/.../components/OsIcons.kt`.
|
||||||
|
|
||||||
|
## Regenerating the per-client derivatives
|
||||||
|
|
||||||
|
`bash scripts/gen-os-icons.sh [token ...]` turns a master into the three baked forms (GTK
|
||||||
|
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
|
||||||
|
that inline it (web console, Decky plugin, Android). Adding a **new** token also means adding it
|
||||||
|
to each client's shipped-token list — the script prints that checklist too.
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
|
||||||
|
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
|
||||||
|
are used here nominatively — to *identify* the operating system a host runs, the standard
|
||||||
|
practice in this ecosystem — and imply no affiliation or endorsement.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- bazzite — the Bazzite "b", from ublue-os/bazzite repo_content/Bazzite.svg (Apache-2.0), lifted out of the badge and normalized to a 24x24 box. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"/></svg>
|
||||||
|
After Width: | Height: | Size: 523 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- cachyos — from Simple Icons (CC0 1.0). See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"/></svg>
|
||||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- nobara — from Simple Icons (CC0 1.0), slug "nobaralinux". See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"/></svg>
|
||||||
|
After Width: | Height: | Size: 681 B |
@@ -1,2 +1,2 @@
|
|||||||
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
|
<!-- windows — the modern (Windows 11) four-pane mark: four equal squares, no perspective skew. Own geometry, see README.md. -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"/></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 323 B |
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
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.DsDevice
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.Sc2Capture
|
import io.unom.punktfunk.kit.Sc2Capture
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -149,13 +150,14 @@ 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-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||||
// capture claims even those away), so it's enumerated on the capture side — USB device
|
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||||
// list + bonded BLE — and re-checked on USB hot-plug.
|
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
|
||||||
var sc2Generation by remember { mutableIntStateOf(0) }
|
// row supplements the PadRow below with the capture status + the USB grant.
|
||||||
|
var usbGeneration by remember { mutableIntStateOf(0) }
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val receiver = object : android.content.BroadcastReceiver() {
|
val receiver = object : android.content.BroadcastReceiver() {
|
||||||
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
|
||||||
}
|
}
|
||||||
val filter = android.content.IntentFilter().apply {
|
val filter = android.content.IntentFilter().apply {
|
||||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||||
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
|||||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||||
}
|
}
|
||||||
val sc2Probe = remember { Sc2Capture(context) }
|
val sc2Probe = remember { Sc2Capture(context) }
|
||||||
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
|
||||||
val sc2Ble = remember(sc2Generation) {
|
val sc2Ble = remember(usbGeneration) {
|
||||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
) sc2Probe.pairedBleAddress() else null
|
) sc2Probe.pairedBleAddress() else null
|
||||||
}
|
}
|
||||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||||
|
val dsUsb = remember(usbGeneration) {
|
||||||
|
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
|
||||||
|
.deviceList.values.firstOrNull {
|
||||||
|
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Group("Gamepads") {
|
Group("Gamepads") {
|
||||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||||
|
dsUsb?.let { DsRow(it) }
|
||||||
if (pads.isEmpty() && !sc2Present) {
|
if (pads.isEmpty() && !sc2Present) {
|
||||||
Text(
|
Text(
|
||||||
"No controller detected. punktfunk can only forward devices Android " +
|
"No controller detected. punktfunk can only forward devices Android " +
|
||||||
@@ -319,6 +328,104 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast action for the Sony-pad USB grants — fired by both the menu-time auto-ask
|
||||||
|
* ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card
|
||||||
|
* refreshes whichever dialog was answered.
|
||||||
|
*/
|
||||||
|
internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Sony USB pad card — capture status + the USB grant. The grant normally arrives via the
|
||||||
|
* menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the
|
||||||
|
* button here is the recovery path after a deny (the auto-ask fires once per attach). Shown
|
||||||
|
* ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture
|
||||||
|
* itself only runs inside a stream, so at menu time this card is pure status.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val settingOn = remember { SettingsStore(context).load().dsCapture }
|
||||||
|
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
||||||
|
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
|
||||||
|
val model = DsDevice.modelFor(usbDev.productId)
|
||||||
|
val label = when (model) {
|
||||||
|
DsDevice.Model.DUALSENSE -> "DualSense"
|
||||||
|
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
|
||||||
|
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
|
||||||
|
null -> return
|
||||||
|
}
|
||||||
|
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
|
||||||
|
// this receiver only updates the card).
|
||||||
|
val action = DS_USB_PERMISSION_ACTION
|
||||||
|
DisposableEffect(usbDev) {
|
||||||
|
val receiver = object : android.content.BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, i: android.content.Intent?) {
|
||||||
|
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.core.content.ContextCompat.registerReceiver(
|
||||||
|
context,
|
||||||
|
receiver,
|
||||||
|
android.content.IntentFilter(action),
|
||||||
|
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||||
|
}
|
||||||
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
"Wired (USB)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
!settingOn -> Text(
|
||||||
|
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
|
||||||
|
"passthrough (USB)\" to capture it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
!permitted -> {
|
||||||
|
Text(
|
||||||
|
"Needs USB access — grant it now and streams capture the pad silently.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
usbManager.requestPermission(
|
||||||
|
usbDev,
|
||||||
|
android.app.PendingIntent.getBroadcast(
|
||||||
|
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
|
||||||
|
android.content.Intent(action).setPackage(context.packageName),
|
||||||
|
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||||
|
android.app.PendingIntent.FLAG_MUTABLE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Text("Grant USB access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Text(
|
||||||
|
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||||
|
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||||
|
"driven directly."
|
||||||
|
} else {
|
||||||
|
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||||
|
"and gyro are driven directly."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
/** 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) {
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ suspend fun connectToHost(
|
|||||||
host, port, w, h, hz,
|
host, port, w, h, hz,
|
||||||
identity.certPem, identity.privateKeyPem, pinHex,
|
identity.certPem, identity.privateKeyPem, pinHex,
|
||||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
hdrEnabled, settings.audioChannels,
|
hdrEnabled, VideoDecoders.multiSliceTolerant(),
|
||||||
|
// Slice-progressive delivery: decoder truth AND the async decode loop — the legacy
|
||||||
|
// sync loop feeds whole AUs only, so parts must never arrive when it is selected.
|
||||||
|
settings.lowLatencyMode && VideoDecoders.partialFrameCapable(),
|
||||||
|
settings.audioChannels,
|
||||||
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
|
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
|
||||||
// the user's soft codec preference — the host resolves the emitted codec from both.
|
// the user's soft codec preference — the host resolves the emitted codec from both.
|
||||||
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
|
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
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.DsDevice
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.GamepadRouter
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
@@ -169,6 +170,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var sc2Receiver: BroadcastReceiver? = null
|
private var sc2Receiver: BroadcastReceiver? = null
|
||||||
private var sc2PermissionAsked = false
|
private var sc2PermissionAsked = false
|
||||||
|
|
||||||
|
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
|
||||||
|
* Controllers screen's explicit button). */
|
||||||
|
private var dsPermissionAsked = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
|
* 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
|
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
|
||||||
@@ -225,6 +230,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||||
sc2PermissionAsked = false // a fresh attach may ask once again
|
sc2PermissionAsked = false // a fresh attach may ask once again
|
||||||
startSc2MenuNav()
|
startSc2MenuNav()
|
||||||
|
dsPermissionAsked = false
|
||||||
|
maybeAskDsPermission()
|
||||||
}
|
}
|
||||||
SC2_MENU_PERMISSION -> {
|
SC2_MENU_PERMISSION -> {
|
||||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||||
@@ -281,6 +288,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
startSc2MenuNav()
|
startSc2MenuNav()
|
||||||
|
maybeAskDsPermission()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@@ -341,6 +349,37 @@ class MainActivity : ComponentActivity() {
|
|||||||
sc2MenuActive = false
|
sc2MenuActive = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for USB access to an attached Sony pad the moment it appears — a fresh attach while
|
||||||
|
* the app is open, or the app coming to the foreground with one already plugged in — at most
|
||||||
|
* once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages
|
||||||
|
* silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow
|
||||||
|
* there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice
|
||||||
|
* at menu time, so the grant is simply recorded (Android keeps it while the pad stays
|
||||||
|
* attached). The broadcast only refreshes the Controllers screen's card if it happens to be
|
||||||
|
* open; a deny leaves that card's explicit button as the re-ask.
|
||||||
|
*/
|
||||||
|
private fun maybeAskDsPermission() {
|
||||||
|
if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming
|
||||||
|
if (dsPermissionAsked) return
|
||||||
|
if (!SettingsStore(this).load().dsCapture) return
|
||||||
|
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
val dev = usbManager.deviceList.values.firstOrNull {
|
||||||
|
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||||
|
} ?: return
|
||||||
|
if (usbManager.hasPermission(dev)) return
|
||||||
|
dsPermissionAsked = true
|
||||||
|
usbManager.requestPermission(
|
||||||
|
dev,
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants
|
||||||
|
Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName),
|
||||||
|
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||||
|
PendingIntent.FLAG_MUTABLE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
|
* 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,
|
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
|
||||||
@@ -405,8 +444,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
/**
|
/**
|
||||||
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||||
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
|
||||||
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
|
||||||
*/
|
*/
|
||||||
fun setConsoleHighRefreshRate(high: Boolean) {
|
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||||
if (highRefreshModeId == 0) return
|
if (highRefreshModeId == 0) return
|
||||||
@@ -415,6 +454,64 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration —
|
||||||
|
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
|
||||||
|
* pulldown), else the highest available. Same-resolution modes only.
|
||||||
|
*
|
||||||
|
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
|
||||||
|
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
|
||||||
|
* logic among them) ignore it entirely for third-party apps — leaving a 120 Hz session
|
||||||
|
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
|
||||||
|
* preferredDisplayModeId is the one signal they all honor. [hz] ≤ 0 falls back to releasing
|
||||||
|
* the pin (the pre-pin behaviour).
|
||||||
|
*/
|
||||||
|
fun setStreamDisplayMode(hz: Int) {
|
||||||
|
if (hz <= 0) {
|
||||||
|
setConsoleHighRefreshRate(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val target = streamModeFor(hz) ?: return
|
||||||
|
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel refresh rate a [hz] stream runs against — [streamModeFor]'s pick, from the mode
|
||||||
|
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
|
||||||
|
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
|
||||||
|
* override, not the panel — observed on-glass as a 120 Hz panel reading back as 60. The
|
||||||
|
* supported-modes list is not override-filtered. `0` when unresolvable.
|
||||||
|
*/
|
||||||
|
fun streamPanelFps(hz: Int): Int =
|
||||||
|
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
|
||||||
|
|
||||||
|
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
|
||||||
|
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
|
||||||
|
if (hz <= 0) return null
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||||
|
val current = disp?.mode ?: return null
|
||||||
|
val sameRes = disp.supportedModes.filter {
|
||||||
|
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
|
||||||
|
}
|
||||||
|
fun multiple(rate: Float): Int {
|
||||||
|
val k = (rate / hz).toInt()
|
||||||
|
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
|
||||||
|
}
|
||||||
|
return sameRes.minWithOrNull(
|
||||||
|
compareBy(
|
||||||
|
{
|
||||||
|
when {
|
||||||
|
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
|
||||||
|
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
|
||||||
|
else -> 2 // no relation — prefer highest so at least nothing is halved
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
val handle = streamHandle
|
val handle = streamHandle
|
||||||
if (handle != 0L) {
|
if (handle != 0L) {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ data class SettingsOverlay(
|
|||||||
* else, but here it is the one knob a marginal link wants turned off per host.
|
* else, but here it is the one knob a marginal link wants turned off per host.
|
||||||
*/
|
*/
|
||||||
val lowLatencyMode: Boolean? = null,
|
val lowLatencyMode: Boolean? = null,
|
||||||
|
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
|
||||||
|
val presentPriority: String? = null,
|
||||||
|
val smoothBuffer: Int? = null,
|
||||||
/**
|
/**
|
||||||
* Overlay keys a newer build wrote and this one doesn't model — carried through a load→save
|
* Overlay keys a newer build wrote and this one doesn't model — carried through a load→save
|
||||||
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
|
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
|
||||||
@@ -73,6 +76,8 @@ data class SettingsOverlay(
|
|||||||
gamepad = gamepad ?: base.gamepad,
|
gamepad = gamepad ?: base.gamepad,
|
||||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||||
|
presentPriority = presentPriority ?: base.presentPriority,
|
||||||
|
smoothBuffer = smoothBuffer ?: base.smoothBuffer,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +109,8 @@ data class SettingsOverlay(
|
|||||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||||
|
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||||
|
smoothBuffer = if (after.smoothBuffer != before.smoothBuffer) after.smoothBuffer else smoothBuffer,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,6 +134,8 @@ data class SettingsOverlay(
|
|||||||
"gamepad" -> copy(gamepad = null)
|
"gamepad" -> copy(gamepad = null)
|
||||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||||
|
"present_priority" -> copy(presentPriority = null)
|
||||||
|
"smooth_buffer" -> copy(smoothBuffer = null)
|
||||||
else -> this
|
else -> this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +156,8 @@ data class SettingsOverlay(
|
|||||||
if (gamepad != null) add("gamepad")
|
if (gamepad != null) add("gamepad")
|
||||||
if (statsVerbosity != null) add("stats_verbosity")
|
if (statsVerbosity != null) add("stats_verbosity")
|
||||||
if (lowLatencyMode != null) add("low_latency_mode")
|
if (lowLatencyMode != null) add("low_latency_mode")
|
||||||
|
if (presentPriority != null) add("present_priority")
|
||||||
|
if (smoothBuffer != null) add("smooth_buffer")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,6 +186,8 @@ data class SettingsOverlay(
|
|||||||
gamepad?.let { j.put("gamepad", it) }
|
gamepad?.let { j.put("gamepad", it) }
|
||||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||||
|
presentPriority?.let { j.put("present_priority", it) }
|
||||||
|
smoothBuffer?.let { j.put("smooth_buffer", it) }
|
||||||
return j
|
return j
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +200,7 @@ data class SettingsOverlay(
|
|||||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
|
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
|
||||||
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
|
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
|
||||||
|
"present_priority", "smooth_buffer",
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
|
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
|
||||||
@@ -209,6 +223,8 @@ data class SettingsOverlay(
|
|||||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||||
|
presentPriority = j.optStringOrNull("present_priority"),
|
||||||
|
smoothBuffer = j.optIntOrNull("smooth_buffer"),
|
||||||
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
|
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,19 @@ data class Settings(
|
|||||||
* feeds a queue that only grows.
|
* feeds a queue that only grows.
|
||||||
*/
|
*/
|
||||||
val lowLatencyMode: Boolean = true,
|
val lowLatencyMode: Boolean = true,
|
||||||
|
/**
|
||||||
|
* The timeline presenter's intent — the cross-client `present_priority` pair (the Apple
|
||||||
|
* client's "Prioritize" picker, same stored values): `"latency"` (default) = newest-wins,
|
||||||
|
* a frame reaches glass the instant the glass budget opens; `"smooth"` = a small FIFO
|
||||||
|
* drained one frame per vsync, absorbing network/decode jitter at one refresh of added
|
||||||
|
* display latency per buffered frame. Anything unrecognized resolves to latency.
|
||||||
|
*/
|
||||||
|
val presentPriority: String = "latency",
|
||||||
|
/**
|
||||||
|
* The smoothness buffer depth (`smooth_buffer`): 0 = Automatic (2 frames), else 1..3.
|
||||||
|
* Only meaningful when [presentPriority] is `"smooth"`.
|
||||||
|
*/
|
||||||
|
val smoothBuffer: Int = 0,
|
||||||
/**
|
/**
|
||||||
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
||||||
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
||||||
@@ -110,6 +123,18 @@ data class Settings(
|
|||||||
*/
|
*/
|
||||||
val sc2Capture: Boolean = true,
|
val sc2Capture: Boolean = true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
|
||||||
|
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
|
||||||
|
* by writing USB output reports — rumble works on every phone (no kernel force-feedback
|
||||||
|
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
|
||||||
|
* platform API for any of them). ON by default — it engages only when such a pad is attached
|
||||||
|
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
|
||||||
|
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
|
||||||
|
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
|
||||||
|
*/
|
||||||
|
val dsCapture: Boolean = true,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||||
@@ -201,9 +226,12 @@ class SettingsStore(context: Context) {
|
|||||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||||
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),
|
||||||
|
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
|
||||||
|
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||||
|
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||||
@@ -231,9 +259,12 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
|
.putString(K_PRESENT_PRIORITY, s.presentPriority)
|
||||||
|
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||||
|
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||||
.apply()
|
.apply()
|
||||||
@@ -271,9 +302,12 @@ class SettingsStore(context: Context) {
|
|||||||
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
|
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
|
||||||
*/
|
*/
|
||||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||||
|
const val K_PRESENT_PRIORITY = "present_priority"
|
||||||
|
const val K_SMOOTH_BUFFER = "smooth_buffer"
|
||||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||||
const val K_SC2_CAPTURE = "sc2_capture"
|
const val K_SC2_CAPTURE = "sc2_capture"
|
||||||
|
const val K_DS_CAPTURE = "ds_capture"
|
||||||
const val K_MOUSE_MODE = "mouse_mode"
|
const val K_MOUSE_MODE = "mouse_mode"
|
||||||
|
|
||||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||||
@@ -491,6 +525,29 @@ val COMPOSITOR_OPTIONS = listOf(
|
|||||||
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
|
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
|
||||||
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
|
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
|
||||||
|
|
||||||
|
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
|
||||||
|
* Unrecognized values resolve to latency — same rule as the Apple client. */
|
||||||
|
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
|
||||||
|
|
||||||
|
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
|
||||||
|
val PRESENT_PRIORITY_OPTIONS = listOf(
|
||||||
|
"latency" to "Lowest latency",
|
||||||
|
"smooth" to "Smoothness",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** (frames, label) for the smoothness-buffer picker; each buffered frame ≈ one refresh interval
|
||||||
|
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
|
||||||
|
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
||||||
|
val periodMs = 1000.0 / maxOf(24, hz)
|
||||||
|
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
|
||||||
|
return listOf(
|
||||||
|
0 to "Automatic",
|
||||||
|
1 to "1 frame (${cost(1)})",
|
||||||
|
2 to "2 frames (${cost(2)})",
|
||||||
|
3 to "3 frames (${cost(3)})",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** (mode, label) for the touch-input model. */
|
/** (mode, label) for the touch-input model. */
|
||||||
val TOUCH_MODE_OPTIONS = listOf(
|
val TOUCH_MODE_OPTIONS = listOf(
|
||||||
TouchMode.TRACKPAD to "Trackpad",
|
TouchMode.TRACKPAD to "Trackpad",
|
||||||
|
|||||||
@@ -711,6 +711,26 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
field = "low_latency_mode",
|
field = "low_latency_mode",
|
||||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||||
)
|
)
|
||||||
|
// The timeline presenter's intent — the Apple client's "Prioritize" pair, same stored
|
||||||
|
// values, so a profile written on one platform means the same thing here.
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Prioritize",
|
||||||
|
options = PRESENT_PRIORITY_OPTIONS,
|
||||||
|
selected = if (s.presentPriority == "smooth") "smooth" else "latency",
|
||||||
|
field = "present_priority",
|
||||||
|
caption = "Lowest latency shows each frame the moment it can reach the panel; " +
|
||||||
|
"Smoothness buffers a little to absorb network jitter.",
|
||||||
|
) { v -> update(s.copy(presentPriority = v)) }
|
||||||
|
if (s.presentPriority == "smooth") {
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Smoothness buffer",
|
||||||
|
options = smoothBufferOptions(if (s.hz > 0) s.hz else nhz),
|
||||||
|
selected = if (s.smoothBuffer in 1..3) s.smoothBuffer else 0,
|
||||||
|
field = "smooth_buffer",
|
||||||
|
caption = "Each buffered frame absorbs one refresh of jitter and adds one of " +
|
||||||
|
"display latency — the cost shown is at the session's refresh rate.",
|
||||||
|
) { v -> update(s.copy(smoothBuffer = v)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
|
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
|
||||||
@@ -816,6 +836,15 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
|||||||
checked = s.sc2Capture,
|
checked = s.sc2Capture,
|
||||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||||
)
|
)
|
||||||
|
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||||
|
// the CONTROLLER's own motors/LEDs, not this device's.
|
||||||
|
ToggleRow(
|
||||||
|
title = "DualSense / DualShock passthrough (USB)",
|
||||||
|
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||||
|
"plus adaptive triggers, lightbar and gyro",
|
||||||
|
checked = s.dsCapture,
|
||||||
|
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,20 @@ internal fun StatsOverlay(
|
|||||||
* common case: no profile) the line is exactly what it always was.
|
* common case: no profile) the line is exactly what it always was.
|
||||||
*/
|
*/
|
||||||
profileName: String? = null,
|
profileName: String? = null,
|
||||||
|
/**
|
||||||
|
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
|
||||||
|
* it sits below the stream rate — the "an OEM governor ignored the mode pin" tell, which
|
||||||
|
* otherwise reads as inexplicable judder and an extra refresh of latency.
|
||||||
|
*/
|
||||||
|
panelHz: Float = 0f,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||||
val w = s[6].toInt()
|
val w = s[6].toInt()
|
||||||
val h = s[7].toInt()
|
val h = s[7].toInt()
|
||||||
val hz = s[8].toInt()
|
val hz = s[8].toInt()
|
||||||
|
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
|
||||||
|
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
|
||||||
val latValid = s[4] != 0.0
|
val latValid = s[4] != 0.0
|
||||||
val skew = s[5] != 0.0
|
val skew = s[5] != 0.0
|
||||||
val lost = s[9].toLong()
|
val lost = s[9].toLong()
|
||||||
@@ -65,12 +73,12 @@ internal fun StatsOverlay(
|
|||||||
val profileTag = profileName?.let { " · $it" }.orEmpty()
|
val profileTag = profileName?.let { " · $it" }.orEmpty()
|
||||||
// Compact: everything the glance-value needs on one line, nothing else.
|
// Compact: everything the glance-value needs on one line, nothing else.
|
||||||
if (verbosity == StatsVerbosity.COMPACT) {
|
if (verbosity == StatsVerbosity.COMPACT) {
|
||||||
statLine(compactLine(s, latValid) + profileTag, Color.White)
|
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
|
||||||
return@Column
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
statLine(
|
statLine(
|
||||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
|
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
|
||||||
Color.White,
|
Color.White,
|
||||||
)
|
)
|
||||||
if (detailed && decoderLabel.isNotEmpty()) {
|
if (detailed && decoderLabel.isNotEmpty()) {
|
||||||
@@ -104,8 +112,27 @@ internal fun StatsOverlay(
|
|||||||
} else {
|
} else {
|
||||||
"host+network ${"%.1f".format(s[14])}"
|
"host+network ${"%.1f".format(s[14])}"
|
||||||
}
|
}
|
||||||
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
|
// Timeline-presenter split (s[26]/s[27], when s[29] flags it active): the display
|
||||||
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
|
// term decomposes into pace (store + glass budget) + latch (SurfaceFlinger), and
|
||||||
|
// s[28] is the on-glass confirm count — presents ≪ fps means the presenter is
|
||||||
|
// dropping/serializing, an fps deficit is upstream.
|
||||||
|
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
|
||||||
|
val displayTerm = when {
|
||||||
|
dispValid && split ->
|
||||||
|
" + display ${"%.1f".format(s[23])} " +
|
||||||
|
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
|
||||||
|
dispValid -> " + display ${"%.1f".format(s[23])}"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
val presents = if (s.size >= 30 && s[29] != 0.0) {
|
||||||
|
" · presents ${s[28].toInt()}"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
statLine(
|
||||||
|
"= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm$presents",
|
||||||
|
Color.White,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import io.unom.punktfunk.kit.DsCapture
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.GamepadRouter
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||||
@@ -62,6 +63,7 @@ import io.unom.punktfunk.kit.Sc2Capture
|
|||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import io.unom.punktfunk.models.ActiveSession
|
import io.unom.punktfunk.models.ActiveSession
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +79,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
val micEnabled = initialSettings.micEnabled
|
val micEnabled = initialSettings.micEnabled
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? MainActivity
|
val activity = context as? MainActivity
|
||||||
|
// The View hosting this composition — the one that receives the stream's touch/pointer events
|
||||||
|
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
|
||||||
|
// requested.
|
||||||
|
val composeView = androidx.compose.ui.platform.LocalView.current
|
||||||
val window = activity?.window
|
val window = activity?.window
|
||||||
|
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
|
||||||
|
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
|
||||||
|
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
|
||||||
val controller = remember(window) {
|
val controller = remember(window) {
|
||||||
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
||||||
}
|
}
|
||||||
@@ -99,6 +108,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||||
var decoderLabel by remember { mutableStateOf("") }
|
var decoderLabel by remember { mutableStateOf("") }
|
||||||
var codecLabel by remember { mutableStateOf("") }
|
var codecLabel by remember { mutableStateOf("") }
|
||||||
|
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
|
||||||
|
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
|
||||||
|
var panelHz by remember { mutableStateOf(0f) }
|
||||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
@@ -120,6 +132,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
stats = NativeBridge.nativeVideoStats(handle)
|
||||||
|
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
|
||||||
// The decoder is fixed for the session; fetch its label once it's resolved.
|
// The decoder is fixed for the session; fetch its label once it's resolved.
|
||||||
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
|
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
|
||||||
}
|
}
|
||||||
@@ -226,13 +239,39 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
val priorSoftInput = window?.attributes?.softInputMode
|
val priorSoftInput = window?.attributes?.softInputMode
|
||||||
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
// Draw under the display cutout, explicitly. Android 15's SDK-35 edge-to-edge enforcement
|
||||||
|
// makes ALWAYS the immersive default, but pre-15 devices letterbox the notch as a dead
|
||||||
|
// black bar unless asked — and the stream's own letterbox is black anyway, so the cutout
|
||||||
|
// region can never show anything wrong. Captured + restored like the rest of the window
|
||||||
|
// state so the menus keep their platform-default behaviour.
|
||||||
|
val priorCutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window?.attributes?.layoutInDisplayCutoutMode
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window?.let { w ->
|
||||||
|
w.attributes = w.attributes.apply {
|
||||||
|
layoutInDisplayCutoutMode =
|
||||||
|
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
// 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
|
||||||
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
|
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
|
||||||
// The prior request is captured and restored on the way out.
|
// The prior request is captured and restored on the way out.
|
||||||
|
//
|
||||||
|
// COMPACT devices only (sw < 600 dp): on tablets/foldables/desktop windows the lock is a
|
||||||
|
// large-display anti-pattern (Play flags it; Android 16+ ignores it there outright), and the
|
||||||
|
// stream doesn't need it — the aspect-ratio letterbox renders correctly in any orientation,
|
||||||
|
// the lock is purely a phone-ergonomics choice.
|
||||||
|
val compactDevice = context.resources.configuration.smallestScreenWidthDp < 600
|
||||||
val priorOrientation = activity?.requestedOrientation
|
val priorOrientation = activity?.requestedOrientation
|
||||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
if (compactDevice) {
|
||||||
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
|
}
|
||||||
activity?.streamHandle = handle // route hardware keys to this session
|
activity?.streamHandle = handle // route hardware keys to this session
|
||||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
// 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
|
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||||
@@ -304,7 +343,30 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
|
||||||
|
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
|
||||||
|
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
|
||||||
|
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
|
||||||
|
if (isTv) {
|
||||||
|
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
|
||||||
|
} else {
|
||||||
|
activity?.setStreamDisplayMode(streamHz)
|
||||||
|
}
|
||||||
|
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
|
||||||
|
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
|
||||||
|
// Undone by passing 0 on the way out (API 30+).
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
|
||||||
|
}
|
||||||
|
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
|
||||||
|
// panel, but the platform separately down-rates a quiet app's choreographer stream
|
||||||
|
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
|
||||||
|
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
|
||||||
|
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
|
||||||
|
// that braces. Reset to no-preference on the way out.
|
||||||
|
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
|
||||||
|
composeView.requestedFrameRate = streamHz.toFloat()
|
||||||
|
}
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||||
// index via the router; poll threads stopped + joined before the router is released and the
|
// 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
|
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||||
@@ -367,13 +429,59 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
|
||||||
|
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
|
||||||
|
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
|
||||||
|
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
|
||||||
|
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
|
||||||
|
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||||
|
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||||
|
// hands over deterministically.
|
||||||
|
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
|
||||||
|
var dsUsbReceiver: BroadcastReceiver? = null
|
||||||
|
if (ds != null) {
|
||||||
|
feedback.sink = ds
|
||||||
|
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
val usbDev = ds.findUsbDevice()
|
||||||
|
when {
|
||||||
|
usbDev != null && usbManager.hasPermission(usbDev) -> ds.startUsb(usbDev)
|
||||||
|
usbDev != null -> {
|
||||||
|
// One-time system dialog; capture engages on grant (Android remembers the
|
||||||
|
// grant for as long as the device stays attached).
|
||||||
|
val action = "io.unom.punktfunk.DS_USB_PERMISSION"
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != action) return
|
||||||
|
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||||
|
if (ok) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dsUsbReceiver = receiver
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
usbManager.requestPermission(
|
||||||
|
usbDev,
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
|
||||||
|
Intent(action).setPackage(context.packageName),
|
||||||
|
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||||
|
PendingIntent.FLAG_MUTABLE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
onDispose {
|
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
|
||||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
||||||
feedback.onHidRaw = null
|
feedback.onHidRaw = null
|
||||||
|
feedback.sink = null
|
||||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||||
|
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
|
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
|
||||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
router.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
|
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||||
activity?.gamepadRouter = null
|
activity?.gamepadRouter = null
|
||||||
@@ -388,8 +496,19 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||||
activity?.startSc2MenuNav()
|
activity?.startSc2MenuNav()
|
||||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= 35) {
|
||||||
|
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
|
||||||
|
}
|
||||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||||
window?.setSoftInputMode(priorSoftInput)
|
window?.setSoftInputMode(priorSoftInput)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
|
||||||
|
window?.let { w ->
|
||||||
|
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||||
|
}
|
||||||
|
}
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
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) {
|
||||||
@@ -480,6 +599,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
lowLatencyMode,
|
lowLatencyMode,
|
||||||
choice?.lowLatencyFeature ?: false,
|
choice?.lowLatencyFeature ?: false,
|
||||||
isTv,
|
isTv,
|
||||||
|
initialSettings.presentPriorityWire(),
|
||||||
|
initialSettings.smoothBuffer,
|
||||||
|
// The panel's own refresh — from the mode TABLE (streamPanelFps),
|
||||||
|
// because display.refreshRate reports a per-uid override, not the
|
||||||
|
// panel. Fallback: the (possibly lying) live rate.
|
||||||
|
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
|
||||||
|
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||||
|
.roundToInt(),
|
||||||
)
|
)
|
||||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||||
@@ -508,6 +635,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
stats?.let {
|
stats?.let {
|
||||||
StatsOverlay(
|
StatsOverlay(
|
||||||
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
|
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
|
||||||
|
panelHz,
|
||||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ private const val PEN_TOUCHING = 2f
|
|||||||
private const val PEN_BARREL1 = 4f
|
private const val PEN_BARREL1 = 4f
|
||||||
private const val PEN_BARREL2 = 8f
|
private const val PEN_BARREL2 = 8f
|
||||||
private const val STRIDE = 10
|
private const val STRIDE = 10
|
||||||
private const val MAX_SAMPLES = 8
|
// Ceiling on samples per emit, NOT the wire batch size: the JNI layer splits an over-8 run into
|
||||||
|
// consecutive wire batches (never truncates — a long historical run means the UI thread hitched,
|
||||||
|
// which is exactly when dropping its head would notch the stroke). 64 samples ≈ >250 ms of
|
||||||
|
// 240 Hz history; anything past that clamp is a pathological stall, not stroke geometry.
|
||||||
|
private const val MAX_SAMPLES = 64
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
|
* Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
|
||||||
@@ -117,7 +121,8 @@ internal class StylusStream(private val handle: Long) {
|
|||||||
NativeBridge.nativeSendPen(handle, last, 1)
|
NativeBridge.nativeSendPen(handle, last, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
|
/** Historical (coalesced) samples oldest-first, then the current one — one emit; the JNI
|
||||||
|
* layer splits runs longer than the wire's 8-sample batch cap into consecutive sends. */
|
||||||
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
|
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
|
||||||
val history = minOf(me.historySize, MAX_SAMPLES - 1)
|
val history = minOf(me.historySize, MAX_SAMPLES - 1)
|
||||||
var count = 0
|
var count = 0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.graphics.vector.PathParser
|
import androidx.compose.ui.graphics.vector.PathParser
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.kit.discovery.osIconTokens
|
import io.unom.punktfunk.kit.discovery.osIconTokens
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
|
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
|
||||||
@@ -14,18 +15,19 @@ import io.unom.punktfunk.kit.discovery.osIconTokens
|
|||||||
* ship, so an unknown distro degrades to its family's mark and finally to Tux; null means
|
* ship, so an unknown distro degrades to its family's mark and finally to Tux; null means
|
||||||
* "no icon", rendering the card exactly as before the field existed.
|
* "no icon", rendering the card exactly as before the field existed.
|
||||||
*
|
*
|
||||||
* Path data is vendored from the assets/os-icons masters (Font Awesome Free brands
|
* Path data is vendored from the assets/os-icons masters (per-mark provenance and licensing
|
||||||
* CC BY 4.0 + Simple Icons CC0 — provenance in that directory's README); Material ships
|
* in that directory's README; `bash scripts/gen-os-icons.sh <token>` prints a master's
|
||||||
* no brand icons. Hand-kept as raw SVG path strings (one line each) rather than
|
* viewport + path ready to paste); Material ships no brand icons. Hand-kept as raw SVG path
|
||||||
* transcribed ImageVector DSL — [PathParser] builds the vector once, then it's cached.
|
* strings (one line each) rather than transcribed ImageVector DSL — [PathParser] builds the
|
||||||
|
* vector once, then it's cached.
|
||||||
*/
|
*/
|
||||||
private class OsGlyph(val viewportWidth: Float, val viewportHeight: Float, val d: String)
|
private class OsGlyph(val viewportWidth: Float, val viewportHeight: Float, val d: String)
|
||||||
|
|
||||||
private val GLYPHS: Map<String, OsGlyph> = mapOf(
|
private val GLYPHS: Map<String, OsGlyph> = mapOf(
|
||||||
"windows" to OsGlyph(
|
"windows" to OsGlyph(
|
||||||
viewportWidth = 448f,
|
viewportWidth = 24f,
|
||||||
viewportHeight = 512f,
|
viewportHeight = 24f,
|
||||||
d = "M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z",
|
d = "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
|
||||||
),
|
),
|
||||||
"apple" to OsGlyph(
|
"apple" to OsGlyph(
|
||||||
viewportWidth = 384f,
|
viewportWidth = 384f,
|
||||||
@@ -72,8 +74,28 @@ private val GLYPHS: Map<String, OsGlyph> = mapOf(
|
|||||||
viewportHeight = 512f,
|
viewportHeight = 512f,
|
||||||
d = "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
|
d = "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
|
||||||
),
|
),
|
||||||
|
// The gaming distros get their own mark rather than their family's: "a Bazzite box" and
|
||||||
|
// "a Fedora box" are different machines to the person reading the card.
|
||||||
|
"bazzite" to OsGlyph(
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
d = "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
|
||||||
|
),
|
||||||
|
"cachyos" to OsGlyph(
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
d = "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
|
||||||
|
),
|
||||||
|
"nobara" to OsGlyph(
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
d = "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Longest edge of a built mark, in dp — the box callers size the [Icon] to. */
|
||||||
|
private const val GLYPH_DP = 24f
|
||||||
|
|
||||||
private val built = mutableMapOf<String, ImageVector>()
|
private val built = mutableMapOf<String, ImageVector>()
|
||||||
|
|
||||||
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
|
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
|
||||||
@@ -82,11 +104,18 @@ fun resolveOsIcon(chain: String): ImageVector? =
|
|||||||
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
|
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun OsGlyph.build(token: String): ImageVector =
|
private fun OsGlyph.build(token: String): ImageVector {
|
||||||
ImageVector.Builder(
|
// The intrinsic size has to carry the VIEWPORT'S ASPECT RATIO, not a fixed square:
|
||||||
|
// a VectorPainter maps the viewport onto the default size with independent x and y
|
||||||
|
// scales, so declaring a 448x512 mark as 24x24 dp stretches it horizontally — which is
|
||||||
|
// exactly how Tux and the Apple mark used to come out on a phone. Scaling the longest
|
||||||
|
// edge to GLYPH_DP instead keeps the ratio, and Icon() paints with ContentScale.Fit, so
|
||||||
|
// the mark letterboxes inside whatever box the caller sized us to.
|
||||||
|
val longest = max(viewportWidth, viewportHeight)
|
||||||
|
return ImageVector.Builder(
|
||||||
name = "OsIcon.$token",
|
name = "OsIcon.$token",
|
||||||
defaultWidth = 24.dp,
|
defaultWidth = (GLYPH_DP * viewportWidth / longest).dp,
|
||||||
defaultHeight = 24.dp,
|
defaultHeight = (GLYPH_DP * viewportHeight / longest).dp,
|
||||||
viewportWidth = viewportWidth,
|
viewportWidth = viewportWidth,
|
||||||
viewportHeight = viewportHeight,
|
viewportHeight = viewportHeight,
|
||||||
).apply {
|
).apply {
|
||||||
@@ -96,3 +125,4 @@ private fun OsGlyph.build(token: String): ImageVector =
|
|||||||
fill = SolidColor(Color.Black),
|
fill = SolidColor(Color.Black),
|
||||||
)
|
)
|
||||||
}.build()
|
}.build()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import io.unom.punktfunk.components.resolveOsIcon
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure JVM test of the host card's OS marks (`components/OsIcons.kt`). Run:
|
||||||
|
* `./gradlew :app:testDebugUnitTest`.
|
||||||
|
*
|
||||||
|
* The aspect assertions are the point: a [androidx.compose.ui.graphics.vector.VectorPainter] maps
|
||||||
|
* the viewport onto the vector's default size with independent x and y scales, so a mark whose
|
||||||
|
* default size does not carry its viewport's ratio renders STRETCHED — silently, with no crash and
|
||||||
|
* no warning. That is exactly how Tux and the Apple mark used to look on a phone.
|
||||||
|
*/
|
||||||
|
class OsIconsTest {
|
||||||
|
/** `ImageVector.name` is "OsIcon.<token>" — the only handle on which mark got resolved. */
|
||||||
|
private fun markOf(chain: String) = resolveOsIcon(chain)?.name?.removePrefix("OsIcon.")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultSizeCarriesTheViewportAspect() {
|
||||||
|
for (chain in listOf("windows", "linux", "opensuse", "steam", "apple", "bazzite")) {
|
||||||
|
val v = resolveOsIcon(chain)
|
||||||
|
assertNotNull("no mark for $chain", v)
|
||||||
|
v!!
|
||||||
|
assertEquals(
|
||||||
|
"$chain default size must keep the viewport ratio",
|
||||||
|
v.viewportWidth / v.viewportHeight,
|
||||||
|
v.defaultWidth.value / v.defaultHeight.value,
|
||||||
|
0.001f,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"$chain longest edge must be the 24dp box",
|
||||||
|
24f,
|
||||||
|
maxOf(v.defaultWidth.value, v.defaultHeight.value),
|
||||||
|
0.001f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tallMarkIsNarrowerThanItsBox() {
|
||||||
|
// Tux is 448x512, so a correct build is 21x24dp — 24x24 would be the stretched bug.
|
||||||
|
val tux = resolveOsIcon("linux")!!
|
||||||
|
assertEquals(21f, tux.defaultWidth.value, 0.001f)
|
||||||
|
assertEquals(24f, tux.defaultHeight.value, 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gamingDistrosResolveToTheirOwnMark() {
|
||||||
|
// The whole reason these three ship art: without it they'd draw their family's mark.
|
||||||
|
assertEquals("bazzite", markOf("linux/fedora/bazzite"))
|
||||||
|
assertEquals("cachyos", markOf("linux/arch/cachyos"))
|
||||||
|
assertEquals("nobara", markOf("linux/rhel/nobara"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownDistroStillDegradesThroughItsFamily() {
|
||||||
|
assertEquals("fedora", markOf("linux/fedora/somethingnew"))
|
||||||
|
assertEquals("linux", markOf("linux/frontier/chimera"))
|
||||||
|
assertEquals("steam", markOf("linux/arch/steamos")) // brand alias
|
||||||
|
assertEquals("apple", markOf("macos"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noChainMeansNoMark() {
|
||||||
|
assertNull(resolveOsIcon(""))
|
||||||
|
assertNull(resolveOsIcon("!!!"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -366,6 +366,8 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
|||||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||||
2.0, 1.0, 5.0, 238.0,
|
2.0, 1.0, 5.0, 238.0,
|
||||||
1.0, 0.5, 1.8, 2.6,
|
1.0, 0.5, 1.8, 2.6,
|
||||||
|
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
|
||||||
|
0.2, 0.3, 236.0, 1.0,
|
||||||
),
|
),
|
||||||
verbosity = verbosity,
|
verbosity = verbosity,
|
||||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.usb.UsbDevice
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.InputDevice
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB — stream mode only.
|
||||||
|
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
|
||||||
|
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
|
||||||
|
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
|
||||||
|
* phone, plus gyro + touchpad the standard path never captured.
|
||||||
|
*
|
||||||
|
* Unlike [Sc2Capture] there is no raw passthrough — the host's DualSense/DS4 backends consume
|
||||||
|
* only typed events — and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
|
||||||
|
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
|
||||||
|
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
|
||||||
|
* engage (toggle off, permission denied, Bluetooth).
|
||||||
|
*
|
||||||
|
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||||
|
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||||
|
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||||
|
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||||
|
* and freed on unplug/[stop], so indices never leak.
|
||||||
|
*
|
||||||
|
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||||
|
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||||
|
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
|
||||||
|
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
|
||||||
|
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
|
||||||
|
* itself if the poll thread stalls — the engine's explicit zeros remain the real stop mechanism.
|
||||||
|
*/
|
||||||
|
class DsCapture(
|
||||||
|
context: Context,
|
||||||
|
private val router: GamepadRouter,
|
||||||
|
) : GamepadFeedback.PadFeedbackSink {
|
||||||
|
private val usb = HidUsbLink(
|
||||||
|
context,
|
||||||
|
HidUsbLink.Config(
|
||||||
|
tag = TAG,
|
||||||
|
threadName = "pf-ds-usb",
|
||||||
|
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
|
||||||
|
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
|
||||||
|
// class check already leaves them (and the pad's headset routing) to Android; the
|
||||||
|
// single HID interface is the only claim.
|
||||||
|
),
|
||||||
|
::onReport,
|
||||||
|
::onLinkClosed,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile private var model: DsDevice.Model? = null
|
||||||
|
@Volatile private var pad: GamepadRouter.ExternalPad? = null
|
||||||
|
|
||||||
|
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
|
||||||
|
private val state = DsDevice.State()
|
||||||
|
private var wireButtons = 0
|
||||||
|
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||||
|
private val lastTouchActive = BooleanArray(2)
|
||||||
|
private val lastTouchX = IntArray(2) { -1 }
|
||||||
|
private val lastTouchY = IntArray(2) { -1 }
|
||||||
|
|
||||||
|
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
|
||||||
|
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
|
||||||
|
// rumble, before any host Led lands) doesn't black the bar out.
|
||||||
|
@Volatile private var ds4Low = 0
|
||||||
|
@Volatile private var ds4High = 0
|
||||||
|
@Volatile private var ds4Rgb = 0x000040
|
||||||
|
|
||||||
|
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
|
||||||
|
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
@Volatile private var backstop: Runnable? = null
|
||||||
|
|
||||||
|
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
|
||||||
|
@Volatile
|
||||||
|
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
val isActive: Boolean get() = model != null
|
||||||
|
|
||||||
|
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||||
|
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start capturing [dev] (permission already granted). Claims the HID interface — the kernel
|
||||||
|
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
|
||||||
|
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
|
||||||
|
* than waiting for the system's removal callback — so the freed wire index is deterministic
|
||||||
|
* for this capture's ExternalPad instead of racing the first report against the callback. A
|
||||||
|
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
|
||||||
|
* reopens a slot on its next input event, so over-matching self-heals.
|
||||||
|
*/
|
||||||
|
fun startUsb(dev: UsbDevice): Boolean {
|
||||||
|
if (model != null) return false
|
||||||
|
val m = DsDevice.modelFor(dev.productId) ?: return false
|
||||||
|
if (!usb.start(dev)) return false
|
||||||
|
model = m
|
||||||
|
for (id in InputDevice.getDeviceIds()) {
|
||||||
|
val d = InputDevice.getDevice(id) ?: continue
|
||||||
|
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
|
||||||
|
}
|
||||||
|
// Release the firmware's lightbar animation once so host lightbar writes take effect
|
||||||
|
// (the same init hid-playstation/SDL send on open).
|
||||||
|
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||||
|
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||||
|
onActiveChanged?.invoke(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||||
|
fun stop() {
|
||||||
|
val m = model
|
||||||
|
if (m != null) {
|
||||||
|
// The interfaces are about to release with the kernel driver still detached — a
|
||||||
|
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||||
|
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||||
|
usb.writeControl(stopReport(m))
|
||||||
|
}
|
||||||
|
disarmBackstop()
|
||||||
|
usb.stop()
|
||||||
|
val wasActive = model != null
|
||||||
|
model = null
|
||||||
|
releaseSlot()
|
||||||
|
if (wasActive) onActiveChanged?.invoke(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- link callbacks (link thread) ----
|
||||||
|
|
||||||
|
private fun onReport(report: ByteArray, len: Int) {
|
||||||
|
val m = model ?: return
|
||||||
|
if (!DsDevice.parseState(m, report, len, state)) return
|
||||||
|
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||||
|
pad = it
|
||||||
|
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||||
|
} ?: return // all 16 wire indices taken — drop until one frees
|
||||||
|
mirrorTyped(p)
|
||||||
|
mirrorRich(p, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLinkClosed() {
|
||||||
|
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||||
|
disarmBackstop()
|
||||||
|
val wasActive = model != null
|
||||||
|
model = null
|
||||||
|
releaseSlot()
|
||||||
|
if (wasActive) onActiveChanged?.invoke(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||||
|
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||||
|
var changed = state.buttons xor wireButtons
|
||||||
|
while (changed != 0) {
|
||||||
|
val bit = changed and -changed // lowest changed bit
|
||||||
|
p.button(bit, state.buttons and bit != 0)
|
||||||
|
changed = changed and bit.inv()
|
||||||
|
}
|
||||||
|
wireButtons = state.buttons
|
||||||
|
axis(p, Gamepad.AXIS_LS_X, state.lsX)
|
||||||
|
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
|
||||||
|
axis(p, Gamepad.AXIS_RS_X, state.rsX)
|
||||||
|
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
|
||||||
|
axis(p, Gamepad.AXIS_LT, state.lt)
|
||||||
|
axis(p, Gamepad.AXIS_RT, state.rt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
|
||||||
|
if (lastAxis[id] == v) return
|
||||||
|
lastAxis[id] = v
|
||||||
|
p.axis(id, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
|
||||||
|
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
|
||||||
|
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
|
||||||
|
*/
|
||||||
|
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
|
||||||
|
for (f in 0 until 2) {
|
||||||
|
if (state.touchActive[f]) {
|
||||||
|
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
|
||||||
|
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
|
||||||
|
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
|
||||||
|
p.touch(f, true, x, y)
|
||||||
|
lastTouchActive[f] = true
|
||||||
|
lastTouchX[f] = x
|
||||||
|
lastTouchY[f] = y
|
||||||
|
}
|
||||||
|
} else if (lastTouchActive[f]) {
|
||||||
|
p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||||
|
lastTouchActive[f] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.motion(state.gyro, state.accel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseSlot() {
|
||||||
|
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
|
||||||
|
val p = pad
|
||||||
|
if (p != null) {
|
||||||
|
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||||
|
}
|
||||||
|
p?.close()
|
||||||
|
pad = null
|
||||||
|
wireButtons = 0
|
||||||
|
lastAxis.fill(Int.MIN_VALUE)
|
||||||
|
lastTouchActive.fill(false)
|
||||||
|
lastTouchX.fill(-1)
|
||||||
|
lastTouchY.fill(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PadFeedbackSink (feedback poll threads) ----
|
||||||
|
|
||||||
|
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
|
||||||
|
|
||||||
|
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||||
|
val m = model ?: return
|
||||||
|
if (low == 0 && high == 0) {
|
||||||
|
disarmBackstop()
|
||||||
|
} else {
|
||||||
|
armBackstop(backstopMs)
|
||||||
|
}
|
||||||
|
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||||
|
ds4Low = low
|
||||||
|
ds4High = high
|
||||||
|
writeDs4()
|
||||||
|
} else {
|
||||||
|
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun led(pad: Int, r: Int, g: Int, b: Int) {
|
||||||
|
val m = model ?: return
|
||||||
|
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||||
|
ds4Rgb = (r shl 16) or (g shl 8) or b
|
||||||
|
writeDs4()
|
||||||
|
} else {
|
||||||
|
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playerLeds(pad: Int, bits: Int) {
|
||||||
|
val m = model ?: return
|
||||||
|
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
|
||||||
|
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
|
||||||
|
val m = model ?: return
|
||||||
|
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
|
||||||
|
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeDs4() = usb.writeRaw(
|
||||||
|
0,
|
||||||
|
DsDevice.ds4Report(
|
||||||
|
ds4Low,
|
||||||
|
ds4High,
|
||||||
|
(ds4Rgb shr 16) and 0xFF,
|
||||||
|
(ds4Rgb shr 8) and 0xFF,
|
||||||
|
ds4Rgb and 0xFF,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||||
|
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||||
|
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||||
|
ds4Low = 0
|
||||||
|
ds4High = 0
|
||||||
|
DsDevice.ds4Report(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(ds4Rgb shr 16) and 0xFF,
|
||||||
|
(ds4Rgb shr 8) and 0xFF,
|
||||||
|
ds4Rgb and 0xFF,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DsDevice.ds5RumbleReport(m, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
|
||||||
|
private fun armBackstop(ms: Long) {
|
||||||
|
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
val r = Runnable {
|
||||||
|
backstop = null
|
||||||
|
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||||
|
}
|
||||||
|
backstop = r
|
||||||
|
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disarmBackstop() {
|
||||||
|
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
backstop = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "DsCapture"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
|
||||||
|
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
|
||||||
|
* as-is passthrough, nothing rides the wire raw here — the host's DualSense/DS4 backends consume
|
||||||
|
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
|
||||||
|
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
|
||||||
|
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
|
||||||
|
* USB output reports itself.
|
||||||
|
*
|
||||||
|
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
|
||||||
|
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
|
||||||
|
* `dualsense_proto.rs` / `dualshock4_proto.rs` — this file is the byte-exact inverse of those
|
||||||
|
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
|
||||||
|
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
|
||||||
|
* reaches this code — an uncaptured pad stays on the ordinary InputDevice path.
|
||||||
|
*/
|
||||||
|
object DsDevice {
|
||||||
|
const val VID_SONY = 0x054C
|
||||||
|
const val PID_DUALSENSE = 0x0CE6
|
||||||
|
const val PID_DUALSENSE_EDGE = 0x0DF2
|
||||||
|
const val PID_DUALSHOCK4_V1 = 0x05C4
|
||||||
|
const val PID_DUALSHOCK4_V2 = 0x09CC
|
||||||
|
|
||||||
|
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
|
||||||
|
* the physical one), its output-report size (the descriptor-declared size the firmware
|
||||||
|
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
|
||||||
|
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
|
||||||
|
* onto the wire's 0..65535 space.
|
||||||
|
*/
|
||||||
|
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
|
||||||
|
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
|
||||||
|
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
|
||||||
|
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
|
||||||
|
fun modelFor(pid: Int): Model? = when (pid) {
|
||||||
|
PID_DUALSENSE -> Model.DUALSENSE
|
||||||
|
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
|
||||||
|
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
|
||||||
|
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
|
||||||
|
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
|
||||||
|
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
|
||||||
|
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
|
||||||
|
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
|
||||||
|
*/
|
||||||
|
class State {
|
||||||
|
var buttons = 0
|
||||||
|
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
|
||||||
|
var rsX = 0; var rsY = 0
|
||||||
|
var lt = 0; var rt = 0 // 0..255
|
||||||
|
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
|
||||||
|
val accel = IntArray(3)
|
||||||
|
val touchActive = BooleanArray(2)
|
||||||
|
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
|
||||||
|
val touchY = IntArray(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
|
||||||
|
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
|
||||||
|
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
|
||||||
|
private const val DS5_INPUT_ID = 0x01
|
||||||
|
// report[8] high nibble (`dualsense_proto::btn0`).
|
||||||
|
private const val DS5_SQUARE = 0x10
|
||||||
|
private const val DS5_CROSS = 0x20
|
||||||
|
private const val DS5_CIRCLE = 0x40
|
||||||
|
private const val DS5_TRIANGLE = 0x80
|
||||||
|
// report[9] (`btn1`).
|
||||||
|
private const val DS5_L1 = 0x01
|
||||||
|
private const val DS5_R1 = 0x02
|
||||||
|
private const val DS5_CREATE = 0x10
|
||||||
|
private const val DS5_OPTIONS = 0x20
|
||||||
|
private const val DS5_L3 = 0x40
|
||||||
|
private const val DS5_R3 = 0x80
|
||||||
|
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
|
||||||
|
private const val DS5_PS = 0x01
|
||||||
|
private const val DS5_TOUCHPAD = 0x02
|
||||||
|
private const val DS5_MUTE = 0x04
|
||||||
|
private const val EDGE_FN_LEFT = 0x10
|
||||||
|
private const val EDGE_FN_RIGHT = 0x20
|
||||||
|
private const val EDGE_BACK_LEFT = 0x40
|
||||||
|
private const val EDGE_BACK_RIGHT = 0x80
|
||||||
|
|
||||||
|
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
|
||||||
|
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
|
||||||
|
// [35..43) two touch points (same 4-byte packing as the DS5).
|
||||||
|
private const val DS4_L1 = 0x01
|
||||||
|
private const val DS4_R1 = 0x02
|
||||||
|
private const val DS4_SHARE = 0x10
|
||||||
|
private const val DS4_OPTIONS = 0x20
|
||||||
|
private const val DS4_L3 = 0x40
|
||||||
|
private const val DS4_R3 = 0x80
|
||||||
|
private const val DS4_PS = 0x01
|
||||||
|
private const val DS4_TOUCHPAD = 0x02
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
|
||||||
|
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
|
||||||
|
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
|
||||||
|
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
|
||||||
|
*/
|
||||||
|
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
|
||||||
|
if (model == Model.DUALSHOCK4) {
|
||||||
|
parseDs4(report, len, out)
|
||||||
|
} else {
|
||||||
|
parseDs5(model, report, len, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
|
||||||
|
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
|
||||||
|
out.lsX = stickX(u8(r, 1))
|
||||||
|
out.lsY = stickY(u8(r, 2))
|
||||||
|
out.rsX = stickX(u8(r, 3))
|
||||||
|
out.rsY = stickY(u8(r, 4))
|
||||||
|
out.lt = u8(r, 5)
|
||||||
|
out.rt = u8(r, 6)
|
||||||
|
val b8 = u8(r, 8)
|
||||||
|
val b9 = u8(r, 9)
|
||||||
|
val b10 = u8(r, 10)
|
||||||
|
var w = hatBits(b8 and 0x0F)
|
||||||
|
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||||
|
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||||
|
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||||
|
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||||
|
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
|
||||||
|
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
|
||||||
|
// L2/R2 digital bits ride the analog axes instead (wire convention).
|
||||||
|
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
|
||||||
|
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||||
|
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||||
|
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||||
|
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||||
|
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||||
|
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
|
||||||
|
if (model == Model.DUALSENSE_EDGE) {
|
||||||
|
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
|
||||||
|
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
|
||||||
|
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
|
||||||
|
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
|
||||||
|
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
|
||||||
|
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
|
||||||
|
}
|
||||||
|
out.buttons = w
|
||||||
|
if (len >= 28) {
|
||||||
|
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
|
||||||
|
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
|
||||||
|
}
|
||||||
|
if (len >= 41) {
|
||||||
|
unpackTouch(r, 33, out, 0)
|
||||||
|
unpackTouch(r, 37, out, 1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
|
||||||
|
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
|
||||||
|
out.lsX = stickX(u8(r, 1))
|
||||||
|
out.lsY = stickY(u8(r, 2))
|
||||||
|
out.rsX = stickX(u8(r, 3))
|
||||||
|
out.rsY = stickY(u8(r, 4))
|
||||||
|
val b5 = u8(r, 5)
|
||||||
|
val b6 = u8(r, 6)
|
||||||
|
val b7 = u8(r, 7)
|
||||||
|
out.lt = u8(r, 8)
|
||||||
|
out.rt = u8(r, 9)
|
||||||
|
var w = hatBits(b5 and 0x0F)
|
||||||
|
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||||
|
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||||
|
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||||
|
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||||
|
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
|
||||||
|
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
|
||||||
|
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
|
||||||
|
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||||
|
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||||
|
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||||
|
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||||
|
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||||
|
out.buttons = w
|
||||||
|
if (len >= 25) {
|
||||||
|
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
|
||||||
|
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
|
||||||
|
}
|
||||||
|
if (len >= 43) {
|
||||||
|
unpackTouch(r, 35, out, 0)
|
||||||
|
unpackTouch(r, 39, out, 1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
|
||||||
|
private fun hatBits(h: Int): Int = when (h) {
|
||||||
|
0 -> Gamepad.BTN_DPAD_UP
|
||||||
|
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
|
||||||
|
2 -> Gamepad.BTN_DPAD_RIGHT
|
||||||
|
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
|
||||||
|
4 -> Gamepad.BTN_DPAD_DOWN
|
||||||
|
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
|
||||||
|
6 -> Gamepad.BTN_DPAD_LEFT
|
||||||
|
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One 4-byte touch point (shared DS5/DS4 packing — `dualsense_proto::pack_touch`): byte0
|
||||||
|
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
|
||||||
|
*/
|
||||||
|
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
|
||||||
|
val b0 = u8(r, o)
|
||||||
|
out.touchActive[slot] = b0 and 0x80 == 0
|
||||||
|
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
|
||||||
|
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
|
||||||
|
|
||||||
|
private fun i16(r: ByteArray, o: Int): Int =
|
||||||
|
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
|
||||||
|
|
||||||
|
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
|
||||||
|
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
|
||||||
|
private fun stickX(raw: Int): Int = raw * 257 - 32768
|
||||||
|
|
||||||
|
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
|
||||||
|
|
||||||
|
// ---- Output reports ----
|
||||||
|
//
|
||||||
|
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
|
||||||
|
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
|
||||||
|
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
|
||||||
|
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
|
||||||
|
|
||||||
|
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
|
||||||
|
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
|
||||||
|
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
|
||||||
|
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
|
||||||
|
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
|
||||||
|
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
|
||||||
|
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
|
||||||
|
private const val DS5_FLAG0_R2_EFFECT = 0x04
|
||||||
|
private const val DS5_FLAG0_L2_EFFECT = 0x08
|
||||||
|
private const val DS5_FLAG1_LIGHTBAR = 0x04
|
||||||
|
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
|
||||||
|
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
|
||||||
|
private const val DS5_FLAG2_VIBRATION2 = 0x04
|
||||||
|
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
|
||||||
|
|
||||||
|
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
|
||||||
|
const val TRIGGER_EFFECT_LEN = 11
|
||||||
|
|
||||||
|
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
|
||||||
|
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect — the same
|
||||||
|
* init both hid-playstation and SDL send on open. No-op fields otherwise.
|
||||||
|
*/
|
||||||
|
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
|
||||||
|
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
|
||||||
|
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
|
||||||
|
* light/right — the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
|
||||||
|
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||||
|
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||||
|
*/
|
||||||
|
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||||
|
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||||
|
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||||
|
it[3] = amp8(high).toByte()
|
||||||
|
it[4] = amp8(low).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
|
||||||
|
* trigger block from the wire (`HidOutput::Trigger` — the game's bytes verbatim), copied to
|
||||||
|
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
|
||||||
|
*/
|
||||||
|
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
|
||||||
|
val at = if (which == 1) 11 else 22
|
||||||
|
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
|
||||||
|
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
|
||||||
|
System.arraycopy(effect, 0, it, at, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DS5/Edge lightbar RGB. */
|
||||||
|
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
|
||||||
|
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
|
||||||
|
it[45] = r.toByte()
|
||||||
|
it[46] = g.toByte()
|
||||||
|
it[47] = b.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
|
||||||
|
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
|
||||||
|
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
|
||||||
|
it[44] = (bits and 0x1F).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
|
||||||
|
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
|
||||||
|
// [6..9) RGB, [9]/[10] blink on/off.
|
||||||
|
private const val DS4_FLAG0_MOTORS = 0x01
|
||||||
|
private const val DS4_FLAG0_LED = 0x02
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One full-state DS4 write: motors + lightbar together, both flags set — the composed-state
|
||||||
|
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
|
||||||
|
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
|
||||||
|
*/
|
||||||
|
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
|
||||||
|
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||||
|
it[0] = 0x05
|
||||||
|
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||||
|
it[4] = amp8(high).toByte()
|
||||||
|
it[5] = amp8(low).toByte()
|
||||||
|
it[6] = r.toByte()
|
||||||
|
it[7] = g.toByte()
|
||||||
|
it[8] = b.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
|
||||||
|
// vibrator path's toAmplitude).
|
||||||
|
private fun amp8(v16: Int): Int {
|
||||||
|
val a = (v16 ushr 8) and 0xFF
|
||||||
|
return if (v16 != 0 && a == 0) 1 else a
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,42 @@ class GamepadFeedback(
|
|||||||
private val router: GamepadRouter?,
|
private val router: GamepadRouter?,
|
||||||
private val deviceVibrator: Vibrator? = null,
|
private val deviceVibrator: Vibrator? = null,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
|
||||||
|
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
|
||||||
|
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
|
||||||
|
* resolves null and the platform paths no-op) — the link renders instead, by composing USB
|
||||||
|
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
|
||||||
|
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
|
||||||
|
* Invoked on the feedback poll threads; implementations must be thread-safe.
|
||||||
|
*/
|
||||||
|
interface PadFeedbackSink {
|
||||||
|
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
|
||||||
|
* invoked while true. Racing a pad close is fine — a late render is a harmless no-op. */
|
||||||
|
fun ownsPad(pad: Int): Boolean
|
||||||
|
|
||||||
|
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
|
||||||
|
* [backstopMs] as the self-termination net — see [GamepadFeedback.renderRumble]). */
|
||||||
|
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
|
||||||
|
|
||||||
|
/** Lightbar RGB. */
|
||||||
|
fun led(pad: Int, r: Int, g: Int, b: Int)
|
||||||
|
|
||||||
|
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
|
||||||
|
fun playerLeds(pad: Int, bits: Int)
|
||||||
|
|
||||||
|
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
|
||||||
|
* block (mode byte + parameters) exactly as the game wrote it host-side. */
|
||||||
|
fun trigger(pad: Int, which: Int, effect: ByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
|
||||||
|
* [onHidRaw]; cleared before the poll threads stop.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var sink: PadFeedbackSink? = null
|
||||||
|
|
||||||
private companion object {
|
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
|
||||||
@@ -221,6 +257,12 @@ class GamepadFeedback(
|
|||||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||||
// already decided the bind, and the user opted in.
|
// already decided the bind, and the user opted in.
|
||||||
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
||||||
|
// A captured pad's link renders on the physical controller itself (its slot has no
|
||||||
|
// InputDevice, so the vibrator bind below would resolve null and drop the command).
|
||||||
|
sink?.takeIf { it.ownsPad(pad) }?.let {
|
||||||
|
it.rumble(pad, low, high, durationMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
val bind = rumbleBindFor(pad) ?: return
|
val bind = rumbleBindFor(pad) ?: return
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
@@ -313,23 +355,36 @@ class GamepadFeedback(
|
|||||||
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 pad=$pad 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(pad, Color.rgb(r, g, b))
|
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||||
|
if (s != null) s.led(pad, r, g, b)
|
||||||
|
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||||
}
|
}
|
||||||
TAG_PLAYER_LEDS -> {
|
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 pad=$pad 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(pad, player)
|
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||||
|
if (s != null) s.playerLeds(pad, bits)
|
||||||
|
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||||
}
|
}
|
||||||
TAG_TRIGGER -> {
|
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 - 3 // [pad][kind][which] header, then the effect block
|
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 s = sink?.takeIf { it.ownsPad(pad) }
|
||||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
if (s != null && effLen > 0) {
|
||||||
Log.i(
|
// A captured DualSense: the raw trigger block replays onto the physical pad.
|
||||||
TAG,
|
val effect = ByteArray(effLen)
|
||||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
buf.get(effect)
|
||||||
)
|
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
|
||||||
|
s.trigger(pad, which, effect)
|
||||||
|
} else {
|
||||||
|
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||||
|
// No platform adaptive-trigger API — parse-validate the mode + log only.
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TAG_HID_RAW -> {
|
TAG_HID_RAW -> {
|
||||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||||
|
|||||||
@@ -210,6 +210,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||||
|
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||||
|
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||||
|
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||||
|
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||||
|
fun motion(gyro: IntArray, accel: IntArray) {
|
||||||
|
if (slot != null) {
|
||||||
|
NativeBridge.nativeSendPadMotion(
|
||||||
|
handle, index,
|
||||||
|
gyro[0], gyro[1], gyro[2],
|
||||||
|
accel[0], accel[1], accel[2],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||||
fun close() = closeSlot(syntheticId)
|
fun close() = closeSlot(syntheticId)
|
||||||
}
|
}
|
||||||
@@ -228,6 +246,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
return ExternalPad(syntheticId, index)
|
return ExternalPad(syntheticId, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
|
||||||
|
* detaches the kernel driver, so the system's own removal callback would close it moments
|
||||||
|
* later anyway — doing it at claim time makes the freed wire index deterministic for the
|
||||||
|
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
|
||||||
|
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
|
||||||
|
* slot on its next input event). Main thread, like the hot-plug callbacks.
|
||||||
|
*/
|
||||||
|
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.hardware.usb.UsbConstants
|
||||||
|
import android.hardware.usb.UsbDevice
|
||||||
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
|
import android.hardware.usb.UsbEndpoint
|
||||||
|
import android.hardware.usb.UsbInterface
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
|
import android.hardware.usb.UsbRequest
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.TimeoutException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||||
|
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
|
||||||
|
* interface(s) — `force = true` detaches the kernel/OS driver, so a captured pad can't
|
||||||
|
* double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest] read loop, and
|
||||||
|
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
|
||||||
|
* else EP0 `SET_REPORT`).
|
||||||
|
*
|
||||||
|
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
|
||||||
|
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence —
|
||||||
|
* the SC2's lizard-mode refresh; a DualSense needs none).
|
||||||
|
*
|
||||||
|
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||||
|
* (an SC2 on-glass round tripped exactly this — a 5 s silence heuristic firing on an idle pad).
|
||||||
|
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||||
|
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||||
|
*/
|
||||||
|
class HidUsbLink(
|
||||||
|
private val context: Context,
|
||||||
|
private val config: Config,
|
||||||
|
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||||
|
private val onClosed: () -> Unit,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
|
||||||
|
* HID/vendor-class interfaces get claimed (the class check itself is built in) — e.g. the SC2
|
||||||
|
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
|
||||||
|
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
|
||||||
|
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
|
||||||
|
*/
|
||||||
|
class Config(
|
||||||
|
val tag: String,
|
||||||
|
val threadName: String,
|
||||||
|
val deviceMatch: (UsbDevice) -> Boolean,
|
||||||
|
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
|
||||||
|
val keepAliveFeatures: List<ByteArray> = emptyList(),
|
||||||
|
val keepAliveMs: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
|
||||||
|
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||||
|
private class Claim(
|
||||||
|
val iface: UsbInterface,
|
||||||
|
val epIn: UsbEndpoint,
|
||||||
|
val epOut: UsbEndpoint?,
|
||||||
|
) {
|
||||||
|
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||||
|
var inReq: UsbRequest? = null
|
||||||
|
var outReq: UsbRequest? = null
|
||||||
|
var outBusy = false
|
||||||
|
var reports = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connection: UsbDeviceConnection? = null
|
||||||
|
private var device: UsbDevice? = null
|
||||||
|
private var claims: List<Claim> = emptyList()
|
||||||
|
|
||||||
|
/** The claim whose IN endpoint last produced data — where output/feature writes go.
|
||||||
|
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||||
|
@Volatile private var activeClaim: Claim? = null
|
||||||
|
|
||||||
|
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||||
|
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||||
|
* request; a second waiter would steal the reader's completions). */
|
||||||
|
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||||
|
|
||||||
|
private var reader: Thread? = null
|
||||||
|
private var detachReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
@Volatile private var running = false
|
||||||
|
|
||||||
|
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||||
|
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||||
|
* obtained USB permission. Returns false when nothing could be claimed.
|
||||||
|
*/
|
||||||
|
fun start(dev: UsbDevice): Boolean {
|
||||||
|
if (!usb.hasPermission(dev)) {
|
||||||
|
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val conn = usb.openDevice(dev) ?: run {
|
||||||
|
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val claimed = claimControllerInterfaces(dev, conn)
|
||||||
|
if (claimed.isEmpty()) {
|
||||||
|
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||||
|
conn.close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
connection = conn
|
||||||
|
device = dev
|
||||||
|
claims = claimed
|
||||||
|
running = true
|
||||||
|
Log.i(
|
||||||
|
config.tag,
|
||||||
|
"USB link up: PID=0x%04x ifaces=%s".format(
|
||||||
|
dev.productId,
|
||||||
|
claimed.joinToString {
|
||||||
|
"%d(in=0x%02x out=%s)".format(
|
||||||
|
it.iface.id, it.epIn.address,
|
||||||
|
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||||
|
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||||
|
if (gone?.deviceName == dev.deviceName) {
|
||||||
|
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||||
|
if (running) {
|
||||||
|
running = false
|
||||||
|
onClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detachReceiver = receiver
|
||||||
|
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||||
|
context.registerReceiver(receiver, filter)
|
||||||
|
}
|
||||||
|
if (config.keepAliveFeatures.isNotEmpty()) {
|
||||||
|
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||||
|
}
|
||||||
|
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
|
||||||
|
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional — the fallback is
|
||||||
|
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
|
||||||
|
* from Android's own input stack while captured.
|
||||||
|
*/
|
||||||
|
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||||
|
val out = mutableListOf<Claim>()
|
||||||
|
for (i in 0 until dev.interfaceCount) {
|
||||||
|
val iface = dev.getInterface(i)
|
||||||
|
if (!config.ifaceFilter(dev, iface)) continue
|
||||||
|
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||||
|
iface.interfaceClass == 0xFF
|
||||||
|
if (!hidOrVendor) continue
|
||||||
|
var inEp: UsbEndpoint? = null
|
||||||
|
var outEp: UsbEndpoint? = null
|
||||||
|
for (e in 0 until iface.endpointCount) {
|
||||||
|
val ep = iface.getEndpoint(e)
|
||||||
|
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||||
|
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||||
|
if (!usable) continue
|
||||||
|
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||||
|
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||||
|
}
|
||||||
|
if (inEp == null) continue
|
||||||
|
if (conn.claimInterface(iface, true)) {
|
||||||
|
out.add(Claim(iface, inEp, outEp))
|
||||||
|
} else {
|
||||||
|
Log.w(config.tag, "could not claim iface ${iface.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||||
|
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||||
|
*/
|
||||||
|
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||||
|
val live = claims.filter { c ->
|
||||||
|
val req = UsbRequest()
|
||||||
|
if (!req.initialize(conn, c.epIn)) {
|
||||||
|
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
req.clientData = c
|
||||||
|
c.inReq = req
|
||||||
|
c.epOut?.let { ep ->
|
||||||
|
val o = UsbRequest()
|
||||||
|
if (o.initialize(conn, ep)) {
|
||||||
|
o.clientData = c
|
||||||
|
c.outReq = o
|
||||||
|
} else {
|
||||||
|
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.inBuf.clear()
|
||||||
|
req.queue(c.inBuf)
|
||||||
|
}
|
||||||
|
if (live.isEmpty()) {
|
||||||
|
Log.e(config.tag, "no IN request could be queued")
|
||||||
|
finishReader(claims)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scratch = ByteArray(64)
|
||||||
|
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
|
||||||
|
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||||
|
try {
|
||||||
|
while (running) {
|
||||||
|
val now = android.os.SystemClock.elapsedRealtime()
|
||||||
|
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
|
||||||
|
now - lastKeepAlive >= config.keepAliveMs
|
||||||
|
) {
|
||||||
|
// Refresh the firmware settings on the streaming interface (else every live
|
||||||
|
// one, before a streaming interface is known) — replaying also repairs state
|
||||||
|
// some other consumer changed after capture started.
|
||||||
|
val target = activeClaim
|
||||||
|
if (target != null) sendKeepAlive(conn, target.iface.id)
|
||||||
|
else live.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||||
|
lastKeepAlive = now
|
||||||
|
}
|
||||||
|
// Submit the next pending OUT report on the active (else first) interface.
|
||||||
|
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||||
|
if (outTarget != null) {
|
||||||
|
outQueue.poll()?.let { data ->
|
||||||
|
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val done = try {
|
||||||
|
conn.requestWait(READ_TIMEOUT_MS)
|
||||||
|
} catch (_: TimeoutException) {
|
||||||
|
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||||
|
// detach broadcast is the real signal.
|
||||||
|
errorsSince = 0L
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (done == null) {
|
||||||
|
// Hard error. On a real unplug these storm continuously (the detach
|
||||||
|
// broadcast usually beats us to it); tolerate transient ones.
|
||||||
|
if (errorsSince == 0L) errorsSince = now
|
||||||
|
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||||
|
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errorsSince = 0L
|
||||||
|
val claim = done.clientData as? Claim ?: continue
|
||||||
|
if (done === claim.inReq) {
|
||||||
|
val n = claim.inBuf.position()
|
||||||
|
if (n > 0) {
|
||||||
|
claim.inBuf.flip()
|
||||||
|
claim.inBuf.get(scratch, 0, n)
|
||||||
|
if (claim.reports++ == 0L) {
|
||||||
|
Log.i(
|
||||||
|
config.tag,
|
||||||
|
"first report on iface %d: id=0x%02x len=%d".format(
|
||||||
|
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
activeClaim = claim
|
||||||
|
onReport(scratch, n)
|
||||||
|
}
|
||||||
|
claim.inBuf.clear()
|
||||||
|
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||||
|
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if (done === claim.outReq) {
|
||||||
|
claim.outBusy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
finishReader(claims)
|
||||||
|
}
|
||||||
|
if (running) {
|
||||||
|
running = false
|
||||||
|
onClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishReader(claims: List<Claim>) {
|
||||||
|
for (c in claims) {
|
||||||
|
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||||
|
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||||
|
c.inReq = null
|
||||||
|
c.outReq = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||||
|
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||||
|
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||||
|
*/
|
||||||
|
fun writeRaw(kind: Int, data: ByteArray) {
|
||||||
|
if (data.isEmpty()) return
|
||||||
|
when (kind) {
|
||||||
|
0 -> {
|
||||||
|
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||||
|
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||||
|
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||||
|
while (outQueue.size >= 32) outQueue.poll()
|
||||||
|
outQueue.offer(data)
|
||||||
|
} else {
|
||||||
|
setReport(REPORT_TYPE_OUTPUT, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setReport(type: Int, data: ByteArray) {
|
||||||
|
val conn = connection ?: return
|
||||||
|
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||||
|
sendReport(conn, ifId, type, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
|
||||||
|
* queue — for a teardown write that must land while the reader thread is stopping and the
|
||||||
|
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||||
|
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||||
|
*/
|
||||||
|
fun writeControl(data: ByteArray) {
|
||||||
|
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||||
|
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||||
|
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||||
|
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||||
|
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||||
|
*/
|
||||||
|
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||||
|
val id = data[0].toInt() and 0xFF
|
||||||
|
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||||
|
conn.controlTransfer(
|
||||||
|
0x21, // host→device, class, interface
|
||||||
|
0x09, // SET_REPORT
|
||||||
|
(type shl 8) or id,
|
||||||
|
ifaceId,
|
||||||
|
payload,
|
||||||
|
payload.size,
|
||||||
|
WRITE_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
|
detachReceiver = null
|
||||||
|
runCatching { reader?.join(1000) }
|
||||||
|
reader = null
|
||||||
|
outQueue.clear()
|
||||||
|
activeClaim = null
|
||||||
|
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||||
|
claims = emptyList()
|
||||||
|
runCatching { connection?.close() }
|
||||||
|
connection = null
|
||||||
|
device = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val READ_TIMEOUT_MS = 100L
|
||||||
|
const val WRITE_TIMEOUT_MS = 250
|
||||||
|
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||||
|
const val ERROR_UNPLUG_MS = 2000L
|
||||||
|
const val REPORT_TYPE_OUTPUT = 0x02
|
||||||
|
const val REPORT_TYPE_FEATURE = 0x03
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,14 @@ object NativeBridge {
|
|||||||
compositorPref: Int,
|
compositorPref: Int,
|
||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
hdrEnabled: Boolean,
|
hdrEnabled: Boolean,
|
||||||
|
/** Every decoder this device would use tolerates multi-slice AUs
|
||||||
|
* ([VideoDecoders.multiSliceTolerant]) — advertises `VIDEO_CAP_MULTI_SLICE`; false keeps
|
||||||
|
* the host at single-slice frames (the safe pre-0.17 wire shape). */
|
||||||
|
multiSliceOk: Boolean,
|
||||||
|
/** Every decoder this device would use accepts partial-frame input
|
||||||
|
* ([VideoDecoders.partialFrameCapable]) — opts into slice-progressive delivery (the
|
||||||
|
* decode loop then feeds slices with `BUFFER_FLAG_PARTIAL_FRAME` as they arrive). */
|
||||||
|
framePartsOk: Boolean,
|
||||||
audioChannels: Int,
|
audioChannels: Int,
|
||||||
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
|
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
|
||||||
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this ∩ its GPU. */
|
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this ∩ its GPU. */
|
||||||
@@ -184,10 +192,12 @@ object NativeBridge {
|
|||||||
external fun nativeVideoMime(handle: Long): String
|
external fun nativeVideoMime(handle: Long): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
|
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
|
||||||
* handshake, so it is known before the first frame — the stream view sizes itself to THIS
|
* Resolved at the handshake, so it is known before the first frame — the stream view sizes
|
||||||
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
|
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
|
||||||
* Cheap; UI-safe.
|
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
|
||||||
|
* (an older native lib returns only `[width, height]` — index defensively). Fixed for the
|
||||||
|
* session; read once. Cheap; UI-safe.
|
||||||
*/
|
*/
|
||||||
external fun nativeVideoSize(handle: Long): IntArray?
|
external fun nativeVideoSize(handle: Long): IntArray?
|
||||||
|
|
||||||
@@ -204,11 +214,13 @@ object NativeBridge {
|
|||||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||||
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
|
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
|
||||||
* the MIME — what the pre-overhaul client always did); [lowLatencyMode] is the user's
|
* the MIME — what the pre-overhaul client always did); [lowLatencyMode] is the user's
|
||||||
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
|
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
|
||||||
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
|
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
|
||||||
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
|
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
|
||||||
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
|
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
|
||||||
* hint otherwise). No-op if already started.
|
* hint otherwise). [presentPriority]/[smoothBuffer] are the timeline presenter's intent
|
||||||
|
* (0 = lowest latency / 1 = smoothness; buffer 0 = automatic, else 1..3 frames) — the Apple
|
||||||
|
* client's `present_priority`/`smooth_buffer` pair. No-op if already started.
|
||||||
*/
|
*/
|
||||||
external fun nativeStartVideo(
|
external fun nativeStartVideo(
|
||||||
handle: Long,
|
handle: Long,
|
||||||
@@ -217,6 +229,11 @@ object NativeBridge {
|
|||||||
lowLatencyMode: Boolean,
|
lowLatencyMode: Boolean,
|
||||||
lowLatencyFeature: Boolean,
|
lowLatencyFeature: Boolean,
|
||||||
isTv: Boolean,
|
isTv: Boolean,
|
||||||
|
presentPriority: Int,
|
||||||
|
smoothBuffer: Int,
|
||||||
|
/** The display mode's own refresh rate (0 = unknown) — the latch grid the presenter
|
||||||
|
* subdivides onto when the platform down-rates the app's choreographer stream. */
|
||||||
|
panelFps: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||||
@@ -231,11 +248,11 @@ object NativeBridge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||||
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
|
* Returns 30 doubles (unified stats spec, `design/stats-unification.md`):
|
||||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||||
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||||
* e2eDispP50Ms, e2eDispP95Ms]`
|
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
|
||||||
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||||
@@ -407,6 +424,30 @@ object NativeBridge {
|
|||||||
*/
|
*/
|
||||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
|
||||||
|
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
|
||||||
|
* are normalized 0..65535 in SCREEN convention (+y down — the wire's fixed meaning); active
|
||||||
|
* false lifts the finger. Send on change only — the host holds per-slot state.
|
||||||
|
*/
|
||||||
|
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
|
||||||
|
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units — the host passes
|
||||||
|
* them straight into the virtual DualSense report. Called at the pad's report rate.
|
||||||
|
*/
|
||||||
|
external fun nativeSendPadMotion(
|
||||||
|
handle: Long,
|
||||||
|
pad: Int,
|
||||||
|
gyroPitch: Int,
|
||||||
|
gyroYaw: Int,
|
||||||
|
gyroRoll: Int,
|
||||||
|
accelX: Int,
|
||||||
|
accelY: Int,
|
||||||
|
accelZ: Int,
|
||||||
|
)
|
||||||
|
|
||||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
package io.unom.punktfunk.kit
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
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.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
|
* 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
|
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
|
||||||
* the pad can't double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest]
|
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
|
||||||
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
|
* SC2-specific:
|
||||||
* 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
|
* **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
|
* HID interface each, and there is no way to know which slot a controller bonded to — claiming
|
||||||
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
|
|||||||
* on-glass symptom: the pad surfaced as a generic InputDevice → Xbox360). Whichever interface
|
* on-glass symptom: the pad surfaced as a generic InputDevice → Xbox360). Whichever interface
|
||||||
* streams state becomes the write target for rumble/settings.
|
* streams state becomes the write target for rumble/settings.
|
||||||
*
|
*
|
||||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
|
||||||
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
|
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
|
||||||
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence — the generic link's keep-alive.
|
||||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
|
||||||
*/
|
*/
|
||||||
class Sc2UsbLink(
|
class Sc2UsbLink(
|
||||||
private val context: Context,
|
context: Context,
|
||||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
onReport: (report: ByteArray, len: Int) -> Unit,
|
||||||
private val onClosed: () -> Unit,
|
onClosed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
private val link = HidUsbLink(
|
||||||
|
context,
|
||||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
HidUsbLink.Config(
|
||||||
private class Claim(
|
tag = "Sc2UsbLink",
|
||||||
val iface: UsbInterface,
|
threadName = "pf-sc2-usb",
|
||||||
val epIn: UsbEndpoint,
|
deviceMatch = {
|
||||||
val epOut: UsbEndpoint?,
|
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||||
) {
|
},
|
||||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
|
||||||
var inReq: UsbRequest? = null
|
ifaceFilter = { dev, iface ->
|
||||||
var outReq: UsbRequest? = null
|
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
|
||||||
var outBusy = false
|
},
|
||||||
var reports = 0L
|
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
|
||||||
}
|
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
|
||||||
|
),
|
||||||
private var connection: UsbDeviceConnection? = null
|
onReport,
|
||||||
private var device: UsbDevice? = null
|
onClosed,
|
||||||
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. */
|
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
||||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
|
fun findDevice(): UsbDevice? = link.findDevice()
|
||||||
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
|
* 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.
|
* obtained USB permission. Returns false when nothing could be claimed.
|
||||||
*/
|
*/
|
||||||
fun start(dev: UsbDevice): Boolean {
|
fun start(dev: UsbDevice): Boolean = link.start(dev)
|
||||||
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`
|
* 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)`
|
* rumble & friends), kind 1 = feature report. [data] is the full report, id byte first,
|
||||||
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
|
* exactly as hidapi framed it host-side.
|
||||||
* report, id byte first, exactly as hidapi framed it host-side.
|
|
||||||
*/
|
*/
|
||||||
fun writeRaw(kind: Int, data: ByteArray) {
|
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, data)
|
||||||
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) {
|
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
|
||||||
val conn = connection ?: return
|
fun stop() = link.stop()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,53 @@ object VideoDecoders {
|
|||||||
fun decodableCodecBits(): Int =
|
fun decodableCodecBits(): Int =
|
||||||
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
|
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether EVERY decoder this device would use tolerates multi-slice access units — the
|
||||||
|
* `VIDEO_CAP_MULTI_SLICE` advertisement (decoder truth, per the 0.17.0 field regression:
|
||||||
|
* Amlogic HEVC decoders wedge the whole DEVICE on multi-slice frames — Chromecast with
|
||||||
|
* Google TV, onn 4K — which is why Moonlight requests single-slice for every hardware
|
||||||
|
* decoder). We advertise per-family instead: tolerant unless the pick for ANY advertised
|
||||||
|
* codec is an Amlogic decoder or can't be inspected (a null pick = the platform default —
|
||||||
|
* uninspectable, so conservatively single-slice). Probed once at connect time; the host
|
||||||
|
* defaults to >1 slice only toward clients that set the bit.
|
||||||
|
*/
|
||||||
|
fun multiSliceTolerant(): Boolean {
|
||||||
|
val mimes = buildList {
|
||||||
|
add("video/avc")
|
||||||
|
add("video/hevc")
|
||||||
|
if (decodableCodecBits() and 4 != 0) add("video/av01")
|
||||||
|
}
|
||||||
|
return mimes.all { mime ->
|
||||||
|
val name = pickDecoder(mime)?.name?.lowercase() ?: return@all false
|
||||||
|
!name.startsWith("omx.amlogic") && !name.startsWith("c2.amlogic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when EVERY decoder this device would stream with supports partial-frame input
|
||||||
|
* (MediaCodec `FEATURE_PartialFrame`): the client may then opt into slice-progressive
|
||||||
|
* delivery and feed slices with `BUFFER_FLAG_PARTIAL_FRAME` ahead of the AU's tail.
|
||||||
|
* Codec selection happens after connect, so all advertised mimes must qualify — the same
|
||||||
|
* conservative shape as [multiSliceTolerant] (an unprobeable pick disqualifies).
|
||||||
|
*/
|
||||||
|
fun partialFrameCapable(): Boolean {
|
||||||
|
val mimes = buildList {
|
||||||
|
add("video/avc")
|
||||||
|
add("video/hevc")
|
||||||
|
if (decodableCodecBits() and 4 != 0) add("video/av01")
|
||||||
|
}
|
||||||
|
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||||
|
.getOrNull() ?: return false
|
||||||
|
return mimes.all { mime ->
|
||||||
|
val pick = pickDecoder(mime)?.name ?: return@all false
|
||||||
|
val info = infos.firstOrNull { it.name == pick } ?: return@all false
|
||||||
|
runCatching {
|
||||||
|
info.getCapabilitiesForType(mime)
|
||||||
|
.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun pickDecoder(mime: String): DecoderChoice? {
|
fun pickDecoder(mime: String): DecoderChoice? {
|
||||||
if (mime.isEmpty()) return null
|
if (mime.isEmpty()) return null
|
||||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure JVM tests of the Sony USB report codec ([DsDevice]) — the byte-exact inverse of the
|
||||||
|
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
|
||||||
|
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
|
||||||
|
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||||
|
*/
|
||||||
|
class DsDeviceTest {
|
||||||
|
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||||
|
ByteArray(64).also {
|
||||||
|
it[0] = 0x01
|
||||||
|
// Sticks centred, hat neutral (8).
|
||||||
|
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||||
|
it[8] = 0x08
|
||||||
|
// Touch points inactive (bit7 set).
|
||||||
|
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
|
||||||
|
mutate(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||||
|
ByteArray(64).also {
|
||||||
|
it[0] = 0x01
|
||||||
|
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||||
|
it[5] = 0x08
|
||||||
|
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
|
||||||
|
mutate(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- input parse ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ds5ButtonsMapPositionally() {
|
||||||
|
val s = DsDevice.State()
|
||||||
|
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
|
||||||
|
val r = ds5Report {
|
||||||
|
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
|
||||||
|
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
|
||||||
|
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
|
||||||
|
}
|
||||||
|
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||||
|
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
|
||||||
|
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
|
||||||
|
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
|
||||||
|
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
|
||||||
|
assertEquals(expected, s.buttons)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ds5SticksInvertYAndCoverTheFullRange() {
|
||||||
|
val s = DsDevice.State()
|
||||||
|
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
|
||||||
|
val r = ds5Report {
|
||||||
|
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
|
||||||
|
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
|
||||||
|
it[5] = 0x40; it[6] = 0xFF.toByte()
|
||||||
|
}
|
||||||
|
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||||
|
assertEquals(-32768, s.lsX)
|
||||||
|
assertEquals(32767, s.lsY) // device up → wire +32767
|
||||||
|
assertEquals(32767, s.rsX)
|
||||||
|
assertEquals(-32768, s.rsY) // device down → wire −32768
|
||||||
|
assertEquals(0x40, s.lt)
|
||||||
|
assertEquals(0xFF, s.rt)
|
||||||
|
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
|
||||||
|
val c = DsDevice.State()
|
||||||
|
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
|
||||||
|
assertEquals(128, c.lsX)
|
||||||
|
assertEquals(-129, c.lsY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ds5MotionAndTouchUnpack() {
|
||||||
|
val s = DsDevice.State()
|
||||||
|
val r = ds5Report {
|
||||||
|
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
|
||||||
|
it[16] = 0x02; it[17] = 0x01
|
||||||
|
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
|
||||||
|
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
|
||||||
|
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
|
||||||
|
it[33] = 0x05
|
||||||
|
it[34] = 0x7F
|
||||||
|
it[35] = (0x07 or (0x07 shl 4)).toByte()
|
||||||
|
it[36] = 0x43
|
||||||
|
}
|
||||||
|
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||||
|
assertEquals(0x0102, s.gyro[0])
|
||||||
|
assertEquals(-2, s.accel[2])
|
||||||
|
assertTrue(s.touchActive[0])
|
||||||
|
assertEquals(1919, s.touchX[0])
|
||||||
|
assertEquals(1079, s.touchY[0])
|
||||||
|
assertFalse(s.touchActive[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun edgePaddlesParseOnlyOnTheEdge() {
|
||||||
|
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
|
||||||
|
val edge = DsDevice.State()
|
||||||
|
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
|
||||||
|
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
|
||||||
|
assertEquals(
|
||||||
|
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
|
||||||
|
edge.buttons,
|
||||||
|
)
|
||||||
|
val plain = DsDevice.State()
|
||||||
|
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
|
||||||
|
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ds4LayoutDiffersWhereItShould() {
|
||||||
|
val s = DsDevice.State()
|
||||||
|
val r = ds4Report {
|
||||||
|
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
|
||||||
|
it[6] = (0x10 or 0x20).toByte() // share | options
|
||||||
|
it[7] = 0x03 // PS | touchpad click
|
||||||
|
it[8] = 0x11 // L2 analog
|
||||||
|
it[9] = 0x99.toByte() // R2 analog
|
||||||
|
// gyro yaw at 15.. (second i16 of 13..19).
|
||||||
|
it[15] = 0x34; it[16] = 0x12
|
||||||
|
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
|
||||||
|
it[35] = 0x03
|
||||||
|
it[36] = 0x64
|
||||||
|
it[37] = 0xD0.toByte()
|
||||||
|
it[38] = 0x3A
|
||||||
|
}
|
||||||
|
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
|
||||||
|
assertEquals(
|
||||||
|
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
|
||||||
|
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
|
||||||
|
s.buttons,
|
||||||
|
)
|
||||||
|
assertEquals(0x11, s.lt)
|
||||||
|
assertEquals(0x99, s.rt)
|
||||||
|
assertEquals(0x1234, s.gyro[1])
|
||||||
|
assertTrue(s.touchActive[0])
|
||||||
|
assertEquals(100, s.touchX[0])
|
||||||
|
assertEquals(941, s.touchY[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsForeignAndShortReports() {
|
||||||
|
val s = DsDevice.State()
|
||||||
|
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
|
||||||
|
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
|
||||||
|
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ds5RumbleReportFlagsAndMotors() {
|
||||||
|
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
|
||||||
|
assertEquals(48, r.size)
|
||||||
|
assertEquals(0x02, r[0].toInt())
|
||||||
|
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
|
||||||
|
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
|
||||||
|
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
|
||||||
|
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
|
||||||
|
// A nonzero amplitude never collapses to motor 0.
|
||||||
|
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
|
||||||
|
// The Edge's output report is the 64-byte variant.
|
||||||
|
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ds5TriggerReportPlacesTheBlockPerSide() {
|
||||||
|
val effect = ByteArray(11) { (it + 1).toByte() }
|
||||||
|
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
|
||||||
|
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
|
||||||
|
assertEquals(1, r2[11].toInt()) // block at [11..22)
|
||||||
|
assertEquals(11, r2[21].toInt())
|
||||||
|
assertEquals(0, r2[22].toInt())
|
||||||
|
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
|
||||||
|
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
|
||||||
|
assertEquals(1, l2[22].toInt()) // block at [22..33)
|
||||||
|
assertEquals(11, l2[32].toInt())
|
||||||
|
// Oversized wire effects clamp to the 11-byte hardware block.
|
||||||
|
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
|
||||||
|
assertEquals(0, big[22].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ds5LightbarPlayerLedsAndInit() {
|
||||||
|
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
|
||||||
|
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
|
||||||
|
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
|
||||||
|
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
|
||||||
|
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
|
||||||
|
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
|
||||||
|
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
|
||||||
|
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
|
||||||
|
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ds4ReportIsAFullStateWrite() {
|
||||||
|
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
|
||||||
|
assertEquals(32, r.size)
|
||||||
|
assertEquals(0x05, r[0].toInt())
|
||||||
|
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
|
||||||
|
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
|
||||||
|
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
|
||||||
|
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
|
||||||
|
assertEquals(0, r[9].toInt()) // blink untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modelResolution() {
|
||||||
|
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
|
||||||
|
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
|
||||||
|
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
|
||||||
|
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
|
||||||
|
assertEquals(null, DsDevice.modelFor(0x1234))
|
||||||
|
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
|
||||||
|
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
|
||||||
|
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,10 +17,12 @@ use super::display::{
|
|||||||
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||||
};
|
};
|
||||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
||||||
|
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||||
use super::setup::{
|
use super::setup::{
|
||||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||||
configure_low_latency, create_codec, try_set_frame_rate,
|
configure_low_latency, create_codec, try_set_frame_rate,
|
||||||
};
|
};
|
||||||
|
use super::vsync::{now_monotonic_ns, VsyncClock};
|
||||||
use super::{
|
use super::{
|
||||||
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
|
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
|
||||||
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
|
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
|
||||||
@@ -55,6 +57,8 @@ enum DecodeEvent {
|
|||||||
},
|
},
|
||||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||||
FormatChanged,
|
FormatChanged,
|
||||||
|
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
|
||||||
|
Vsync,
|
||||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||||
Error { fatal: bool },
|
Error { fatal: bool },
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,9 @@ pub(super) fn run_async(
|
|||||||
ll_feature,
|
ll_feature,
|
||||||
low_latency_mode,
|
low_latency_mode,
|
||||||
is_tv,
|
is_tv,
|
||||||
|
present_priority,
|
||||||
|
smooth_buffer,
|
||||||
|
panel_hz,
|
||||||
} = opts;
|
} = opts;
|
||||||
boost_thread_priority();
|
boost_thread_priority();
|
||||||
let mode = client.mode();
|
let mode = client.mode();
|
||||||
@@ -196,9 +203,33 @@ pub(super) fn run_async(
|
|||||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
// reclaimed after the codec is dropped below.
|
// reclaimed after the codec is dropped below.
|
||||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
let meter = Arc::new(PresentMeter::new());
|
||||||
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
|
||||||
let render_cb = install_render_callback(&codec, &tracker);
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
|
|
||||||
|
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
|
||||||
|
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
|
||||||
|
// legacy release-immediately path for a rebuild-free on-device A/B.
|
||||||
|
let mut presenter = if presenter_disabled_by_sysprop() {
|
||||||
|
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
|
||||||
|
log::info!(
|
||||||
|
"decode: presenter = timeline ({})",
|
||||||
|
match priority {
|
||||||
|
PresentPriority::Latency => "lowest latency".to_string(),
|
||||||
|
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Some(Presenter::new(priority))
|
||||||
|
};
|
||||||
|
stats.set_presenter_active(presenter.is_some());
|
||||||
|
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
|
||||||
|
// the same event channel. The Sender parks here until that moment.
|
||||||
|
let mut vsync: Option<VsyncClock> = None;
|
||||||
|
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
|
||||||
|
|
||||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||||
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||||
let feeder = {
|
let feeder = {
|
||||||
@@ -238,6 +269,8 @@ pub(super) fn run_async(
|
|||||||
|
|
||||||
let mut free_inputs: VecDeque<usize> = VecDeque::new();
|
let mut free_inputs: VecDeque<usize> = VecDeque::new();
|
||||||
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
|
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
|
||||||
|
// Phase-lock v3: per-AU arrival stamps for the circular arrival-lead report (drained 1 Hz).
|
||||||
|
let mut arrival_stamps: Vec<i128> = Vec::new();
|
||||||
let mut ready: Vec<OutputReady> = Vec::new();
|
let mut ready: Vec<OutputReady> = Vec::new();
|
||||||
let mut applied_ds: Option<DataSpace> = None;
|
let mut applied_ds: Option<DataSpace> = None;
|
||||||
let mut fed: u64 = 0;
|
let mut fed: u64 = 0;
|
||||||
@@ -245,6 +278,8 @@ pub(super) 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;
|
||||||
|
// Slice-progressive continuity ledger (see `PartFeed`).
|
||||||
|
let mut part_open: Option<PartFeed> = None;
|
||||||
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
// 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
|
// (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
|
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||||
@@ -277,6 +312,7 @@ pub(super) fn run_async(
|
|||||||
};
|
};
|
||||||
let work_t0 = Instant::now();
|
let work_t0 = Instant::now();
|
||||||
let mut fmt_dirty = false;
|
let mut fmt_dirty = false;
|
||||||
|
let mut vsync_tick = false;
|
||||||
let mut aus_dropped: u64 = 0;
|
let mut aus_dropped: u64 = 0;
|
||||||
if let Some(ev) = ev0 {
|
if let Some(ev) = ev0 {
|
||||||
aus_dropped += u64::from(dispatch_event(
|
aus_dropped += u64::from(dispatch_event(
|
||||||
@@ -285,9 +321,11 @@ pub(super) fn run_async(
|
|||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
|
&mut vsync_tick,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
&mut gate,
|
&mut gate,
|
||||||
&mut recovery_flags,
|
&mut recovery_flags,
|
||||||
|
&mut arrival_stamps,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// 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
|
||||||
@@ -299,11 +337,18 @@ pub(super) fn run_async(
|
|||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
|
&mut vsync_tick,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
&mut gate,
|
&mut gate,
|
||||||
&mut recovery_flags,
|
&mut recovery_flags,
|
||||||
|
&mut arrival_stamps,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if vsync_tick {
|
||||||
|
if let Some(p) = presenter.as_mut() {
|
||||||
|
p.on_vsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||||
if fmt_dirty {
|
if fmt_dirty {
|
||||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||||
@@ -315,8 +360,11 @@ pub(super) fn run_async(
|
|||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut fed,
|
&mut fed,
|
||||||
&mut oversized_dropped,
|
&mut oversized_dropped,
|
||||||
|
&mut part_open,
|
||||||
|
&mut gate,
|
||||||
);
|
);
|
||||||
let had_output = !ready.is_empty();
|
let had_output = !ready.is_empty();
|
||||||
|
let rendered_before = rendered;
|
||||||
present_ready(
|
present_ready(
|
||||||
&codec,
|
&codec,
|
||||||
&client,
|
&client,
|
||||||
@@ -326,14 +374,88 @@ pub(super) fn run_async(
|
|||||||
&in_flight,
|
&in_flight,
|
||||||
clock_offset.load(Ordering::Relaxed),
|
clock_offset.load(Ordering::Relaxed),
|
||||||
&tracker,
|
&tracker,
|
||||||
|
&mut presenter,
|
||||||
&mut rendered,
|
&mut rendered,
|
||||||
&mut discarded,
|
&mut discarded,
|
||||||
&mut gate,
|
&mut gate,
|
||||||
&mut recovery_flags,
|
&mut recovery_flags,
|
||||||
);
|
);
|
||||||
|
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
|
||||||
|
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
|
||||||
|
// even when the choreographer clock is absent.
|
||||||
|
if let Some(p) = presenter.as_mut() {
|
||||||
|
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||||
|
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||||
|
rendered += 1;
|
||||||
|
}
|
||||||
|
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||||
|
// CIRCULAR mean + coherence of the ARRIVAL lead — each AU's reassembly stamp
|
||||||
|
// against the panel's latch grid — because arrival is the phase the host actually
|
||||||
|
// controls; the v2 latch statistic measured downstream of the decoder pipeline,
|
||||||
|
// which absorbed the actuation (on-glass 2026-07-31). Timestamps convert
|
||||||
|
// monotonic→realtime→host — the skew offset lives client-side.
|
||||||
|
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
|
||||||
|
let period = c.panel_period_ns().max(c.period_ns());
|
||||||
|
if period > 0 {
|
||||||
|
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
|
||||||
|
let mono_now = now_monotonic_ns();
|
||||||
|
let real_now = now_realtime_ns();
|
||||||
|
let leads_us: Vec<u64> = arrival_stamps
|
||||||
|
.iter()
|
||||||
|
.map(|&r_ns| {
|
||||||
|
let arrival_mono = mono_now as i128 - (real_now - r_ns);
|
||||||
|
((t.expected_present_ns as i128 - arrival_mono)
|
||||||
|
.rem_euclid(period as i128)
|
||||||
|
/ 1000) as u64
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
arrival_stamps.clear();
|
||||||
|
if let Some((lead_mean_ns, coherence)) =
|
||||||
|
punktfunk_core::phase::circular_latch(&leads_us, period)
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
target: "pf.phase",
|
||||||
|
"arrival lead circ={:.2}ms coh={}",
|
||||||
|
lead_mean_ns as f64 / 1e6,
|
||||||
|
coherence
|
||||||
|
);
|
||||||
|
let latch_real_ns =
|
||||||
|
real_now + (t.expected_present_ns - mono_now) as i128;
|
||||||
|
let latch_host_ns = (latch_real_ns
|
||||||
|
+ clock_offset.load(Ordering::Relaxed) as i128)
|
||||||
|
.max(0) as u64;
|
||||||
|
client.report_phase(
|
||||||
|
latch_host_ns,
|
||||||
|
period.clamp(0, u32::MAX as i64) as u32,
|
||||||
|
1_000_000, // skew residual — conservative 1 ms
|
||||||
|
lead_mean_ns.min(u32::MAX as u64) as u32,
|
||||||
|
coherence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let presented_now = rendered > rendered_before;
|
||||||
|
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
|
||||||
|
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
|
||||||
|
// lesson). A `None` from start (no choreographer surface) simply leaves ASAP targets.
|
||||||
|
if had_output && vsync.is_none() {
|
||||||
|
if let Some(tx) = vsync_tx.take() {
|
||||||
|
vsync = VsyncClock::start(
|
||||||
|
panel_hz,
|
||||||
|
Box::new(move || {
|
||||||
|
let _ = tx.send(DecodeEvent::Vsync);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if vsync.is_none() {
|
||||||
|
log::info!("decode: no choreographer clock — presenter uses ASAP targets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||||
if had_output {
|
if presented_now {
|
||||||
if !hint_tried {
|
if !hint_tried {
|
||||||
hint_tried = true;
|
hint_tried = true;
|
||||||
let tids = client.hot_thread_ids();
|
let tids = client.hot_thread_ids();
|
||||||
@@ -420,6 +542,10 @@ pub(super) fn run_async(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(p) = presenter.as_mut() {
|
||||||
|
p.release_all(&codec); // hand every held output buffer back before the codec stops
|
||||||
|
}
|
||||||
|
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
|
||||||
let _ = codec.stop();
|
let _ = codec.stop();
|
||||||
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||||
if let Some(j) = feeder {
|
if let Some(j) = feeder {
|
||||||
@@ -448,6 +574,9 @@ fn feeder_loop(
|
|||||||
) {
|
) {
|
||||||
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
|
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
|
||||||
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||||
|
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
|
||||||
|
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
|
||||||
|
let mut last_phase_ack: Option<i32> = None;
|
||||||
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) => {
|
||||||
@@ -455,11 +584,15 @@ fn feeder_loop(
|
|||||||
// invalidation request so an RFI-capable host recovers with a cheap clean P-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
|
// 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.
|
// 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);
|
// Slice-progressive parts repeat their AU's index — note it once, on the
|
||||||
|
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
|
||||||
|
// counting AUs.
|
||||||
|
let au_first = frame.part.is_none_or(|p| p.first);
|
||||||
|
let gap = au_first && client.note_frame_index(frame.frame_index);
|
||||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
// 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
|
// 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.
|
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||||
if stats.enabled() || measure_decode {
|
if (stats.enabled() || measure_decode) && frame.complete {
|
||||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
||||||
// here would fold the hand-off queue wait into the network latency figure
|
// here would fold the hand-off queue wait into the network latency figure
|
||||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
// (a client-side standing backlog masquerading as network). 0 = older core.
|
||||||
@@ -482,7 +615,10 @@ fn feeder_loop(
|
|||||||
let lat_ns = received_ns + clock_offset - frame.pts_ns 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)
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
.then_some((lat_ns / 1000) as u64);
|
.then_some((lat_ns / 1000) as u64);
|
||||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
// On a parts stream the completing delivery carries only the AU's
|
||||||
|
// suffix — its offset restores the full AU byte count for bitrate.
|
||||||
|
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
|
||||||
|
stats.note_received(au_len, lat_us, clock_offset != 0);
|
||||||
if let Some(hostnet_us) = lat_us {
|
if let Some(hostnet_us) = lat_us {
|
||||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||||
@@ -490,6 +626,17 @@ fn feeder_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||||
|
// Phase-lock closed-loop readout: the host's applied hold rides the
|
||||||
|
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
|
||||||
|
// once a second). None = a host without the tail (pre-phase-lock).
|
||||||
|
if t.applied_phase_ns != last_phase_ack {
|
||||||
|
log::info!(
|
||||||
|
target: "pf.phase",
|
||||||
|
"host applied_phase={:?}us",
|
||||||
|
t.applied_phase_ns.map(|n| n / 1000)
|
||||||
|
);
|
||||||
|
last_phase_ack = t.applied_phase_ns;
|
||||||
|
}
|
||||||
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();
|
||||||
@@ -520,9 +667,11 @@ fn dispatch_event(
|
|||||||
free_inputs: &mut VecDeque<usize>,
|
free_inputs: &mut VecDeque<usize>,
|
||||||
ready: &mut Vec<OutputReady>,
|
ready: &mut Vec<OutputReady>,
|
||||||
fmt_dirty: &mut bool,
|
fmt_dirty: &mut bool,
|
||||||
|
vsync_tick: &mut bool,
|
||||||
fatal: &mut bool,
|
fatal: &mut bool,
|
||||||
gate: &mut ReanchorGate,
|
gate: &mut ReanchorGate,
|
||||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
|
arrival_stamps: &mut Vec<i128>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
match ev {
|
match ev {
|
||||||
DecodeEvent::Au(f, gap) => {
|
DecodeEvent::Au(f, gap) => {
|
||||||
@@ -531,9 +680,27 @@ fn dispatch_event(
|
|||||||
if gap {
|
if gap {
|
||||||
gate.arm(Instant::now());
|
gate.arm(Instant::now());
|
||||||
}
|
}
|
||||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
// One entry per AU (parts share the pts): the completing delivery carries it.
|
||||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
if f.complete {
|
||||||
recovery_flags.pop_front();
|
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||||
|
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||||
|
recovery_flags.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Phase-lock v3 sensor: the ARRIVAL stamp (reassembly completion, realtime) — the
|
||||||
|
// phase the host actually controls. The latch-based v2 sensor measured downstream
|
||||||
|
// of the decoder pipeline, which absorbed the host's actuation (on-glass 07-31).
|
||||||
|
// Phase sensor: AU completion is the arrival the host's hold actually moves —
|
||||||
|
// prefix parts would smear the phase toward the first slice's landing.
|
||||||
|
if f.complete {
|
||||||
|
arrival_stamps.push(if f.received_ns > 0 {
|
||||||
|
f.received_ns as i128
|
||||||
|
} else {
|
||||||
|
now_realtime_ns()
|
||||||
|
});
|
||||||
|
if arrival_stamps.len() > 256 {
|
||||||
|
arrival_stamps.remove(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pending_aus.push_back(f);
|
pending_aus.push_back(f);
|
||||||
if pending_aus.len() > FRAME_PARK_CAP {
|
if pending_aus.len() > FRAME_PARK_CAP {
|
||||||
@@ -552,6 +719,7 @@ fn dispatch_event(
|
|||||||
decoded_ns,
|
decoded_ns,
|
||||||
}),
|
}),
|
||||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||||
|
DecodeEvent::Vsync => *vsync_tick = true,
|
||||||
DecodeEvent::Error { fatal: f } => {
|
DecodeEvent::Error { fatal: f } => {
|
||||||
if f {
|
if f {
|
||||||
*fatal = true;
|
*fatal = true;
|
||||||
@@ -565,10 +733,35 @@ fn dispatch_event(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `AMEDIACODEC_BUFFER_FLAG_PARTIAL_FRAME` (NDK ≥ 26, gated by the Kotlin
|
||||||
|
/// `FEATURE_PartialFrame` probe): this input buffer is a PIECE of an AU — the codec assembles
|
||||||
|
/// pieces until a buffer WITHOUT the flag closes the AU.
|
||||||
|
const BUFFER_FLAG_PARTIAL_FRAME: u32 = 8;
|
||||||
|
|
||||||
|
/// The slice-progressive feed's open access unit: parts already queued into the codec under
|
||||||
|
/// [`BUFFER_FLAG_PARTIAL_FRAME`], awaiting the rest. Loop-local — a codec rebuild tears the
|
||||||
|
/// whole loop down, so the state can never outlive the codec instance it fed.
|
||||||
|
pub(super) struct PartFeed {
|
||||||
|
index: u32,
|
||||||
|
/// The AU byte offset the next part must carry — a mismatch means the hand-off dropped a
|
||||||
|
/// piece (memory cap / jump-to-live clear) and the AU is unrecoverable.
|
||||||
|
expected: usize,
|
||||||
|
/// The dead-close pts: an abandoned AU is CLOSED with an empty non-PARTIAL buffer at its
|
||||||
|
/// own pts — the codec then emits (concealed garbage) at that pts, which the reanchor
|
||||||
|
/// freeze gate withholds from glass while the keyframe request recovers the chain. No
|
||||||
|
/// mid-stream codec flush needed.
|
||||||
|
pts_us: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
||||||
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
||||||
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
||||||
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||||
|
///
|
||||||
|
/// Slice-progressive deliveries ([`Frame::part`]) feed as they arrive: every piece rides
|
||||||
|
/// [`BUFFER_FLAG_PARTIAL_FRAME`] except the AU's last, all at the AU's pts. `part_open` is the
|
||||||
|
/// continuity ledger — any break (gap, orphan, oversize) abandons the AU per [`PartFeed::pts_us`]'s
|
||||||
|
/// close contract and re-syncs at the next `first`.
|
||||||
fn feed_ready(
|
fn feed_ready(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
client: &NativeClient,
|
client: &NativeClient,
|
||||||
@@ -576,11 +769,49 @@ fn feed_ready(
|
|||||||
free_inputs: &mut VecDeque<usize>,
|
free_inputs: &mut VecDeque<usize>,
|
||||||
fed: &mut u64,
|
fed: &mut u64,
|
||||||
oversized_dropped: &mut u64,
|
oversized_dropped: &mut u64,
|
||||||
|
part_open: &mut Option<PartFeed>,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
) {
|
) {
|
||||||
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||||
let idx = free_inputs.pop_front().unwrap();
|
let idx = free_inputs.pop_front().unwrap();
|
||||||
let frame = pending_aus.pop_front().unwrap();
|
let frame = pending_aus.pop_front().unwrap();
|
||||||
let pts_us = frame.pts_ns / 1000;
|
let pts_us = frame.pts_ns / 1000;
|
||||||
|
let (first, last, offset) = match frame.part {
|
||||||
|
None => (true, true, 0usize),
|
||||||
|
Some(p) => (p.first, p.last, p.offset as usize),
|
||||||
|
};
|
||||||
|
// Continuity ledger. `continues` = this piece extends the open AU exactly;
|
||||||
|
// anything else with an AU open means that AU died mid-flight and must be closed
|
||||||
|
// (empty non-PARTIAL buffer at ITS pts) before this frame may touch the codec.
|
||||||
|
let continues = part_open
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|o| frame.frame_index == o.index && offset == o.expected && !first);
|
||||||
|
if !continues {
|
||||||
|
if let Some(o) = part_open.take() {
|
||||||
|
// Spend THIS slot on the close; the current frame re-queues for the next one.
|
||||||
|
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, 0, o.pts_us, 0) {
|
||||||
|
log::warn!("decode: close of abandoned partial AU {}: {e}", o.index);
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"decode: partial AU {} abandoned mid-feed — closed empty, requesting keyframe",
|
||||||
|
o.index
|
||||||
|
);
|
||||||
|
// The close makes the codec emit concealed garbage at the dead pts — freeze it
|
||||||
|
// off the glass until the recovery keyframe re-anchors.
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
pending_aus.push_front(frame);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// No AU open: an orphan non-first piece lost its head upstream — discard and
|
||||||
|
// re-sync at the next `first` (the recovery request rides the same loss).
|
||||||
|
if !first {
|
||||||
|
free_inputs.push_front(idx);
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(dst) = codec.input_buffer(idx) else {
|
let Some(dst) = codec.input_buffer(idx) else {
|
||||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||||
continue;
|
continue;
|
||||||
@@ -597,6 +828,16 @@ fn feed_ready(
|
|||||||
*oversized_dropped
|
*oversized_dropped
|
||||||
);
|
);
|
||||||
let _ = client.request_keyframe();
|
let _ = client.request_keyframe();
|
||||||
|
if frame.part.is_some() {
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
// Pieces already queued can't be unqueued: poison the ledger so the next
|
||||||
|
// delivery mismatches and takes the close-empty path above.
|
||||||
|
*part_open = Some(PartFeed {
|
||||||
|
index: frame.frame_index,
|
||||||
|
expected: usize::MAX,
|
||||||
|
pts_us,
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let n = au.len();
|
let n = au.len();
|
||||||
@@ -605,21 +846,44 @@ fn feed_ready(
|
|||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
||||||
}
|
}
|
||||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
|
let flags = if last { 0 } else { BUFFER_FLAG_PARTIAL_FRAME };
|
||||||
|
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, flags) {
|
||||||
log::warn!("decode: queue_input_buffer_by_index: {e}");
|
log::warn!("decode: queue_input_buffer_by_index: {e}");
|
||||||
|
if frame.part.is_some() && !last {
|
||||||
|
// The piece never reached the codec — same unrecoverable-AU shape as oversize.
|
||||||
|
*part_open = Some(PartFeed {
|
||||||
|
index: frame.frame_index,
|
||||||
|
expected: usize::MAX,
|
||||||
|
pts_us,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
*fed += 1;
|
// `fed` counts ACCESS UNITS toward the HUD's fed/decoded balance — the closing
|
||||||
|
// piece (or a whole AU) bumps it.
|
||||||
|
if last {
|
||||||
|
*fed += 1;
|
||||||
|
}
|
||||||
|
*part_open = if last {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(PartFeed {
|
||||||
|
index: frame.frame_index,
|
||||||
|
expected: offset + n,
|
||||||
|
pts_us,
|
||||||
|
})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
|
/// Route the ready outputs toward glass. With the timeline presenter (default): fold each output
|
||||||
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
/// through the re-anchor gate in pts order, hand the approved ones to the presenter's store
|
||||||
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
/// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and
|
||||||
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
/// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop):
|
||||||
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
|
/// present only the NEWEST ready output immediately and release the rest unrendered — the
|
||||||
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
|
/// original policy. Every dequeued buffer, rendered or not, is the HUD's `decoded` measurement
|
||||||
/// drained.
|
/// point (it finished decoding either way); samples are recorded in pts order so the receipt-map
|
||||||
|
/// eviction stays monotonic. `ready` is drained.
|
||||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||||
fn present_ready(
|
fn present_ready(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
@@ -630,6 +894,7 @@ fn present_ready(
|
|||||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
tracker: &DisplayTracker,
|
tracker: &DisplayTracker,
|
||||||
|
presenter: &mut Option<Presenter>,
|
||||||
rendered: &mut u64,
|
rendered: &mut u64,
|
||||||
discarded: &mut u64,
|
discarded: &mut u64,
|
||||||
gate: &mut ReanchorGate,
|
gate: &mut ReanchorGate,
|
||||||
@@ -657,31 +922,46 @@ fn present_ready(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
// 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
|
// so the two-mark re-anchor count stays correct; a `false` verdict is withheld concealment (the
|
||||||
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
// SurfaceView keeps the last rendered frame frozen on).
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let last = ready.len() - 1;
|
|
||||||
let mut skipped: u64 = 0;
|
let mut skipped: u64 = 0;
|
||||||
for (i, o) in ready.drain(..).enumerate() {
|
if let Some(p) = presenter.as_mut() {
|
||||||
let flags = take_flags(recovery_flags, o.pts_us);
|
for o in ready.drain(..) {
|
||||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
let flags = take_flags(recovery_flags, o.pts_us);
|
||||||
let render = i == last && present;
|
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
|
||||||
match codec.release_output_buffer_by_index(o.index, render) {
|
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
|
||||||
Ok(()) if render => {
|
skipped += dropped;
|
||||||
*rendered += 1;
|
*discarded += dropped;
|
||||||
if stats.enabled() {
|
} else {
|
||||||
tracker.note_rendered(o.pts_us, o.decoded_ns);
|
if let Err(e) = codec.release_output_buffer_by_index(o.index, false) {
|
||||||
|
log::warn!("decode: release_output_buffer_by_index({}): {e}", o.index);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(()) => {
|
|
||||||
*discarded += 1;
|
*discarded += 1;
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
log::warn!(
|
} else {
|
||||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
let last = ready.len() - 1;
|
||||||
o.index
|
for (i, o) in ready.drain(..).enumerate() {
|
||||||
)
|
let flags = take_flags(recovery_flags, o.pts_us);
|
||||||
|
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||||
|
let render = i == last && present;
|
||||||
|
match codec.release_output_buffer_by_index(o.index, render) {
|
||||||
|
Ok(()) if render => {
|
||||||
|
*rendered += 1;
|
||||||
|
tracker.note_rendered(o.pts_us, o.decoded_ns, now_realtime_ns());
|
||||||
|
}
|
||||||
|
Ok(()) => {
|
||||||
|
*discarded += 1;
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||||
|
o.index
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,32 +35,37 @@ pub(super) struct DisplayTracker {
|
|||||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||||
clock_offset: Arc<AtomicI64>,
|
clock_offset: Arc<AtomicI64>,
|
||||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
|
||||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
|
||||||
/// callback early-outs) while the overlay is hidden.
|
meter: Arc<super::presenter::PresentMeter>,
|
||||||
rendered: Mutex<VecDeque<(u64, i128)>>,
|
/// `(pts_us, decoded_real_ns, released_real_ns)` of frames released with `render = true`, in
|
||||||
|
/// release order, awaiting their callback. Pushed on EVERY render (no HUD gate — the ring is
|
||||||
|
/// a 64-tuple bound and the latch metric wants to exist when nobody is watching).
|
||||||
|
rendered: Mutex<VecDeque<(u64, i128, i128)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DisplayTracker {
|
impl DisplayTracker {
|
||||||
pub(super) fn new(
|
pub(super) fn new(
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
clock_offset: Arc<AtomicI64>,
|
clock_offset: Arc<AtomicI64>,
|
||||||
|
meter: Arc<super::presenter::PresentMeter>,
|
||||||
) -> Arc<DisplayTracker> {
|
) -> Arc<DisplayTracker> {
|
||||||
Arc::new(DisplayTracker {
|
Arc::new(DisplayTracker {
|
||||||
stats,
|
stats,
|
||||||
clock_offset,
|
clock_offset,
|
||||||
|
meter,
|
||||||
rendered: Mutex::new(VecDeque::new()),
|
rendered: Mutex::new(VecDeque::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
|
/// Park one just-rendered frame's `(pts, decoded stamp, release stamp)` for the render
|
||||||
/// Caller gates on the HUD being visible.
|
/// callback to pair — the release stamp is the latch metric's start (release→displayed).
|
||||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
|
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128, released_ns: i128) {
|
||||||
let mut g = self
|
let mut g = self
|
||||||
.rendered
|
.rendered
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
g.push_back((pts_us, decoded_ns));
|
g.push_back((pts_us, decoded_ns, released_ns));
|
||||||
if g.len() > RENDERED_CAP {
|
if g.len() > RENDERED_CAP {
|
||||||
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
||||||
}
|
}
|
||||||
@@ -152,36 +157,42 @@ unsafe extern "C" fn on_frame_rendered(
|
|||||||
// `Arc::into_raw` pointer from `install_render_callback`, whose refcount is held for as long as
|
// `Arc::into_raw` pointer from `install_render_callback`, whose refcount is held for as long as
|
||||||
// the codec exists, and the codec is what delivers this call.
|
// the codec exists, and the codec is what delivers this call.
|
||||||
let t = unsafe { &*(userdata as *const DisplayTracker) };
|
let t = unsafe { &*(userdata as *const DisplayTracker) };
|
||||||
if !t.stats.enabled() {
|
|
||||||
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
|
||||||
}
|
|
||||||
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
||||||
let pts_us = media_time_us.max(0) as u64;
|
let pts_us = media_time_us.max(0) as u64;
|
||||||
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
||||||
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
|
// dropped by the platform) — same monotonic-eviction discipline as `note_decoded_pts`.
|
||||||
// discipline as `note_decoded_pts`.
|
let mut paired = None;
|
||||||
let mut decoded_ns = None;
|
|
||||||
{
|
{
|
||||||
let mut g = t
|
let mut g = t
|
||||||
.rendered
|
.rendered
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
while let Some(&(p, d)) = g.front() {
|
while let Some(&(p, d, r)) = g.front() {
|
||||||
if p > pts_us {
|
if p > pts_us {
|
||||||
break; // future frame — leave it for its own callback
|
break; // future frame — leave it for its own callback
|
||||||
}
|
}
|
||||||
g.pop_front();
|
g.pop_front();
|
||||||
if p == pts_us {
|
if p == pts_us {
|
||||||
decoded_ns = Some(d);
|
paired = Some((d, r));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Clamped to (0, 10 s) like the e2e sample: a vendor's first render callbacks can carry a
|
||||||
|
// garbage `system_nano` (observed on-glass: an epoch-sized latch max on the session's first
|
||||||
|
// window), and one such sample would poison every max/percentile it lands in.
|
||||||
|
let clamp = |v: i128| (v > 0 && v < 10_000_000_000).then_some((v / 1000) as u64);
|
||||||
|
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
|
||||||
|
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
|
||||||
|
// Always-on half: the presenter's pf-present line reads these with the HUD off.
|
||||||
|
t.meter.note_latch(latch_us);
|
||||||
|
if !t.stats.enabled() {
|
||||||
|
return; // HUD hidden — skip the skew math + the stats lock
|
||||||
|
}
|
||||||
let e2e_ns =
|
let e2e_ns =
|
||||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||||
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
t.stats.note_displayed(e2e_us, display_us, latch_us);
|
||||||
t.stats.note_displayed(e2e_us, display_us);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
mod async_loop;
|
mod async_loop;
|
||||||
mod display;
|
mod display;
|
||||||
mod latency;
|
mod latency;
|
||||||
|
mod presenter;
|
||||||
mod setup;
|
mod setup;
|
||||||
mod sync_loop;
|
mod sync_loop;
|
||||||
|
mod vsync;
|
||||||
|
|
||||||
use async_loop::run_async;
|
use async_loop::run_async;
|
||||||
pub(crate) use setup::{codec_label, codec_mime};
|
pub(crate) use setup::{codec_label, codec_mime};
|
||||||
@@ -106,6 +108,17 @@ pub(crate) struct DecodeOptions {
|
|||||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||||
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||||
pub is_tv: bool,
|
pub is_tv: bool,
|
||||||
|
/// The user's presentation intent (`present_priority` setting): 0 = lowest latency
|
||||||
|
/// (newest-wins), 1 = smoothness (a small FIFO). Resolved by
|
||||||
|
/// [`presenter::PresentPriority::resolve`]; anything else = latency.
|
||||||
|
pub present_priority: i32,
|
||||||
|
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||||
|
/// Only meaningful with `present_priority` = smooth.
|
||||||
|
pub smooth_buffer: i32,
|
||||||
|
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||||
|
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||||
|
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||||
|
pub panel_hz: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
//! The timeline presenter — Android's port of the Apple client's stage-4 deadline discipline
|
||||||
|
//! (`clients/apple/.../Stage2Pipeline.swift`, the `.deadline` pacing):
|
||||||
|
//!
|
||||||
|
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||||
|
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||||
|
//! queueing behind the display;
|
||||||
|
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||||
|
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||||
|
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||||
|
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||||
|
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||||
|
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||||
|
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||||
|
//! identical to the legacy path — and only the budget prediction uses the measured period.
|
||||||
|
//!
|
||||||
|
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
|
||||||
|
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
|
||||||
|
//! A/B needs no rebuild. The user-facing escape hatch stays the "Low-latency mode" master toggle
|
||||||
|
//! (off = the synchronous pre-overhaul loop, no presenter at all).
|
||||||
|
|
||||||
|
use ndk::media::media_codec::MediaCodec;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use super::display::DisplayTracker;
|
||||||
|
use super::latency::now_realtime_ns;
|
||||||
|
use super::vsync::VsyncShared;
|
||||||
|
|
||||||
|
/// Submit-margin ahead of a timeline's EXPECTED PRESENT — SurfaceFlinger's own latch lead: the
|
||||||
|
/// released buffer must be in the BufferQueue by SF's wakeup for that vsync (a few ms before
|
||||||
|
/// present). A present closer than this is treated as missed and the next one is targeted. This
|
||||||
|
/// is deliberately NOT the timeline's `deadline` (which budgets for GPU rendering a video
|
||||||
|
/// buffer doesn't do — see `VsyncShared::next_target`); a too-tight gamble here presents one
|
||||||
|
/// vsync later, the exact cost the deadline gate paid on every frame.
|
||||||
|
///
|
||||||
|
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||||
|
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||||
|
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||||
|
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||||
|
/// signal to widen, not stutter.
|
||||||
|
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||||
|
|
||||||
|
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||||
|
/// setprop + stream restart, no rebuild. Unset/invalid = `None` = the adaptive default.
|
||||||
|
fn latch_margin_ns() -> Option<i64> {
|
||||||
|
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||||
|
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||||
|
let n = unsafe {
|
||||||
|
libc::__system_property_get(
|
||||||
|
c"debug.punktfunk.latch_margin_us".as_ptr(),
|
||||||
|
buf.as_mut_ptr().cast(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if n > 0 {
|
||||||
|
if let Ok(us) = std::str::from_utf8(&buf[..n as usize])
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.parse::<i64>()
|
||||||
|
{
|
||||||
|
if (0..=8_000).contains(&us) {
|
||||||
|
return Some(us * 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The budget's liveness backstop: a release whose predicted latch never seems to arrive
|
||||||
|
/// (clock glitch, mode switch) force-reopens the budget this long after the release, counted in
|
||||||
|
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||||
|
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||||
|
|
||||||
|
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||||
|
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||||
|
|
||||||
|
/// The user's presentation intent — the Apple client's `PresentPriority`, same resolution rules:
|
||||||
|
/// anything but an explicit "smooth" is latency; a smooth buffer outside 1..=3 becomes 2.
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) enum PresentPriority {
|
||||||
|
/// Newest-wins, release the instant the budget opens. The default.
|
||||||
|
Latency,
|
||||||
|
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
|
||||||
|
/// added display latency per slot, which the metrics show rather than hide.
|
||||||
|
Smooth { buffer: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresentPriority {
|
||||||
|
/// From the JNI ints (`presentPriority` 0 = latency / 1 = smooth; `smoothBuffer` 0 = auto).
|
||||||
|
pub(crate) fn resolve(priority: i32, buffer: i32) -> PresentPriority {
|
||||||
|
if priority != 1 {
|
||||||
|
return PresentPriority::Latency;
|
||||||
|
}
|
||||||
|
let b = if (1..=3).contains(&buffer) {
|
||||||
|
buffer as usize
|
||||||
|
} else {
|
||||||
|
2
|
||||||
|
};
|
||||||
|
PresentPriority::Smooth { buffer: b }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One decoded output buffer held for presentation.
|
||||||
|
struct HeldFrame {
|
||||||
|
index: usize,
|
||||||
|
pts_us: u64,
|
||||||
|
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
|
||||||
|
decoded_ns: i128,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one-in-flight glass budget.
|
||||||
|
struct InFlight {
|
||||||
|
/// Monotonic instant the budget reopens: the release target's expected present (clock), or
|
||||||
|
/// `release + period` on the fallback path.
|
||||||
|
reopen_at_ns: i64,
|
||||||
|
released_at_ns: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latch samples + display confirms recorded by the `OnFrameRendered` callback thread, drained by
|
||||||
|
/// the presenter's 1 Hz `pf-present` line. Always on (independent of the HUD) — this is what makes
|
||||||
|
/// a HUD-off wireless A/B readable from logcat.
|
||||||
|
pub(super) struct PresentMeter {
|
||||||
|
inner: Mutex<PresentMeterInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PresentMeterInner {
|
||||||
|
latch_us: Vec<u64>,
|
||||||
|
displays: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresentMeter {
|
||||||
|
pub(super) fn new() -> PresentMeter {
|
||||||
|
PresentMeter {
|
||||||
|
inner: Mutex::new(PresentMeterInner {
|
||||||
|
latch_us: Vec::with_capacity(256),
|
||||||
|
displays: 0,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||||
|
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.displays += 1;
|
||||||
|
if let Some(l) = latch_us {
|
||||||
|
if g.latch_us.len() < 4096 {
|
||||||
|
g.latch_us.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain(&self) -> (Vec<u64>, u64) {
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let displays = g.displays;
|
||||||
|
g.displays = 0;
|
||||||
|
(std::mem::take(&mut g.latch_us), displays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
|
||||||
|
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
|
||||||
|
if v.is_empty() {
|
||||||
|
return (0.0, 0.0);
|
||||||
|
}
|
||||||
|
v.sort_unstable();
|
||||||
|
let p50 = v[v.len() / 2] as f64 / 1000.0;
|
||||||
|
let max = *v.last().unwrap() as f64 / 1000.0;
|
||||||
|
(p50, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct Presenter {
|
||||||
|
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
|
||||||
|
fifo_capacity: usize,
|
||||||
|
frames: VecDeque<HeldFrame>,
|
||||||
|
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
|
||||||
|
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
|
||||||
|
prerolled: bool,
|
||||||
|
inflight: Option<InFlight>,
|
||||||
|
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
|
||||||
|
vsync_tick: bool,
|
||||||
|
// -- 1 Hz pf-present window, always on --
|
||||||
|
released: u64,
|
||||||
|
paced_drops: u64,
|
||||||
|
no_budget: u64,
|
||||||
|
forced: u64,
|
||||||
|
dry: u64,
|
||||||
|
pace_us: Vec<u64>,
|
||||||
|
last_flush: Instant,
|
||||||
|
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||||
|
/// release with NO lead on the NP3 — the fixed 2.5 ms was pure display latency) and
|
||||||
|
/// widens by measurement: `paced` misses in a 1 Hz window push it +500 µs toward
|
||||||
|
/// [`LATCH_MARGIN_NS`], the pre-sweep known-safe ceiling. A sysprop override PINS it.
|
||||||
|
margin_ns: i64,
|
||||||
|
/// `debug.punktfunk.latch_margin_us` was set — the margin is pinned, never adapted.
|
||||||
|
margin_pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Presenter {
|
||||||
|
pub(super) fn new(priority: PresentPriority) -> Presenter {
|
||||||
|
let pinned = latch_margin_ns();
|
||||||
|
let (margin_ns, margin_pinned) = match pinned {
|
||||||
|
Some(ns) => (ns, true),
|
||||||
|
None => (0, false),
|
||||||
|
};
|
||||||
|
log::info!(
|
||||||
|
"presenter: latch margin {}us{}",
|
||||||
|
margin_ns / 1_000,
|
||||||
|
if margin_pinned {
|
||||||
|
" (debug.punktfunk.latch_margin_us pin)"
|
||||||
|
} else {
|
||||||
|
" (adaptive — widens on latch misses)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Presenter {
|
||||||
|
fifo_capacity: match priority {
|
||||||
|
PresentPriority::Latency => 0,
|
||||||
|
PresentPriority::Smooth { buffer } => buffer,
|
||||||
|
},
|
||||||
|
frames: VecDeque::new(),
|
||||||
|
prerolled: false,
|
||||||
|
inflight: None,
|
||||||
|
vsync_tick: false,
|
||||||
|
released: 0,
|
||||||
|
paced_drops: 0,
|
||||||
|
no_budget: 0,
|
||||||
|
forced: 0,
|
||||||
|
dry: 0,
|
||||||
|
pace_us: Vec::with_capacity(256),
|
||||||
|
last_flush: Instant::now(),
|
||||||
|
margin_ns,
|
||||||
|
margin_pinned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
|
||||||
|
/// FIFO's drain pace.
|
||||||
|
pub(super) fn on_vsync(&mut self) {
|
||||||
|
self.vsync_tick = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
|
||||||
|
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
|
||||||
|
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
|
||||||
|
pub(super) fn submit(
|
||||||
|
&mut self,
|
||||||
|
codec: &MediaCodec,
|
||||||
|
index: usize,
|
||||||
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
|
) -> u64 {
|
||||||
|
let mut dropped = 0u64;
|
||||||
|
if self.fifo_capacity == 0 {
|
||||||
|
while let Some(stale) = self.frames.pop_front() {
|
||||||
|
release_unrendered(codec, stale.index);
|
||||||
|
dropped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.frames.push_back(HeldFrame {
|
||||||
|
index,
|
||||||
|
pts_us,
|
||||||
|
decoded_ns,
|
||||||
|
});
|
||||||
|
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
|
||||||
|
if let Some(stale) = self.frames.pop_front() {
|
||||||
|
release_unrendered(codec, stale.index);
|
||||||
|
dropped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.paced_drops += dropped;
|
||||||
|
dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
|
||||||
|
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
|
||||||
|
/// `true` when a frame was released to glass this call.
|
||||||
|
#[allow(clippy::too_many_arguments)] // one call site; the seams are the point
|
||||||
|
pub(super) fn pump(
|
||||||
|
&mut self,
|
||||||
|
codec: &MediaCodec,
|
||||||
|
clock: Option<&VsyncShared>,
|
||||||
|
tracker: &DisplayTracker,
|
||||||
|
stats: &crate::stats::VideoStats,
|
||||||
|
now_mono_ns: i64,
|
||||||
|
) -> bool {
|
||||||
|
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
|
||||||
|
if let Some(f) = &self.inflight {
|
||||||
|
if now_mono_ns >= f.reopen_at_ns {
|
||||||
|
self.inflight = None;
|
||||||
|
} else if now_mono_ns - f.released_at_ns > STALE_REOPEN_NS {
|
||||||
|
self.forced += 1;
|
||||||
|
self.inflight = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pick the frame this pump may release.
|
||||||
|
let frame = if self.fifo_capacity == 0 {
|
||||||
|
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||||
|
} else {
|
||||||
|
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
|
||||||
|
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
|
||||||
|
// the previous frame persists on glass, a repeat by omission, while headroom
|
||||||
|
// rebuilds). Everything is gated on the tick so an idle stream neither counts
|
||||||
|
// underflows nor churns the preroll flag 200×/s.
|
||||||
|
if !self.vsync_tick {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !self.prerolled {
|
||||||
|
if self.frames.len() < self.fifo_capacity {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.prerolled = true;
|
||||||
|
}
|
||||||
|
if self.frames.is_empty() {
|
||||||
|
self.prerolled = false;
|
||||||
|
self.dry += 1;
|
||||||
|
self.vsync_tick = false; // this tick's drain ran (and found nothing)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.frames.pop_front()
|
||||||
|
};
|
||||||
|
let Some(frame) = frame else { return false };
|
||||||
|
if self.inflight.is_some() {
|
||||||
|
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||||
|
// vsync tick / loop pass retries the pairing.
|
||||||
|
self.no_budget += 1;
|
||||||
|
match self.fifo_capacity {
|
||||||
|
0 => self.frames.push_back(frame),
|
||||||
|
_ => self.frames.push_front(frame),
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Release: timeline-timed when the clock has one, ASAP otherwise.
|
||||||
|
let target = clock.and_then(|c| c.next_target(now_mono_ns, self.margin_ns));
|
||||||
|
let released = match target {
|
||||||
|
Some(t) => codec
|
||||||
|
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
|
||||||
|
.map_err(|e| log::warn!("presenter: release_at_time({}): {e}", frame.index)),
|
||||||
|
None => codec
|
||||||
|
.release_output_buffer_by_index(frame.index, true)
|
||||||
|
.map_err(|e| log::warn!("presenter: release({}): {e}", frame.index)),
|
||||||
|
};
|
||||||
|
self.vsync_tick = false;
|
||||||
|
if released.is_err() {
|
||||||
|
return false; // the buffer is gone either way; nothing to book-keep
|
||||||
|
}
|
||||||
|
let period = clock.map(|c| c.period_ns()).filter(|&p| p > 0);
|
||||||
|
// Reopen at SurfaceFlinger's LATCH for the targeted vsync (expected present minus the
|
||||||
|
// latch lead) — the instant SF consumes the queued buffer and the slot frees, so the
|
||||||
|
// next release can target the NEXT refresh. Not the platform `deadline` (with the
|
||||||
|
// aggressive present gate it can already be in the past — an instant reopen would let
|
||||||
|
// two releases pile onto the same vsync) and not the present time itself (a period too
|
||||||
|
// late — it would cap the sustainable rate at roughly half the panel).
|
||||||
|
let reopen_at_ns = target
|
||||||
|
.map(|t| t.expected_present_ns - self.margin_ns)
|
||||||
|
.unwrap_or(now_mono_ns + period.unwrap_or(FALLBACK_PERIOD_NS));
|
||||||
|
self.inflight = Some(InFlight {
|
||||||
|
reopen_at_ns,
|
||||||
|
released_at_ns: now_mono_ns,
|
||||||
|
});
|
||||||
|
self.released += 1;
|
||||||
|
let release_real_ns = now_realtime_ns();
|
||||||
|
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||||
|
if self.pace_us.len() < 4096 {
|
||||||
|
self.pace_us.push(pace_us);
|
||||||
|
}
|
||||||
|
stats.note_release(pace_us);
|
||||||
|
tracker.note_rendered(frame.pts_us, frame.decoded_ns, release_real_ns);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||||
|
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||||
|
while let Some(f) = self.frames.pop_front() {
|
||||||
|
release_unrendered(codec, f.index);
|
||||||
|
}
|
||||||
|
self.inflight = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 1 Hz `pf-present` logcat mirror (target `pf.present`) — the Apple client's Console
|
||||||
|
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||||
|
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||||
|
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||||
|
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||||
|
/// `vsync` (the measured panel period).
|
||||||
|
///
|
||||||
|
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
|
||||||
|
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
|
||||||
|
/// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter).
|
||||||
|
pub(super) fn flush_log(
|
||||||
|
&mut self,
|
||||||
|
meter: &PresentMeter,
|
||||||
|
clock: Option<&VsyncShared>,
|
||||||
|
) -> Option<(u64, u16)> {
|
||||||
|
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.last_flush = Instant::now();
|
||||||
|
let (latch, displays) = meter.drain();
|
||||||
|
if self.released == 0 && displays == 0 {
|
||||||
|
return None; // idle stream — nothing worth a line
|
||||||
|
}
|
||||||
|
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
|
||||||
|
let circ = clock.and_then(|c| {
|
||||||
|
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||||
|
});
|
||||||
|
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||||
|
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||||
|
let panel_ms = clock
|
||||||
|
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
log::info!(
|
||||||
|
target: "pf.present",
|
||||||
|
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||||
|
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||||
|
vsyncMs={:.2} panelMs={:.2}",
|
||||||
|
self.released,
|
||||||
|
displays,
|
||||||
|
self.paced_drops,
|
||||||
|
self.no_budget,
|
||||||
|
self.forced,
|
||||||
|
self.dry,
|
||||||
|
pace_p50,
|
||||||
|
pace_max,
|
||||||
|
latch_p50,
|
||||||
|
latch_max,
|
||||||
|
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||||
|
circ.map(|(_, c)| c).unwrap_or(0),
|
||||||
|
period_ms,
|
||||||
|
panel_ms,
|
||||||
|
);
|
||||||
|
self.released = 0;
|
||||||
|
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
|
||||||
|
// late and coalesces the next frame into `paced`) mean this device's SF does need
|
||||||
|
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
|
||||||
|
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||||
|
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
|
||||||
|
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||||
|
log::warn!(
|
||||||
|
"presenter: {} latch misses in 1s — margin widened to {}us",
|
||||||
|
self.paced_drops,
|
||||||
|
self.margin_ns / 1_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.paced_drops = 0;
|
||||||
|
self.no_budget = 0;
|
||||||
|
self.forced = 0;
|
||||||
|
self.dry = 0;
|
||||||
|
circ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_unrendered(codec: &MediaCodec, index: usize) {
|
||||||
|
if let Err(e) = codec.release_output_buffer_by_index(index, false) {
|
||||||
|
log::warn!("presenter: release_output_buffer({index}, false): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `debug.punktfunk.presenter` sysprop: `arrival` = the legacy release-immediately path,
|
||||||
|
/// anything else / unset = the timeline presenter. The rebuild-free on-device A/B lever.
|
||||||
|
pub(super) fn presenter_disabled_by_sysprop() -> bool {
|
||||||
|
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||||
|
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||||
|
let n = unsafe {
|
||||||
|
libc::__system_property_get(
|
||||||
|
c"debug.punktfunk.presenter".as_ptr(),
|
||||||
|
buf.as_mut_ptr().cast(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
n > 0 && &buf[..n as usize] == b"arrival"
|
||||||
|
}
|
||||||
@@ -180,8 +180,14 @@ pub(super) fn boost_thread_priority() {
|
|||||||
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
||||||
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
||||||
/// phone. Falls through to the 2-arg hint on API 30.
|
/// phone. Falls through to the 2-arg hint on API 30.
|
||||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
|
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) — the seamless-preferred hint for
|
||||||
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
|
/// phones/tablets and the universal fallback.
|
||||||
|
///
|
||||||
|
/// Both paths pass `compatibility = FIXED_SOURCE` (1): the stream is fixed-rate video content the
|
||||||
|
/// client cannot re-pace, which is exactly what that value declares — `DEFAULT` (0) told the
|
||||||
|
/// platform the app could adapt to whatever rate it picked, an invitation some OEM refresh
|
||||||
|
/// governors accepted by simply not switching. (The window-level `preferredDisplayModeId` pin in
|
||||||
|
/// `MainActivity.setStreamDisplayMode` is the phone-side belt to this braces.)
|
||||||
///
|
///
|
||||||
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
||||||
/// decline.
|
/// decline.
|
||||||
@@ -200,8 +206,10 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
|||||||
if lib.is_null() {
|
if lib.is_null() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
|
// ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE — fixed-rate video content.
|
||||||
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
const FIXED_SOURCE: i8 = 1;
|
||||||
|
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 =
|
||||||
|
// ALWAYS). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||||
if is_tv {
|
if is_tv {
|
||||||
let sym = libc::dlsym(
|
let sym = libc::dlsym(
|
||||||
lib,
|
lib,
|
||||||
@@ -209,7 +217,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
|||||||
);
|
);
|
||||||
if !sym.is_null() {
|
if !sym.is_null() {
|
||||||
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
||||||
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
|
return set(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE, 1) == 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||||
@@ -217,7 +225,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
|||||||
return false; // device API < 30 — no per-surface frame-rate hint
|
return false; // device API < 30 — no per-surface frame-rate hint
|
||||||
}
|
}
|
||||||
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE) == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ pub(super) fn run_sync(
|
|||||||
ll_feature,
|
ll_feature,
|
||||||
low_latency_mode,
|
low_latency_mode,
|
||||||
is_tv,
|
is_tv,
|
||||||
|
// The timeline presenter lives in the async loop only; this loop IS the escape hatch.
|
||||||
|
present_priority: _,
|
||||||
|
smooth_buffer: _,
|
||||||
|
panel_hz: _,
|
||||||
} = opts;
|
} = opts;
|
||||||
boost_thread_priority();
|
boost_thread_priority();
|
||||||
let mode = client.mode();
|
let mode = client.mode();
|
||||||
@@ -181,7 +185,11 @@ pub(super) fn run_sync(
|
|||||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
// 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(),
|
||||||
|
std::sync::Arc::new(super::presenter::PresentMeter::new()),
|
||||||
|
);
|
||||||
let render_cb = install_render_callback(&codec, &tracker);
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||||
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||||
@@ -598,7 +606,7 @@ fn drain(
|
|||||||
Ok(()) if held_present => {
|
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, super::latency::now_realtime_ns());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
//! The vsync clock behind the timeline presenter: an `AChoreographer` thread publishing the
|
||||||
|
//! panel's vsync grid + upcoming frame timelines, and pulsing the decode loop's event channel so
|
||||||
|
//! a frame parked on a closed glass budget gets its retry tick.
|
||||||
|
//!
|
||||||
|
//! On API 33+ the thread rides `AChoreographer_postVsyncCallback`, whose callback payload carries
|
||||||
|
//! the platform's FRAME TIMELINES — for each upcoming refresh, when SurfaceFlinger expects to
|
||||||
|
//! present and the deadline by which a frame must be submitted to make it. That pair is exactly
|
||||||
|
//! what `AMediaCodec_releaseOutputBufferAtTime` wants as its target. On 31/32 the older
|
||||||
|
//! `postFrameCallback64` supplies only the vsync instant; the presenter then releases ASAP
|
||||||
|
//! (identical to the legacy path) and uses the measured period purely to predict the latch for
|
||||||
|
//! its glass budget.
|
||||||
|
//!
|
||||||
|
//! Every `AChoreographer_*` symbol is dlsym-resolved from `libandroid.so` (mirrors
|
||||||
|
//! [`super::setup::try_set_frame_rate`]): several sit above the crate's API floor, and one hard
|
||||||
|
//! import of a too-new symbol fails `System.loadLibrary` on every older device.
|
||||||
|
//!
|
||||||
|
//! Started LAZILY on the first decoded frame (the Apple deadline presenter's bootstrap lesson:
|
||||||
|
//! an eagerly started clock ticks uselessly for the whole connect window), stopped + joined via
|
||||||
|
//! [`VsyncClock`]'s `Drop`.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// `CLOCK_MONOTONIC` now in nanoseconds — the clock AChoreographer stamps its timelines on and
|
||||||
|
/// the one `AMediaCodec_releaseOutputBufferAtTime` compares against (`System.nanoTime` basis).
|
||||||
|
/// Distinct from the stats path's `CLOCK_REALTIME`: presenter scheduling stays monotonic.
|
||||||
|
pub(super) fn now_monotonic_ns() -> i64 {
|
||||||
|
let mut ts = libc::timespec {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_nsec: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||||
|
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||||
|
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
|
||||||
|
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
|
||||||
|
/// frame, and the last instant it can be submitted to make that present. Monotonic ns.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(super) struct FrameTimeline {
|
||||||
|
pub expected_present_ns: i64,
|
||||||
|
pub deadline_ns: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State the choreographer thread publishes and the decode loop reads. All monotonic ns.
|
||||||
|
pub(super) struct VsyncShared {
|
||||||
|
stop: AtomicBool,
|
||||||
|
/// The latest vsync callback's frame time (0 = no callback yet).
|
||||||
|
last_vsync_ns: AtomicI64,
|
||||||
|
/// Estimated vsync period (EMA over callback deltas / timeline spacing; 0 = unmeasured).
|
||||||
|
///
|
||||||
|
/// ⚠ This is the APP's render rate, not necessarily the panel's: Android down-rates a
|
||||||
|
/// process's vsync stream (frame-rate categories / per-uid overrides), so a quiet UI can be
|
||||||
|
/// served 60 Hz callbacks while the panel scans at 120 (observed on-glass, A024). Pacing
|
||||||
|
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||||
|
/// [`Self::next_target`].
|
||||||
|
period_ns: AtomicI64,
|
||||||
|
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||||
|
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||||
|
panel_period_ns: AtomicI64,
|
||||||
|
/// Callback count, for the one-shot cadence diagnostic log.
|
||||||
|
ticks: std::sync::atomic::AtomicU32,
|
||||||
|
/// The latest callback's upcoming timelines, soonest first. Empty on the 31/32 fallback.
|
||||||
|
timelines: Mutex<Vec<FrameTimeline>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VsyncShared {
|
||||||
|
/// The measured vsync period, or 0 while unmeasured.
|
||||||
|
pub(super) fn period_ns(&self) -> i64 {
|
||||||
|
self.period_ns.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The panel's own refresh period (0 = unknown) — for the pf-present line's decomposition.
|
||||||
|
pub(super) fn panel_period_ns(&self) -> i64 {
|
||||||
|
self.panel_period_ns.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
|
||||||
|
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
|
||||||
|
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
|
||||||
|
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
|
||||||
|
///
|
||||||
|
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
|
||||||
|
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
|
||||||
|
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
|
||||||
|
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
|
||||||
|
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
|
||||||
|
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
|
||||||
|
/// frame presents one vsync later — exactly what the conservative gate always paid.
|
||||||
|
///
|
||||||
|
/// The picked target is then SUBDIVIDED onto the panel grid: the platform reports timelines
|
||||||
|
/// at the app's assigned render rate, but the panel latches at its own — when the app is
|
||||||
|
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
|
||||||
|
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
|
||||||
|
/// by whole panel periods (while its present still clears the margin) restores the true
|
||||||
|
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
|
||||||
|
/// a no-op.
|
||||||
|
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
|
||||||
|
let mut t = {
|
||||||
|
let g = self
|
||||||
|
.timelines
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let found = g
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.expected_present_ns > now_ns + margin_ns)
|
||||||
|
.copied();
|
||||||
|
match found {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
let last = g.last().copied()?;
|
||||||
|
let period = self.period_ns();
|
||||||
|
if period <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// All stored timelines have passed — step the last one forward whole
|
||||||
|
// periods until its present clears `now + margin` again.
|
||||||
|
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
|
||||||
|
let k = behind / period + 1;
|
||||||
|
FrameTimeline {
|
||||||
|
expected_present_ns: last.expected_present_ns + k * period,
|
||||||
|
deadline_ns: last.deadline_ns + k * period,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let panel = self.panel_period_ns.load(Ordering::Relaxed);
|
||||||
|
if panel > 0 {
|
||||||
|
while t.expected_present_ns - panel > now_ns + margin_ns {
|
||||||
|
t.deadline_ns -= panel;
|
||||||
|
t.expected_present_ns -= panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dlsym'd AChoreographer surface ----
|
||||||
|
|
||||||
|
type PostFrameCallback64 =
|
||||||
|
unsafe extern "C" fn(*mut c_void, unsafe extern "C" fn(i64, *mut c_void), *mut c_void);
|
||||||
|
type PostVsyncCallback = unsafe extern "C" fn(
|
||||||
|
*mut c_void,
|
||||||
|
unsafe extern "C" fn(*const c_void, *mut c_void),
|
||||||
|
*mut c_void,
|
||||||
|
);
|
||||||
|
|
||||||
|
struct ChoreoApi {
|
||||||
|
get_instance: unsafe extern "C" fn() -> *mut c_void,
|
||||||
|
/// API 33: vsync callback with frame-timeline payload. Preferred.
|
||||||
|
post_vsync: Option<PostVsyncCallback>,
|
||||||
|
/// API 29 fallback: frame callback with only the vsync instant.
|
||||||
|
post_frame64: Option<PostFrameCallback64>,
|
||||||
|
// AChoreographerFrameCallbackData accessors (API 33; present iff `post_vsync` is).
|
||||||
|
fcd_frame_time: Option<unsafe extern "C" fn(*const c_void) -> i64>,
|
||||||
|
fcd_timelines_len: Option<unsafe extern "C" fn(*const c_void) -> usize>,
|
||||||
|
fcd_preferred_index: Option<unsafe extern "C" fn(*const c_void) -> usize>,
|
||||||
|
fcd_expected_present: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
|
||||||
|
fcd_deadline: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChoreoApi {
|
||||||
|
/// Resolve from `libandroid.so`. `None` when even the baseline symbols are missing.
|
||||||
|
fn resolve() -> Option<ChoreoApi> {
|
||||||
|
// SAFETY: dlopen of the always-mapped libandroid.so (refcount bump, never closed); each
|
||||||
|
// dlsym is null-checked before the transmute to its fn-pointer type.
|
||||||
|
unsafe {
|
||||||
|
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||||
|
if lib.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sym = |name: &std::ffi::CStr| {
|
||||||
|
let p = libc::dlsym(lib, name.as_ptr());
|
||||||
|
(!p.is_null()).then_some(p)
|
||||||
|
};
|
||||||
|
let get_instance = sym(c"AChoreographer_getInstance")?;
|
||||||
|
let post_vsync = sym(c"AChoreographer_postVsyncCallback");
|
||||||
|
let post_frame64 = sym(c"AChoreographer_postFrameCallback64");
|
||||||
|
post_vsync.or(post_frame64)?; // neither post entry point — no clock on this device
|
||||||
|
Some(ChoreoApi {
|
||||||
|
get_instance: std::mem::transmute::<
|
||||||
|
*mut c_void,
|
||||||
|
unsafe extern "C" fn() -> *mut c_void,
|
||||||
|
>(get_instance),
|
||||||
|
post_vsync: post_vsync.map(|p| std::mem::transmute::<*mut c_void, PostVsyncCallback>(p)),
|
||||||
|
post_frame64: post_frame64
|
||||||
|
.map(|p| std::mem::transmute::<*mut c_void, PostFrameCallback64>(p)),
|
||||||
|
fcd_frame_time: sym(c"AChoreographerFrameCallbackData_getFrameTimeNanos").map(|p| {
|
||||||
|
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> i64>(p)
|
||||||
|
}),
|
||||||
|
fcd_timelines_len: sym(c"AChoreographerFrameCallbackData_getFrameTimelinesLength")
|
||||||
|
.map(|p| {
|
||||||
|
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(
|
||||||
|
p,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
fcd_preferred_index: sym(
|
||||||
|
c"AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex",
|
||||||
|
)
|
||||||
|
.map(|p| {
|
||||||
|
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(p)
|
||||||
|
}),
|
||||||
|
fcd_expected_present: sym(
|
||||||
|
c"AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos",
|
||||||
|
)
|
||||||
|
.map(|p| {
|
||||||
|
std::mem::transmute::<
|
||||||
|
*mut c_void,
|
||||||
|
unsafe extern "C" fn(*const c_void, usize) -> i64,
|
||||||
|
>(p)
|
||||||
|
}),
|
||||||
|
fcd_deadline: sym(c"AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos")
|
||||||
|
.map(|p| {
|
||||||
|
std::mem::transmute::<
|
||||||
|
*mut c_void,
|
||||||
|
unsafe extern "C" fn(*const c_void, usize) -> i64,
|
||||||
|
>(p)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a callback invocation needs. Owned by the choreographer thread's stack; callbacks
|
||||||
|
/// only ever fire inside that thread's looper poll, so the borrow can't outlive the thread.
|
||||||
|
struct CallbackCtx {
|
||||||
|
api: ChoreoApi,
|
||||||
|
choreographer: *mut c_void,
|
||||||
|
shared: Arc<VsyncShared>,
|
||||||
|
on_tick: Box<dyn Fn() + Send>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallbackCtx {
|
||||||
|
/// Common tail of both callback flavours: update the grid estimate, publish, pulse, re-arm.
|
||||||
|
fn tick(&self, frame_time_ns: i64, timelines: Vec<FrameTimeline>) {
|
||||||
|
let prev = self
|
||||||
|
.shared
|
||||||
|
.last_vsync_ns
|
||||||
|
.swap(frame_time_ns, Ordering::Relaxed);
|
||||||
|
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||||
|
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||||
|
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||||
|
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||||
|
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||||
|
// valid, widening on a later down-rated window never is.
|
||||||
|
if timelines.len() >= 2 {
|
||||||
|
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||||
|
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||||
|
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||||
|
if cur == 0 || spacing < cur - 200_000 {
|
||||||
|
self.shared
|
||||||
|
.panel_period_ns
|
||||||
|
.store(spacing, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||||
|
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||||
|
if self.shared.ticks.fetch_add(1, Ordering::Relaxed) == 2 {
|
||||||
|
let spacing = if timelines.len() >= 2 {
|
||||||
|
timelines[1].expected_present_ns - timelines[0].expected_present_ns
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
log::info!(
|
||||||
|
"vsync: cadence Δ={:.2}ms timelines={} spacing={:.2}ms panel={:.2}ms",
|
||||||
|
if prev > 0 {
|
||||||
|
(frame_time_ns - prev) as f64 / 1e6
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
timelines.len(),
|
||||||
|
spacing as f64 / 1e6,
|
||||||
|
self.shared.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Period: prefer timeline spacing (exact, straight from the platform), else the delta of
|
||||||
|
// successive callbacks (jittery — EMA'd), clamped to sane panel rates (24..500 Hz).
|
||||||
|
let mut period = 0i64;
|
||||||
|
if timelines.len() >= 2 {
|
||||||
|
period = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||||
|
} else if prev > 0 {
|
||||||
|
period = frame_time_ns - prev;
|
||||||
|
}
|
||||||
|
if (2_000_000..=42_000_000).contains(&period) {
|
||||||
|
let old = self.shared.period_ns.load(Ordering::Relaxed);
|
||||||
|
let smoothed = if old > 0 {
|
||||||
|
(old * 7 + period) / 8
|
||||||
|
} else {
|
||||||
|
period
|
||||||
|
};
|
||||||
|
self.shared.period_ns.store(smoothed, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
if !timelines.is_empty() {
|
||||||
|
let mut g = self
|
||||||
|
.shared
|
||||||
|
.timelines
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
*g = timelines;
|
||||||
|
}
|
||||||
|
(self.on_tick)();
|
||||||
|
if !self.shared.stop.load(Ordering::Relaxed) {
|
||||||
|
self.repost();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repost(&self) {
|
||||||
|
// SAFETY: `choreographer` is this thread's instance; the ctx pointer stays valid for the
|
||||||
|
// thread's life and callbacks only fire on this thread (see the struct doc).
|
||||||
|
unsafe {
|
||||||
|
let ud = self as *const CallbackCtx as *mut c_void;
|
||||||
|
if let Some(post) = self.api.post_vsync {
|
||||||
|
post(self.choreographer, on_vsync, ud);
|
||||||
|
} else if let Some(post) = self.api.post_frame64 {
|
||||||
|
post(self.choreographer, on_frame64, ud);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 33+ trampoline: harvest the frame timelines, then the common tick. Panic-free (an unwind
|
||||||
|
/// out of an `extern "C"` fn aborts).
|
||||||
|
unsafe extern "C" fn on_vsync(data: *const c_void, ud: *mut c_void) {
|
||||||
|
// SAFETY: `ud` is the thread's `CallbackCtx`, alive for the whole poll loop (see struct doc).
|
||||||
|
let ctx = unsafe { &*(ud as *const CallbackCtx) };
|
||||||
|
let api = &ctx.api;
|
||||||
|
let (mut frame_time, mut timelines) = (now_monotonic_ns(), Vec::new());
|
||||||
|
// SAFETY: `data` is the platform's callback payload, valid for this invocation; the accessors
|
||||||
|
// were resolved together with `post_vsync` (same API level) and are only called when present.
|
||||||
|
unsafe {
|
||||||
|
if let Some(f) = api.fcd_frame_time {
|
||||||
|
frame_time = f(data);
|
||||||
|
}
|
||||||
|
if let (Some(len_f), Some(pref_f), Some(exp_f), Some(dl_f)) = (
|
||||||
|
api.fcd_timelines_len,
|
||||||
|
api.fcd_preferred_index,
|
||||||
|
api.fcd_expected_present,
|
||||||
|
api.fcd_deadline,
|
||||||
|
) {
|
||||||
|
let len = len_f(data).min(8);
|
||||||
|
// From the PREFERRED index on: earlier timelines are ones the platform already
|
||||||
|
// considers missed for a frame starting now.
|
||||||
|
let start = pref_f(data).min(len);
|
||||||
|
timelines = (start..len)
|
||||||
|
.map(|i| FrameTimeline {
|
||||||
|
expected_present_ns: exp_f(data, i),
|
||||||
|
deadline_ns: dl_f(data, i),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.tick(frame_time, timelines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 29 fallback trampoline: vsync instant only.
|
||||||
|
unsafe extern "C" fn on_frame64(frame_time_ns: i64, ud: *mut c_void) {
|
||||||
|
// SAFETY: `ud` is the thread's `CallbackCtx` (see `on_vsync`).
|
||||||
|
let ctx = unsafe { &*(ud as *const CallbackCtx) };
|
||||||
|
ctx.tick(frame_time_ns, Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clock: a dedicated looper thread the choreographer calls back on. Dropping stops + joins.
|
||||||
|
pub(super) struct VsyncClock {
|
||||||
|
shared: Arc<VsyncShared>,
|
||||||
|
join: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VsyncClock {
|
||||||
|
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||||
|
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||||
|
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||||
|
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||||
|
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||||
|
/// budget).
|
||||||
|
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||||
|
let api = ChoreoApi::resolve()?;
|
||||||
|
let timelines_live = api.post_vsync.is_some();
|
||||||
|
let shared = Arc::new(VsyncShared {
|
||||||
|
stop: AtomicBool::new(false),
|
||||||
|
last_vsync_ns: AtomicI64::new(0),
|
||||||
|
period_ns: AtomicI64::new(0),
|
||||||
|
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||||
|
1_000_000_000 / panel_hz as i64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}),
|
||||||
|
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||||
|
timelines: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let thread_shared = shared.clone();
|
||||||
|
let join = std::thread::Builder::new()
|
||||||
|
.name("pf-vsync".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let looper = ndk::looper::ThreadLooper::prepare();
|
||||||
|
// SAFETY: getInstance on a thread with a prepared looper returns this thread's
|
||||||
|
// choreographer (never null once a looper exists).
|
||||||
|
let choreographer = unsafe { (api.get_instance)() };
|
||||||
|
if choreographer.is_null() {
|
||||||
|
log::warn!("vsync: AChoreographer_getInstance returned null — no clock");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ctx = CallbackCtx {
|
||||||
|
api,
|
||||||
|
choreographer,
|
||||||
|
shared: thread_shared,
|
||||||
|
on_tick,
|
||||||
|
};
|
||||||
|
ctx.repost();
|
||||||
|
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||||
|
// case teardown waits one timeout out. Callbacks fire inside poll_once_timeout.
|
||||||
|
while !ctx.shared.stop.load(Ordering::Relaxed) {
|
||||||
|
let _ = looper.poll_once_timeout(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
// `ctx` drops here — after the loop, so no queued callback can outlive it (they
|
||||||
|
// only ever fire inside this thread's poll).
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
log::info!(
|
||||||
|
"vsync: choreographer clock started ({})",
|
||||||
|
if timelines_live {
|
||||||
|
"frame timelines"
|
||||||
|
} else {
|
||||||
|
"frame callback fallback"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Some(VsyncClock {
|
||||||
|
shared,
|
||||||
|
join: Some(join),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn shared(&self) -> &Arc<VsyncShared> {
|
||||||
|
&self.shared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for VsyncClock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shared.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(j) = self.join.take() {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
compositor_pref: jint,
|
compositor_pref: jint,
|
||||||
gamepad_pref: jint,
|
gamepad_pref: jint,
|
||||||
hdr_enabled: jboolean,
|
hdr_enabled: jboolean,
|
||||||
|
multi_slice_ok: jboolean,
|
||||||
|
frame_parts_ok: jboolean,
|
||||||
audio_channels: jint,
|
audio_channels: jint,
|
||||||
video_codecs: jint,
|
video_codecs: jint,
|
||||||
preferred_codec: jint,
|
preferred_codec: jint,
|
||||||
@@ -182,11 +184,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
|
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
|
||||||
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
|
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
|
||||||
// metadata (see crate::decode).
|
// metadata (see crate::decode).
|
||||||
if hdr_enabled != 0 {
|
// 10-bit/HDR by panel truth (above) + multi-slice by DECODER truth: Kotlin probes every
|
||||||
|
// decoder this device would use (`VideoDecoders.multiSliceTolerant` — Amlogic wedges the
|
||||||
|
// whole device on multi-slice AUs, the 0.17.0 field regression) and only then may the
|
||||||
|
// host default to >1 slice per frame (its sub-frame readback / the P2 slice pipeline).
|
||||||
|
(if hdr_enabled != 0 {
|
||||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
},
|
}) | (if multi_slice_ok != 0 {
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}),
|
||||||
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
|
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
|
||||||
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
|
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
|
||||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||||
@@ -213,9 +223,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
||||||
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
||||||
None,
|
None,
|
||||||
// No non-video caps: this client does not render the host cursor locally (no shape/state
|
// No CLIENT_CAP_CURSOR: this client does not render the host cursor locally (no
|
||||||
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
|
// shape/state planes in the jni surface) — advertising it would stream cursor-less.
|
||||||
0,
|
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||||
|
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||||
|
// should say what the client does).
|
||||||
|
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||||
|
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||||
|
// every decoder this device would use): AU prefixes then arrive as `Frame::part`
|
||||||
|
// pieces and the decode loop feeds them with BUFFER_FLAG_PARTIAL_FRAME.
|
||||||
|
frame_parts_ok != 0,
|
||||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||||
device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label
|
device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
|
|||||||
@@ -185,7 +185,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupport
|
|||||||
/// Floats per sample in the `nativeSendPen` flat array.
|
/// Floats per sample in the `nativeSendPen` flat array.
|
||||||
const PEN_JNI_STRIDE: usize = 10;
|
const PEN_JNI_STRIDE: usize = 10;
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
|
/// Sample ceiling per `nativeSendPen` call: over-cap runs are SPLIT into consecutive ≤8-sample
|
||||||
|
/// `send_pen` batches (the send_pen contract — never truncated), so this only bounds the stack
|
||||||
|
/// buffer. 64 samples ≈ >250 ms of 240 Hz history = a pathological UI-thread stall.
|
||||||
|
const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus emit of STATE-FULL
|
||||||
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
|
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
|
||||||
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
|
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
|
||||||
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
|
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
|
||||||
@@ -203,53 +208,56 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
|||||||
if handle == 0 || count <= 0 {
|
if handle == 0 || count <= 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let count = (count as usize).min(PEN_BATCH_MAX);
|
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
|
||||||
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
|
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
|
||||||
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
||||||
if env.get_float_array_region(&samples, 0, flat).is_err() {
|
if env.get_float_array_region(&samples, 0, flat).is_err() {
|
||||||
return; // short array — a bridge bug, never worth a crash on the input path
|
return; // short array — a bridge bug, never worth a crash on the input path
|
||||||
}
|
}
|
||||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
|
||||||
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
|
|
||||||
if !s[2].is_finite() || !s[3].is_finite() {
|
|
||||||
return; // never forward a NaN coordinate
|
|
||||||
}
|
|
||||||
*slot = PenSample {
|
|
||||||
state: s[0] as u8,
|
|
||||||
tool: if s[1] as u8 == 1 {
|
|
||||||
PenTool::Eraser
|
|
||||||
} else {
|
|
||||||
PenTool::Pen
|
|
||||||
},
|
|
||||||
x: s[2].clamp(0.0, 1.0),
|
|
||||||
y: s[3].clamp(0.0, 1.0),
|
|
||||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
distance: if s[5] < 0.0 {
|
|
||||||
PEN_DISTANCE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
|
||||||
},
|
|
||||||
tilt_deg: if s[6] < 0.0 {
|
|
||||||
PEN_TILT_UNKNOWN
|
|
||||||
} else {
|
|
||||||
(s[6].clamp(0.0, 90.0)) as u8
|
|
||||||
},
|
|
||||||
azimuth_deg: if s[7] < 0.0 {
|
|
||||||
PEN_ANGLE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
(s[7] as u16) % 360
|
|
||||||
},
|
|
||||||
roll_deg: if s[8] < 0.0 {
|
|
||||||
PEN_ANGLE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
(s[8] as u16) % 360
|
|
||||||
},
|
|
||||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
let _ = h.client.send_pen(&batch[..count]);
|
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||||
|
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
|
||||||
|
let n = run.len() / PEN_JNI_STRIDE;
|
||||||
|
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
|
||||||
|
if !s[2].is_finite() || !s[3].is_finite() {
|
||||||
|
return; // never forward a NaN coordinate
|
||||||
|
}
|
||||||
|
*slot = PenSample {
|
||||||
|
state: s[0] as u8,
|
||||||
|
tool: if s[1] as u8 == 1 {
|
||||||
|
PenTool::Eraser
|
||||||
|
} else {
|
||||||
|
PenTool::Pen
|
||||||
|
},
|
||||||
|
x: s[2].clamp(0.0, 1.0),
|
||||||
|
y: s[3].clamp(0.0, 1.0),
|
||||||
|
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||||
|
distance: if s[5] < 0.0 {
|
||||||
|
PEN_DISTANCE_UNKNOWN
|
||||||
|
} else {
|
||||||
|
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||||
|
},
|
||||||
|
tilt_deg: if s[6] < 0.0 {
|
||||||
|
PEN_TILT_UNKNOWN
|
||||||
|
} else {
|
||||||
|
(s[6].clamp(0.0, 90.0)) as u8
|
||||||
|
},
|
||||||
|
azimuth_deg: if s[7] < 0.0 {
|
||||||
|
PEN_ANGLE_UNKNOWN
|
||||||
|
} else {
|
||||||
|
(s[7] as u16) % 360
|
||||||
|
},
|
||||||
|
roll_deg: if s[8] < 0.0 {
|
||||||
|
PEN_ANGLE_UNKNOWN
|
||||||
|
} else {
|
||||||
|
(s[8] as u16) % 360
|
||||||
|
},
|
||||||
|
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let _ = h.client.send_pen(&batch[..n]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
|
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
|
||||||
@@ -406,3 +414,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
|
||||||
|
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
|
||||||
|
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
|
||||||
|
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||||
|
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||||
|
/// the capture diffs, the host holds per-slot state.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pad: jint,
|
||||||
|
finger: jint,
|
||||||
|
active: jboolean,
|
||||||
|
x: jint,
|
||||||
|
y: jint,
|
||||||
|
) {
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let _ = h.client.send_rich_input(RichInput::Touchpad {
|
||||||
|
pad: (pad as u32 & 0xF) as u8,
|
||||||
|
finger: (finger as u32 & 0x1) as u8,
|
||||||
|
active: active != 0,
|
||||||
|
x: (x as i64).clamp(0, 65535) as u16,
|
||||||
|
y: (y as i64).clamp(0, 65535) as u16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
|
||||||
|
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
|
||||||
|
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||||
|
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||||
|
/// controller's report rate.
|
||||||
|
#[no_mangle]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pad: jint,
|
||||||
|
gyro_pitch: jint,
|
||||||
|
gyro_yaw: jint,
|
||||||
|
gyro_roll: jint,
|
||||||
|
accel_x: jint,
|
||||||
|
accel_y: jint,
|
||||||
|
accel_z: jint,
|
||||||
|
) {
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let _ = h.client.send_rich_input(RichInput::Motion {
|
||||||
|
pad: (pad as u32 & 0xF) as u8,
|
||||||
|
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
|
||||||
|
accel: [c(accel_x), c(accel_y), c(accel_z)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ use jni::JNIEnv;
|
|||||||
|
|
||||||
use super::{jni_guard, SessionHandle};
|
use super::{jni_guard, SessionHandle};
|
||||||
|
|
||||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
|
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||||||
/// — wrap the SurfaceView's `Surface` as an `ANativeWindow` and start the decode thread rendering
|
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||||||
/// onto it. `decoderName` is the codec Kotlin ranked from `MediaCodecList` (`""` = let the platform
|
/// and start the decode thread rendering onto it. `decoderName` is the codec Kotlin ranked from
|
||||||
/// resolve the default for the MIME); `lowLatencyMode` is the user's master toggle;
|
/// `MediaCodecList` (`""` = let the platform resolve the default for the MIME); `lowLatencyMode`
|
||||||
/// `lowLatencyFeature` is whether that decoder advertised `FEATURE_LowLatency` (HUD label only).
|
/// is the user's master toggle; `lowLatencyFeature` is whether that decoder advertised
|
||||||
|
/// `FEATURE_LowLatency` (HUD label only); `presentPriority`/`smoothBuffer` are the timeline
|
||||||
|
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||||||
/// No-op if already started.
|
/// No-op if already started.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
@@ -27,6 +29,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
|||||||
low_latency_mode: jboolean,
|
low_latency_mode: jboolean,
|
||||||
ll_feature: jboolean,
|
ll_feature: jboolean,
|
||||||
is_tv: jboolean,
|
is_tv: jboolean,
|
||||||
|
present_priority: jni::sys::jint,
|
||||||
|
smooth_buffer: jni::sys::jint,
|
||||||
|
panel_fps: jni::sys::jint,
|
||||||
) {
|
) {
|
||||||
use super::VideoThread;
|
use super::VideoThread;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -70,6 +75,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
|||||||
ll_feature: ll_feature != 0,
|
ll_feature: ll_feature != 0,
|
||||||
low_latency_mode: low_latency_mode != 0,
|
low_latency_mode: low_latency_mode != 0,
|
||||||
is_tv: is_tv != 0,
|
is_tv: is_tv != 0,
|
||||||
|
present_priority,
|
||||||
|
smooth_buffer,
|
||||||
|
panel_hz: panel_fps,
|
||||||
};
|
};
|
||||||
let join = std::thread::Builder::new()
|
let join = std::thread::Builder::new()
|
||||||
.name("pf-decode".into())
|
.name("pf-decode".into())
|
||||||
@@ -173,7 +181,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
|||||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||||
/// e2eDispP50Ms, e2eDispP95Ms]`
|
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
|
||||||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||||
@@ -186,7 +194,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
|||||||
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
|
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
|
||||||
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
|
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
|
||||||
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
|
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
|
||||||
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
|
/// the capture→decoded headline at 2/3; 26–29 are the timeline presenter's split of the `display`
|
||||||
|
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
|
||||||
|
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
|
||||||
|
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
|
||||||
|
/// is active this session), or `null` when no decode thread is running.
|
||||||
/// Poll ~1 Hz from the UI; each call
|
/// Poll ~1 Hz from the UI; each call
|
||||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||||
/// the host build too (Kotlin only ever calls it on device).
|
/// the host build too (Kotlin only ever calls it on device).
|
||||||
@@ -210,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||||
let mode = h.client.mode();
|
let mode = h.client.mode();
|
||||||
let color = h.client.color;
|
let color = h.client.color;
|
||||||
let buf: [f64; 26] = [
|
let buf: [f64; 30] = [
|
||||||
snap.fps,
|
snap.fps,
|
||||||
snap.mbps,
|
snap.mbps,
|
||||||
snap.e2e_p50_ms,
|
snap.e2e_p50_ms,
|
||||||
@@ -251,6 +263,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
snap.display_p50_ms,
|
snap.display_p50_ms,
|
||||||
snap.e2e_disp_p50_ms,
|
snap.e2e_disp_p50_ms,
|
||||||
snap.e2e_disp_p95_ms,
|
snap.e2e_disp_p95_ms,
|
||||||
|
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
|
||||||
|
// release→displayed), the window's on-glass confirm count, and whether the presenter
|
||||||
|
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
|
||||||
|
snap.pace_p50_ms,
|
||||||
|
snap.latch_p50_ms,
|
||||||
|
snap.presents as f64,
|
||||||
|
if h.stats.presenter_active() { 1.0 } else { 0.0 },
|
||||||
];
|
];
|
||||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
@@ -264,10 +283,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
||||||
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
|
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
|
||||||
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
|
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
|
||||||
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
|
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
|
||||||
/// on the host build too. Cheap; safe on the UI thread.
|
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||||||
|
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||||||
|
/// build too. Cheap; safe on the UI thread.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||||
env: JNIEnv,
|
env: JNIEnv,
|
||||||
@@ -281,7 +302,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
|||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
let mode = h.client.mode();
|
let mode = h.client.mode();
|
||||||
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
|
let buf: [i32; 3] = [
|
||||||
|
mode.width as i32,
|
||||||
|
mode.height as i32,
|
||||||
|
mode.refresh_hz as i32,
|
||||||
|
];
|
||||||
let arr = match env.new_int_array(buf.len() as jsize) {
|
let arr = match env.new_int_array(buf.len() as jsize) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(_) => return std::ptr::null_mut(),
|
Err(_) => return std::ptr::null_mut(),
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ pub struct VideoStats {
|
|||||||
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
||||||
/// Off until Kotlin shows the HUD.
|
/// Off until Kotlin shows the HUD.
|
||||||
enabled: AtomicBool,
|
enabled: AtomicBool,
|
||||||
|
/// Whether the timeline presenter is active this session (async loop, not sysprop-disabled) —
|
||||||
|
/// stats index 29, so the HUD can label the display split it is (or isn't) getting.
|
||||||
|
presenter_active: AtomicBool,
|
||||||
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
|
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
|
||||||
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
|
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
|
||||||
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
|
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
|
||||||
@@ -67,6 +70,16 @@ struct Inner {
|
|||||||
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
|
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
|
||||||
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
|
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
|
||||||
e2e_disp_us: Vec<u64>,
|
e2e_disp_us: Vec<u64>,
|
||||||
|
/// The `display` stage's presenter split, decoded→release (pace wait: store + glass budget),
|
||||||
|
/// µs. Empty on the legacy release-immediately path.
|
||||||
|
pace_us: Vec<u64>,
|
||||||
|
/// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs —
|
||||||
|
/// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame.
|
||||||
|
latch_us: Vec<u64>,
|
||||||
|
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs-
|
||||||
|
/// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps
|
||||||
|
/// deficit is upstream.
|
||||||
|
presents: u64,
|
||||||
/// Client-side newest-wins/pacing drops this window (decoded frames released without
|
/// Client-side newest-wins/pacing drops this window (decoded frames released without
|
||||||
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
|
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
|
||||||
skipped: u64,
|
skipped: u64,
|
||||||
@@ -101,6 +114,13 @@ pub struct Snapshot {
|
|||||||
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
|
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
|
||||||
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
|
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
|
||||||
pub disp_valid: bool,
|
pub disp_valid: bool,
|
||||||
|
/// The `display` stage's presenter split p50s (ms): `pace` = decoded→release (store + glass
|
||||||
|
/// budget), `latch` = release→displayed (SurfaceFlinger). 0.0 when no sample landed (legacy
|
||||||
|
/// path / no render callbacks).
|
||||||
|
pub pace_p50_ms: f64,
|
||||||
|
pub latch_p50_ms: f64,
|
||||||
|
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks).
|
||||||
|
pub presents: u64,
|
||||||
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
||||||
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
||||||
pub host_p50_ms: f64,
|
pub host_p50_ms: f64,
|
||||||
@@ -132,6 +152,7 @@ impl VideoStats {
|
|||||||
pub fn new() -> VideoStats {
|
pub fn new() -> VideoStats {
|
||||||
VideoStats {
|
VideoStats {
|
||||||
enabled: AtomicBool::new(false),
|
enabled: AtomicBool::new(false),
|
||||||
|
presenter_active: AtomicBool::new(false),
|
||||||
decoder: Mutex::new(None),
|
decoder: Mutex::new(None),
|
||||||
inner: Mutex::new(Inner {
|
inner: Mutex::new(Inner {
|
||||||
window_start: Instant::now(),
|
window_start: Instant::now(),
|
||||||
@@ -144,6 +165,9 @@ impl VideoStats {
|
|||||||
decode_us: Vec::with_capacity(256),
|
decode_us: Vec::with_capacity(256),
|
||||||
display_us: Vec::with_capacity(256),
|
display_us: Vec::with_capacity(256),
|
||||||
e2e_disp_us: Vec::with_capacity(256),
|
e2e_disp_us: Vec::with_capacity(256),
|
||||||
|
pace_us: Vec::with_capacity(256),
|
||||||
|
latch_us: Vec::with_capacity(256),
|
||||||
|
presents: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
last_dropped_total: 0,
|
last_dropped_total: 0,
|
||||||
last_fec_total: 0,
|
last_fec_total: 0,
|
||||||
@@ -160,6 +184,18 @@ impl VideoStats {
|
|||||||
self.enabled.load(Ordering::Relaxed)
|
self.enabled.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record whether the timeline presenter runs this session (decode thread, once at start).
|
||||||
|
// Set only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub fn set_presenter_active(&self, on: bool) {
|
||||||
|
self.presenter_active.store(on, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the timeline presenter is active (stats index 29).
|
||||||
|
pub fn presenter_active(&self) -> bool {
|
||||||
|
self.presenter_active.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
||||||
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||||
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
|
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
|
||||||
@@ -181,6 +217,9 @@ impl VideoStats {
|
|||||||
g.decode_us.clear();
|
g.decode_us.clear();
|
||||||
g.display_us.clear();
|
g.display_us.clear();
|
||||||
g.e2e_disp_us.clear();
|
g.e2e_disp_us.clear();
|
||||||
|
g.pace_us.clear();
|
||||||
|
g.latch_us.clear();
|
||||||
|
g.presents = 0;
|
||||||
g.skipped = 0;
|
g.skipped = 0;
|
||||||
g.last_dropped_total = dropped_total;
|
g.last_dropped_total = dropped_total;
|
||||||
g.last_fec_total = fec_total;
|
g.last_fec_total = fec_total;
|
||||||
@@ -298,13 +337,19 @@ impl VideoStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
|
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
|
||||||
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
|
/// realtime clock): its capture→displayed `end-to-end` sample, its decoded→displayed
|
||||||
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
|
/// `display` stage sample, and the presenter split's `latch` half (release→displayed) — any
|
||||||
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
|
/// may be absent (the e2e clamp rejected an out-of-range value, or the release record for
|
||||||
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
|
/// this pts was already evicted). Fired from the codec's render-callback thread, not the
|
||||||
|
/// decode thread — the lock makes that safe.
|
||||||
// Driven only by the android-only decode path; unreferenced on the host build — expected.
|
// Driven only by the android-only decode path; unreferenced on the host build — expected.
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
|
pub fn note_displayed(
|
||||||
|
&self,
|
||||||
|
e2e_us: Option<u64>,
|
||||||
|
display_us: Option<u64>,
|
||||||
|
latch_us: Option<u64>,
|
||||||
|
) {
|
||||||
if !self.enabled.load(Ordering::Relaxed) {
|
if !self.enabled.load(Ordering::Relaxed) {
|
||||||
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
|
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
|
||||||
}
|
}
|
||||||
@@ -313,12 +358,32 @@ impl VideoStats {
|
|||||||
.inner
|
.inner
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.presents += 1;
|
||||||
if let Some(l) = e2e_us {
|
if let Some(l) = e2e_us {
|
||||||
g.e2e_disp_us.push(l);
|
g.e2e_disp_us.push(l);
|
||||||
}
|
}
|
||||||
if let Some(l) = display_us {
|
if let Some(l) = display_us {
|
||||||
g.display_us.push(l);
|
g.display_us.push(l);
|
||||||
}
|
}
|
||||||
|
if let Some(l) = latch_us {
|
||||||
|
g.latch_us.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one released frame's pace-wait (decoded→release: the presenter's store + glass
|
||||||
|
/// budget), µs — the `display` stage's other half. Decode-thread only.
|
||||||
|
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub fn note_release(&self, pace_us: u64) {
|
||||||
|
if !self.enabled.load(Ordering::Relaxed) {
|
||||||
|
return; // HUD hidden — skip the lock
|
||||||
|
}
|
||||||
|
// Poison-proof for the same reason as `note_received`.
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.pace_us.push(pace_us);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||||
@@ -341,6 +406,8 @@ impl VideoStats {
|
|||||||
g.decode_us.sort_unstable();
|
g.decode_us.sort_unstable();
|
||||||
g.display_us.sort_unstable();
|
g.display_us.sort_unstable();
|
||||||
g.e2e_disp_us.sort_unstable();
|
g.e2e_disp_us.sort_unstable();
|
||||||
|
g.pace_us.sort_unstable();
|
||||||
|
g.latch_us.sort_unstable();
|
||||||
let snap = Snapshot {
|
let snap = Snapshot {
|
||||||
fps,
|
fps,
|
||||||
mbps,
|
mbps,
|
||||||
@@ -352,6 +419,9 @@ impl VideoStats {
|
|||||||
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
||||||
display_p50_ms: pctl_ms(&g.display_us, 0.50),
|
display_p50_ms: pctl_ms(&g.display_us, 0.50),
|
||||||
disp_valid: !g.e2e_disp_us.is_empty(),
|
disp_valid: !g.e2e_disp_us.is_empty(),
|
||||||
|
pace_p50_ms: pctl_ms(&g.pace_us, 0.50),
|
||||||
|
latch_p50_ms: pctl_ms(&g.latch_us, 0.50),
|
||||||
|
presents: g.presents,
|
||||||
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
||||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||||
lat_valid: !g.e2e_us.is_empty(),
|
lat_valid: !g.e2e_us.is_empty(),
|
||||||
@@ -371,6 +441,9 @@ impl VideoStats {
|
|||||||
g.decode_us.clear();
|
g.decode_us.clear();
|
||||||
g.display_us.clear();
|
g.display_us.clear();
|
||||||
g.e2e_disp_us.clear();
|
g.e2e_disp_us.clear();
|
||||||
|
g.pace_us.clear();
|
||||||
|
g.latch_us.clear();
|
||||||
|
g.presents = 0;
|
||||||
g.skipped = 0;
|
g.skipped = 0;
|
||||||
g.last_dropped_total = dropped_total;
|
g.last_dropped_total = dropped_total;
|
||||||
g.last_fec_total = fec_total;
|
g.last_fec_total = fec_total;
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ let package = Package(
|
|||||||
// Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website.
|
// Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website.
|
||||||
// Registered with Core Text at first use; see BrandFont.swift.
|
// Registered with Core Text at first use; see BrandFont.swift.
|
||||||
.copy("Resources/Fonts"),
|
.copy("Resources/Fonts"),
|
||||||
// The host cards' OS marks (template vector imagesets derived from the
|
// The host cards' OS marks (template vector imagesets generated from the
|
||||||
// assets/os-icons masters — FA brands CC BY 4.0 + Simple Icons CC0, see that
|
// assets/os-icons masters by scripts/gen-os-icons.sh — per-mark provenance and
|
||||||
// README). `.process` compiles the catalog; loaded via OsIcon.swift.
|
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||||
|
// OsIcon.swift.
|
||||||
.process("Resources/OsIcons.xcassets"),
|
.process("Resources/OsIcons.xcassets"),
|
||||||
],
|
],
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
|
|||||||
@@ -338,7 +338,10 @@ final class SessionModel: ObservableObject {
|
|||||||
let clientCaps: UInt8 =
|
let clientCaps: UInt8 =
|
||||||
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
|
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
|
||||||
#else
|
#else
|
||||||
let clientCaps: UInt8 = 0
|
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
|
||||||
|
// reportPhase — advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
|
||||||
|
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
|
||||||
|
let clientCaps: UInt8 = 0x02
|
||||||
#endif
|
#endif
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
|
|||||||
@@ -761,6 +761,21 @@ public final class PunktfunkConnection {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Report the display-latch grid + circular arrival-phase statistic so the host can
|
||||||
|
/// phase-lock its capture tick (design/phase-locked-capture.md). Fire-and-forget; call
|
||||||
|
/// ~1 Hz from a vsync-aware presenter. `nextLatchHostNs` must already be HOST clock —
|
||||||
|
/// convert with `clockOffsetNs` (host − client). No-op toward a host that never armed.
|
||||||
|
public func reportPhase(
|
||||||
|
nextLatchHostNs: UInt64, latchPeriodNs: UInt32, uncertaintyNs: UInt32,
|
||||||
|
arrivalLeadNs: UInt32, coherenceMilli: UInt16
|
||||||
|
) {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return }
|
||||||
|
_ = punktfunk_connection_report_phase(
|
||||||
|
h, nextLatchHostNs, latchPeriodNs, uncertaintyNs, arrivalLeadNs, coherenceMilli)
|
||||||
|
}
|
||||||
|
|
||||||
/// 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()
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
// The host cards' OS marks: template vector imagesets in Resources/OsIcons.xcassets
|
// The host cards' OS marks: template vector imagesets in Resources/OsIcons.xcassets
|
||||||
// (derived from the repo's assets/os-icons masters — Font Awesome Free brands CC BY 4.0 +
|
// (generated from the repo's assets/os-icons masters by scripts/gen-os-icons.sh —
|
||||||
// Simple Icons CC0; provenance in that directory's README), resolved from the host's
|
// per-mark provenance and licensing in that directory's README), resolved from the host's
|
||||||
// OS-identity chain via PunktfunkShared's `osIconTokens` walk. Template rendering means
|
// OS-identity chain via PunktfunkShared's `osIconTokens` walk. Template rendering means
|
||||||
// they tint with `foregroundStyle` like an SF Symbol.
|
// they tint with `foregroundStyle` like an SF Symbol.
|
||||||
|
|
||||||
import PunktfunkShared
|
import PunktfunkShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The icon tokens this client ships art for. A distro without its own mark (Bazzite,
|
/// The icon tokens this client ships art for: the families a chain can land on, plus the
|
||||||
/// CachyOS, ...) degrades to its family's and finally to Tux via the chain walk.
|
/// gaming distros that earn their own mark because "a Bazzite box" and "a Fedora box" are
|
||||||
|
/// different machines to the person reading the card. A distro with no mark of its own
|
||||||
|
/// still degrades to its family's and finally to Tux via the chain walk.
|
||||||
private let osIconTokensShipped: Set<String> = [
|
private let osIconTokensShipped: Set<String> = [
|
||||||
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos",
|
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos",
|
||||||
"opensuse",
|
"opensuse", "bazzite", "cachyos", "nobara",
|
||||||
]
|
]
|
||||||
|
|
||||||
/// The mark for an OS-identity chain (`linux/fedora/bazzite`, ...), or nil — no view at
|
/// The mark for an OS-identity chain (`linux/fedora/bazzite`, ...), or nil — no view at
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "bazzite.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "cachyos.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "nobara.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,6 +355,13 @@ final class SessionPresenter {
|
|||||||
/// behavior — iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native
|
/// behavior — iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native
|
||||||
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
||||||
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
||||||
|
/// Pen-proximity panel-rate boost pass-through (Stage2Pipeline.setInteractionBoost):
|
||||||
|
/// deadline pacing only — under arrival/glass the staged hint feeds no link, so this
|
||||||
|
/// is a no-op there. MAIN thread.
|
||||||
|
func setInteractionBoost(_ on: Bool) {
|
||||||
|
stage2?.setInteractionBoost(on)
|
||||||
|
}
|
||||||
|
|
||||||
private func syncFrameRate(hz: UInt32) {
|
private func syncFrameRate(hz: UInt32) {
|
||||||
guard hz > 0 else { return }
|
guard hz > 0 else { return }
|
||||||
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
|
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
|
||||||
|
|||||||
@@ -215,7 +215,8 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
||||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||||
/// "fixed-interval" jitter reports).
|
/// "fixed-interval" jitter reports).
|
||||||
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
|
/// - `glass` (stage-3; tvOS's default until the 2026-07 rebuild moved it to stage-4, now an
|
||||||
|
/// on-device A/B rung): at most a small BOUNDED number of presented-but-
|
||||||
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
||||||
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
||||||
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
||||||
@@ -278,10 +279,27 @@ final class LatestBox<T>: @unchecked Sendable {
|
|||||||
private final class FrameRateHint: @unchecked Sendable {
|
private final class FrameRateHint: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var pending: CAFrameRateRange?
|
private var pending: CAFrameRateRange?
|
||||||
|
private var streamHz: Float = 0
|
||||||
|
private var boosted = false
|
||||||
func stage(hz: Float) {
|
func stage(hz: Float) {
|
||||||
guard hz > 0 else { return }
|
guard hz > 0 else { return }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
|
streamHz = hz
|
||||||
|
pending = Self.range(hz: hz, boosted: boosted)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
/// Pen-proximity boost: pin `minimum = preferred` at the range's CEILING instead of the
|
||||||
|
/// stream rate. UIKit delivers touch/Pencil events at the PANEL's cadence, and the panel
|
||||||
|
/// follows this link's vote — so a 60 fps stream on a 120 Hz iPad halves pencil sampling
|
||||||
|
/// unless a boost lifts the panel while the Pencil is in range. Presents still pace at
|
||||||
|
/// stream rate (extra link updates just vend into the newest-wins stash), so the cost is
|
||||||
|
/// empty wakes, scoped to pen proximity.
|
||||||
|
func setBoost(_ on: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
if boosted != on {
|
||||||
|
boosted = on
|
||||||
|
if streamHz > 0 { pending = Self.range(hz: streamHz, boosted: on) }
|
||||||
|
}
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
func drain() -> CAFrameRateRange? {
|
func drain() -> CAFrameRateRange? {
|
||||||
@@ -291,6 +309,113 @@ private final class FrameRateHint: @unchecked Sendable {
|
|||||||
pending = nil
|
pending = nil
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
|
||||||
|
let cap = max(hz, 120)
|
||||||
|
let preferred = boosted ? cap : hz
|
||||||
|
return CAFrameRateRange(minimum: preferred, maximum: cap, preferred: preferred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client half of phase-locked capture (design/phase-locked-capture.md): the decode
|
||||||
|
/// callback deposits per-AU arrival stamps (client CLOCK_REALTIME — the core's reassembly-
|
||||||
|
/// completion time), the deadline link's thread deposits the latch grid, and ~1 Hz that same
|
||||||
|
/// thread flushes the circular arrival-phase statistic to the host. The statistic is a
|
||||||
|
/// verbatim port of `punktfunk_core::phase::circular_latch` — the host's v3 controller
|
||||||
|
/// (grid-locked submits, coherence-gated engage) was tuned against exactly it, and a
|
||||||
|
/// period-smeared Wi-Fi link correctly reads coherence ≈ 0 there, so the controller never
|
||||||
|
/// engages where alignment is physically pointless. Shared box (never captures the pipeline);
|
||||||
|
/// a session's connection binds/unbinds like DecodeReport/KeyframeRecovery.
|
||||||
|
final class PhaseReporter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var connection: PunktfunkConnection?
|
||||||
|
/// Arrival stamps since the last flush, client CLOCK_REALTIME. Bounded: ~1 s at 240 fps.
|
||||||
|
private var arrivalsNs: [Int64] = []
|
||||||
|
/// Smallest update-to-update spacing this window: successive `nextLatch` values sit one
|
||||||
|
/// panel period apart except across skipped link updates (2×, 3×, …), so the window
|
||||||
|
/// minimum IS the period. Re-learned every flush so VRR/mode switches track both ways.
|
||||||
|
private var periodNs: Int64 = 0
|
||||||
|
private var prevLatchRealNs: Int64 = 0
|
||||||
|
private var lastFlushRealNs: Int64 = 0
|
||||||
|
|
||||||
|
func bind(_ c: PunktfunkConnection?) {
|
||||||
|
lock.lock()
|
||||||
|
connection = c
|
||||||
|
arrivalsNs.removeAll()
|
||||||
|
periodNs = 0
|
||||||
|
prevLatchRealNs = 0
|
||||||
|
lastFlushRealNs = 0
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode-callback side: one AU's arrival (reassembly-completion) stamp.
|
||||||
|
func noteArrival(receivedNs: Int64) {
|
||||||
|
lock.lock()
|
||||||
|
if connection != nil, arrivalsNs.count < 256 { arrivalsNs.append(receivedNs) }
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Link-thread side, once per update: where the NEXT latch sits on the client's realtime
|
||||||
|
/// clock (the arrival stamps' domain). Learns the period from update spacing and ~1 Hz
|
||||||
|
/// converts the window's arrivals into leads against this grid, then reports — a
|
||||||
|
/// fire-and-forget datagram push on the control plane.
|
||||||
|
func noteGrid(nextLatchRealNs: Int64) {
|
||||||
|
lock.lock()
|
||||||
|
if prevLatchRealNs > 0 {
|
||||||
|
let delta = nextLatchRealNs - prevLatchRealNs
|
||||||
|
// 2–100 ms accepts 10–500 Hz panels, rejects wakeup hiccups and clock jumps.
|
||||||
|
if delta > 2_000_000, delta < 100_000_000, periodNs == 0 || delta < periodNs {
|
||||||
|
periodNs = delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevLatchRealNs = nextLatchRealNs
|
||||||
|
guard let c = connection, periodNs > 0, arrivalsNs.count >= 8,
|
||||||
|
nextLatchRealNs - lastFlushRealNs >= 1_000_000_000
|
||||||
|
else {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFlushRealNs = nextLatchRealNs
|
||||||
|
let period = periodNs
|
||||||
|
let leadsUs = arrivalsNs.map { a -> UInt64 in
|
||||||
|
let m = (nextLatchRealNs - a) % period
|
||||||
|
return UInt64(m < 0 ? m + period : m) / 1000
|
||||||
|
}
|
||||||
|
arrivalsNs.removeAll(keepingCapacity: true)
|
||||||
|
periodNs = 0
|
||||||
|
let offsetNs = c.clockOffsetNs
|
||||||
|
lock.unlock()
|
||||||
|
guard
|
||||||
|
let (leadMeanNs, coherence) = Self.circularLatch(
|
||||||
|
samplesUs: leadsUs, periodNs: period)
|
||||||
|
else { return }
|
||||||
|
c.reportPhase(
|
||||||
|
nextLatchHostNs: UInt64(max(0, nextLatchRealNs + offsetNs)),
|
||||||
|
latchPeriodNs: UInt32(clamping: period),
|
||||||
|
uncertaintyNs: 1_000_000, // skew residual — same conservative 1 ms as Android
|
||||||
|
arrivalLeadNs: UInt32(clamping: leadMeanNs),
|
||||||
|
coherenceMilli: coherence)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verbatim port of `punktfunk_core::phase::circular_latch` (µs samples against an ns
|
||||||
|
/// period; nil under 8 samples). The MEAN is what a phase controller can steer under
|
||||||
|
/// jitter — a period-spanning distribution's median is immovable — and the coherence
|
||||||
|
/// (resultant length, ‰) says whether any phase exists to steer at all.
|
||||||
|
static func circularLatch(samplesUs: [UInt64], periodNs: Int64) -> (UInt64, UInt16)? {
|
||||||
|
guard samplesUs.count >= 8, periodNs > 0 else { return nil }
|
||||||
|
let periodUs = Double(periodNs) / 1000.0
|
||||||
|
var x = 0.0
|
||||||
|
var y = 0.0
|
||||||
|
for s in samplesUs {
|
||||||
|
let theta = Double(s).truncatingRemainder(dividingBy: periodUs) / periodUs * 2 * .pi
|
||||||
|
x += cos(theta)
|
||||||
|
y += sin(theta)
|
||||||
|
}
|
||||||
|
let n = Double(samplesUs.count)
|
||||||
|
let r = (x * x + y * y).squareRoot() / n
|
||||||
|
var meanTheta = atan2(y, x)
|
||||||
|
if meanTheta < 0 { meanTheta += 2 * .pi }
|
||||||
|
return (UInt64(meanTheta / (2 * .pi) * Double(periodNs)), UInt16(r * 1000.0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
|
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
|
||||||
@@ -304,6 +429,8 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
|||||||
private let renderSignal: DispatchSemaphore
|
private let renderSignal: DispatchSemaphore
|
||||||
private let hint: FrameRateHint
|
private let hint: FrameRateHint
|
||||||
private let stats: PresentDebugStats?
|
private let stats: PresentDebugStats?
|
||||||
|
/// Phase-locked capture's grid feed — this link IS the latch grid presents pace against.
|
||||||
|
private let phase: PhaseReporter?
|
||||||
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vend→glass
|
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vend→glass
|
||||||
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
|
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
|
||||||
/// shown display/e2e numbers. Self-adapting — reads ~2 refresh periods composited today,
|
/// shown display/e2e numbers. Self-adapting — reads ~2 refresh periods composited today,
|
||||||
@@ -316,13 +443,15 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
||||||
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
|
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?,
|
||||||
|
phase: PhaseReporter?
|
||||||
) {
|
) {
|
||||||
self.stash = stash
|
self.stash = stash
|
||||||
self.renderSignal = renderSignal
|
self.renderSignal = renderSignal
|
||||||
self.hint = hint
|
self.hint = hint
|
||||||
self.stats = stats
|
self.stats = stats
|
||||||
self.floorMeter = floorMeter
|
self.floorMeter = floorMeter
|
||||||
|
self.phase = phase
|
||||||
}
|
}
|
||||||
|
|
||||||
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
||||||
@@ -354,6 +483,15 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
|||||||
floorMeter.record(
|
floorMeter.record(
|
||||||
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
|
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
|
||||||
}
|
}
|
||||||
|
// Phase-locked capture: this update's target present, converted into the arrival
|
||||||
|
// stamps' CLOCK_REALTIME domain. Per-update cost is one clock read; the reporter
|
||||||
|
// itself flushes ~1 Hz.
|
||||||
|
if let phase {
|
||||||
|
var ts = timespec()
|
||||||
|
clock_gettime(CLOCK_REALTIME, &ts)
|
||||||
|
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||||
|
phase.noteGrid(nextLatchRealNs: nowNs + Int64(leadS * 1_000_000_000))
|
||||||
|
}
|
||||||
stash.put(update.drawable)
|
stash.put(update.drawable)
|
||||||
renderSignal.signal()
|
renderSignal.signal()
|
||||||
}
|
}
|
||||||
@@ -583,6 +721,7 @@ public final class Stage2Pipeline {
|
|||||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||||
/// binds the live connection + arming flag (see DecodeReport).
|
/// binds the live connection + arming flag (see DecodeReport).
|
||||||
private let decodeReport = DecodeReport()
|
private let decodeReport = DecodeReport()
|
||||||
|
private let phaseReporter = PhaseReporter()
|
||||||
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
|
/// 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
|
/// `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).
|
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||||
@@ -649,6 +788,7 @@ public final class Stage2Pipeline {
|
|||||||
let renderSignal = renderSignal
|
let renderSignal = renderSignal
|
||||||
let gate = gate
|
let gate = gate
|
||||||
let decodeReport = decodeReport
|
let decodeReport = decodeReport
|
||||||
|
let phaseReporter = phaseReporter
|
||||||
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
|
||||||
@@ -660,6 +800,9 @@ public final class Stage2Pipeline {
|
|||||||
// device's real decode limit instead of the network link ceiling. Every decoded
|
// 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.
|
// frame (not just presented ones), so a newest-wins drop can't hide the backlog.
|
||||||
decodeReport.record(receivedNs: frame.receivedNs, decodedNs: frame.decodedNs)
|
decodeReport.record(receivedNs: frame.receivedNs, decodedNs: frame.decodedNs)
|
||||||
|
// Phase-locked capture's arrival half: the stamp VALUE is reassembly
|
||||||
|
// completion, so recording it at decode adds no bias to the 1 Hz aggregate.
|
||||||
|
phaseReporter.noteArrival(receivedNs: frame.receivedNs)
|
||||||
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
|
// 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,
|
// 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
|
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
|
||||||
@@ -689,6 +832,7 @@ 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
|
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
|
||||||
|
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
|
||||||
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to 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)
|
||||||
|
|
||||||
@@ -975,6 +1119,7 @@ public final class Stage2Pipeline {
|
|||||||
let stash = LatestBox<CAMetalDrawable>()
|
let stash = LatestBox<CAMetalDrawable>()
|
||||||
|
|
||||||
let floorMeter = presentFloorMeter
|
let floorMeter = presentFloorMeter
|
||||||
|
let phaseReporter = phaseReporter
|
||||||
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
||||||
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
|
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
|
||||||
// drawableSize for the whole connect window: every vend fails allocation and the system
|
// drawableSize for the whole connect window: every vend fails allocation and the system
|
||||||
@@ -985,7 +1130,7 @@ public final class Stage2Pipeline {
|
|||||||
let linkThread = Thread {
|
let linkThread = Thread {
|
||||||
let delegate = DeadlineLinkDelegate(
|
let delegate = DeadlineLinkDelegate(
|
||||||
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
||||||
floorMeter: floorMeter)
|
floorMeter: floorMeter, phase: phaseReporter)
|
||||||
let link = CAMetalDisplayLink(metalLayer: layer)
|
let link = CAMetalDisplayLink(metalLayer: layer)
|
||||||
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
||||||
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
||||||
@@ -1107,6 +1252,15 @@ public final class Stage2Pipeline {
|
|||||||
frameRateHint.stage(hz: hz)
|
frameRateHint.stage(hz: hz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pen-proximity rate boost (drawing workloads): drive the deadline link — and with it the
|
||||||
|
/// panel, whose cadence paces UIKit's touch/Pencil event delivery — at the range ceiling
|
||||||
|
/// while a Pencil is in range, so a sub-panel-rate stream stops halving pencil sampling.
|
||||||
|
/// Staged like the rate hint; no-op under arrival/glass pacing. MAIN thread.
|
||||||
|
public func setInteractionBoost(_ on: Bool) {
|
||||||
|
frameRateHint.setBoost(on)
|
||||||
|
presentLog.info("pen boost \(on ? "engaged" : "released", privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
||||||
/// `MetalVideoPresenter.setDrawableTarget`).
|
/// `MetalVideoPresenter.setDrawableTarget`).
|
||||||
public func setDrawableTarget(_ size: CGSize) {
|
public func setDrawableTarget(_ size: CGSize) {
|
||||||
@@ -1154,6 +1308,7 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
||||||
|
phaseReporter.bind(nil) // and stop phase reports toward the dead connection
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
|||||||
|
|
||||||
/// One assembled batch (≤ `PUNKTFUNK_PEN_BATCH_MAX` samples) ready for the connection.
|
/// One assembled batch (≤ `PUNKTFUNK_PEN_BATCH_MAX` samples) ready for the connection.
|
||||||
var send: (([PunktfunkPenSample]) -> Void)?
|
var send: (([PunktfunkPenSample]) -> Void)?
|
||||||
|
/// Proximity transitions (in-range ∪ touching) — drives the panel-rate boost while the
|
||||||
|
/// Pencil is near the glass. Fired from emit(), the choke point every state change exits
|
||||||
|
/// through.
|
||||||
|
var onProximity: ((Bool) -> Void)?
|
||||||
/// View-space point → normalized [0,1] video coordinates (the letterbox mapping the
|
/// View-space point → normalized [0,1] video coordinates (the letterbox mapping the
|
||||||
/// touch path already uses). nil until a mode is negotiated — samples are dropped then.
|
/// touch path already uses). nil until a mode is negotiated — samples are dropped then.
|
||||||
var videoNorm: ((CGPoint) -> (Float, Float)?)?
|
var videoNorm: ((CGPoint) -> (Float, Float)?)?
|
||||||
@@ -40,6 +44,10 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
|||||||
private(set) var hoverActive = false
|
private(set) var hoverActive = false
|
||||||
private var last = PencilStream.idleSample()
|
private var last = PencilStream.idleSample()
|
||||||
private var heartbeat: Timer?
|
private var heartbeat: Timer?
|
||||||
|
/// When the last batch (event or keepalive) went out — the heartbeat tick's idle test.
|
||||||
|
private var lastSendTs: TimeInterval = 0
|
||||||
|
/// The last proximity state surfaced through `onProximity` (transition detection).
|
||||||
|
private var lastProximity = false
|
||||||
|
|
||||||
// MARK: - Contact path (UITouch, `.pencil` only)
|
// MARK: - Contact path (UITouch, `.pencil` only)
|
||||||
|
|
||||||
@@ -52,11 +60,12 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
|||||||
touching = true
|
touching = true
|
||||||
inRange = true
|
inRange = true
|
||||||
// Coalesced samples restore the Pencil's full capture rate (UIKit delivers at
|
// Coalesced samples restore the Pencil's full capture rate (UIKit delivers at
|
||||||
// display cadence); oldest first, `dt_us` preserving their spacing.
|
// display cadence); oldest first, `dt_us` preserving their spacing. The whole run
|
||||||
|
// is kept — emit() splits anything over the wire's batch cap.
|
||||||
let raw = event?.coalescedTouches(for: touch) ?? [touch]
|
let raw = event?.coalescedTouches(for: touch) ?? [touch]
|
||||||
var batch: [PunktfunkPenSample] = []
|
var batch: [PunktfunkPenSample] = []
|
||||||
var prevTs: TimeInterval?
|
var prevTs: TimeInterval?
|
||||||
for t in raw.suffix(Int(PUNKTFUNK_PEN_BATCH_MAX)) {
|
for t in raw {
|
||||||
guard let s = contactSample(t, in: view, prevTs: prevTs) else { continue }
|
guard let s = contactSample(t, in: view, prevTs: prevTs) else { continue }
|
||||||
prevTs = t.timestamp
|
prevTs = t.timestamp
|
||||||
batch.append(s)
|
batch.append(s)
|
||||||
@@ -202,27 +211,51 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
|||||||
guard !batch.isEmpty else { return }
|
guard !batch.isEmpty else { return }
|
||||||
last = batch[batch.count - 1]
|
last = batch[batch.count - 1]
|
||||||
last.dt_us = 0
|
last.dt_us = 0
|
||||||
send?(batch)
|
// A run longer than the wire's batch cap is SPLIT into consecutive sends, never
|
||||||
armHeartbeat()
|
// truncated (the send_pen contract): an over-cap run means the main thread hitched
|
||||||
|
// past a frame of 240 Hz samples, and dropping its head would notch exactly the
|
||||||
|
// stroke geometry a drawing app can least afford to lose.
|
||||||
|
var start = 0
|
||||||
|
while start < batch.count {
|
||||||
|
let end = min(start + Int(PUNKTFUNK_PEN_BATCH_MAX), batch.count)
|
||||||
|
send?(Array(batch[start..<end]))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
lastSendTs = CACurrentMediaTime()
|
||||||
|
syncHeartbeat()
|
||||||
|
let near = inRange || touching
|
||||||
|
if near != lastProximity {
|
||||||
|
lastProximity = near
|
||||||
|
onProximity?(near)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ≤100 ms keepalive while in range (see the file header). 80 ms leaves headroom
|
/// The ≤100 ms keepalive while in range (see the file header): ONE long-lived 50 ms timer
|
||||||
/// under the host's 200 ms failsafe even with one lost datagram.
|
/// in `.common` run-loop mode, armed on range entry and torn down on exit. `.default`-mode
|
||||||
private func armHeartbeat() {
|
/// scheduling pauses during run-loop tracking, where a stationary pen would silently cross
|
||||||
heartbeat?.invalidate()
|
/// the host's 200 ms force-release mid-stroke; the old per-emit re-arm also churned the run
|
||||||
|
/// loop once per event during a stroke. The tick resends only after ≥50 ms of send silence,
|
||||||
|
/// so an active stroke costs nothing and the worst-case gap stays ≈100 ms — the wire bound.
|
||||||
|
private func syncHeartbeat() {
|
||||||
guard inRange || touching else {
|
guard inRange || touching else {
|
||||||
|
heartbeat?.invalidate()
|
||||||
heartbeat = nil
|
heartbeat = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
heartbeat = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) {
|
guard heartbeat == nil else { return }
|
||||||
[weak self] _ in
|
let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] t in
|
||||||
guard let self, self.inRange || self.touching else {
|
guard let self, self.inRange || self.touching else {
|
||||||
self?.heartbeat?.invalidate()
|
t.invalidate()
|
||||||
self?.heartbeat = nil
|
self?.heartbeat = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.send?([self.last])
|
if CACurrentMediaTime() - self.lastSendTs >= 0.05 {
|
||||||
|
self.send?([self.last])
|
||||||
|
self.lastSendTs = CACurrentMediaTime()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
heartbeat = timer
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Angle conversions
|
// MARK: - Angle conversions
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
|
/// Pending pen-boost release — 2 s of hysteresis so hover flicker at the glass edge
|
||||||
|
/// doesn't thrash the deadline link's rate range (see `setInteractionBoost`).
|
||||||
|
private var penBoostRelease: DispatchWorkItem?
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
/// The window's display manager the session's mode request was set on — held weakly so
|
/// The window's display manager the session's mode request was set on — held weakly so
|
||||||
/// stop() can clear the request even after the view has left the window.
|
/// stop() can clear the request even after the view has left the window.
|
||||||
@@ -320,12 +323,22 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
// prior session (stop() doesn't clear it). Otherwise a stale `true` could later
|
// prior session (stop() doesn't clear it). Otherwise a stale `true` could later
|
||||||
// re-engage capture on a foreground that the new session never asked for.
|
// re-engage capture on a foreground that the new session never asked for.
|
||||||
wasCapturedOnResign = false
|
wasCapturedOnResign = false
|
||||||
// Read the LIVE mode per touch batch — an accepted requestMode() mid-stream
|
// The letterbox must follow an accepted requestMode() mid-stream, so this stays a live
|
||||||
// changes the letterbox, and touches must follow it.
|
// read — but behind a short TTL: the pencil path maps every coalesced sample through
|
||||||
|
// here (≤8 per event at panel rate, main thread), and a per-sample FFI read re-takes
|
||||||
|
// the ABI lock the batch send itself needs next. 250 ms staleness is invisible next to
|
||||||
|
// the video reconfigure a mode switch performs anyway. Main-thread-only closure.
|
||||||
|
var cachedMode = CGSize.zero
|
||||||
|
var cachedAt = CACurrentMediaTime() - 1
|
||||||
streamView.currentHostMode = { [weak connection] in
|
streamView.currentHostMode = { [weak connection] in
|
||||||
guard let connection else { return .zero }
|
let now = CACurrentMediaTime()
|
||||||
let mode = connection.currentMode()
|
if now - cachedAt > 0.25 {
|
||||||
return CGSize(width: Double(mode.width), height: Double(mode.height))
|
guard let connection else { return .zero }
|
||||||
|
let mode = connection.currentMode()
|
||||||
|
cachedMode = CGSize(width: Double(mode.width), height: Double(mode.height))
|
||||||
|
cachedAt = now
|
||||||
|
}
|
||||||
|
return cachedMode
|
||||||
}
|
}
|
||||||
streamView.onTouchEvent = { [weak self, weak connection] event in
|
streamView.onTouchEvent = { [weak self, weak connection] event in
|
||||||
// Touch IS the intent during a trusted session, but must not leak to the host
|
// Touch IS the intent during a trusted session, but must not leak to the host
|
||||||
@@ -341,6 +354,26 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
guard self?.captureEnabled == true else { return }
|
guard self?.captureEnabled == true else { return }
|
||||||
connection?.sendPen(batch)
|
connection?.sendPen(batch)
|
||||||
}
|
}
|
||||||
|
// Pencil near the glass ⇒ pin the panel (and with it UIKit's event cadence) at the
|
||||||
|
// link range's ceiling, so a sub-panel-rate stream stops halving pencil sampling.
|
||||||
|
// Engage is immediate; release waits 2 s so edge-of-canvas hover flicker can't
|
||||||
|
// thrash the link. MAIN thread (UIKit events + main-queue work item).
|
||||||
|
streamView.onPenProximity = { [weak self] near in
|
||||||
|
guard let self else { return }
|
||||||
|
self.penBoostRelease?.cancel()
|
||||||
|
self.penBoostRelease = nil
|
||||||
|
if near {
|
||||||
|
self.presenter.setInteractionBoost(true)
|
||||||
|
} else {
|
||||||
|
let release = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.penBoostRelease = nil
|
||||||
|
self.presenter.setInteractionBoost(false)
|
||||||
|
}
|
||||||
|
self.penBoostRelease = release
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: release)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll.
|
// Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll.
|
||||||
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
|
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
|
||||||
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
|
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
|
||||||
@@ -513,6 +546,9 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
streamView.resetTouchInput()
|
streamView.resetTouchInput()
|
||||||
streamView.onTouchEvent = nil
|
streamView.onTouchEvent = nil
|
||||||
streamView.onPenBatch = nil // after reset — the pen's leave-range sample rides it
|
streamView.onPenBatch = nil // after reset — the pen's leave-range sample rides it
|
||||||
|
streamView.onPenProximity = nil // after reset — its leave-range transition fired above
|
||||||
|
penBoostRelease?.cancel()
|
||||||
|
penBoostRelease = nil
|
||||||
streamView.penEnabled = false
|
streamView.penEnabled = false
|
||||||
streamView.onPointerMoveAbs = nil
|
streamView.onPointerMoveAbs = nil
|
||||||
streamView.onPointerButton = nil
|
streamView.onPointerButton = nil
|
||||||
@@ -713,6 +749,8 @@ final class StreamLayerUIView: UIView {
|
|||||||
/// The host advertised `HOST_CAP_PEN`, so Pencil input splits out of the finger path onto
|
/// The host advertised `HOST_CAP_PEN`, so Pencil input splits out of the finger path onto
|
||||||
/// the pen plane — independent of the touch-input mode (drawing must not depend on it).
|
/// the pen plane — independent of the touch-input mode (drawing must not depend on it).
|
||||||
var penEnabled = false
|
var penEnabled = false
|
||||||
|
/// Pencil proximity transitions (hover or contact) — the presenter's panel-rate boost.
|
||||||
|
var onPenProximity: ((Bool) -> Void)?
|
||||||
/// Indirect pointer (mouse/trackpad with no lock) → absolute cursor moves.
|
/// Indirect pointer (mouse/trackpad with no lock) → absolute cursor moves.
|
||||||
var onPointerMoveAbs: ((HostPoint) -> Void)?
|
var onPointerMoveAbs: ((HostPoint) -> Void)?
|
||||||
/// Indirect-pointer buttons (GameStream ids: 1=left 3=right); `down` = press.
|
/// Indirect-pointer buttons (GameStream ids: 1=left 3=right); `down` = press.
|
||||||
@@ -740,6 +778,7 @@ final class StreamLayerUIView: UIView {
|
|||||||
private lazy var pencil: PencilStream = {
|
private lazy var pencil: PencilStream = {
|
||||||
let stream = PencilStream()
|
let stream = PencilStream()
|
||||||
stream.send = { [weak self] batch in self?.onPenBatch?(batch) }
|
stream.send = { [weak self] batch in self?.onPenBatch?(batch) }
|
||||||
|
stream.onProximity = { [weak self] near in self?.onPenProximity?(near) }
|
||||||
stream.videoNorm = { [weak self] point in
|
stream.videoNorm = { [weak self] point in
|
||||||
guard let h = self?.hostPoint(from: point) else { return nil }
|
guard let h = self?.hostPoint(from: point) else { return nil }
|
||||||
return (Float(h.x) / Float(max(h.w - 1, 1)), Float(h.y) / Float(max(h.h - 1, 1)))
|
return (Float(h.x) / Float(max(h.w - 1, 1)), Float(h.y) / Float(max(h.h - 1, 1)))
|
||||||
|
|||||||
@@ -798,14 +798,15 @@ from the config directory for a true factory reset."
|
|||||||
},
|
},
|
||||||
punktfunk_core::config::CompositorPref::Auto,
|
punktfunk_core::config::CompositorPref::Auto,
|
||||||
punktfunk_core::config::GamepadPref::Auto,
|
punktfunk_core::config::GamepadPref::Auto,
|
||||||
0, // bitrate_kbps: the host's default; this connect never presents
|
0, // bitrate_kbps: the host's default; this connect never presents
|
||||||
0, // video_caps: nothing decodes here
|
0, // video_caps: nothing decodes here
|
||||||
2, // audio_channels
|
2, // audio_channels
|
||||||
0, // video_codecs: the probe carries no video
|
0, // video_codecs: the probe carries no video
|
||||||
0, // preferred_codec
|
0, // preferred_codec
|
||||||
None, // display_hdr
|
None, // display_hdr
|
||||||
0, // client_caps: nothing renders a cursor
|
0, // client_caps: nothing renders a cursor
|
||||||
None, // launch
|
false, // frame_parts: probe/whole-AU consumer
|
||||||
|
None, // launch
|
||||||
Some(punktfunk_core::client::device_name()),
|
Some(punktfunk_core::client::device_name()),
|
||||||
Some(pin),
|
Some(pin),
|
||||||
Some(identity),
|
Some(identity),
|
||||||
|
|||||||
@@ -54,6 +54,25 @@ Loader's own (SHA-256-verified) install. Installs and updates can take a couple
|
|||||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||||
before the actual download proceeds.
|
before the actual download proceeds.
|
||||||
|
|
||||||
|
### Updating the client
|
||||||
|
|
||||||
|
The plugin also reports — and where it can, installs — updates for the **client** it launches.
|
||||||
|
What is possible depends on how that client was installed, and the About tab names the install
|
||||||
|
kind so the answer is never a mystery:
|
||||||
|
|
||||||
|
| Install | Update |
|
||||||
|
| --- | --- |
|
||||||
|
| **Flatpak** (the usual Deck client) | One tap. `flatpak update --user io.unom.Punktfunk` — a per-user install, which is why `sudo flatpak update` never touches it. |
|
||||||
|
| **.deb / .rpm** (and rpm-ostree, which stages for the next reboot) | One tap, *after* an explicit opt-in: `sudo usermod -aG punktfunk-update $USER`. The tap starts a fixed, parameterless root oneshot (`punktfunk-client-update.service`) through polkit — nothing about the request is attacker-influenceable, and the payload comes from your distro's own signed repositories. |
|
||||||
|
| **pacman** | Same, plus the root-owned `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf` — a partial upgrade is against Arch doctrine, so the only thing the helper will run is a full `pacman -Syu`. |
|
||||||
|
| **sysext, nix, a source build** | The plugin shows the command and stops. There is no feed behind those installs, and a button that can only fail is worse than one honest line. |
|
||||||
|
|
||||||
|
Whether a *newer* client exists is the client's own answer (`punktfunk-client --check-update`),
|
||||||
|
read from the Ed25519-signed per-channel manifest the host's update check already trusts —
|
||||||
|
`PUNKTFUNK_UPDATE_CHECK=0` disables the check, `PUNKTFUNK_UPDATE_APPLY=0` keeps the check but
|
||||||
|
never offers to install. A client too old to have that mode is reported as such rather than as
|
||||||
|
up to date.
|
||||||
|
|
||||||
## Build & sideload (development)
|
## Build & sideload (development)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ The backend's jobs are the things Steam can't do:
|
|||||||
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
||||||
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
||||||
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
||||||
* **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a
|
* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's
|
||||||
newer build is available (the frontend then drives Decky's own install RPC to apply it).
|
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
|
||||||
|
install RPC to apply it); the client's depends on how it was installed — a flatpak is compared
|
||||||
|
by OSTree commit here, anything else is asked of the client itself
|
||||||
|
(``punktfunk-client --check-update``, which verifies a signed manifest).
|
||||||
|
* **update_client()** — apply the client update by whichever route that install supports:
|
||||||
|
``flatpak update --user``, ``punktfunk-client --apply-update`` (the packaged root helper), or
|
||||||
|
a refusal carrying the command to run by hand.
|
||||||
|
|
||||||
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
|
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
|
||||||
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||||
@@ -392,6 +398,18 @@ def _client_argv() -> list[str] | None:
|
|||||||
return [native] if native else None
|
return [native] if native else None
|
||||||
|
|
||||||
|
|
||||||
|
def _client_is_flatpak() -> bool:
|
||||||
|
"""Is the client this plugin actually drives the FLATPAK one?
|
||||||
|
|
||||||
|
Not the same question as "is the flatpak installed": `PF_DECKY_CLIENT=native` forces the
|
||||||
|
native binary on a box that has both, and the update check has to describe the client the
|
||||||
|
launcher will really run — otherwise a Deck with both would be offered a flatpak update for
|
||||||
|
a client it never starts.
|
||||||
|
"""
|
||||||
|
prefix = _client_argv()
|
||||||
|
return bool(prefix) and prefix[0] == _flatpak()
|
||||||
|
|
||||||
|
|
||||||
def _flatpak_env() -> dict:
|
def _flatpak_env() -> dict:
|
||||||
"""Environment for a headless client run from the backend (no display needed for pairing).
|
"""Environment for a headless client run from the backend (no display needed for pairing).
|
||||||
Reconstruct the user-session bits flatpak wants; the backend may not inherit them. Harmless
|
Reconstruct the user-session bits flatpak wants; the backend may not inherit them. Harmless
|
||||||
@@ -562,7 +580,12 @@ async def _client_update_state() -> dict:
|
|||||||
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
|
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
|
||||||
it versions independently of this plugin — so we compare the installed commit against the
|
it versions independently of this plugin — so we compare the installed commit against the
|
||||||
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
|
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
|
||||||
(not installed, no flatpak, offline)."""
|
(not installed, no flatpak, offline).
|
||||||
|
|
||||||
|
Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a
|
||||||
|
flatpak built from main between releases carries the release's crate version, so the
|
||||||
|
signed-manifest comparison the native path uses would call it up to date when it isn't.
|
||||||
|
Native installs have no commit to compare and go through :func:`_native_update_state`."""
|
||||||
state = {"available": False, "installed": "", "remote": ""}
|
state = {"available": False, "installed": "", "remote": ""}
|
||||||
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
@@ -581,6 +604,50 @@ async def _client_update_state() -> dict:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
# --- native (non-flatpak) client updates ----------------------------------------------------
|
||||||
|
#
|
||||||
|
# A .deb/.rpm/pacman/sysext/nix client is not something this plugin can reason about on its own:
|
||||||
|
# working out whether a newer build exists means fetching a per-channel manifest and verifying
|
||||||
|
# its Ed25519 signature, and Decky's embedded Python has no crypto library to do that with (nor
|
||||||
|
# should the trust rule live in two languages). So the CLIENT answers both questions —
|
||||||
|
# `--check-update --json` says what is available and who could install it, `--apply-update`
|
||||||
|
# drives the packaged root helper — and this backend is a UI over those, exactly as it already
|
||||||
|
# is for `--pair` / `--library` / `--list-hosts`.
|
||||||
|
#
|
||||||
|
# Shape of `--check-update --json` (pf_client_core::update::Status):
|
||||||
|
# {kind, channel, current, latest, update_available, apply, applier, command,
|
||||||
|
# opt_in_hint?, notes_url, error?}
|
||||||
|
# `applier` is what this file routes on: "flatpak" (we run flatpak), "helper" (the client runs
|
||||||
|
# the root helper), "none" (show `command` — nothing here can install it).
|
||||||
|
|
||||||
|
|
||||||
|
async def _native_update_state() -> dict:
|
||||||
|
"""Ask a NATIVE client whether a newer build exists for its channel. Returns the client's
|
||||||
|
own status dict, or ``{}`` when it couldn't be asked (no native client, a client too old to
|
||||||
|
have the mode, offline). Best-effort by design: an unanswerable check must read as "can't
|
||||||
|
tell", never as "up to date"."""
|
||||||
|
rc, out, err = await _run_client(["--check-update", "--json"], timeout=30.0)
|
||||||
|
# The JSON is authoritative whenever there IS JSON, whatever the exit code: the client
|
||||||
|
# exits 0 up-to-date, 10 update-available, and 1 when the check failed — but in that last
|
||||||
|
# case it STILL prints a status carrying `error` plus the install kind and the command for
|
||||||
|
# this box, which is exactly what the UI needs to explain itself. Reading only the exit
|
||||||
|
# code would throw all of that away and report a bare "couldn't check".
|
||||||
|
if out.strip():
|
||||||
|
try:
|
||||||
|
data = json.loads(out)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
decky.logger.warning("check-update: unparseable output: %s", out[:200])
|
||||||
|
if rc == -1:
|
||||||
|
return {}
|
||||||
|
# A client predating `--check-update` ignores the flag and falls through to GTK init, which
|
||||||
|
# fails headless — the same signature the other headless modes classify.
|
||||||
|
code = _classify_library_error(err)
|
||||||
|
decky.logger.info("native check-update unavailable (rc=%s, %s)", rc, code)
|
||||||
|
return {"error": code} if code == "client-outdated" else {}
|
||||||
|
|
||||||
|
|
||||||
def _split_txt(txt: str) -> list[str]:
|
def _split_txt(txt: str) -> list[str]:
|
||||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
||||||
tokens: list[str] = []
|
tokens: list[str] = []
|
||||||
@@ -1138,14 +1205,71 @@ class Plugin:
|
|||||||
return {"ok": False}
|
return {"ok": False}
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
async def _update_native_client(self) -> dict:
|
||||||
|
"""The non-flatpak leg of :meth:`update_client` — drive the client's own
|
||||||
|
``--apply-update``, which starts the packaged root helper.
|
||||||
|
|
||||||
|
The timeout is generous because a package-manager run on a stale box is slow; the
|
||||||
|
client caps its own wait at 30 min, so this one sits below that and reports a timeout
|
||||||
|
rather than hanging the QAM forever.
|
||||||
|
"""
|
||||||
|
state = await _native_update_state()
|
||||||
|
if not state:
|
||||||
|
# No client answered at all (none installed, or one too old for the mode) — say
|
||||||
|
# that, rather than "this install updates by hand", which would be a guess.
|
||||||
|
return {"ok": False, "updated": False, "error": "client-unavailable"}
|
||||||
|
applier = state.get("applier")
|
||||||
|
if applier != "helper":
|
||||||
|
# Nothing here can install it — hand back the command the client computed, so the
|
||||||
|
# UI shows one true line instead of guessing per install kind.
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"updated": False,
|
||||||
|
"error": "manual",
|
||||||
|
"command": state.get("opt_in_hint") or state.get("command", ""),
|
||||||
|
}
|
||||||
|
rc, out, err = await _run_client(["--apply-update", "--json"], timeout=900.0)
|
||||||
|
if rc == -1:
|
||||||
|
return {"ok": False, "updated": False, "error": "timeout"}
|
||||||
|
outcome: dict = {}
|
||||||
|
if out.strip():
|
||||||
|
try:
|
||||||
|
outcome = json.loads(out)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
if not outcome.get("ok"):
|
||||||
|
detail = outcome.get("error") or (err.strip().splitlines() or ["update failed"])[-1]
|
||||||
|
decky.logger.warning("native client update failed (rc=%s): %s", rc, detail)
|
||||||
|
return {"ok": False, "updated": False, "error": "update-failed", "detail": detail}
|
||||||
|
_update_cache["data"] = None # invalidate the cached "update available" snapshot
|
||||||
|
decky.logger.info(
|
||||||
|
"native client update: %s -> %s (changed=%s, staged=%s)",
|
||||||
|
outcome.get("before", ""), outcome.get("after", ""),
|
||||||
|
outcome.get("changed"), outcome.get("staged"),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"updated": bool(outcome.get("changed")),
|
||||||
|
"staged": bool(outcome.get("staged")),
|
||||||
|
}
|
||||||
|
|
||||||
async def update_client(self) -> dict:
|
async def update_client(self) -> dict:
|
||||||
"""Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a
|
"""Update the **client**, by whichever route this box's install actually supports.
|
||||||
Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches.
|
|
||||||
Returns whether a new commit was actually pulled. Best-effort; non-fatal. A NATIVE client
|
* **flatpak** — ``flatpak update --user`` in the USER installation, the scope a Steam
|
||||||
is updated by whatever installed it (distro package manager, sysext, nix), never here —
|
Deck install lives in and which ``sudo flatpak update`` (system-scope) never reaches.
|
||||||
`check_update` reports no client update for one, so the UI never offers this."""
|
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
|
||||||
if not _flatpak_installed():
|
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
|
||||||
return {"ok": False, "updated": False, "error": "flatpak-not-found"}
|
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
|
||||||
|
passes nothing to it; the helper derives everything from root-owned state.
|
||||||
|
* **anything else** (sysext, nix, a source build, no opt-in) — refused with the exact
|
||||||
|
command to run, which the UI shows. `check_update` reports the same, so the UI knows
|
||||||
|
not to offer a button in the first place.
|
||||||
|
|
||||||
|
Returns ``{ok, updated, error?, detail?, command?, staged?}``. Best-effort; non-fatal.
|
||||||
|
"""
|
||||||
|
if not _client_is_flatpak():
|
||||||
|
return await self._update_native_client()
|
||||||
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||||
before_commit = _field_from(before, "Commit")
|
before_commit = _field_from(before, "Commit")
|
||||||
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
|
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
|
||||||
@@ -1183,6 +1307,13 @@ class Plugin:
|
|||||||
"client_update_available": False,
|
"client_update_available": False,
|
||||||
"client_current": "",
|
"client_current": "",
|
||||||
"client_latest": "",
|
"client_latest": "",
|
||||||
|
# How the client got here (`flatpak`, `apt`, `dnf`, `sysext`, `nix`, `source`, …),
|
||||||
|
# who could install an update (`flatpak` | `helper` | `none`), and the one line that
|
||||||
|
# does it by hand. Empty on a flatpak-only box that never reaches the native path.
|
||||||
|
"client_install": "",
|
||||||
|
"client_applier": "",
|
||||||
|
"client_command": "",
|
||||||
|
"client_opt_in": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
@@ -1190,12 +1321,31 @@ class Plugin:
|
|||||||
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
|
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Client (flatpak) update — checked ALWAYS, even on a dev/sideloaded plugin build.
|
# Client update — checked ALWAYS, even on a dev/sideloaded plugin build. Which check
|
||||||
|
# runs depends on how the client was installed: the flatpak compares OSTree commits
|
||||||
|
# (exact for a per-user flatpak), everything else asks the client itself, which
|
||||||
|
# verifies the signed per-channel manifest. See _client_update_state / _native_update_state.
|
||||||
try:
|
try:
|
||||||
cu = await _client_update_state()
|
if _client_is_flatpak():
|
||||||
result["client_update_available"] = bool(cu["available"])
|
cu = await _client_update_state()
|
||||||
result["client_current"] = (cu["installed"] or "")[:10]
|
result["client_update_available"] = bool(cu["available"])
|
||||||
result["client_latest"] = (cu["remote"] or "")[:10]
|
result["client_current"] = (cu["installed"] or "")[:10]
|
||||||
|
result["client_latest"] = (cu["remote"] or "")[:10]
|
||||||
|
result["client_install"] = "flatpak"
|
||||||
|
result["client_applier"] = "flatpak"
|
||||||
|
result["client_command"] = f"flatpak update --user {APP_ID}"
|
||||||
|
else:
|
||||||
|
nu = await _native_update_state()
|
||||||
|
result["client_update_available"] = bool(nu.get("update_available"))
|
||||||
|
result["client_current"] = str(nu.get("current", ""))
|
||||||
|
result["client_latest"] = str(nu.get("latest", ""))
|
||||||
|
result["client_install"] = str(nu.get("kind", ""))
|
||||||
|
result["client_applier"] = str(nu.get("applier", ""))
|
||||||
|
result["client_command"] = str(nu.get("command", ""))
|
||||||
|
result["client_opt_in"] = str(nu.get("opt_in_hint", "") or "")
|
||||||
|
if nu.get("error"):
|
||||||
|
# "Couldn't tell" — never rendered as up to date; the UI shows the reason.
|
||||||
|
result["client_error"] = str(nu["error"])
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
decky.logger.warning("client update check failed", exc_info=True)
|
decky.logger.warning("client update check failed", exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ export interface StreamSettings {
|
|||||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||||
|
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
|
||||||
|
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
|
||||||
|
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
|
||||||
|
// here would be a dead one. The desktop client's row still edits this same file.
|
||||||
inhibit_shortcuts: boolean;
|
inhibit_shortcuts: boolean;
|
||||||
mic_enabled: boolean;
|
mic_enabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -124,11 +128,20 @@ export interface UpdateInfo {
|
|||||||
hash: string; // sha256 of that zip (Decky verifies it)
|
hash: string; // sha256 of that zip (Decky verifies it)
|
||||||
channel: string; // "latest" (stable) | "canary"
|
channel: string; // "latest" (stable) | "canary"
|
||||||
update_available: boolean; // a newer PLUGIN build is available
|
update_available: boolean; // a newer PLUGIN build is available
|
||||||
// The flatpak CLIENT (io.unom.Punktfunk) versions independently and is a per-user install, so
|
// The CLIENT versions independently of this plugin, and how it updates depends on how it was
|
||||||
// `sudo flatpak update` never touches it — the plugin offers a user-scope update instead.
|
// installed. A flatpak is a per-user install `sudo flatpak update` never touches, compared by
|
||||||
|
// OSTree commit; every other install (.deb/.rpm/pacman/sysext/nix/source) is compared by the
|
||||||
|
// client itself against the signed per-channel manifest (`punktfunk-client --check-update`).
|
||||||
client_update_available: boolean;
|
client_update_available: boolean;
|
||||||
client_current: string; // installed client commit (short) — informational
|
client_current: string; // installed client commit (flatpak) or version (native)
|
||||||
client_latest: string; // remote client commit (short) — informational
|
client_latest: string; // newest client commit (flatpak) or version (native)
|
||||||
|
client_install: string; // "flatpak" | "apt" | "dnf" | "pacman" | "sysext" | "nix" | "source" | ""
|
||||||
|
// Who can perform the update: "flatpak" (this plugin runs it), "helper" (the client drives the
|
||||||
|
// packaged root helper), "none" (nothing here can — show `client_command`).
|
||||||
|
client_applier: string;
|
||||||
|
client_command: string; // one copy-pastable line that updates this install by hand
|
||||||
|
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
|
||||||
|
client_error?: string; // the client check couldn't complete (e.g. "client-outdated")
|
||||||
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +219,18 @@ export const probeHost = callable<
|
|||||||
{ ok: boolean; online?: boolean; error?: string }
|
{ ok: boolean; online?: boolean; error?: string }
|
||||||
>("probe_host");
|
>("probe_host");
|
||||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
// Update the client by whichever route its install supports: `flatpak update --user` for the
|
||||||
|
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
|
||||||
|
// native install. Everything else comes back `ok: false, error: "manual"` with `command` — the
|
||||||
|
// line to run by hand. A package-manager run can take minutes; the backend allows 15.
|
||||||
export const updateClient = callable<
|
export const updateClient = callable<
|
||||||
[],
|
[],
|
||||||
{ ok: boolean; updated: boolean; error?: string }
|
{
|
||||||
|
ok: boolean;
|
||||||
|
updated: boolean;
|
||||||
|
staged?: boolean; // installed, but a reboot activates it (rpm-ostree)
|
||||||
|
error?: string;
|
||||||
|
detail?: string;
|
||||||
|
command?: string; // set with error "manual"
|
||||||
|
}
|
||||||
>("update_client");
|
>("update_client");
|
||||||
|
|||||||
@@ -240,11 +240,61 @@ export function useUpdate() {
|
|||||||
return { info, checking, check };
|
return { info, checking, check };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when EITHER the plugin or the flatpak client has a pending update. */
|
/** True when EITHER the plugin or the client has a pending update. */
|
||||||
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
|
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
|
||||||
return !!info && (info.update_available || info.client_update_available);
|
return !!info && (info.update_available || info.client_update_available);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can this Deck actually INSTALL the pending client update, or only tell you how?
|
||||||
|
*
|
||||||
|
* A flatpak and a one-tap-capable native install (the packaged root helper + the operator's
|
||||||
|
* group opt-in) get a button; a sysext, a nix profile, a source build or a box that hasn't
|
||||||
|
* opted in gets the command. Offering a button that can only fail is worse than saying so.
|
||||||
|
*/
|
||||||
|
export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boolean {
|
||||||
|
return (
|
||||||
|
!!info &&
|
||||||
|
info.client_update_available &&
|
||||||
|
(info.client_applier === "flatpak" || info.client_applier === "helper")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
|
||||||
|
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
|
||||||
|
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
|
||||||
|
*/
|
||||||
|
export function clientInstallLabel(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "flatpak":
|
||||||
|
return "Flatpak (per-user)";
|
||||||
|
case "apt":
|
||||||
|
return "System package (apt)";
|
||||||
|
case "dnf":
|
||||||
|
return "System package (dnf)";
|
||||||
|
case "rpm-ostree":
|
||||||
|
return "Layered package (rpm-ostree)";
|
||||||
|
case "pacman":
|
||||||
|
return "System package (pacman)";
|
||||||
|
case "sysext":
|
||||||
|
return "System extension (sysext)";
|
||||||
|
case "nix":
|
||||||
|
return "Nix profile";
|
||||||
|
case "steamos-source":
|
||||||
|
return "On-device build";
|
||||||
|
case "source":
|
||||||
|
return "Built from source";
|
||||||
|
default:
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the only pending update is one this Deck can't apply itself. */
|
||||||
|
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
|
||||||
|
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
|
||||||
|
}
|
||||||
|
|
||||||
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
||||||
export async function checkForUpdatesNow(
|
export async function checkForUpdatesNow(
|
||||||
check: (force: boolean) => Promise<UpdateInfo | null>,
|
check: (force: boolean) => Promise<UpdateInfo | null>,
|
||||||
@@ -256,8 +306,20 @@ export async function checkForUpdatesNow(
|
|||||||
} else if (hasUpdate(res)) {
|
} else if (hasUpdate(res)) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
|
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
|
||||||
if (res.client_update_available) parts.push("client");
|
if (res.client_update_available) {
|
||||||
|
parts.push(res.client_latest ? `client ${res.client_latest}` : "client");
|
||||||
|
}
|
||||||
body = `Update available: ${parts.join(" + ")}.`;
|
body = `Update available: ${parts.join(" + ")}.`;
|
||||||
|
if (clientUpdateIsManualOnly(res)) {
|
||||||
|
// Say the honest thing up front rather than letting the user find out at the button.
|
||||||
|
body += " The client updates outside Punktfunk on this install.";
|
||||||
|
}
|
||||||
|
} else if (res.client_error) {
|
||||||
|
// A failed CLIENT check must never read as "up to date" — that is the one wrong answer.
|
||||||
|
body =
|
||||||
|
res.client_error === "client-outdated"
|
||||||
|
? "Couldn’t check the client — it predates update checks. Update it once by hand."
|
||||||
|
: "Couldn’t check the client for updates.";
|
||||||
} else if (res.error === "update-channel-unknown") {
|
} else if (res.error === "update-channel-unknown") {
|
||||||
body = "Development build — plugin updates are disabled; the client is up to date.";
|
body = "Development build — plugin updates are disabled; the client is up to date.";
|
||||||
} else {
|
} else {
|
||||||
@@ -266,32 +328,67 @@ export async function checkForUpdatesNow(
|
|||||||
toaster.toast({ title: "Punktfunk", body });
|
toaster.toast({ title: "Punktfunk", body });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One line of user-facing copy for whatever `updateClient()` came back with. */
|
||||||
|
function clientUpdateResultBody(r: Awaited<ReturnType<typeof updateClient>>): string {
|
||||||
|
if (r.ok) {
|
||||||
|
if (r.staged) return "Client updated — reboot to finish.";
|
||||||
|
return r.updated ? "Client updated to the latest version." : "Client is already up to date.";
|
||||||
|
}
|
||||||
|
// "manual" is not a failure: the box simply can't install it, and `command` says how.
|
||||||
|
if (r.error === "manual") {
|
||||||
|
return r.command
|
||||||
|
? `This client updates outside Punktfunk. Run: ${r.command}`
|
||||||
|
: "This client updates outside Punktfunk — use the way you installed it.";
|
||||||
|
}
|
||||||
|
if (r.error === "timeout") return "Client update timed out — check the box and try again.";
|
||||||
|
if (r.error === "client-unavailable")
|
||||||
|
return "Couldn’t reach the client to update it — is it still installed?";
|
||||||
|
return `Client update failed${r.detail ? `: ${r.detail}` : r.error ? ` (${r.error})` : ""}.`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope
|
* Apply whichever updates are pending.
|
||||||
* `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC
|
*
|
||||||
* reinstalls it — which reloads the plugin and tears this panel down, so it goes last and is
|
* The CLIENT goes first and is awaited, by whichever route its install supports — a user-scope
|
||||||
* fire-and-forget. `check` (when passed) refreshes the panel state after a client-only update so
|
* `flatpak update`, or the packaged root helper via `punktfunk-client --apply-update`. An
|
||||||
* the "Update available" button clears.
|
* install neither can serve is not attempted at all: the user gets the command in a toast,
|
||||||
|
* because a button that can only fail teaches nothing.
|
||||||
|
*
|
||||||
|
* The PLUGIN goes last and is fire-and-forget: Decky's install RPC reinstalls and reloads the
|
||||||
|
* plugin, tearing this panel down before any result could arrive. `check` (when passed)
|
||||||
|
* refreshes the panel state after a client-only update so the "Update available" button clears.
|
||||||
*/
|
*/
|
||||||
export async function applyUpdate(
|
export async function applyUpdate(
|
||||||
info: UpdateInfo,
|
info: UpdateInfo,
|
||||||
check?: (force: boolean) => Promise<UpdateInfo | null>,
|
check?: (force: boolean) => Promise<UpdateInfo | null>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (info.client_update_available) {
|
if (info.client_update_available && clientUpdateIsOneTap(info)) {
|
||||||
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
|
toaster.toast({
|
||||||
|
title: "Punktfunk",
|
||||||
|
// A package-manager run is not instant; say so before the wait, not after.
|
||||||
|
body:
|
||||||
|
info.client_applier === "helper"
|
||||||
|
? "Updating the client — this can take a few minutes…"
|
||||||
|
: "Updating the client…",
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const r = await updateClient();
|
const r = await updateClient();
|
||||||
toaster.toast({
|
toaster.toast({ title: "Punktfunk", body: clientUpdateResultBody(r) });
|
||||||
title: "Punktfunk",
|
|
||||||
body: !r.ok
|
|
||||||
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
|
|
||||||
: r.updated
|
|
||||||
? "Client updated to the latest version."
|
|
||||||
: "Client is already up to date.",
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
|
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
|
||||||
}
|
}
|
||||||
|
} else if (info.client_update_available) {
|
||||||
|
// Nothing here can install it — hand over the one line that does, rather than a button
|
||||||
|
// that would fail. `client_opt_in` wins when joining the group is what's missing, since
|
||||||
|
// that is the step that turns this into a one-tap update from then on.
|
||||||
|
const line = info.client_opt_in || info.client_command;
|
||||||
|
toaster.toast({
|
||||||
|
title: "Punktfunk",
|
||||||
|
body: line
|
||||||
|
? `Client update available (${info.client_latest}). Run: ${line}`
|
||||||
|
: `A newer client (${info.client_latest}) is available — update it the way you installed it.`,
|
||||||
|
duration: 12_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.update_available) {
|
if (info.update_available) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { PluginErrorBoundary } from "./boundary";
|
|||||||
import {
|
import {
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
checkForUpdatesNow,
|
checkForUpdatesNow,
|
||||||
|
clientUpdateIsManualOnly,
|
||||||
hasUpdate,
|
hasUpdate,
|
||||||
mergeHosts,
|
mergeHosts,
|
||||||
needsPair,
|
needsPair,
|
||||||
@@ -72,27 +73,42 @@ const QamPanel: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasUpdate(update) && (
|
{hasUpdate(update) &&
|
||||||
<PanelSection title="Update available">
|
// A client this Deck can't install (a sysext, a nix profile, a source build, or a box
|
||||||
<PanelSectionRow>
|
// that hasn't opted into one-tap updates) gets the command, not a button — tapping
|
||||||
<ButtonItem
|
// something that can only fail is worse than reading one line. A pending PLUGIN update
|
||||||
layout="below"
|
// still wins the button, since that half always works.
|
||||||
onClick={() => applyUpdate(update!, check)}
|
(clientUpdateIsManualOnly(update) && !update!.update_available ? (
|
||||||
label={
|
<PanelSection title="Client update available">
|
||||||
update!.update_available
|
<PanelSectionRow>
|
||||||
? `Plugin v${update!.current} → v${update!.latest}${
|
<Field
|
||||||
update!.client_update_available ? " + client" : ""
|
focusable
|
||||||
}`
|
label={update!.client_latest || "Newer version"}
|
||||||
: "New client version"
|
description={update!.client_opt_in || update!.client_command}
|
||||||
}
|
/>
|
||||||
description="Installing can take a couple of minutes"
|
</PanelSectionRow>
|
||||||
>
|
</PanelSection>
|
||||||
<FaDownload style={{ marginRight: "0.5em" }} />
|
) : (
|
||||||
Update Punktfunk
|
<PanelSection title="Update available">
|
||||||
</ButtonItem>
|
<PanelSectionRow>
|
||||||
</PanelSectionRow>
|
<ButtonItem
|
||||||
</PanelSection>
|
layout="below"
|
||||||
)}
|
onClick={() => applyUpdate(update!, check)}
|
||||||
|
label={
|
||||||
|
update!.update_available
|
||||||
|
? `Plugin v${update!.current} → v${update!.latest}${
|
||||||
|
update!.client_update_available ? " + client" : ""
|
||||||
|
}`
|
||||||
|
: "New client version"
|
||||||
|
}
|
||||||
|
description="Installing can take a couple of minutes"
|
||||||
|
>
|
||||||
|
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||||
|
Update Punktfunk
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
|
</PanelSection>
|
||||||
|
))}
|
||||||
|
|
||||||
<PanelSection title="Punktfunk">
|
<PanelSection title="Punktfunk">
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
|
|||||||
@@ -1,40 +1,82 @@
|
|||||||
// The host row's OS mark, resolved from the host's OS-identity chain (mDNS `os` TXT /
|
// The host row's OS mark, resolved from the host's OS-identity chain (mDNS `os` TXT /
|
||||||
// `--list-hosts` `os`, e.g. "linux/fedora/bazzite"): walk the chain most-specific-first and
|
// `--list-hosts` `os`, e.g. "linux/fedora/bazzite"): walk the chain most-specific-first and
|
||||||
// take the first token react-icons has a brand mark for, so an unknown distro degrades to its
|
// take the first token we ship a mark for, so an unknown distro degrades to its family's
|
||||||
// family's mark and finally to Tux. Mirrors pf-client-core's `os_icon_tokens` (aliases
|
// mark and finally to Tux. Mirrors pf-client-core's `os_icon_tokens` (aliases macos→apple,
|
||||||
// macos→apple, steamos→steam); null when the chain is absent or entirely unknown — the row
|
// steamos→steam); null when the chain is absent or entirely unknown — the row then renders
|
||||||
// then renders exactly as it did before the field existed.
|
// exactly as it did before the field existed.
|
||||||
|
//
|
||||||
|
// Path data is transcribed from the assets/os-icons masters rather than pulled from
|
||||||
|
// react-icons: that package's Font Awesome 5 brand set still carries the perspective-skewed
|
||||||
|
// Windows flag, and it has no Bazzite or CachyOS mark at all. `bash scripts/gen-os-icons.sh
|
||||||
|
// <token>` prints a master's viewBox + path ready to paste here.
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import {
|
|
||||||
FaApple,
|
|
||||||
FaFedora,
|
|
||||||
FaLinux,
|
|
||||||
FaSteam,
|
|
||||||
FaSuse,
|
|
||||||
FaUbuntu,
|
|
||||||
FaWindows,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
import { SiArchlinux, SiDebian, SiNixos } from "react-icons/si";
|
|
||||||
import { IconType } from "react-icons";
|
|
||||||
|
|
||||||
const OS_ICONS: Record<string, IconType> = {
|
/** One monochrome brand mark: original per-icon viewBox, drawn in currentColor. */
|
||||||
windows: FaWindows,
|
const OS_ICONS: Record<string, { viewBox: string; d: string }> = {
|
||||||
apple: FaApple,
|
windows: {
|
||||||
macos: FaApple,
|
viewBox: "0 0 24 24",
|
||||||
linux: FaLinux,
|
d: "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
|
||||||
steam: FaSteam,
|
},
|
||||||
steamos: FaSteam,
|
apple: {
|
||||||
ubuntu: FaUbuntu,
|
viewBox: "0 0 384 512",
|
||||||
fedora: FaFedora,
|
d: "M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z",
|
||||||
opensuse: FaSuse,
|
},
|
||||||
arch: SiArchlinux,
|
linux: {
|
||||||
debian: SiDebian,
|
viewBox: "0 0 448 512",
|
||||||
nixos: SiNixos,
|
d: "M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z",
|
||||||
|
},
|
||||||
|
steam: {
|
||||||
|
viewBox: "0 0 496 512",
|
||||||
|
d: "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
|
||||||
|
},
|
||||||
|
ubuntu: {
|
||||||
|
viewBox: "0 0 496 512",
|
||||||
|
d: "M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z",
|
||||||
|
},
|
||||||
|
fedora: {
|
||||||
|
viewBox: "0 0 448 512",
|
||||||
|
d: "M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z",
|
||||||
|
},
|
||||||
|
arch: {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
d: "M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091",
|
||||||
|
},
|
||||||
|
debian: {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
d: "M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83",
|
||||||
|
},
|
||||||
|
nixos: {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
d: "M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z",
|
||||||
|
},
|
||||||
|
opensuse: {
|
||||||
|
viewBox: "0 0 640 512",
|
||||||
|
d: "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
|
||||||
|
},
|
||||||
|
// The gaming distros get their own mark rather than their family's: on a Deck, "a Bazzite
|
||||||
|
// box" and "a Fedora box" are different machines to the person reading the row.
|
||||||
|
bazzite: {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
d: "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
|
||||||
|
},
|
||||||
|
cachyos: {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
d: "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
|
||||||
|
},
|
||||||
|
nobara: {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
d: "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveOsIcon(os: string | undefined): IconType | null {
|
/** `macos` renders the apple mark, `steamos` the steam mark — same aliases as every client. */
|
||||||
for (const token of (os ?? "").toLowerCase().split("/").reverse()) {
|
const ALIASES: Record<string, string> = { macos: "apple", steamos: "steam" };
|
||||||
const icon = OS_ICONS[token];
|
|
||||||
|
export function resolveOsIcon(
|
||||||
|
os: string | undefined,
|
||||||
|
): { viewBox: string; d: string } | null {
|
||||||
|
for (const raw of (os ?? "").toLowerCase().split("/").reverse()) {
|
||||||
|
const icon = OS_ICONS[ALIASES[raw] ?? raw];
|
||||||
if (icon) {
|
if (icon) {
|
||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
@@ -44,6 +86,20 @@ export function resolveOsIcon(os: string | undefined): IconType | null {
|
|||||||
|
|
||||||
/** The mark itself, or nothing — sized/colored by the surrounding text like the lock glyph. */
|
/** The mark itself, or nothing — sized/colored by the surrounding text like the lock glyph. */
|
||||||
export const OsMark: FC<{ os?: string }> = ({ os }) => {
|
export const OsMark: FC<{ os?: string }> = ({ os }) => {
|
||||||
const Icon = resolveOsIcon(os);
|
const icon = resolveOsIcon(os);
|
||||||
return Icon ? <Icon /> : null;
|
if (!icon) return null;
|
||||||
|
// 1em square with the viewBox letterboxed inside it: the same box react-icons drew, so a
|
||||||
|
// non-square mark keeps its aspect ratio instead of being stretched to fill.
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox={icon.viewBox}
|
||||||
|
height="1em"
|
||||||
|
width="1em"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d={icon.d} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
PinsApi,
|
PinsApi,
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
checkForUpdatesNow,
|
checkForUpdatesNow,
|
||||||
|
clientInstallLabel,
|
||||||
|
clientUpdateIsManualOnly,
|
||||||
hasUpdate,
|
hasUpdate,
|
||||||
mergeHosts,
|
mergeHosts,
|
||||||
needsPair,
|
needsPair,
|
||||||
@@ -391,6 +393,16 @@ const AboutTab: FC<{
|
|||||||
</DialogButton>
|
</DialogButton>
|
||||||
</RowActions>
|
</RowActions>
|
||||||
</Field>
|
</Field>
|
||||||
|
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
|
||||||
|
install kind decides everything below it. */}
|
||||||
|
{!!update?.client_install && (
|
||||||
|
<Field
|
||||||
|
label="Client"
|
||||||
|
description={`${clientInstallLabel(update.client_install)}${
|
||||||
|
update.client_current ? ` · ${update.client_current}` : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{hasUpdate(update) && (
|
{hasUpdate(update) && (
|
||||||
<Field
|
<Field
|
||||||
label={
|
label={
|
||||||
@@ -398,19 +410,37 @@ const AboutTab: FC<{
|
|||||||
? `Plugin update — v${update!.latest}${
|
? `Plugin update — v${update!.latest}${
|
||||||
update!.client_update_available ? " + client" : ""
|
update!.client_update_available ? " + client" : ""
|
||||||
}`
|
}`
|
||||||
: "Client update available"
|
: `Client update — ${update!.client_latest || "available"}`
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
// Only promise a one-tap install when there is one. On a notify-only install the
|
||||||
|
// row becomes the command itself, which is the whole answer for that box.
|
||||||
|
clientUpdateIsManualOnly(update) && !update!.update_available
|
||||||
|
? update!.client_opt_in || update!.client_command
|
||||||
|
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||||
}
|
}
|
||||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
|
||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<RowActions>
|
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
|
||||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
<RowActions>
|
||||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||||
Update
|
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||||
</DialogButton>
|
Update
|
||||||
</RowActions>
|
</DialogButton>
|
||||||
|
</RowActions>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
{!!update?.client_error && (
|
||||||
|
<Field
|
||||||
|
label="Client update check"
|
||||||
|
description={
|
||||||
|
update.client_error === "client-outdated"
|
||||||
|
? "This client predates update checks — update it once by hand and the check starts working."
|
||||||
|
: "Couldn’t check the client for updates."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Field
|
<Field
|
||||||
label="Setup guide"
|
label="Setup guide"
|
||||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
//! Compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic icons)
|
//! Compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic icons)
|
||||||
//! into a gresource bundle, registered at startup via `gio::resources_register_include!`.
|
//! into a gresource bundle, registered at startup via `gio::resources_register_include!`,
|
||||||
|
//! and stamp the build version the update check compares against.
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Build provenance, identical to the host's (crates/punktfunk-host/build.rs): the packaging
|
||||||
|
// jobs set PUNKTFUNK_BUILD_VERSION to the full package version (`0.23.0~ci10250.gab12cd34`
|
||||||
|
// on deb, `0.23.0-0.ci10250.g…` on rpm, …), a plain `cargo build` falls back to the crate
|
||||||
|
// version. This is what `--version` prints and what `--check-update` compares against the
|
||||||
|
// signed manifest — and the canary suffix is load-bearing there, because canary channels
|
||||||
|
// compare CI run numbers rather than patch fields (pf_update_check::version).
|
||||||
|
let version = std::env::var("PUNKTFUNK_BUILD_VERSION")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
|
||||||
|
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
|
||||||
|
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
|
||||||
|
|
||||||
// Host cfg gate mirrors this crate's `#[cfg(target_os = "linux")]` modules: on any other
|
// Host cfg gate mirrors this crate's `#[cfg(target_os = "linux")]` modules: on any other
|
||||||
// host the crate compiles to an empty stub and `glib-compile-resources` may not exist.
|
// host the crate compiles to an empty stub and `glib-compile-resources` may not exist.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- bazzite — the Bazzite "b", from ublue-os/bazzite repo_content/Bazzite.svg (Apache-2.0), lifted out of the badge and normalized to a 24x24 box. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"/></svg>
|
||||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- cachyos — from Simple Icons (CC0 1.0). See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"/></svg>
|
||||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- nobara — from Simple Icons (CC0 1.0), slug "nobaralinux". See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"/></svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
@@ -1,2 +1,2 @@
|
|||||||
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
|
<!-- windows — the modern (Windows 11) four-pane mark: four equal squares, no perspective skew. Own geometry, see README.md. -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"/></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 318 B |
@@ -14,5 +14,8 @@
|
|||||||
<file>icons/scalable/actions/pf-os-debian-symbolic.svg</file>
|
<file>icons/scalable/actions/pf-os-debian-symbolic.svg</file>
|
||||||
<file>icons/scalable/actions/pf-os-nixos-symbolic.svg</file>
|
<file>icons/scalable/actions/pf-os-nixos-symbolic.svg</file>
|
||||||
<file>icons/scalable/actions/pf-os-opensuse-symbolic.svg</file>
|
<file>icons/scalable/actions/pf-os-opensuse-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-os-bazzite-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-os-cachyos-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-os-nobara-symbolic.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
|
|||||||
@@ -417,15 +417,11 @@ impl SimpleComponent for AppModel {
|
|||||||
self.hosts.emit(HostsMsg::ClearError);
|
self.hosts.emit(HostsMsg::ClearError);
|
||||||
self.hosts
|
self.hosts
|
||||||
.emit(HostsMsg::SetConnecting(Some(req.card_key())));
|
.emit(HostsMsg::SetConnecting(Some(req.card_key())));
|
||||||
let fullscreen = self.settings.borrow().fullscreen_on_stream;
|
// No settings ride along: the spawner resolves this host's effective ones
|
||||||
if let Err(e) = spawn::spawn_session(
|
// (globals + its profile) for both the argv and the child's spec.
|
||||||
sender.input_sender().clone(),
|
if let Err(e) =
|
||||||
req,
|
spawn::spawn_session(sender.input_sender().clone(), req, fp_hex, tofu, opts)
|
||||||
fp_hex,
|
{
|
||||||
tofu,
|
|
||||||
fullscreen,
|
|
||||||
opts,
|
|
||||||
) {
|
|
||||||
self.busy = false;
|
self.busy = false;
|
||||||
self.hosts.emit(HostsMsg::SetConnecting(None));
|
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||||
self.hosts.emit(HostsMsg::ShowError(e));
|
self.hosts.emit(HostsMsg::ShowError(e));
|
||||||
@@ -696,9 +692,10 @@ impl AppModel {
|
|||||||
2, // audio_channels: stereo
|
2, // audio_channels: stereo
|
||||||
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
||||||
0, // preferred_codec: no preference
|
0, // preferred_codec: no preference
|
||||||
None, // display_hdr: probe connect, nothing presents
|
None, // display_hdr: probe connect, nothing presents
|
||||||
0, // client_caps: probe connect, nothing renders a cursor
|
0, // client_caps: probe connect, nothing renders a cursor
|
||||||
None, // launch: probe connect, no game
|
false, // frame_parts: probe/whole-AU consumer
|
||||||
|
None, // launch: probe connect, no game
|
||||||
// Knock under this device's name, not a fingerprint placeholder, when the
|
// Knock under this device's name, not a fingerprint placeholder, when the
|
||||||
// probed host doesn't know us yet.
|
// probed host doesn't know us yet.
|
||||||
Some(pf_client_core::trust::device_name()),
|
Some(pf_client_core::trust::device_name()),
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ pub fn exec_session() -> glib::ExitCode {
|
|||||||
"--launch",
|
"--launch",
|
||||||
"--mgmt",
|
"--mgmt",
|
||||||
"--connect-timeout",
|
"--connect-timeout",
|
||||||
|
// A one-off profile pick, same grammar the session documents (`--profile ""` forces
|
||||||
|
// the global defaults). Without it here, `punktfunk --connect … --profile Work` from a
|
||||||
|
// script or a Decky wrapper streamed with the host's binding instead.
|
||||||
|
"--profile",
|
||||||
];
|
];
|
||||||
let mut cmd = std::process::Command::new(crate::spawn::session_binary());
|
let mut cmd = std::process::Command::new(crate::spawn::session_binary());
|
||||||
let mut args = std::env::args().skip(1).peekable();
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
@@ -446,6 +450,106 @@ pub fn headless_reset() -> glib::ExitCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The version this binary was built as — the CI-stamped string (`0.23.0~ci10250.gab12cd34`
|
||||||
|
/// and friends) when built by a packaging job, the crate version otherwise. See `build.rs`.
|
||||||
|
pub fn version_string() -> &'static str {
|
||||||
|
env!("PUNKTFUNK_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--version` — one line, so a packaging script's run-the-binary gate and the Decky plugin
|
||||||
|
/// can both ask "what is installed here?" without a display or a config dir.
|
||||||
|
fn headless_version() -> glib::ExitCode {
|
||||||
|
println!("punktfunk-client {}", version_string());
|
||||||
|
glib::ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--check-update [--json]` — is a newer client available for this box's channel, and can
|
||||||
|
/// anything here install it? The answer comes from the same Ed25519-signed manifest the host
|
||||||
|
/// checks (`pf_client_core::update`); this is only its presentation.
|
||||||
|
///
|
||||||
|
/// Exit code carries the verdict for scripts: 0 = up to date, 10 = an update is available,
|
||||||
|
/// 1 = the check could not be completed (offline, bad signature, disabled). The distinction
|
||||||
|
/// matters — "could not tell" must never be scripted as "up to date".
|
||||||
|
fn headless_check_update() -> glib::ExitCode {
|
||||||
|
let status = pf_client_core::update::check(version_string());
|
||||||
|
if arg_flag("--json") {
|
||||||
|
match serde_json::to_string(&status) {
|
||||||
|
Ok(s) => println!("{s}"),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("check-update: {e}");
|
||||||
|
return glib::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"installed {} ({}, {})",
|
||||||
|
status.current, status.kind, status.channel
|
||||||
|
);
|
||||||
|
println!("available {}", status.latest);
|
||||||
|
if let Some(err) = &status.error {
|
||||||
|
eprintln!("check-update: {err}");
|
||||||
|
} else if status.update_available {
|
||||||
|
println!("update yes");
|
||||||
|
println!("apply {}", update_apply_line(&status));
|
||||||
|
} else {
|
||||||
|
println!("update no");
|
||||||
|
}
|
||||||
|
if let Some(hint) = &status.opt_in_hint {
|
||||||
|
println!("opt-in {hint}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status.error.is_some() {
|
||||||
|
glib::ExitCode::FAILURE
|
||||||
|
} else if status.update_available {
|
||||||
|
// Distinct from both success and failure so `--check-update` is scriptable.
|
||||||
|
glib::ExitCode::from(10u8)
|
||||||
|
} else {
|
||||||
|
glib::ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The human line describing how an update would be applied.
|
||||||
|
fn update_apply_line(status: &pf_client_core::update::Status) -> String {
|
||||||
|
use pf_client_core::update::Applier;
|
||||||
|
match status.applier {
|
||||||
|
Applier::Helper => format!("punktfunk-client --apply-update ({})", status.command),
|
||||||
|
Applier::Flatpak => status.command.clone(),
|
||||||
|
Applier::None => status.command.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--apply-update [--json]` — drive the packaged root helper for this install. Refuses (with
|
||||||
|
/// the command to run instead) for every kind it does not own; see
|
||||||
|
/// `pf_client_core::update::apply`.
|
||||||
|
fn headless_apply_update() -> glib::ExitCode {
|
||||||
|
let outcome = pf_client_core::update::apply(version_string());
|
||||||
|
if arg_flag("--json") {
|
||||||
|
match serde_json::to_string(&outcome) {
|
||||||
|
Ok(s) => println!("{s}"),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("apply-update: {e}");
|
||||||
|
return glib::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(err) = &outcome.error {
|
||||||
|
eprintln!("apply-update: {err}");
|
||||||
|
} else if outcome.staged {
|
||||||
|
println!(
|
||||||
|
"staged {} -> {} (reboot to finish)",
|
||||||
|
outcome.before, outcome.after
|
||||||
|
);
|
||||||
|
} else if outcome.changed {
|
||||||
|
println!("updated {} -> {}", outcome.before, outcome.after);
|
||||||
|
} else {
|
||||||
|
println!("already up to date ({})", outcome.before);
|
||||||
|
}
|
||||||
|
if outcome.ok {
|
||||||
|
glib::ExitCode::SUCCESS
|
||||||
|
} else {
|
||||||
|
glib::ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
|
/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
|
||||||
/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
|
/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
|
||||||
/// dispatch in `app.rs` is a single line.
|
/// dispatch in `app.rs` is a single line.
|
||||||
@@ -468,6 +572,15 @@ pub fn headless_host_command() -> Option<glib::ExitCode> {
|
|||||||
if arg_flag("--reset") {
|
if arg_flag("--reset") {
|
||||||
return Some(headless_reset());
|
return Some(headless_reset());
|
||||||
}
|
}
|
||||||
|
if arg_flag("--version") {
|
||||||
|
return Some(headless_version());
|
||||||
|
}
|
||||||
|
if arg_flag("--check-update") {
|
||||||
|
return Some(headless_check_update());
|
||||||
|
}
|
||||||
|
if arg_flag("--apply-update") {
|
||||||
|
return Some(headless_apply_update());
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
use crate::app::AppMsg;
|
use crate::app::AppMsg;
|
||||||
use crate::ui_hosts::ConnectRequest;
|
use crate::ui_hosts::ConnectRequest;
|
||||||
use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
|
use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
|
||||||
use pf_client_core::trust::Settings;
|
|
||||||
|
|
||||||
/// Spawn tunables beyond a plain connect.
|
/// Spawn tunables beyond a plain connect.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -32,11 +31,49 @@ pub struct SpawnOpts {
|
|||||||
|
|
||||||
pub use orchestrate::{session_binary, CancelHandle};
|
pub use orchestrate::{session_binary, CancelHandle};
|
||||||
|
|
||||||
|
/// The plan one card click resolves to. `for_target` does ALL of the resolving — this
|
||||||
|
/// device's settings with the host's bound profile (or the "Connect with ▸" one-off) overlaid,
|
||||||
|
/// and the per-host clipboard decision — through the same helpers the session's own compat path
|
||||||
|
/// uses, so the two cannot disagree.
|
||||||
|
///
|
||||||
|
/// Resolving is not optional: `orchestrate::spawn_session` writes `plan.settings` into the
|
||||||
|
/// child's `--resolved-spec`, and a session running from a spec reads NO stores. This used to
|
||||||
|
/// build the plan with `..Settings::default()`, on the theory that only the argv read it —
|
||||||
|
/// which meant every stream since the arch-split ran at the DEFAULTS: the host's fallback
|
||||||
|
/// bitrate (20 Mbps), the native resolution, `auto` codec, stereo, and no profile.
|
||||||
|
fn plan_for(req: &ConnectRequest, fp_hex: &str, tofu: bool, opts: &SpawnOpts) -> ConnectPlan {
|
||||||
|
let mut plan = ConnectPlan::for_target(
|
||||||
|
HostTarget {
|
||||||
|
name: req.name.clone(),
|
||||||
|
addr: req.addr.clone(),
|
||||||
|
port: req.port,
|
||||||
|
fp_hex: Some(fp_hex.to_string()),
|
||||||
|
mac: req.mac.clone(),
|
||||||
|
id: None,
|
||||||
|
},
|
||||||
|
req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||||
|
// A plain card click carries no one-off: the resolver honors the host's own binding
|
||||||
|
// (design/client-settings-profiles.md §4.6). Only a "Connect with ▸" pick (or a URL's
|
||||||
|
// `profile=`) sets one, and it applies to this session alone.
|
||||||
|
req.profile.clone(),
|
||||||
|
);
|
||||||
|
// This shell still runs its own dial-first wake fallback in `app.rs`, so the plan's own
|
||||||
|
// wake stays off (it becomes real when the GTK connect path moves onto
|
||||||
|
// `ConnectOrchestrator`, arch-split C0).
|
||||||
|
plan.wake = false;
|
||||||
|
plan.connect_timeout_secs = opts.connect_timeout_secs;
|
||||||
|
plan.tofu = tofu;
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn the session binary for a connect with `fp_hex` pinned and translate its
|
/// Spawn the session binary for a connect with `fp_hex` pinned and translate its
|
||||||
/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert
|
/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert
|
||||||
/// rather than the store — the app persists it once the child reports ready (the child
|
/// rather than the store — the app persists it once the child reports ready (the child
|
||||||
/// connects pinned to it, so ready proves the host really holds that identity).
|
/// connects pinned to it, so ready proves the host really holds that identity).
|
||||||
///
|
///
|
||||||
|
/// Settings are NOT a parameter: the plan resolves them (including the fullscreen policy,
|
||||||
|
/// which is a profileable field — a shell-read global would override a profile that set it).
|
||||||
|
///
|
||||||
/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` =
|
/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` =
|
||||||
/// the spawn itself failed (binary missing?) — surfaced as a connect error.
|
/// the spawn itself failed (binary missing?) — surfaced as a connect error.
|
||||||
pub fn spawn_session(
|
pub fn spawn_session(
|
||||||
@@ -44,48 +81,9 @@ pub fn spawn_session(
|
|||||||
req: ConnectRequest,
|
req: ConnectRequest,
|
||||||
fp_hex: String,
|
fp_hex: String,
|
||||||
tofu: bool,
|
tofu: bool,
|
||||||
fullscreen_on_stream: bool,
|
|
||||||
opts: SpawnOpts,
|
opts: SpawnOpts,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// The plan this connect resolves to. A plain card click carries no `--profile`: it honors
|
let plan = plan_for(&req, &fp_hex, tofu, &opts);
|
||||||
// the host's own binding, which the session resolves through the same helper this shell
|
|
||||||
// would have used (design/client-settings-profiles.md §4.6) — passing it here would be a
|
|
||||||
// second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's
|
|
||||||
// `profile=`) sets one, and it applies to this session alone.
|
|
||||||
//
|
|
||||||
// Two fields are deliberately not the shell's state yet, because nothing in the spawn path
|
|
||||||
// reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake`
|
|
||||||
// is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both
|
|
||||||
// become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0).
|
|
||||||
let plan = ConnectPlan {
|
|
||||||
host: HostTarget {
|
|
||||||
name: req.name.clone(),
|
|
||||||
addr: req.addr.clone(),
|
|
||||||
port: req.port,
|
|
||||||
fp_hex: Some(fp_hex.clone()),
|
|
||||||
mac: req.mac.clone(),
|
|
||||||
id: None,
|
|
||||||
},
|
|
||||||
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
|
|
||||||
profile: None,
|
|
||||||
// A one-off pick rides the flag; without one the session resolves the host's own
|
|
||||||
// binding through the same helper this shell would have used.
|
|
||||||
profile_override: req.profile.clone(),
|
|
||||||
settings: Settings {
|
|
||||||
fullscreen_on_stream,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
wake: false,
|
|
||||||
connect_timeout_secs: opts.connect_timeout_secs,
|
|
||||||
tofu,
|
|
||||||
// The per-host clipboard decision, resolved here so the child doesn't look it up
|
|
||||||
// again — matched by address, the way every other per-host lookup matches.
|
|
||||||
clipboard: pf_client_core::trust::KnownHosts::load()
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.any(|h| h.addr == req.addr && h.port == req.port && h.clipboard_sync),
|
|
||||||
};
|
|
||||||
|
|
||||||
let persist_paired = opts.persist_paired;
|
let persist_paired = opts.persist_paired;
|
||||||
let (mut error, mut ended) = (None::<(String, bool)>, None::<String>);
|
let (mut error, mut ended) = (None::<(String, bool)>, None::<String>);
|
||||||
orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev {
|
orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev {
|
||||||
@@ -116,3 +114,104 @@ pub fn spawn_session(
|
|||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The plan a card click builds carries THIS DEVICE'S settings, with the host's bound
|
||||||
|
/// profile overlaid — not `Settings::default()`.
|
||||||
|
///
|
||||||
|
/// This is the 0.22.x regression, and it was invisible because the defaults are all
|
||||||
|
/// plausible: the spec's `bitrate_kbps: 0` means "host default", which the host reads as
|
||||||
|
/// its 20 Mbps fallback. A stream that ignores every setting looks exactly like a stream
|
||||||
|
/// that is merely capped. One test, one `HOME` — the stores are read from it, so this
|
||||||
|
/// deliberately does not split into several that would race over the same env var.
|
||||||
|
#[test]
|
||||||
|
fn the_plan_carries_resolved_settings_not_defaults() {
|
||||||
|
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
|
||||||
|
use pf_client_core::trust::{KnownHost, KnownHosts, Settings};
|
||||||
|
|
||||||
|
let home = std::env::temp_dir().join(format!("pf-spawn-test-{}", std::process::id()));
|
||||||
|
let cfg = home.join(".config/punktfunk");
|
||||||
|
std::fs::create_dir_all(&cfg).unwrap();
|
||||||
|
std::env::set_var("HOME", &home);
|
||||||
|
|
||||||
|
// A device whose owner has set a bitrate, and a host bound to a profile that raises it
|
||||||
|
// further — the two layers the spec has to carry.
|
||||||
|
let globals = Settings {
|
||||||
|
bitrate_kbps: 200_000,
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
codec: "av1".into(),
|
||||||
|
fullscreen_on_stream: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
globals.save();
|
||||||
|
let mut catalog = ProfilesFile {
|
||||||
|
version: 1,
|
||||||
|
profiles: vec![StreamProfile {
|
||||||
|
id: "aaaaaaaaaaaa".into(),
|
||||||
|
name: "Game".into(),
|
||||||
|
overrides: SettingsOverlay {
|
||||||
|
bitrate_kbps: Some(750_000),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..StreamProfile::new("")
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
catalog.save().unwrap();
|
||||||
|
let known = KnownHosts {
|
||||||
|
hosts: vec![KnownHost {
|
||||||
|
name: "Desk".into(),
|
||||||
|
addr: "192.168.1.50".into(),
|
||||||
|
port: 9777,
|
||||||
|
fp_hex: "a".repeat(64),
|
||||||
|
paired: true,
|
||||||
|
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||||
|
clipboard_sync: true,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
known.save().unwrap();
|
||||||
|
|
||||||
|
let req = ConnectRequest {
|
||||||
|
name: "Desk".into(),
|
||||||
|
addr: "192.168.1.50".into(),
|
||||||
|
port: 9777,
|
||||||
|
fp_hex: Some("a".repeat(64)),
|
||||||
|
pair_optional: false,
|
||||||
|
launch: None,
|
||||||
|
mac: vec![],
|
||||||
|
profile: None,
|
||||||
|
};
|
||||||
|
let opts = SpawnOpts::default();
|
||||||
|
|
||||||
|
// A card click: globals, with the BOUND profile's overrides on top.
|
||||||
|
let plan = plan_for(&req, &"a".repeat(64), false, &opts);
|
||||||
|
assert_eq!(plan.settings.bitrate_kbps, 750_000, "profile override");
|
||||||
|
assert_eq!((plan.settings.width, plan.settings.height), (2560, 1440));
|
||||||
|
assert_eq!(plan.settings.codec, "av1");
|
||||||
|
assert_eq!(plan.profile.as_ref().map(|p| p.name.as_str()), Some("Game"));
|
||||||
|
assert!(plan.clipboard, "the host's own opt-in");
|
||||||
|
// …and the spec the child actually runs from is those same settings.
|
||||||
|
assert_eq!(plan.spec(plan.clipboard).settings, plan.settings);
|
||||||
|
// The fullscreen policy rides the argv from the resolved settings, not a shell global.
|
||||||
|
assert!(!plan.session_args().contains(&"--fullscreen".to_string()));
|
||||||
|
|
||||||
|
// "Connect with ▸ Default settings" (`Some("")`) drops back to the globals, and the
|
||||||
|
// flag still rides so the session agrees about which layer it is on.
|
||||||
|
let defaults_req = ConnectRequest {
|
||||||
|
profile: Some(String::new()),
|
||||||
|
..req.clone()
|
||||||
|
};
|
||||||
|
let plan = plan_for(&defaults_req, &"a".repeat(64), false, &opts);
|
||||||
|
assert_eq!(plan.settings.bitrate_kbps, 200_000, "back to the globals");
|
||||||
|
assert_eq!(plan.profile, None);
|
||||||
|
let args = plan.session_args();
|
||||||
|
let i = args.iter().position(|a| a == "--profile").unwrap();
|
||||||
|
assert_eq!(args[i + 1], "");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -583,10 +583,13 @@ const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12);
|
|||||||
/// at a glance is the whole reason the chip exists. No colour set keeps the neutral pill, so
|
/// at a glance is the whole reason the chip exists. No colour set keeps the neutral pill, so
|
||||||
/// the palette stays opt-in.
|
/// the palette stays opt-in.
|
||||||
/// The OS-icon tokens this shell ships symbolic art for (`data/icons/.../pf-os-<t>-symbolic.svg`,
|
/// The OS-icon tokens this shell ships symbolic art for (`data/icons/.../pf-os-<t>-symbolic.svg`,
|
||||||
/// embedded via gresource). Chains walk most-specific-first, so a distro without its own mark
|
/// embedded via gresource): the families a chain can land on, plus the gaming distros that earn
|
||||||
/// (Bazzite, CachyOS, ...) lands on its family's and finally on plain Tux.
|
/// their own mark because "a Bazzite box" and "a Fedora box" are different machines to the person
|
||||||
|
/// reading the card. Chains walk most-specific-first, so a distro without a mark of its own still
|
||||||
|
/// lands on its family's and finally on plain Tux.
|
||||||
const OS_ICON_TOKENS: &[&str] = &[
|
const OS_ICON_TOKENS: &[&str] = &[
|
||||||
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos", "opensuse",
|
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos",
|
||||||
|
"opensuse", "bazzite", "cachyos", "nobara",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The card's OS glyph for an advertised chain, or `None` (no widget) when the host doesn't
|
/// The card's OS glyph for an advertised chain, or `None` (no widget) when the host doesn't
|
||||||
|
|||||||
@@ -140,10 +140,10 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
let settings_at_start = trust::Settings::load();
|
let settings_at_start = trust::Settings::load();
|
||||||
// The console's window and its input models are built ONCE, from the global defaults, and
|
// The console's window and its input models are built ONCE, from the global defaults, and
|
||||||
// live across every launch — so the presentation-tier fields below (stats tier, touch and
|
// live across every launch — so the presentation-tier fields below (stats tier, touch and
|
||||||
// mouse model, match-window, render scale) are latched here and a per-host profile cannot
|
// mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host
|
||||||
// move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio, pad) is
|
// profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec,
|
||||||
// re-resolved per launch and does honor the binding. Closing that gap means rebuilding the
|
// audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means
|
||||||
// presenter's models per launch — profiles P4 territory, not P0.
|
// rebuilding the presenter's models per launch — profiles P4 territory, not P0.
|
||||||
let latched_mouse = settings_at_start.mouse_mode();
|
let latched_mouse = settings_at_start.mouse_mode();
|
||||||
|
|
||||||
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
||||||
@@ -168,6 +168,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
touch_mode: settings_at_start.touch_mode(),
|
touch_mode: settings_at_start.touch_mode(),
|
||||||
mouse_mode: settings_at_start.mouse_mode(),
|
mouse_mode: settings_at_start.mouse_mode(),
|
||||||
invert_scroll: settings_at_start.invert_scroll,
|
invert_scroll: settings_at_start.invert_scroll,
|
||||||
|
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||||
let fp_hex = trust::hex(&fingerprint);
|
let fp_hex = trust::hex(&fingerprint);
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ mod session_main {
|
|||||||
} else {
|
} else {
|
||||||
settings.width
|
settings.width
|
||||||
},
|
},
|
||||||
height: if settings.width == 0 {
|
height: if settings.height == 0 {
|
||||||
native.height
|
native.height
|
||||||
} else {
|
} else {
|
||||||
settings.height
|
settings.height
|
||||||
@@ -240,11 +240,28 @@ mod session_main {
|
|||||||
audio_channels: settings.audio_channels,
|
audio_channels: settings.audio_channels,
|
||||||
preferred_codec: settings.preferred_codec(),
|
preferred_codec: settings.preferred_codec(),
|
||||||
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
|
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
|
||||||
video_caps: if settings.hdr_enabled {
|
// MULTI_SLICE is decoder truth for THIS embedder: every desktop decode stack
|
||||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
// (FFmpeg software, VAAPI, D3D11VA, Vulkan Video) handles AUs carrying several
|
||||||
} else {
|
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
|
||||||
0
|
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
|
||||||
},
|
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
|
||||||
|
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit only says
|
||||||
|
// "upgrade me if you can" — the host still gates on its own policy, its capturer,
|
||||||
|
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
|
||||||
|
// Welcome BEFORE we build a decoder. Advertised unconditionally when the user asks
|
||||||
|
// for it because every decode path here can produce it: the hardware ones where the
|
||||||
|
// driver decodes RExt, and swscale for the rest (the decoder demotes on its own).
|
||||||
|
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||||
|
| if settings.hdr_enabled {
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
| if settings.enable_444 {
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_444
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
|
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
|
||||||
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||||
// pump) pins one manually.
|
// pump) pins one manually.
|
||||||
@@ -566,6 +583,7 @@ mod session_main {
|
|||||||
touch_mode: settings.touch_mode(),
|
touch_mode: settings.touch_mode(),
|
||||||
mouse_mode: settings.mouse_mode(),
|
mouse_mode: settings.mouse_mode(),
|
||||||
invert_scroll: settings.invert_scroll,
|
invert_scroll: settings.invert_scroll,
|
||||||
|
inhibit_shortcuts: settings.inhibit_shortcuts,
|
||||||
json_status: true,
|
json_status: true,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
// This host's card carries the accent bar in the desktop client now.
|
// This host's card carries the accent bar in the desktop client now.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 867 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 355 B After Width: | Height: | Size: 174 B |
@@ -222,7 +222,16 @@ fn connect_spawn(
|
|||||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||||
ctx.shared.stats_line.lock().unwrap().clear();
|
ctx.shared.stats_line.lock().unwrap().clear();
|
||||||
ctx.shared.browse.store(false, Ordering::SeqCst);
|
ctx.shared.browse.store(false, Ordering::SeqCst);
|
||||||
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
// Through the same resolver the session uses, not the raw globals: "Start streams
|
||||||
|
// fullscreen" is a profileable (tier-P) field, so a host bound to a windowed profile has to
|
||||||
|
// win here too — the child takes this decision from the argv, not from its own settings.
|
||||||
|
let fullscreen = pf_client_core::trust::effective_settings(
|
||||||
|
&target.addr,
|
||||||
|
target.port,
|
||||||
|
target.profile.as_deref(),
|
||||||
|
)
|
||||||
|
.0
|
||||||
|
.fullscreen_on_stream;
|
||||||
set_status.call(String::new());
|
set_status.call(String::new());
|
||||||
set_screen.call(if opts.awaiting_approval {
|
set_screen.call(if opts.awaiting_approval {
|
||||||
Screen::RequestAccess
|
Screen::RequestAccess
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// Embedded PNG per icon token, most-generic set the chain walk can land on. A distro
|
/// Embedded PNG per icon token: the families the chain walk can land on, plus the gaming
|
||||||
/// without its own mark (Bazzite, CachyOS, ...) degrades to its family's and finally Tux.
|
/// distros that earn their own mark because "a Bazzite box" and "a Fedora box" are
|
||||||
|
/// different machines to the person reading the tile. A distro with no mark of its own
|
||||||
|
/// still degrades to its family's and finally to Tux.
|
||||||
const ICONS: &[(&str, &[u8])] = &[
|
const ICONS: &[(&str, &[u8])] = &[
|
||||||
("windows", include_bytes!("../../assets/os/windows.png")),
|
("windows", include_bytes!("../../assets/os/windows.png")),
|
||||||
("apple", include_bytes!("../../assets/os/apple.png")),
|
("apple", include_bytes!("../../assets/os/apple.png")),
|
||||||
@@ -21,6 +23,9 @@ const ICONS: &[(&str, &[u8])] = &[
|
|||||||
("debian", include_bytes!("../../assets/os/debian.png")),
|
("debian", include_bytes!("../../assets/os/debian.png")),
|
||||||
("nixos", include_bytes!("../../assets/os/nixos.png")),
|
("nixos", include_bytes!("../../assets/os/nixos.png")),
|
||||||
("opensuse", include_bytes!("../../assets/os/opensuse.png")),
|
("opensuse", include_bytes!("../../assets/os/opensuse.png")),
|
||||||
|
("bazzite", include_bytes!("../../assets/os/bazzite.png")),
|
||||||
|
("cachyos", include_bytes!("../../assets/os/cachyos.png")),
|
||||||
|
("nobara", include_bytes!("../../assets/os/nobara.png")),
|
||||||
];
|
];
|
||||||
|
|
||||||
fn dir() -> Option<PathBuf> {
|
fn dir() -> Option<PathBuf> {
|
||||||
|
|||||||
@@ -54,10 +54,11 @@ pub fn run_speed_probe(
|
|||||||
0, // video_caps: probe connect, nothing is decoded
|
0, // video_caps: probe connect, nothing is decoded
|
||||||
2, // audio_channels: stereo baseline
|
2, // audio_channels: stereo baseline
|
||||||
decodable_codecs(),
|
decodable_codecs(),
|
||||||
0, // preferred_codec: no preference
|
0, // preferred_codec: no preference
|
||||||
None, // display_hdr: probe connect, nothing presents
|
None, // display_hdr: probe connect, nothing presents
|
||||||
0, // client_caps: probe connect, nothing renders a cursor
|
0, // client_caps: probe connect, nothing renders a cursor
|
||||||
None, // launch: no game
|
false, // frame_parts: probe/whole-AU consumer
|
||||||
|
None, // launch: no game
|
||||||
// Same label a real session sends — a speed test against a host that doesn't know us yet
|
// Same label a real session sends — a speed test against a host that doesn't know us yet
|
||||||
// should knock under this device's name, not a fingerprint placeholder.
|
// should knock under this device's name, not a fingerprint placeholder.
|
||||||
Some(punktfunk_core::client::device_name()),
|
Some(punktfunk_core::client::device_name()),
|
||||||
|
|||||||
@@ -429,6 +429,11 @@ pub struct IddPushCapturer {
|
|||||||
/// One-shot latch for [`Self::poll_display_hdr`]'s "could not pin the negotiated depth" error —
|
/// One-shot latch for [`Self::poll_display_hdr`]'s "could not pin the negotiated depth" error —
|
||||||
/// the poller samples every ~250 ms, and a display that refuses the flip refuses it every time.
|
/// the poller samples every ~250 ms, and a display that refuses the flip refuses it every time.
|
||||||
hdr_pin_warned: bool,
|
hdr_pin_warned: bool,
|
||||||
|
/// Consecutive failed depth-pin attempts ([`Self::poll_display_hdr`]) — past
|
||||||
|
/// [`Self::HDR_PIN_EAGER`] the re-pin backs off to every [`Self::HDR_PIN_RETRY_EVERY`]th
|
||||||
|
/// descriptor sample: a display that refuses the flip was costing 4 CCD writes + 8 display-
|
||||||
|
/// config queries per second, forever, all on the session-global display-config lock.
|
||||||
|
hdr_pin_failures: u32,
|
||||||
/// The session negotiated full-chroma 4:4:4: while the display is SDR the BGRA slot passes
|
/// The session negotiated full-chroma 4:4:4: while the display is SDR the BGRA slot passes
|
||||||
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
|
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
|
||||||
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
|
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
|
||||||
@@ -496,8 +501,11 @@ pub struct IddPushCapturer {
|
|||||||
offered_at_fresh: u64,
|
offered_at_fresh: u64,
|
||||||
max_hb_age_us: u64,
|
max_hb_age_us: u64,
|
||||||
/// The Phase A.2 micro-probe engine (refcounted process singleton) — its window read rides
|
/// The Phase A.2 micro-probe engine (refcounted process singleton) — its window read rides
|
||||||
/// every stall report so the verdict matrix can name the disturbance class.
|
/// every stall report so the verdict matrix can name the disturbance class. `None` when
|
||||||
probes: Arc<probes::ProbeEngine>,
|
/// `PUNKTFUNK_STALL_PROBES=0` opted the box out (the engine costs standing threads); the
|
||||||
|
/// verdict matrix already treats an absent probe window as never-stalled, so reports just
|
||||||
|
/// lose the corroborating legs, never invent them.
|
||||||
|
probes: Option<Arc<probes::ProbeEngine>>,
|
||||||
/// The Phase A.3 DxgKrnl ETW watch; `None` when the session can't start (non-admin dev run)
|
/// The Phase A.3 DxgKrnl ETW watch; `None` when the session can't start (non-admin dev run)
|
||||||
/// — reports then say `etw=unavailable`.
|
/// — reports then say `etw=unavailable`.
|
||||||
etw: Option<Arc<dxgkrnl_etw::EtwWatch>>,
|
etw: Option<Arc<dxgkrnl_etw::EtwWatch>>,
|
||||||
@@ -539,6 +547,13 @@ pub struct IddPushCapturer {
|
|||||||
unsafe impl Send for IddPushCapturer {}
|
unsafe impl Send for IddPushCapturer {}
|
||||||
|
|
||||||
impl IddPushCapturer {
|
impl IddPushCapturer {
|
||||||
|
/// Failed depth-pin attempts before [`Self::poll_display_hdr`] backs its re-pin off
|
||||||
|
/// (≈2 s of eager retries at the descriptor poller's 4 Hz).
|
||||||
|
const HDR_PIN_EAGER: u32 = 8;
|
||||||
|
/// While backed off, re-attempt the depth pin on every this-many-th descriptor sample
|
||||||
|
/// (~4 s at 4 Hz) — self-healing without the 4 Hz CCD-write drumbeat.
|
||||||
|
const HDR_PIN_RETRY_EVERY: u64 = 16;
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn latest(&self) -> u64 {
|
fn latest(&self) -> u64 {
|
||||||
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
|
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
|
||||||
@@ -832,40 +847,63 @@ impl IddPushCapturer {
|
|||||||
// either direction (its encoder re-inits on the depth change).
|
// either direction (its encoder re-inits on the depth change).
|
||||||
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
|
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
|
||||||
let want = self.client_10bit;
|
let want = self.client_10bit;
|
||||||
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s `bool`
|
// A display that refuses the pin refuses it on every 250 ms sample — past
|
||||||
// and then write `now.hdr = self.client_10bit` — substituting the DESIRED state for the
|
// [`Self::HDR_PIN_EAGER`] consecutive failures the write+read-back re-fires only on
|
||||||
// observed one, which broke in both directions on a display that cannot be flipped
|
// every [`Self::HDR_PIN_RETRY_EVERY`]th sample (~4 s), instead of 4 CCD writes +
|
||||||
// (the state this file already logs as "Downgrade point D" at open):
|
// 8 display-config queries per second forever, all on the session-global
|
||||||
// - want HDR, display stays SDR: the fabricated `true` differed from `current`, so two
|
// display-config lock. Skipped samples keep the poller's own advanced-color read,
|
||||||
// samples drove `recreate_ring(true, …)` and rebuilt the ring FP16 while the driver
|
// so nothing is fabricated and a display that becomes flippable is re-pinned on
|
||||||
// composed 8-bit BGRA. Every publish was then dropped by the driver's format guard,
|
// the next retry sample.
|
||||||
// `recovering_since` expired, and `try_consume` bailed — a permanent 3-second
|
if self.hdr_pin_failures < Self::HDR_PIN_EAGER
|
||||||
// reconnect loop.
|
|| self.desc_seq % Self::HDR_PIN_RETRY_EVERY == 0
|
||||||
// - want SDR, display stays HDR: the fabricated `false` MATCHED `current`, so no
|
{
|
||||||
// recreate ever fired and the ring stayed BGRA against an FP16 composition. Same
|
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s
|
||||||
// dropped-publish outcome, silently.
|
// `bool` and then write `now.hdr = self.client_10bit` — substituting the DESIRED
|
||||||
// Reading back immediately can catch a flip that has not settled yet; that costs one
|
// state for the observed one, which broke in both directions on a display that
|
||||||
// debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring, which is why
|
// cannot be flipped (the state this file already logs as "Downgrade point D" at
|
||||||
// this does not block the frame path on a settle poll the way `open_on` does.
|
// open):
|
||||||
let requested = pf_win_display::win_display::set_advanced_color(self.target_id, want);
|
// - want HDR, display stays SDR: the fabricated `true` differed from `current`,
|
||||||
let observed = pf_win_display::win_display::advanced_color_enabled(self.target_id);
|
// so two samples drove `recreate_ring(true, …)` and rebuilt the ring FP16
|
||||||
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
|
// while the driver composed 8-bit BGRA. Every publish was then dropped by the
|
||||||
now.hdr = observed.unwrap_or(now.hdr);
|
// driver's format guard, `recovering_since` expired, and `try_consume`
|
||||||
if now.hdr != want && !self.hdr_pin_warned {
|
// bailed — a permanent 3-second reconnect loop.
|
||||||
self.hdr_pin_warned = true;
|
// - want SDR, display stays HDR: the fabricated `false` MATCHED `current`, so no
|
||||||
tracing::error!(
|
// recreate ever fired and the ring stayed BGRA against an FP16 composition.
|
||||||
target_id = self.target_id,
|
// Same dropped-publish outcome, silently.
|
||||||
want_hdr = want,
|
// Reading back immediately can catch a flip that has not settled yet; that costs
|
||||||
observed_hdr = ?observed,
|
// one debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring,
|
||||||
set_advanced_color_returned = requested,
|
// which is why this does not block the frame path on a settle poll the way
|
||||||
pyrowave = self.pyrowave,
|
// `open_on` does.
|
||||||
client_10bit = self.client_10bit,
|
let requested =
|
||||||
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
|
pf_win_display::win_display::set_advanced_color(self.target_id, want);
|
||||||
it actually composes instead (a physical display forcing HDR, or a driver that \
|
let observed = pf_win_display::win_display::advanced_color_enabled(self.target_id);
|
||||||
refuses the flip). The stream's depth will not match the negotiation; the \
|
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
|
||||||
encoder's caps cross-check reports the truth to the client"
|
now.hdr = observed.unwrap_or(now.hdr);
|
||||||
);
|
if now.hdr != want {
|
||||||
|
self.hdr_pin_failures = self.hdr_pin_failures.saturating_add(1);
|
||||||
|
if !self.hdr_pin_warned {
|
||||||
|
self.hdr_pin_warned = true;
|
||||||
|
tracing::error!(
|
||||||
|
target_id = self.target_id,
|
||||||
|
want_hdr = want,
|
||||||
|
observed_hdr = ?observed,
|
||||||
|
set_advanced_color_returned = requested,
|
||||||
|
pyrowave = self.pyrowave,
|
||||||
|
client_10bit = self.client_10bit,
|
||||||
|
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
|
||||||
|
it actually composes instead (a physical display forcing HDR, or a driver that \
|
||||||
|
refuses the flip). The stream's depth will not match the negotiation; the \
|
||||||
|
encoder's caps cross-check reports the truth to the client"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.hdr_pin_failures = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No mismatch (or the session follows flips): the pin is healthy — a later refusal
|
||||||
|
// starts its eager attempts fresh.
|
||||||
|
self.hdr_pin_failures = 0;
|
||||||
}
|
}
|
||||||
let current = DisplayDescriptor {
|
let current = DisplayDescriptor {
|
||||||
hdr: self.display_hdr,
|
hdr: self.display_hdr,
|
||||||
@@ -1613,15 +1651,38 @@ impl IddPushCapturer {
|
|||||||
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
|
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||||
probes: now
|
probes: now
|
||||||
.checked_sub(stall.gap + Duration::from_millis(300))
|
.checked_sub(stall.gap + Duration::from_millis(300))
|
||||||
.map(|from| self.probes.window(from, now)),
|
.zip(self.probes.as_deref())
|
||||||
|
.map(|(from, p)| p.window(from, now)),
|
||||||
etw: self.etw.as_ref().and_then(|w| {
|
etw: self.etw.as_ref().and_then(|w| {
|
||||||
now.checked_sub(stall.gap + Duration::from_millis(300))
|
now.checked_sub(stall.gap + Duration::from_millis(300))
|
||||||
.map(|from| w.summary(from, now))
|
.map(|from| w.summary(from, now))
|
||||||
}),
|
}),
|
||||||
|
// The discriminator counts span the GAP ONLY — no lead-in: presents from the
|
||||||
|
// healthy flow right before the hole would falsely acquit the content. The
|
||||||
|
// stall-ending frame's own present lands at the window edge and stays well
|
||||||
|
// under the acquit bar.
|
||||||
|
etw_counts: self.etw.as_ref().and_then(|w| {
|
||||||
|
now.checked_sub(stall.gap)
|
||||||
|
.map(|from| w.window_counts(from, now))
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
self.stall_watch.report(&stall, now, &evidence);
|
self.stall_watch.report(&stall, now, &evidence);
|
||||||
}
|
}
|
||||||
if !regen {
|
if !regen {
|
||||||
|
// A degraded stretch just ended (or was cut by a ring recreate): the one line that
|
||||||
|
// makes a sustained ~2 fps phase log-visible — per-hole stall lines gate on prior
|
||||||
|
// ACTIVE flow and go quiet exactly while the stretch runs.
|
||||||
|
if let Some(r) = self.stall_watch.take_recovery() {
|
||||||
|
tracing::info!(
|
||||||
|
degraded_ms = r.degraded.as_millis() as u64,
|
||||||
|
holes = r.holes,
|
||||||
|
hole_time_ms = r.hole_time.as_millis() as u64,
|
||||||
|
worst_hole_ms = r.worst.as_millis() as u64,
|
||||||
|
"IDD-push capture recovered from a degraded stretch — fresh frames arrived \
|
||||||
|
only between stall-sized holes for its whole span; the per-stall lines \
|
||||||
|
above cover at most its first hole"
|
||||||
|
);
|
||||||
|
}
|
||||||
// A fresh driver frame: feed the driver-death watch and roll the stall-evidence
|
// A fresh driver frame: feed the driver-death watch and roll the stall-evidence
|
||||||
// trackers (a regen re-encodes OLD content — it is not evidence of driver progress).
|
// trackers (a regen re-encodes OLD content — it is not evidence of driver progress).
|
||||||
self.last_fresh = now;
|
self.last_fresh = now;
|
||||||
@@ -2090,6 +2151,84 @@ mod tests {
|
|||||||
assert!(out[10].is_some(), "30 fps flow must pass the activity gate");
|
assert!(out[10].is_some(), "30 fps flow must pass the activity gate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed a [`StallWatch`] frames at the given offsets and return the first degraded-stretch
|
||||||
|
/// summary it parks (checking after every frame, the way the capture loop does).
|
||||||
|
fn watch_recovery(offsets_ms: &[u64]) -> (StallWatch, Option<super::stall::Recovery>) {
|
||||||
|
let base = Instant::now();
|
||||||
|
let mut w = StallWatch::new();
|
||||||
|
let mut recovery = None;
|
||||||
|
for ms in offsets_ms {
|
||||||
|
w.note_fresh(base + Duration::from_millis(*ms));
|
||||||
|
if let Some(r) = w.take_recovery() {
|
||||||
|
recovery.get_or_insert(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(w, recovery)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_degraded_stretch_summarizes_on_recovery() {
|
||||||
|
// Active flow, then a ~2 fps phase (10 holes of 500 ms — the sustained-degradation
|
||||||
|
// shape whose holes the activity gate keeps out of the per-stall log), then recovery
|
||||||
|
// flow. One summary, carrying the whole stretch.
|
||||||
|
let mut t = Vec::new();
|
||||||
|
flow(&mut t, 0, 20); // last frame at 304 ms
|
||||||
|
t.extend((1..=10).map(|i| 304 + i * 500)); // 804..5304: ten 500 ms holes
|
||||||
|
t.extend((1..=12).map(|i| 5304 + i * 16)); // sustained flow is back
|
||||||
|
let (_, r) = watch_recovery(&t);
|
||||||
|
let r = r.expect("a multi-hole degraded stretch summarizes at recovery");
|
||||||
|
assert_eq!(r.holes, 10);
|
||||||
|
assert_eq!(r.hole_time.as_millis(), 5000);
|
||||||
|
assert_eq!(r.worst.as_millis(), 500);
|
||||||
|
assert_eq!(r.degraded.as_millis(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_stall_never_summarizes() {
|
||||||
|
// One hole inside otherwise-healthy flow: its own stall line covers it — a
|
||||||
|
// one-hole "stretch" dissolving silently is what keeps the summary meaningful.
|
||||||
|
let mut t = Vec::new();
|
||||||
|
flow(&mut t, 0, 20);
|
||||||
|
t.push(604); // the lone 300 ms hole
|
||||||
|
t.extend((1..=12).map(|i| 604 + i * 16));
|
||||||
|
let (_, r) = watch_recovery(&t);
|
||||||
|
assert!(
|
||||||
|
r.is_none(),
|
||||||
|
"single stall must not produce a stretch summary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_recreate_cut_stretch_still_summarizes() {
|
||||||
|
// A ring recreate resets the flow history mid-stretch; the holes accumulated BEFORE it
|
||||||
|
// are real evidence and must still surface.
|
||||||
|
let mut t = Vec::new();
|
||||||
|
flow(&mut t, 0, 20);
|
||||||
|
t.extend((1..=3).map(|i| 304 + i * 500));
|
||||||
|
let (mut w, r) = watch_recovery(&t);
|
||||||
|
assert!(r.is_none(), "stretch still open — no summary yet");
|
||||||
|
w.reset();
|
||||||
|
let r = w
|
||||||
|
.take_recovery()
|
||||||
|
.expect("reset closes and summarizes the open stretch");
|
||||||
|
assert_eq!(r.holes, 3);
|
||||||
|
assert_eq!(r.hole_time.as_millis(), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_content_stop_closes_the_stretch_without_folding_the_pause_in() {
|
||||||
|
// Two degraded holes, then the content plainly STOPS (a 20 s pause — quit to desktop).
|
||||||
|
// The summary covers the stretch only; the legitimate pause never joins the tally.
|
||||||
|
let mut t = Vec::new();
|
||||||
|
flow(&mut t, 0, 20);
|
||||||
|
t.extend([804, 1304, 21_304]);
|
||||||
|
let (_, r) = watch_recovery(&t);
|
||||||
|
let r = r.expect("the content stop closes the stretch");
|
||||||
|
assert_eq!(r.holes, 2);
|
||||||
|
assert_eq!(r.hole_time.as_millis(), 1000);
|
||||||
|
assert_eq!(r.degraded.as_millis(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn metronomic_stalls_self_diagnose() {
|
fn metronomic_stalls_self_diagnose() {
|
||||||
// The field signature: ~300 ms DWM holes every 4 s inside 60 fps flow. Stalls land at the
|
// The field signature: ~300 ms DWM holes every 4 s inside 60 fps flow. Stalls land at the
|
||||||
@@ -2146,6 +2285,7 @@ mod tests {
|
|||||||
max_heartbeat_age_ms: hb_age_ms,
|
max_heartbeat_age_ms: hb_age_ms,
|
||||||
probes: None,
|
probes: None,
|
||||||
etw: None,
|
etw: None,
|
||||||
|
etw_counts: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -2167,10 +2307,12 @@ mod tests {
|
|||||||
assert_eq!(verdict(3_000, Some(2), 1_600), StallVerdict::WorkerStalled);
|
assert_eq!(verdict(3_000, Some(2), 1_600), StallVerdict::WorkerStalled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`stall::classify`]'s verdict matrix — how the micro-probe window refines (or declines to
|
/// [`stall::classify`]'s verdict matrix — how the micro-probe window and the ETW
|
||||||
/// refine) the driver-telemetry verdict into a named disturbance class.
|
/// present-vs-queue counts refine (or decline to refine) the driver-telemetry verdict into
|
||||||
|
/// a named disturbance class.
|
||||||
#[test]
|
#[test]
|
||||||
fn stall_classification_matrix() {
|
fn stall_classification_matrix() {
|
||||||
|
use super::dxgkrnl_etw::EtwWindowCounts;
|
||||||
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
|
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
|
||||||
let gap = Duration::from_millis(600);
|
let gap = Duration::from_millis(600);
|
||||||
let probes = |fence: Option<u64>, dwm: Option<u64>, flush: Option<u64>| ProbeWindow {
|
let probes = |fence: Option<u64>, dwm: Option<u64>, flush: Option<u64>| ProbeWindow {
|
||||||
@@ -2179,22 +2321,29 @@ mod tests {
|
|||||||
dwm_flush_max_us: flush,
|
dwm_flush_max_us: flush,
|
||||||
..ProbeWindow::default()
|
..ProbeWindow::default()
|
||||||
};
|
};
|
||||||
|
let counts = |presents: u32, queue_adds: u32| EtwWindowCounts {
|
||||||
|
presents,
|
||||||
|
queue_adds,
|
||||||
|
present_history: true,
|
||||||
|
queue_history: true,
|
||||||
|
};
|
||||||
// The driver's own verdicts win outright — probes can't overrule "we lost the frames".
|
// The driver's own verdicts win outright — probes can't overrule "we lost the frames".
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(
|
classify(
|
||||||
gap,
|
gap,
|
||||||
&StallVerdict::WorkerStalled,
|
&StallVerdict::WorkerStalled,
|
||||||
Some(&probes(Some(500_000), None, None))
|
Some(&probes(Some(500_000), None, None)),
|
||||||
|
None
|
||||||
),
|
),
|
||||||
StallClass::OursWorker
|
StallClass::OursWorker
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(gap, &StallVerdict::DeliveryLeg, None),
|
classify(gap, &StallVerdict::DeliveryLeg, None, None),
|
||||||
StallClass::OursDelivery
|
StallClass::OursDelivery
|
||||||
);
|
);
|
||||||
// No probes: compose-silence alone can't name a class.
|
// No probes: compose-silence alone can't name a class.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(gap, &StallVerdict::ComposeSilence, None),
|
classify(gap, &StallVerdict::ComposeSilence, None, None),
|
||||||
StallClass::Unattributed
|
StallClass::Unattributed
|
||||||
);
|
);
|
||||||
// Fences stalled ≥ gap/2 → the adapter froze — Class 1 (even without driver telemetry).
|
// Fences stalled ≥ gap/2 → the adapter froze — Class 1 (even without driver telemetry).
|
||||||
@@ -2202,7 +2351,8 @@ mod tests {
|
|||||||
classify(
|
classify(
|
||||||
gap,
|
gap,
|
||||||
&StallVerdict::ComposeSilence,
|
&StallVerdict::ComposeSilence,
|
||||||
Some(&probes(Some(400_000), Some(400_000), None))
|
Some(&probes(Some(400_000), Some(400_000), None)),
|
||||||
|
None
|
||||||
),
|
),
|
||||||
StallClass::AdapterFreeze
|
StallClass::AdapterFreeze
|
||||||
);
|
);
|
||||||
@@ -2210,7 +2360,8 @@ mod tests {
|
|||||||
classify(
|
classify(
|
||||||
gap,
|
gap,
|
||||||
&StallVerdict::NoTelemetry,
|
&StallVerdict::NoTelemetry,
|
||||||
Some(&probes(Some(400_000), None, None))
|
Some(&probes(Some(400_000), None, None)),
|
||||||
|
None
|
||||||
),
|
),
|
||||||
StallClass::AdapterFreeze
|
StallClass::AdapterFreeze
|
||||||
);
|
);
|
||||||
@@ -2219,7 +2370,8 @@ mod tests {
|
|||||||
classify(
|
classify(
|
||||||
gap,
|
gap,
|
||||||
&StallVerdict::ComposeSilence,
|
&StallVerdict::ComposeSilence,
|
||||||
Some(&probes(Some(16_000), Some(500_000), None))
|
Some(&probes(Some(16_000), Some(500_000), None)),
|
||||||
|
None
|
||||||
),
|
),
|
||||||
StallClass::CompositorBlocked
|
StallClass::CompositorBlocked
|
||||||
);
|
);
|
||||||
@@ -2227,36 +2379,91 @@ mod tests {
|
|||||||
classify(
|
classify(
|
||||||
gap,
|
gap,
|
||||||
&StallVerdict::ComposeSilence,
|
&StallVerdict::ComposeSilence,
|
||||||
Some(&probes(Some(16_000), Some(20_000), Some(450_000)))
|
Some(&probes(Some(16_000), Some(20_000), Some(450_000))),
|
||||||
|
None
|
||||||
),
|
),
|
||||||
StallClass::CompositorBlocked
|
StallClass::CompositorBlocked
|
||||||
);
|
);
|
||||||
// Everything alive + the driver swears E_PENDING → the frame-generation path.
|
// Everything alive + the driver swears E_PENDING, but NO working present witness:
|
||||||
|
// the silence cannot be pinned on either side — UNATTRIBUTED, never a guess. (The
|
||||||
|
// pre-2026-07-30 default of FRAME-GENERATION here mislabeled benign content pauses.)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(
|
classify(
|
||||||
gap,
|
gap,
|
||||||
&StallVerdict::ComposeSilence,
|
&StallVerdict::ComposeSilence,
|
||||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000)))
|
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||||
|
None
|
||||||
|
),
|
||||||
|
StallClass::Unattributed
|
||||||
|
);
|
||||||
|
// A witness that exists but has never produced an event is NOT a working witness.
|
||||||
|
assert_eq!(
|
||||||
|
classify(
|
||||||
|
gap,
|
||||||
|
&StallVerdict::ComposeSilence,
|
||||||
|
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||||
|
Some(&EtwWindowCounts {
|
||||||
|
present_history: false,
|
||||||
|
..EtwWindowCounts::default()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
StallClass::Unattributed
|
||||||
|
);
|
||||||
|
// The present witness splits the silence. Presents flowing through the hole while the
|
||||||
|
// virtual display's queue starved → the OS display path dropped composed frames: the
|
||||||
|
// frame-generation leg, POSITIVELY convicted.
|
||||||
|
assert_eq!(
|
||||||
|
classify(
|
||||||
|
gap,
|
||||||
|
&StallVerdict::ComposeSilence,
|
||||||
|
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||||
|
Some(&counts(54, 0))
|
||||||
),
|
),
|
||||||
StallClass::FrameGeneration
|
StallClass::FrameGeneration
|
||||||
);
|
);
|
||||||
|
// (Essentially) no presents anywhere across the hole — the stall-ending frame and a
|
||||||
|
// caret blink stay under the bar → the CONTENT stopped presenting: benign for the
|
||||||
|
// display path.
|
||||||
|
assert_eq!(
|
||||||
|
classify(
|
||||||
|
gap,
|
||||||
|
&StallVerdict::ComposeSilence,
|
||||||
|
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||||
|
Some(&counts(2, 1))
|
||||||
|
),
|
||||||
|
StallClass::ContentSilence
|
||||||
|
);
|
||||||
|
// The present witness does NOT overrule the driver's own verdicts or the harder
|
||||||
|
// classes — it only refines compose-silence.
|
||||||
|
assert_eq!(
|
||||||
|
classify(
|
||||||
|
gap,
|
||||||
|
&StallVerdict::ComposeSilence,
|
||||||
|
Some(&probes(Some(400_000), Some(400_000), None)),
|
||||||
|
Some(&counts(54, 0))
|
||||||
|
),
|
||||||
|
StallClass::AdapterFreeze
|
||||||
|
);
|
||||||
// Healthy probes but a pre-telemetry driver: delivery-leg is equally possible — honest.
|
// Healthy probes but a pre-telemetry driver: delivery-leg is equally possible — honest.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(
|
classify(
|
||||||
gap,
|
gap,
|
||||||
&StallVerdict::NoTelemetry,
|
&StallVerdict::NoTelemetry,
|
||||||
Some(&probes(Some(16_000), Some(20_000), None))
|
Some(&probes(Some(16_000), Some(20_000), None)),
|
||||||
|
Some(&counts(54, 0))
|
||||||
),
|
),
|
||||||
StallClass::Unattributed
|
StallClass::Unattributed
|
||||||
);
|
);
|
||||||
// An absent probe (None) never reads as "stalled" — absence is stated, not guessed.
|
// An absent probe (None) never reads as "stalled" — absence is stated, not guessed;
|
||||||
|
// with the present witness working, the silence still splits correctly.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(
|
classify(
|
||||||
gap,
|
gap,
|
||||||
&StallVerdict::ComposeSilence,
|
&StallVerdict::ComposeSilence,
|
||||||
Some(&probes(None, Some(20_000), Some(30_000)))
|
Some(&probes(None, Some(20_000), Some(30_000))),
|
||||||
|
Some(&counts(1, 0))
|
||||||
),
|
),
|
||||||
StallClass::FrameGeneration
|
StallClass::ContentSilence
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,30 @@
|
|||||||
//! - 154/155 `SetPowerState` start/stop — monitor/link power transitions (Class-1 servicing),
|
//! - 154/155 `SetPowerState` start/stop — monitor/link power transitions (Class-1 servicing),
|
||||||
//! - 430 `SetTimingsFromVidPn` — modeset-class commits (Level-Two "hardware is idle" freezes),
|
//! - 430 `SetTimingsFromVidPn` — modeset-class commits (Level-Two "hardware is idle" freezes),
|
||||||
//! - 1096/1097 `DisplayDetectControl` start/stop — present on newer builds; filtered in
|
//! - 1096/1097 `DisplayDetectControl` start/stop — present on newer builds; filtered in
|
||||||
//! unconditionally, harmless where absent.
|
//! unconditionally, harmless where absent,
|
||||||
|
//! - 1071 `BltQueueAddEntry` — one event per frame ENTERING the virtual display's kernel present
|
||||||
|
//! queue (the modern IDD path: DWM present → PresentRedirected → BltQueue → soft-vsync →
|
||||||
|
//! `BltQueueCompleteIndirectPresent` → IddCx → our driver; anatomy proven on-glass via xperf,
|
||||||
|
//! 2026-07-30 — its gaps matched our capture holes to the millisecond),
|
||||||
|
//! - 1068 `BltQueueCompleteIndirectPresent` — one event per frame COMPLETED to IddCx.
|
||||||
|
//!
|
||||||
|
//! A second provider rides the same session: `Microsoft-Windows-DXGI` (user-mode), filtered to
|
||||||
|
//! `Present`/`PresentMultiplaneOverlay` starts (ids 42/55) — one event per swapchain present,
|
||||||
|
//! stamped with the PRESENTING process id. Together they are the compose-silence discriminator
|
||||||
|
//! ([`EtwWatch::window_counts`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||||
|
//! display path dropped composed frames (the real display-path bug); BOTH silent = the content
|
||||||
|
//! stopped presenting (benign pause — menus/loading/game hitch). The predecessor witnesses are
|
||||||
|
//! retired for cause: DxgKrnl id 184 `Present` never fires on the modern redirected path, and
|
||||||
|
//! `DWM_TIMING_INFO.cFrame` is refresh-synthesized on Win11 (advances without composes) — both
|
||||||
|
//! proven on-glass 2026-07-30.
|
||||||
//!
|
//!
|
||||||
//! Kernel-side event-id filtering (`EVENT_FILTER_TYPE_EVENT_ID`) keeps the per-vblank firehose
|
//! Kernel-side event-id filtering (`EVENT_FILTER_TYPE_EVENT_ID`) keeps the per-vblank firehose
|
||||||
//! off; the session costs a few events per minute. Starting a real-time session needs admin /
|
//! off; the DDI families cost a few events per minute, and the present/queue streams a few
|
||||||
//! Performance Log Users — the packaged host (service, SYSTEM) has it; a plain dev run degrades
|
//! hundred per second (bounded by the ring + the 64 KB session buffers). Starting a real-time
|
||||||
//! to `None` and every report says `etw=unavailable` instead of guessing. The session's
|
//! session needs admin / Performance Log Users — the packaged host (service, SYSTEM) has it; a
|
||||||
//! `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land AFTER that
|
//! plain dev run degrades to `None` and every report says `etw=unavailable` instead of guessing.
|
||||||
//! stall's report line — the next report (and the metronomic tally) still carries it.
|
//! The session's `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land
|
||||||
|
//! AFTER that stall's report line — the next report (and the metronomic tally) still carries it.
|
||||||
|
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
@@ -25,7 +41,7 @@ use std::sync::{Arc, Mutex, OnceLock, Weak};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use windows::core::{GUID, PWSTR};
|
use windows::core::{GUID, PWSTR};
|
||||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
use windows::Win32::Foundation::{CloseHandle, ERROR_SUCCESS};
|
||||||
use windows::Win32::System::Diagnostics::Etw::{
|
use windows::Win32::System::Diagnostics::Etw::{
|
||||||
CloseTrace, ControlTraceW, EnableTraceEx2, OpenTraceW, ProcessTrace, StartTraceW,
|
CloseTrace, ControlTraceW, EnableTraceEx2, OpenTraceW, ProcessTrace, StartTraceW,
|
||||||
CONTROLTRACE_HANDLE, ENABLE_TRACE_PARAMETERS, ENABLE_TRACE_PARAMETERS_VERSION_2,
|
CONTROLTRACE_HANDLE, ENABLE_TRACE_PARAMETERS, ENABLE_TRACE_PARAMETERS_VERSION_2,
|
||||||
@@ -35,21 +51,59 @@ use windows::Win32::System::Diagnostics::Etw::{
|
|||||||
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
|
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
|
||||||
};
|
};
|
||||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||||
|
use windows::Win32::System::Threading::{
|
||||||
|
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
};
|
||||||
|
|
||||||
/// `Microsoft-Windows-DxgKrnl` (`{802EC45A-1E99-4B83-9920-87C98277BA9D}`).
|
/// `Microsoft-Windows-DxgKrnl` (`{802EC45A-1E99-4B83-9920-87C98277BA9D}`).
|
||||||
const DXGKRNL: GUID = GUID::from_u128(0x802EC45A_1E99_4B83_9920_87C98277BA9D);
|
const DXGKRNL: GUID = GUID::from_u128(0x802EC45A_1E99_4B83_9920_87C98277BA9D);
|
||||||
|
|
||||||
/// The event ids the session filters IN (see the module docs).
|
/// `Microsoft-Windows-DXGI` (`{CA11C036-0102-4A2D-A6AD-F03CFED5D3C9}`) — the user-mode swapchain
|
||||||
const FILTER_IDS: [u16; 8] = [150, 151, 272, 154, 155, 430, 1096, 1097];
|
/// runtime; its `Present` events fire in the presenting process's context.
|
||||||
|
const DXGI: GUID = GUID::from_u128(0xCA11C036_0102_4A2D_A6AD_F03CFED5D3C9);
|
||||||
|
|
||||||
|
/// The DxgKrnl event ids the session filters IN (see the module docs).
|
||||||
|
const FILTER_IDS: [u16; 10] = [
|
||||||
|
150,
|
||||||
|
151,
|
||||||
|
272,
|
||||||
|
154,
|
||||||
|
155,
|
||||||
|
430,
|
||||||
|
1096,
|
||||||
|
1097,
|
||||||
|
BLT_ADD_ID,
|
||||||
|
BLT_COMPLETE_ID,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// `BltQueueAddEntry` — a frame ENTERED the virtual display's kernel present queue. Event id
|
||||||
|
/// resolved from the 26200 manifest (task 1105) and count-verified against an on-glass capture.
|
||||||
|
const BLT_ADD_ID: u16 = 1071;
|
||||||
|
|
||||||
|
/// `BltQueueCompleteIndirectPresent` — a frame COMPLETED from the queue to IddCx (task 1059).
|
||||||
|
const BLT_COMPLETE_ID: u16 = 1068;
|
||||||
|
|
||||||
|
/// The DXGI event ids the session filters IN: `Present` start (42) and
|
||||||
|
/// `PresentMultiplaneOverlay` start (55) — one per app/DWM present call.
|
||||||
|
const DXGI_FILTER_IDS: [u16; 2] = [DXGI_PRESENT_ID, DXGI_PRESENT_MPO_ID];
|
||||||
|
const DXGI_PRESENT_ID: u16 = 42;
|
||||||
|
const DXGI_PRESENT_MPO_ID: u16 = 55;
|
||||||
|
|
||||||
/// Session name — ours, stopped-if-stale at start (a crashed host leaves the session behind;
|
/// Session name — ours, stopped-if-stale at start (a crashed host leaves the session behind;
|
||||||
/// real-time sessions are machine-global named objects).
|
/// real-time sessions are machine-global named objects).
|
||||||
const SESSION: &str = "punktfunk-stallwatch-dxgkrnl";
|
const SESSION: &str = "punktfunk-stallwatch-dxgkrnl";
|
||||||
|
|
||||||
/// The consumer callback's destination: `(event QPC, event id)`, capped. A few events per minute
|
/// The consumer callback's destination: `(event QPC, event id, process id)`, capped. The DDI
|
||||||
/// in the field; the cap only matters under a detection storm — exactly when the tail is the
|
/// families are a few events per minute; the DXGI present stream and the two BltQueue streams
|
||||||
/// least interesting part.
|
/// each run at compose rate, so the cap sizes the history: 16384 entries ≈ 15–60 s at a
|
||||||
static RING: Mutex<VecDeque<(i64, u16)>> = Mutex::new(VecDeque::new());
|
/// 90–240 Hz desktop under a game + DWM — comfortably more than any single stall window the
|
||||||
|
/// reporter asks about (a deeper hole's counts read as floors, which is still evidence).
|
||||||
|
/// Event-id spaces of the two providers are disjoint (42/55 vs 15x/2xx/4xx/10xx/1071/1068), so
|
||||||
|
/// one ring holds both without a provider tag.
|
||||||
|
static RING: Mutex<VecDeque<(i64, u16, u32)>> = Mutex::new(VecDeque::new());
|
||||||
|
|
||||||
|
/// [`RING`]'s cap (see its doc for the sizing math).
|
||||||
|
const RING_CAP: usize = 16384;
|
||||||
|
|
||||||
fn qpc_now() -> i64 {
|
fn qpc_now() -> i64 {
|
||||||
let mut v = 0i64;
|
let mut v = 0i64;
|
||||||
@@ -75,17 +129,18 @@ unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// SAFETY: `record` is the live event the consumer is delivering for the duration of this call.
|
// SAFETY: `record` is the live event the consumer is delivering for the duration of this call.
|
||||||
let (id, ts) = unsafe {
|
let (id, ts, pid) = unsafe {
|
||||||
(
|
(
|
||||||
(*record).EventHeader.EventDescriptor.Id,
|
(*record).EventHeader.EventDescriptor.Id,
|
||||||
(*record).EventHeader.TimeStamp,
|
(*record).EventHeader.TimeStamp,
|
||||||
|
(*record).EventHeader.ProcessId,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let mut ring = RING.lock().unwrap();
|
let mut ring = RING.lock().unwrap();
|
||||||
if ring.len() == 2048 {
|
if ring.len() == RING_CAP {
|
||||||
ring.pop_front();
|
ring.pop_front();
|
||||||
}
|
}
|
||||||
ring.push_back((ts, id));
|
ring.push_back((ts, id, pid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A live DxgKrnl watch: the controller handle (stops the session on drop) + the consumer handle.
|
/// A live DxgKrnl watch: the controller handle (stops the session on drop) + the consumer handle.
|
||||||
@@ -171,45 +226,10 @@ impl EtwWatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
|
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
|
||||||
// vblank/DPC keywords never reach us.
|
// vblank/DPC keywords never reach us. Fatal on failure (the DDI families + queue
|
||||||
let mut filter = Vec::with_capacity(4 + FILTER_IDS.len() * 2);
|
// witnesses are this watch's reason to exist).
|
||||||
filter.extend_from_slice(&[1u8, 0u8]); // FilterIn = TRUE, Reserved
|
if !enable_provider(session, &DXGKRNL, &FILTER_IDS) {
|
||||||
filter.extend_from_slice(&(FILTER_IDS.len() as u16).to_le_bytes());
|
tracing::debug!("DxgKrnl ETW enable failed — stopping the session");
|
||||||
for id in FILTER_IDS {
|
|
||||||
filter.extend_from_slice(&id.to_le_bytes());
|
|
||||||
}
|
|
||||||
let mut desc = EVENT_FILTER_DESCRIPTOR {
|
|
||||||
Ptr: filter.as_ptr() as u64,
|
|
||||||
Size: filter.len() as u32,
|
|
||||||
Type: EVENT_FILTER_TYPE_EVENT_ID,
|
|
||||||
};
|
|
||||||
let params = ENABLE_TRACE_PARAMETERS {
|
|
||||||
Version: ENABLE_TRACE_PARAMETERS_VERSION_2,
|
|
||||||
EnableProperty: 0,
|
|
||||||
ControlFlags: 0,
|
|
||||||
SourceId: GUID::zeroed(),
|
|
||||||
EnableFilterDesc: &mut desc,
|
|
||||||
FilterDescCount: 1,
|
|
||||||
};
|
|
||||||
// SAFETY: `session` is the live handle just started; `params`/`desc`/`filter` are live
|
|
||||||
// locals for this synchronous call (the kernel copies the filter).
|
|
||||||
let rc = unsafe {
|
|
||||||
EnableTraceEx2(
|
|
||||||
session,
|
|
||||||
&DXGKRNL,
|
|
||||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
|
|
||||||
TRACE_LEVEL_INFORMATION as u8,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
Some(¶ms),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if rc != ERROR_SUCCESS {
|
|
||||||
tracing::debug!(
|
|
||||||
rc = rc.0,
|
|
||||||
"DxgKrnl ETW enable failed — stopping the session"
|
|
||||||
);
|
|
||||||
let (mut buf, _) = properties_buffer();
|
let (mut buf, _) = properties_buffer();
|
||||||
// SAFETY: live handle + valid properties allocation, stopped exactly once on this path.
|
// SAFETY: live handle + valid properties allocation, stopped exactly once on this path.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -219,6 +239,14 @@ impl EtwWatch {
|
|||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// The DXGI (user-mode) present witness rides the same session. Degraded-not-fatal: a
|
||||||
|
// refusal only costs the per-process present counts — `window_counts` then reports
|
||||||
|
// no present history and classification stays honest (Unattributed, never a guess).
|
||||||
|
if !enable_provider(session, &DXGI, &DXGI_FILTER_IDS) {
|
||||||
|
tracing::debug!(
|
||||||
|
"DXGI ETW enable failed — present-vs-queue discrimination unavailable this session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut log = EVENT_TRACE_LOGFILEW {
|
let mut log = EVENT_TRACE_LOGFILEW {
|
||||||
LoggerName: PWSTR(name.as_ptr() as *mut _),
|
LoggerName: PWSTR(name.as_ptr() as *mut _),
|
||||||
@@ -283,9 +311,12 @@ impl EtwWatch {
|
|||||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||||
let events: Vec<(i64, u16)> = {
|
let events: Vec<(i64, u16, u32)> = {
|
||||||
let ring = RING.lock().unwrap();
|
let ring = RING.lock().unwrap();
|
||||||
ring.iter().filter(|(ts, _)| *ts <= to_q).copied().collect()
|
ring.iter()
|
||||||
|
.filter(|(ts, _, _)| *ts <= to_q)
|
||||||
|
.copied()
|
||||||
|
.collect()
|
||||||
};
|
};
|
||||||
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
@@ -298,7 +329,7 @@ impl EtwWatch {
|
|||||||
let mut count = 0u32;
|
let mut count = 0u32;
|
||||||
let mut max_ms = 0i64;
|
let mut max_ms = 0i64;
|
||||||
let mut still_open = false;
|
let mut still_open = false;
|
||||||
for &(ts, id) in &events {
|
for &(ts, id, _) in &events {
|
||||||
if id == start_id {
|
if id == start_id {
|
||||||
open = Some(ts);
|
open = Some(ts);
|
||||||
} else if id == stop_id {
|
} else if id == stop_id {
|
||||||
@@ -331,18 +362,184 @@ impl EtwWatch {
|
|||||||
] {
|
] {
|
||||||
let count = events
|
let count = events
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(ts, i)| *i == id && *ts >= from_q && *ts <= to_q)
|
.filter(|(ts, i, _)| *i == id && *ts >= from_q && *ts <= to_q)
|
||||||
.count();
|
.count();
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
parts.push(format!("{label}×{count}"));
|
parts.push(format!("{label}×{count}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Present + queue accounting (DXGI 42/55 + BltQueueAddEntry/Complete): total presents
|
||||||
|
// inside the window plus the top presenters, NAMED — the line that splits a
|
||||||
|
// compose-silence hole into "the content stopped presenting" (no presents anywhere)
|
||||||
|
// versus "presents flowed and the display path dropped them" (presents at rate while
|
||||||
|
// the queue starves). "Present×0" is printed explicitly when the stream has history
|
||||||
|
// but the window is empty — silence is a finding, not an absence.
|
||||||
|
let mut per_pid: Vec<(u32, u32)> = Vec::new();
|
||||||
|
let mut have_present_history = false;
|
||||||
|
let (mut adds, mut completes) = (0u32, 0u32);
|
||||||
|
let mut have_queue_history = false;
|
||||||
|
for &(ts, id, pid) in &events {
|
||||||
|
match id {
|
||||||
|
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||||
|
have_present_history = true;
|
||||||
|
if ts >= from_q && ts <= to_q {
|
||||||
|
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||||
|
Some((_, c)) => *c += 1,
|
||||||
|
None => per_pid.push((pid, 1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BLT_ADD_ID | BLT_COMPLETE_ID => {
|
||||||
|
have_queue_history = true;
|
||||||
|
if ts >= from_q && ts <= to_q {
|
||||||
|
if id == BLT_ADD_ID {
|
||||||
|
adds += 1;
|
||||||
|
} else {
|
||||||
|
completes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !per_pid.is_empty() {
|
||||||
|
per_pid.sort_by_key(|&(_, c)| std::cmp::Reverse(c));
|
||||||
|
let total: u32 = per_pid.iter().map(|(_, c)| c).sum();
|
||||||
|
let top = per_pid
|
||||||
|
.iter()
|
||||||
|
.take(2)
|
||||||
|
.map(|(pid, c)| {
|
||||||
|
let name = process_name(*pid).unwrap_or_else(|| format!("pid{pid}"));
|
||||||
|
format!("{name}:{c}")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
parts.push(format!("Present×{total}({top})"));
|
||||||
|
} else if have_present_history {
|
||||||
|
parts.push("Present×0".to_string());
|
||||||
|
}
|
||||||
|
if have_queue_history {
|
||||||
|
parts.push(format!("blt-queue add×{adds} complete×{completes}"));
|
||||||
|
}
|
||||||
if parts.is_empty() {
|
if parts.is_empty() {
|
||||||
"none".to_string()
|
"none".to_string()
|
||||||
} else {
|
} else {
|
||||||
parts.join(" ")
|
parts.join(" ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The structured discriminator read for `[from, to]` (the stall classifier's evidence):
|
||||||
|
/// how many swapchain presents (DXGI 42/55, any process) and how many virtual-display
|
||||||
|
/// queue entries (`BltQueueAddEntry`) landed in the window, plus whether each stream has
|
||||||
|
/// EVER produced an event (distinguishing a true zero from a witness that is not working —
|
||||||
|
/// e.g. the DXGI enable was refused, or an OS build renumbered the BltQueue events).
|
||||||
|
pub(super) fn window_counts(&self, from: Instant, to: Instant) -> EtwWindowCounts {
|
||||||
|
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||||
|
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||||
|
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||||
|
let ring = RING.lock().unwrap();
|
||||||
|
let mut out = EtwWindowCounts::default();
|
||||||
|
for &(ts, id, _) in ring.iter() {
|
||||||
|
match id {
|
||||||
|
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||||
|
out.present_history = true;
|
||||||
|
if ts >= from_q && ts <= to_q {
|
||||||
|
out.presents += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BLT_ADD_ID => {
|
||||||
|
out.queue_history = true;
|
||||||
|
if ts >= from_q && ts <= to_q {
|
||||||
|
out.queue_adds += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`EtwWatch::window_counts`]'s read: the compose-silence discriminator's structured evidence.
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) struct EtwWindowCounts {
|
||||||
|
/// Swapchain presents (any process — the game AND dwm both count) inside the window.
|
||||||
|
pub(super) presents: u32,
|
||||||
|
/// `BltQueueAddEntry` events (frames entering the virtual display's kernel queue) inside it.
|
||||||
|
pub(super) queue_adds: u32,
|
||||||
|
/// The present stream has produced at least one event EVER (witness known-working).
|
||||||
|
pub(super) present_history: bool,
|
||||||
|
/// The queue stream has produced at least one event EVER (witness known-working).
|
||||||
|
pub(super) queue_history: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable `guid` on `session` with a kernel-side event-id allowlist. `true` on success.
|
||||||
|
fn enable_provider(session: CONTROLTRACE_HANDLE, guid: &GUID, ids: &[u16]) -> bool {
|
||||||
|
let mut filter = Vec::with_capacity(4 + ids.len() * 2);
|
||||||
|
filter.extend_from_slice(&[1u8, 0u8]); // FilterIn = TRUE, Reserved
|
||||||
|
filter.extend_from_slice(&(ids.len() as u16).to_le_bytes());
|
||||||
|
for id in ids {
|
||||||
|
filter.extend_from_slice(&id.to_le_bytes());
|
||||||
|
}
|
||||||
|
let mut desc = EVENT_FILTER_DESCRIPTOR {
|
||||||
|
Ptr: filter.as_ptr() as u64,
|
||||||
|
Size: filter.len() as u32,
|
||||||
|
Type: EVENT_FILTER_TYPE_EVENT_ID,
|
||||||
|
};
|
||||||
|
let params = ENABLE_TRACE_PARAMETERS {
|
||||||
|
Version: ENABLE_TRACE_PARAMETERS_VERSION_2,
|
||||||
|
EnableProperty: 0,
|
||||||
|
ControlFlags: 0,
|
||||||
|
SourceId: GUID::zeroed(),
|
||||||
|
EnableFilterDesc: &mut desc,
|
||||||
|
FilterDescCount: 1,
|
||||||
|
};
|
||||||
|
// SAFETY: `session` is a live handle owned by the caller; `params`/`desc`/`filter` are live
|
||||||
|
// locals for this synchronous call (the kernel copies the filter before returning).
|
||||||
|
let rc = unsafe {
|
||||||
|
EnableTraceEx2(
|
||||||
|
session,
|
||||||
|
guid,
|
||||||
|
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
|
||||||
|
TRACE_LEVEL_INFORMATION as u8,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(¶ms),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
rc == ERROR_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort image base name for `pid` (Present attribution); `None` when the process is gone
|
||||||
|
/// or protected. Runs only at stall-report time — a rare, already-degraded moment.
|
||||||
|
fn process_name(pid: u32) -> Option<String> {
|
||||||
|
// SAFETY: plain FFI; a refused open returns Err (checked via `ok()?`), and the returned
|
||||||
|
// handle is closed exactly once below.
|
||||||
|
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?;
|
||||||
|
let mut buf = [0u16; 512];
|
||||||
|
let mut len = buf.len() as u32;
|
||||||
|
// SAFETY: `process` is the live handle just opened with QUERY_LIMITED (what this API needs);
|
||||||
|
// `buf`/`len` are a valid out-buffer and its capacity, `len` updated to the written UTF-16
|
||||||
|
// length (no NUL) on success.
|
||||||
|
let ok = unsafe {
|
||||||
|
QueryFullProcessImageNameW(
|
||||||
|
process,
|
||||||
|
PROCESS_NAME_WIN32,
|
||||||
|
PWSTR(buf.as_mut_ptr()),
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.is_ok();
|
||||||
|
// SAFETY: `process` is the handle opened above, closed exactly once here.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(process);
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = String::from_utf16_lossy(&buf[..len as usize]);
|
||||||
|
Some(path.rsplit(['\\', '/']).next().unwrap_or(&path).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `Duration` in QPC ticks (saturating; diagnostic precision).
|
/// A `Duration` in QPC ticks (saturating; diagnostic precision).
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ impl IddPushCapturer {
|
|||||||
client_10bit,
|
client_10bit,
|
||||||
display_hdr,
|
display_hdr,
|
||||||
hdr_pin_warned: false,
|
hdr_pin_warned: false,
|
||||||
|
hdr_pin_failures: 0,
|
||||||
want_444,
|
want_444,
|
||||||
pyrowave,
|
pyrowave,
|
||||||
pyro_fence: None,
|
pyro_fence: None,
|
||||||
@@ -635,7 +636,9 @@ impl IddPushCapturer {
|
|||||||
stall_watch: StallWatch::new(),
|
stall_watch: StallWatch::new(),
|
||||||
offered_at_fresh: 0,
|
offered_at_fresh: 0,
|
||||||
max_hb_age_us: 0,
|
max_hb_age_us: 0,
|
||||||
probes: super::probes::acquire(),
|
probes: pf_host_config::config()
|
||||||
|
.stall_probes
|
||||||
|
.then(super::probes::acquire),
|
||||||
etw: super::dxgkrnl_etw::acquire(),
|
etw: super::dxgkrnl_etw::acquire(),
|
||||||
out_ring: Vec::new(),
|
out_ring: Vec::new(),
|
||||||
out_idx: 0,
|
out_idx: 0,
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
//! for the freeze's duration on EVERY engine of that adapter.
|
//! for the freeze's duration on EVERY engine of that adapter.
|
||||||
//! - **dwm-tick**: `DwmGetCompositionTimingInfo(NULL)` `cRefresh` advance — the compositor's own
|
//! - **dwm-tick**: `DwmGetCompositionTimingInfo(NULL)` `cRefresh` advance — the compositor's own
|
||||||
//! clock. Frozen tick with live fences = DWM (or something it waits on) is blocked, not the GPU.
|
//! clock. Frozen tick with live fences = DWM (or something it waits on) is blocked, not the GPU.
|
||||||
|
//! The same sample also tracks `cFrame` (the COMPOSED-frame counter) — the compose-silence
|
||||||
|
//! discriminator: a clock that ticks while `cFrame` freezes means DWM had NOTHING to compose
|
||||||
|
//! (the foreground content stopped presenting); a `cFrame` that keeps advancing through a hole
|
||||||
|
//! means DWM built frames that never reached OUR swap-chain (the OS frame-generation leg).
|
||||||
//! - **dwm-flush**: a watchdogged `DwmFlush` — its latency IS the composition-wait measurement.
|
//! - **dwm-flush**: a watchdogged `DwmFlush` — its latency IS the composition-wait measurement.
|
||||||
//! - **scanline**: `D3DKMTGetScanLine` on an active output (Level-Zero reentrant — documented safe
|
//! - **scanline**: `D3DKMTGetScanLine` on an active output (Level-Zero reentrant — documented safe
|
||||||
//! against every miniport lock, so a BLOCKED call here convicts the KMD itself). Prefers a
|
//! against every miniport lock, so a BLOCKED call here convicts the KMD itself). Prefers a
|
||||||
@@ -155,6 +159,8 @@ struct Inner {
|
|||||||
/// One per hardware adapter, labelled by LUID (hybrids run two).
|
/// One per hardware adapter, labelled by LUID (hybrids run two).
|
||||||
fences: Vec<(String, BlockingProbe)>,
|
fences: Vec<(String, BlockingProbe)>,
|
||||||
dwm_tick: Ring,
|
dwm_tick: Ring,
|
||||||
|
/// `cFrame` (composed-frame counter) frozen spans, sampled by the same dwm-tick thread.
|
||||||
|
dwm_frame: Ring,
|
||||||
dwm_flush: BlockingProbe,
|
dwm_flush: BlockingProbe,
|
||||||
scanline: BlockingProbe,
|
scanline: BlockingProbe,
|
||||||
/// Whether the scanline probe currently targets a PHYSICAL head (see the module docs).
|
/// Whether the scanline probe currently targets a PHYSICAL head (see the module docs).
|
||||||
@@ -201,6 +207,7 @@ impl ProbeEngine {
|
|||||||
.filter_map(|(_, p)| p.window_max(from, to))
|
.filter_map(|(_, p)| p.window_max(from, to))
|
||||||
.max(),
|
.max(),
|
||||||
dwm_tick_frozen_us: i.dwm_tick.window_max(from, to),
|
dwm_tick_frozen_us: i.dwm_tick.window_max(from, to),
|
||||||
|
dwm_frame_frozen_us: i.dwm_frame.window_max(from, to),
|
||||||
dwm_flush_max_us: i.dwm_flush.window_max(from, to),
|
dwm_flush_max_us: i.dwm_flush.window_max(from, to),
|
||||||
scanline_max_us: if i.scanline_running.load(Ordering::Relaxed) {
|
scanline_max_us: if i.scanline_running.load(Ordering::Relaxed) {
|
||||||
i.scanline.window_max(from, to)
|
i.scanline.window_max(from, to)
|
||||||
@@ -221,6 +228,7 @@ impl ProbeEngine {
|
|||||||
stop: AtomicBool::new(false),
|
stop: AtomicBool::new(false),
|
||||||
fences,
|
fences,
|
||||||
dwm_tick: Ring::new(),
|
dwm_tick: Ring::new(),
|
||||||
|
dwm_frame: Ring::new(),
|
||||||
dwm_flush: BlockingProbe::new(),
|
dwm_flush: BlockingProbe::new(),
|
||||||
scanline: BlockingProbe::new(),
|
scanline: BlockingProbe::new(),
|
||||||
scanline_physical: AtomicBool::new(false),
|
scanline_physical: AtomicBool::new(false),
|
||||||
@@ -397,11 +405,17 @@ fn fence_probe_setup(
|
|||||||
Some((context, ctx4, fence, event, (a, b)))
|
Some((context, ctx4, fence, event, (a, b)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `cRefresh` advance sampling: each tick records how long the compositor's frame counter has been
|
/// `cRefresh` + `cFrame` advance sampling: each tick records how long the compositor's refresh
|
||||||
/// unchanged. A frozen span covering a stall (with live fences) = DWM blocked, not the GPU.
|
/// counter and its COMPOSED-frame counter have been unchanged. A frozen refresh span covering a
|
||||||
|
/// stall (with live fences) = DWM blocked, not the GPU. A LIVE refresh with a frozen `cFrame` =
|
||||||
|
/// DWM ticked but composed nothing — no damage anywhere, i.e. the foreground content itself
|
||||||
|
/// stopped presenting; a `cFrame` that advances through a capture hole = DWM composed frames the
|
||||||
|
/// IDD swap-chain never received (the OS frame-generation leg dropped them).
|
||||||
fn dwm_tick_loop(inner: &Inner) {
|
fn dwm_tick_loop(inner: &Inner) {
|
||||||
let mut last_refresh = 0u64;
|
let mut last_refresh = 0u64;
|
||||||
let mut last_change = Instant::now();
|
let mut last_change = Instant::now();
|
||||||
|
let mut last_frame = 0u64;
|
||||||
|
let mut last_frame_change = Instant::now();
|
||||||
while !inner.stop.load(Ordering::Relaxed) {
|
while !inner.stop.load(Ordering::Relaxed) {
|
||||||
let mut info = DWM_TIMING_INFO {
|
let mut info = DWM_TIMING_INFO {
|
||||||
cbSize: std::mem::size_of::<DWM_TIMING_INFO>() as u32,
|
cbSize: std::mem::size_of::<DWM_TIMING_INFO>() as u32,
|
||||||
@@ -417,6 +431,14 @@ fn dwm_tick_loop(inner: &Inner) {
|
|||||||
}
|
}
|
||||||
let frozen = now.duration_since(last_change);
|
let frozen = now.duration_since(last_change);
|
||||||
inner.dwm_tick.push(now, frozen, frozen.as_micros() as u64);
|
inner.dwm_tick.push(now, frozen, frozen.as_micros() as u64);
|
||||||
|
if info.cFrame != last_frame {
|
||||||
|
last_frame = info.cFrame;
|
||||||
|
last_frame_change = now;
|
||||||
|
}
|
||||||
|
let frame_frozen = now.duration_since(last_frame_change);
|
||||||
|
inner
|
||||||
|
.dwm_frame
|
||||||
|
.push(now, frame_frozen, frame_frozen.as_micros() as u64);
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,31 @@ pub(super) struct Stall {
|
|||||||
pub(super) metronomic: Option<Duration>,
|
pub(super) metronomic: Option<Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One degraded stretch, summarized at recovery ([`StallWatch::take_recovery`]). Per-hole stall
|
||||||
|
/// lines gate on prior ACTIVE flow, so inside a sustained ~2 fps phase only the first hole is
|
||||||
|
/// reported and the log goes quiet exactly while the user suffers — this summary is the stretch's
|
||||||
|
/// one visible line.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct Recovery {
|
||||||
|
/// First hole's start → last hole's end.
|
||||||
|
pub(super) degraded: Duration,
|
||||||
|
/// Stall-sized (≥ [`StallWatch::STALL_MIN`]) holes inside the stretch.
|
||||||
|
pub(super) holes: u32,
|
||||||
|
/// Their summed length.
|
||||||
|
pub(super) hole_time: Duration,
|
||||||
|
/// The longest single hole.
|
||||||
|
pub(super) worst: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`StallWatch`]'s in-flight degraded stretch (see [`Recovery`]).
|
||||||
|
struct Episode {
|
||||||
|
started: Instant,
|
||||||
|
last_hole_end: Instant,
|
||||||
|
holes: u32,
|
||||||
|
hole_time: Duration,
|
||||||
|
worst: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
/// Driver-telemetry evidence for one stall window (the v2 header tail — see
|
/// Driver-telemetry evidence for one stall window (the v2 header tail — see
|
||||||
/// `pf_driver_proto::frame::SharedHeader`), sampled by the capturer between the last pre-gap
|
/// `pf_driver_proto::frame::SharedHeader`), sampled by the capturer between the last pre-gap
|
||||||
/// frame and the frame that ended the stall.
|
/// frame and the frame that ended the stall.
|
||||||
@@ -32,6 +57,11 @@ pub(super) struct StallEvidence {
|
|||||||
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
|
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
|
||||||
/// session is unavailable (non-admin dev run).
|
/// session is unavailable (non-admin dev run).
|
||||||
pub(super) etw: Option<String>,
|
pub(super) etw: Option<String>,
|
||||||
|
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_counts`]) —
|
||||||
|
/// the compose-silence discriminator: presents flowing while the queue starves = the OS
|
||||||
|
/// display path dropped composed frames; both silent = the content stopped presenting.
|
||||||
|
/// `None` when the ETW session is unavailable.
|
||||||
|
pub(super) etw_counts: Option<super::dxgkrnl_etw::EtwWindowCounts>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The micro-probes' window read (Phase A.2, built by `probes::ProbeEngine::window`): per-leg
|
/// The micro-probes' window read (Phase A.2, built by `probes::ProbeEngine::window`): per-leg
|
||||||
@@ -43,6 +73,12 @@ pub(super) struct ProbeWindow {
|
|||||||
pub(super) fence_max_us: Option<u64>,
|
pub(super) fence_max_us: Option<u64>,
|
||||||
/// Longest span (µs) with no `DwmGetCompositionTimingInfo` `cRefresh` advance.
|
/// Longest span (µs) with no `DwmGetCompositionTimingInfo` `cRefresh` advance.
|
||||||
pub(super) dwm_tick_frozen_us: Option<u64>,
|
pub(super) dwm_tick_frozen_us: Option<u64>,
|
||||||
|
/// Longest span (µs) with no `cFrame` (composed-frame counter) advance. ADVISORY ONLY —
|
||||||
|
/// never classification evidence: on Win11 `DWM_TIMING_INFO.cFrame` is refresh-synthesized
|
||||||
|
/// and advances without real composes (proven on-glass 2026-07-30 against a kernel trace
|
||||||
|
/// where DWM verifiably presented nothing for 1.6 s while cFrame ticked). The line keeps
|
||||||
|
/// reporting it for older builds' sake; [`classify`] ignores it.
|
||||||
|
pub(super) dwm_frame_frozen_us: Option<u64>,
|
||||||
/// Worst watchdogged `DwmFlush` latency (µs).
|
/// Worst watchdogged `DwmFlush` latency (µs).
|
||||||
pub(super) dwm_flush_max_us: Option<u64>,
|
pub(super) dwm_flush_max_us: Option<u64>,
|
||||||
/// Worst `D3DKMTGetScanLine` CALL latency (µs) — Level-Zero, so blocking convicts the KMD.
|
/// Worst `D3DKMTGetScanLine` CALL latency (µs) — Level-Zero, so blocking convicts the KMD.
|
||||||
@@ -62,9 +98,11 @@ impl std::fmt::Display for ProbeWindow {
|
|||||||
};
|
};
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
"fence={} dwm_tick_frozen={} dwm_flush={} scanline={}({}) cpu_overshoot={}",
|
"fence={} dwm_tick_frozen={} dwm_frames_frozen={} dwm_flush={} scanline={}({}) \
|
||||||
|
cpu_overshoot={}",
|
||||||
ms(self.fence_max_us),
|
ms(self.fence_max_us),
|
||||||
ms(self.dwm_tick_frozen_us),
|
ms(self.dwm_tick_frozen_us),
|
||||||
|
ms(self.dwm_frame_frozen_us),
|
||||||
ms(self.dwm_flush_max_us),
|
ms(self.dwm_flush_max_us),
|
||||||
ms(self.scanline_max_us),
|
ms(self.scanline_max_us),
|
||||||
if self.scanline_physical {
|
if self.scanline_physical {
|
||||||
@@ -92,9 +130,17 @@ pub(super) enum StallClass {
|
|||||||
/// Engines alive but DWM's own tick froze: the compositor is blocked on something (DDC/child
|
/// Engines alive but DWM's own tick froze: the compositor is blocked on something (DDC/child
|
||||||
/// I/O vendor lock, win32k display-config queue). Class 2.
|
/// I/O vendor lock, win32k display-config queue). Class 2.
|
||||||
CompositorBlocked,
|
CompositorBlocked,
|
||||||
/// Engines alive, DWM ticking, driver drained E_PENDING — composition happened for OTHER
|
/// Engines alive, DWM's clock ticking, driver drained E_PENDING, and the ETW present witness
|
||||||
/// surfaces but produced no frame for OUR display: the frame-generation path
|
/// saw (essentially) NO swapchain presents from ANY process across the hole: the content
|
||||||
/// (IddCx/dirty-tracking/divider). Ours to chase with IddCx WPP.
|
/// stopped presenting — no damage, DWM correctly composed nothing (a game hitch, a loading
|
||||||
|
/// screen, a menu). Benign for the display path; the content side is where to look if the
|
||||||
|
/// user FELT it.
|
||||||
|
ContentSilence,
|
||||||
|
/// Engines alive, DWM ticking, driver drained E_PENDING — and the ETW present witness saw
|
||||||
|
/// presents FLOWING through the hole while the virtual display's kernel queue
|
||||||
|
/// (`BltQueueAddEntry`) starved: composed frames existed and the OS display path dropped
|
||||||
|
/// them before our swap-chain. The real display-path bug class — never yet observed in the
|
||||||
|
/// field; a report with this label (counts attached) is the specimen we want.
|
||||||
FrameGeneration,
|
FrameGeneration,
|
||||||
/// Not enough evidence to name a class (pre-telemetry driver and/or probes absent).
|
/// Not enough evidence to name a class (pre-telemetry driver and/or probes absent).
|
||||||
Unattributed,
|
Unattributed,
|
||||||
@@ -111,21 +157,39 @@ impl std::fmt::Display for StallClass {
|
|||||||
Self::CompositorBlocked => {
|
Self::CompositorBlocked => {
|
||||||
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
|
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
|
||||||
}
|
}
|
||||||
|
Self::ContentSilence => {
|
||||||
|
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; not the display path)"
|
||||||
|
}
|
||||||
Self::FrameGeneration => {
|
Self::FrameGeneration => {
|
||||||
"FRAME-GENERATION (DWM ticked, engines alive, no frame for THIS display — IddCx/dirty/divider)"
|
"FRAME-GENERATION (presents FLOWED while the virtual display's kernel queue starved — the OS display path dropped composed frames)"
|
||||||
}
|
}
|
||||||
Self::Unattributed => "UNATTRIBUTED (insufficient telemetry)",
|
Self::Unattributed => "UNATTRIBUTED (insufficient telemetry)",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The verdict matrix: fold the driver-telemetry verdict and the probe window into a class.
|
/// How many window presents acquit the content: ≥8 presents across the hole mirrors
|
||||||
/// Pure — unit-tested beside the [`StallWatch`] tests. A leg is "stalled for the hole" when its
|
/// [`attribute`]'s offered-frames bar and [`StallWatch::RECENT`]'s sustained-flow definition —
|
||||||
/// worst reading covers at least half the gap (the same proportional bar as [`attribute`]).
|
/// a caret blink or a stall-ending frame stays under it, a game presenting through the hole
|
||||||
|
/// clears it by an order of magnitude.
|
||||||
|
const PRESENTS_ACQUIT_CONTENT: u32 = 8;
|
||||||
|
|
||||||
|
/// The verdict matrix: fold the driver-telemetry verdict, the probe window and the ETW
|
||||||
|
/// present-vs-queue counts into a class. Pure — unit-tested beside the [`StallWatch`] tests.
|
||||||
|
/// A leg is "stalled for the hole" when its worst reading covers at least half the gap (the
|
||||||
|
/// same proportional bar as [`attribute`]).
|
||||||
|
///
|
||||||
|
/// Compose-silence is split by the ETW witnesses ONLY (`DWM_TIMING_INFO.cFrame` is
|
||||||
|
/// refresh-synthesized on Win11 and convicts nothing — see [`ProbeWindow::dwm_frame_frozen_us`]):
|
||||||
|
/// presents flowing while the hole ran = the OS display path dropped them (FRAME-GENERATION,
|
||||||
|
/// positively convicted); no presents anywhere = the content stopped (CONTENT-SILENCE). With no
|
||||||
|
/// working witness the class stays UNATTRIBUTED — the pre-2026-07-30 default of blaming the
|
||||||
|
/// frame-generation path mislabeled benign content pauses and is retired.
|
||||||
pub(super) fn classify(
|
pub(super) fn classify(
|
||||||
gap: Duration,
|
gap: Duration,
|
||||||
verdict: &StallVerdict,
|
verdict: &StallVerdict,
|
||||||
probes: Option<&ProbeWindow>,
|
probes: Option<&ProbeWindow>,
|
||||||
|
etw_counts: Option<&super::dxgkrnl_etw::EtwWindowCounts>,
|
||||||
) -> StallClass {
|
) -> StallClass {
|
||||||
match verdict {
|
match verdict {
|
||||||
StallVerdict::WorkerStalled => return StallClass::OursWorker,
|
StallVerdict::WorkerStalled => return StallClass::OursWorker,
|
||||||
@@ -144,10 +208,21 @@ pub(super) fn classify(
|
|||||||
return StallClass::CompositorBlocked;
|
return StallClass::CompositorBlocked;
|
||||||
}
|
}
|
||||||
// Engines alive and DWM ticking: only the driver's own E_PENDING testimony can pin the
|
// Engines alive and DWM ticking: only the driver's own E_PENDING testimony can pin the
|
||||||
// frame-generation path — without it (pre-telemetry driver) the delivery leg is equally
|
// silence on the present path — without it (pre-telemetry driver) the delivery leg is
|
||||||
// possible, so stay honest.
|
// equally possible, so stay honest.
|
||||||
if matches!(verdict, StallVerdict::ComposeSilence) {
|
if matches!(verdict, StallVerdict::ComposeSilence) {
|
||||||
StallClass::FrameGeneration
|
match etw_counts {
|
||||||
|
Some(c) if c.present_history => {
|
||||||
|
if c.presents >= PRESENTS_ACQUIT_CONTENT {
|
||||||
|
StallClass::FrameGeneration
|
||||||
|
} else {
|
||||||
|
StallClass::ContentSilence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No working present witness (session refused / DXGI enable failed / renumbered
|
||||||
|
// events): the silence cannot be attributed to either side.
|
||||||
|
_ => StallClass::Unattributed,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
StallClass::Unattributed
|
StallClass::Unattributed
|
||||||
}
|
}
|
||||||
@@ -228,8 +303,14 @@ pub(super) struct StallWatch {
|
|||||||
/// whole session's beat, not just the stall that tripped the metronome.
|
/// whole session's beat, not just the stall that tripped the metronome.
|
||||||
verdicts: [u32; 4],
|
verdicts: [u32; 4],
|
||||||
/// Running per-class tally ([`StallClass`] order: ours-worker, ours-delivery, adapter-freeze,
|
/// Running per-class tally ([`StallClass`] order: ours-worker, ours-delivery, adapter-freeze,
|
||||||
/// compositor-blocked, frame-generation, unattributed) — the verdict matrix's session summary.
|
/// compositor-blocked, content-silence, frame-generation, unattributed) — the verdict
|
||||||
classes: [u32; 6],
|
/// matrix's session summary.
|
||||||
|
classes: [u32; 7],
|
||||||
|
/// The degraded stretch currently being accumulated, opened by a reported stall and fed by
|
||||||
|
/// every stall-sized hole until sustained flow returns.
|
||||||
|
episode: Option<Episode>,
|
||||||
|
/// A closed episode's summary, parked for the caller ([`Self::take_recovery`]).
|
||||||
|
pending_recovery: Option<Recovery>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StallWatch {
|
impl StallWatch {
|
||||||
@@ -242,6 +323,12 @@ impl StallWatch {
|
|||||||
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
|
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
|
||||||
/// reported 300–700 ms freezes, above encode/present jitter.
|
/// reported 300–700 ms freezes, above encode/present jitter.
|
||||||
const STALL_MIN: Duration = Duration::from_millis(150);
|
const STALL_MIN: Duration = Duration::from_millis(150);
|
||||||
|
/// A hole this long is a content STOP, not a degraded stretch — an open episode is closed
|
||||||
|
/// (and summarized) before it, so a quit-to-idle pause never folds into the tally.
|
||||||
|
const EPISODE_BREAK: Duration = Duration::from_secs(10);
|
||||||
|
/// Episodes with fewer holes than this dissolve silently — the single stall's own report
|
||||||
|
/// line already covers them.
|
||||||
|
const EPISODE_MIN_HOLES: u32 = 2;
|
||||||
|
|
||||||
pub(super) fn new() -> Self {
|
pub(super) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -250,14 +337,39 @@ impl StallWatch {
|
|||||||
seen: 0,
|
seen: 0,
|
||||||
with_os_events: 0,
|
with_os_events: 0,
|
||||||
verdicts: [0; 4],
|
verdicts: [0; 4],
|
||||||
classes: [0; 6],
|
classes: [0; 7],
|
||||||
|
episode: None,
|
||||||
|
pending_recovery: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
||||||
/// the reset the first post-recreate frame would read as one).
|
/// the reset the first post-recreate frame would read as one). An open episode is closed and
|
||||||
|
/// summarized: its holes predate the recreate and are real evidence.
|
||||||
pub(super) fn reset(&mut self) {
|
pub(super) fn reset(&mut self) {
|
||||||
self.recent.clear();
|
self.recent.clear();
|
||||||
|
self.close_episode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close an open episode into [`Self::pending_recovery`] (kept only past the noise bar).
|
||||||
|
fn close_episode(&mut self) {
|
||||||
|
if let Some(ep) = self.episode.take() {
|
||||||
|
if ep.holes >= Self::EPISODE_MIN_HOLES {
|
||||||
|
self.pending_recovery = Some(Recovery {
|
||||||
|
degraded: ep.last_hole_end.duration_since(ep.started),
|
||||||
|
holes: ep.holes,
|
||||||
|
hole_time: ep.hole_time,
|
||||||
|
worst: ep.worst,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A closed degraded stretch's summary, if one is waiting — the caller logs it. Check after
|
||||||
|
/// every [`Self::note_fresh`]/[`Self::reset`]: closure rides frames that are NOT stalls (the
|
||||||
|
/// first sustained-flow frame after recovery).
|
||||||
|
pub(super) fn take_recovery(&mut self) -> Option<Recovery> {
|
||||||
|
self.pending_recovery.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
|
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
|
||||||
@@ -274,6 +386,37 @@ impl StallWatch {
|
|||||||
self.recent.pop_front();
|
self.recent.pop_front();
|
||||||
}
|
}
|
||||||
let gap = gap?;
|
let gap = gap?;
|
||||||
|
if gap >= Self::EPISODE_BREAK {
|
||||||
|
// The content plainly STOPPED (quit to desktop, long idle) — summarize what came
|
||||||
|
// before rather than folding a legitimate pause into the degraded tally.
|
||||||
|
self.close_episode();
|
||||||
|
}
|
||||||
|
if gap >= Self::STALL_MIN {
|
||||||
|
match &mut self.episode {
|
||||||
|
// Inside a degraded stretch every stall-sized hole accumulates — the activity
|
||||||
|
// gate below keeps per-hole reports quiet here (the pre-gap window spans the
|
||||||
|
// slow frames), which is exactly why the episode summary exists.
|
||||||
|
Some(ep) => {
|
||||||
|
ep.holes += 1;
|
||||||
|
ep.hole_time += gap;
|
||||||
|
ep.worst = ep.worst.max(gap);
|
||||||
|
ep.last_hole_end = now;
|
||||||
|
}
|
||||||
|
None if was_active => {
|
||||||
|
self.episode = Some(Episode {
|
||||||
|
started: now - gap,
|
||||||
|
last_hole_end: now,
|
||||||
|
holes: 1,
|
||||||
|
hole_time: gap,
|
||||||
|
worst: gap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
} else if was_active {
|
||||||
|
// Sustained flow is back ([`Self::RECENT`] tight frames) — the stretch is over.
|
||||||
|
self.close_episode();
|
||||||
|
}
|
||||||
if !was_active || gap < Self::STALL_MIN {
|
if !was_active || gap < Self::STALL_MIN {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -312,14 +455,20 @@ impl StallWatch {
|
|||||||
StallVerdict::ComposeSilence => 2,
|
StallVerdict::ComposeSilence => 2,
|
||||||
StallVerdict::DeliveryLeg => 3,
|
StallVerdict::DeliveryLeg => 3,
|
||||||
}] += 1;
|
}] += 1;
|
||||||
let class = classify(stall.gap, &verdict, evidence.probes.as_ref());
|
let class = classify(
|
||||||
|
stall.gap,
|
||||||
|
&verdict,
|
||||||
|
evidence.probes.as_ref(),
|
||||||
|
evidence.etw_counts.as_ref(),
|
||||||
|
);
|
||||||
self.classes[match class {
|
self.classes[match class {
|
||||||
StallClass::OursWorker => 0,
|
StallClass::OursWorker => 0,
|
||||||
StallClass::OursDelivery => 1,
|
StallClass::OursDelivery => 1,
|
||||||
StallClass::AdapterFreeze => 2,
|
StallClass::AdapterFreeze => 2,
|
||||||
StallClass::CompositorBlocked => 3,
|
StallClass::CompositorBlocked => 3,
|
||||||
StallClass::FrameGeneration => 4,
|
StallClass::ContentSilence => 4,
|
||||||
StallClass::Unattributed => 5,
|
StallClass::FrameGeneration => 5,
|
||||||
|
StallClass::Unattributed => 6,
|
||||||
}] += 1;
|
}] += 1;
|
||||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||||
@@ -331,6 +480,11 @@ impl StallWatch {
|
|||||||
class = %class,
|
class = %class,
|
||||||
probes = evidence.probes.as_ref().map(tracing::field::display),
|
probes = evidence.probes.as_ref().map(tracing::field::display),
|
||||||
etw = evidence.etw.as_deref().unwrap_or("unavailable"),
|
etw = evidence.etw.as_deref().unwrap_or("unavailable"),
|
||||||
|
// The discriminator's numeric read (also embedded in `etw` as prose): swapchain
|
||||||
|
// presents from ANY process vs frames entering the virtual display's kernel queue,
|
||||||
|
// inside the gap window. presents≥bar with adds≈0 = FRAME-GENERATION conviction.
|
||||||
|
etw_presents = evidence.etw_counts.map(|c| c.presents),
|
||||||
|
etw_queue_adds = evidence.etw_counts.map(|c| c.queue_adds),
|
||||||
offered_during_gap = evidence.offered_delta,
|
offered_during_gap = evidence.offered_delta,
|
||||||
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
|
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
|
||||||
"IDD-push capture stall — the desktop was composing at speed, then the ring \
|
"IDD-push capture stall — the desktop was composing at speed, then the ring \
|
||||||
@@ -351,13 +505,14 @@ impl StallWatch {
|
|||||||
);
|
);
|
||||||
let class_tally = format!(
|
let class_tally = format!(
|
||||||
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
|
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
|
||||||
frame-generation {}, unattributed {}",
|
content-silence {}, frame-generation {}, unattributed {}",
|
||||||
self.classes[0],
|
self.classes[0],
|
||||||
self.classes[1],
|
self.classes[1],
|
||||||
self.classes[2],
|
self.classes[2],
|
||||||
self.classes[3],
|
self.classes[3],
|
||||||
self.classes[4],
|
self.classes[4],
|
||||||
self.classes[5]
|
self.classes[5],
|
||||||
|
self.classes[6]
|
||||||
);
|
);
|
||||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ ash = { version = "0.38", optional = true }
|
|||||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||||
ureq = "2"
|
ureq = "2"
|
||||||
|
# Signed update-manifest fetch/verify + the install-kind ladder, shared with the host so one
|
||||||
|
# trust rule serves both (crates/pf-update-check).
|
||||||
|
pf-update-check = { path = "../pf-update-check" }
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ pub mod profiles;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
pub mod trust;
|
pub mod trust;
|
||||||
|
// "Is a newer client available, and can this box install it?" — the client half of the
|
||||||
|
// signed-manifest update check the host already runs (design: host-update-from-web-console.md).
|
||||||
|
// Linux only: the Windows client ships inside the host installer and the Mac one through
|
||||||
|
// clients/apple, so neither has a package to reason about here.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod update;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
pub mod video;
|
pub mod video;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
|||||||