Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 b2cf4e908c chore(release): bump workspace version to 0.22.0
audit / bun-audit (sdk) (push) Successful in 17s
audit / bun-audit (web) (push) Successful in 17s
audit / docs-site-audit (push) Successful in 17s
audit / pnpm-audit (push) Successful in 11s
audit / bun-audit (plugin-kit) (push) Successful in 3m15s
audit / cargo-audit (push) Successful in 3m24s
android-screenshots / screenshots (push) Successful in 3m40s
apple / swift (push) Successful in 5m0s
apple / screenshots (push) Waiting to run
audit / license-gate (push) Successful in 7m24s
decky / build-publish (push) Successful in 37s
ci / rust (push) Successful in 15m5s
ci / web (push) Successful in 1m12s
ci / docs-site (push) Failing after 44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
arch / build-publish (push) Successful in 19m5s
linux-client-screenshots / screenshots (push) Successful in 10m39s
sbom / sbom (push) Successful in 1m16s
flatpak / build-publish (push) Failing after 13m53s
release / apple (push) In progress
docker / deploy-docs (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / builders-arm64cross (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
windows-host / package (push) Successful in 14m34s
ci / rust-arm64 (push) Successful in 1m34s
windows-host / winget-source (push) Successful in 17s
android / android (push) Successful in 10m16s
web-screenshots / screenshots (push) Successful in 10m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m36s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
deb / build-publish (push) Successful in 13m51s
deb / build-publish-host (push) Successful in 5m27s
deb / build-publish-client-arm64 (push) Successful in 4m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 27m37s
256 commits since v0.21.0. Minor, not patch: the headline is settings profiles —
named bundles of overrides bound per host, landing on all five clients at once
(Linux, Windows, Apple, Android) with one settings surface editing either layer,
marked-and-resettable override rows, per-profile colours, host bindings, one-off
"Connect with", and pinned host+profile cards. With them: the punktfunk:// link
grammar (one parser, one 44-case vector file run by the Rust, Swift and Kotlin
suites), double-clickable shortcuts, and `punktfunk` — one headless front-end
over the brain layer, which wakes a sleeping host the way a card click does.

Also: opt-in HDR on the gamescope path plus the cursor-in-the-node patch that
makes those sessions zero-copy; Vulkan Video 10-bit so AMD/Intel HDR keeps the
good path; a zero-copy NVENC HDR leg; host OS detection with marks on every
client and the console; PUNKTFUNK_HOST_NAME, PUNKTFUNK_MAX_FPS and
PUNKTFUNK_VDISPLAY_HZ_MULT; monitor enumeration on Windows; and the release-
integrity work (per-asset SHA256 sidecars, a signed sysext feed, one stable
driver publisher identity, fail-closed signing guards on a v* tag).

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows virtual-
display driver protocol at 6 — 0.18-0.22 hosts and clients keep mixing freely.
The Windows virtual-GAMEPAD channel protocol goes 2 -> 3 and fails closed both
ways: it carries the fix for a LocalService principal being able to take over a
pad's shared input section and forge HID input into the interactive desktop, so
the host and its drivers must ship together. The installer ships both.

Additive elsewhere: an advisory mDNS `os=` TXT key, HostInfo.os/os_name,
MonitorsResponse.pin_supported, an eighth field on the Android JNI discovery
record, and appended-last StoredHost fields per the frozen app-widget contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:39:58 +02:00
enricobuehlerandClaude Opus 5 dee9ecf5c5 docs(release): v0.21.0's notes describe what v0.21.0 actually shipped
The file had accumulated the gamescope-HDR work as it landed — one "New: HDR on
the gamescope path" section plus nine Under-the-hood bullets (the gamescope
patches, Vulkan 10-bit, the per-codec probe, the NVENC HDR leg, the EFC BT.2020
model, the managed-spawn flag check, the CI wiring). All of it landed AFTER
d0889338 was tagged: `PUNKTFUNK_GAMESCOPE_HDR` does not exist at v0.21.0 and
neither does packaging/gamescope/, verified by content rather than by SHA.

The live release body never carried any of it — it was PATCHed from the trimmed
file at c4e80fd4 and not re-synced since — so this only restores the file to the
7030 bytes users actually see on the release page. That matters because
announce.yml's apply_release_notes re-asserts this file over the live body: a
re-announce or a tag re-point would have rewritten a shipped release to
advertise features it does not contain.

The content itself is not lost; it is where it belongs, in v0.22.0.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:39:25 +02:00
enricobuehlerandClaude Fable 5 2b82ce6484 fix(ci/flatpak): host networking — ostree's resolver never worked through docker's embedded DNS
ci / rust-arm64 (push) Successful in 1m45s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
ci / web (push) Successful in 4m2s
ci / docs-site (push) Successful in 4m7s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Successful in 7m10s
flatpak / build-publish (push) Successful in 7m23s
The flathub fetch has failed 10/10 retries for months, blamed on fleet load
and DNS tuning. It is neither. Measured on home-runner-2, all inside ONE
container: getent resolved dl.flathub.org, curl fetched the same URL with
HTTP 200 (auto and -4), and flatpak still died '[6] Could not resolve
hostname'. Rewriting resolv.conf to a real nameserver didn't help; the
default bridge failed too; --network host works every time. So it is
ostree's own resolver against Docker's embedded 127.0.0.11, and removing
that resolver from the path is the fix. retry.sh stays as the backstop for
genuine upstream blips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 01:29:13 +02:00
enricobuehlerandClaude Opus 5 53ff313046 fix(packaging/gamescope): the shipped compositor starts on SteamOS — a rolling-distro libstdc++ never followed it there
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
ci / web (push) Successful in 52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m8s
ci / docs-site (push) Successful in 2m13s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 40s
ci / rust (push) Canceled after 5m49s
ci / rust-arm64 (push) Canceled after 5m47s
arch / build-publish (push) Successful in 15m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m46s
`punktfunk-gamescope-3.16.25.pfhdr2-1` off the pacman repo cannot start on SteamOS 3.8.16:
`/usr/lib/libstdc++.so.6: version 'GLIBCXX_3.4.35' not found`. The Arch container CI builds it in
is on gcc 16.1.1; SteamOS ships libstdc++ 3.4.34 and moves when Valve says so. Nothing else about
the binary was wrong — every other soname resolved on the box, and glibc was never close (it asks
for 2.38 at most against SteamOS's 2.41) — so the one dynamic C++ runtime was the whole reason the
gamescope backend's own most important platform got a package that dies at `--version`.

The C++ runtime therefore goes static, for the same reason wlroots already does: this binary is
built on a rolling distro and has to start on a frozen one. It is safe here because gamescope
links no shared C++ library at all — its NEEDED list is all C, and glslang/SPIRV are build-time
only — so no C++ ABI crosses a shared boundary. Cost is ~1 MB (5.9 → 7.1). The flags are appended
to LDFLAGS rather than passed as `-Dcpp_link_args`, which would replace the value meson derives
from the environment and silently drop makepkg's `-z relro`/`-z now`/`--as-needed`.

A static runtime is invisible in a passing build and only surfaces as a binary that will not start
somewhere else, so the build now asserts it: no `libstdc++` in NEEDED, which needs no version
threshold to check and turns the regression back into a build failure.

Verified by building in `archlinux:base-devel` — the environment arch.yml uses, gcc 16.1.1 and
glibc 2.44, both far newer than the target — and running the result on SteamOS 3.8.16 with nothing
supplied: banner `punktfunk-gamescope version 3.16.25-4-g6bbe157+pfhdr2`, no libstdc++ in NEEDED,
max GLIBC_2.38, every soname resolving. The host's whole HDR gate chain then answers on SteamOS
for the first time (780M / RADV PHOENIX): 10-bit PQ capture offered, cursor painted in-node,
native-plane HDR and GameStream HDR capable both true once `PUNKTFUNK_GAMESCOPE_HDR` is on, false
with the distro's gamescope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:23:35 +02:00
enricobuehlerandClaude Opus 5 31db452ca9 feat(client/apple): the host tile wears its OS mark where the initial was
apple / swift (push) Successful in 4m32s
ci / web (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
ci / docs-site (push) Successful in 1m18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 40s
ci / rust (push) Failing after 6m10s
ci / rust-arm64 (push) Successful in 14m47s
release / apple (push) Successful in 25m17s
apple / screenshots (push) Canceled after 13m52s
Apple parity with the Android card (a94b1d3c's sibling): the OS mark moves
out of the status row and into the tile, replacing the monogram. The
initial says nothing the name beside it doesn't already say — twice over on
a row of home-worker-N boxes — while the mark identifies the machine at a
glance.

Both host surfaces, because on tvOS the console home is the only one there
is: the touch cards (saved and discovered) and GamepadHomeView's badge,
which needed the chain carried on HomeTile to reach it. Sized to the
monogram's own point size, so it lands at ~48% of the tile everywhere, and
tinted through the same foregroundStyle the letter used — the assets are
template imagesets, so they follow it like an SF Symbol.

A host that advertises no OS chain, or one we ship no art for, keeps its
letter: `osIconImage` already returns nil for both, so a mixed row still
reads as one set. The mark carries the accessibility label now, since the
status row it used to ride no longer names the OS.

Untouched: the widget draws no monogram, and AboutView's is the app's own
logo, not a host's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:53:46 +02:00
enricobuehlerandClaude Fable 5 af817bc03e fix(lock): cursor-probe's pf-frame dep reaches the lockfile
audit / bun-audit (web) (push) Successful in 14s
audit / docs-site-audit (push) Successful in 13s
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 27s
audit / pnpm-audit (push) Successful in 16s
ci / web (push) Successful in 54s
apple / swift (push) Canceled after 2m17s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 1m49s
ci / rust-arm64 (push) Canceled after 1m49s
ci / docs-site (push) Canceled after 47s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
audit / cargo-audit (push) Successful in 2m46s
audit / license-gate (push) Successful in 6m35s
deb / build-publish-client-arm64 (push) Successful in 8m10s
flatpak / build-publish (push) Failing after 9m0s
deb / build-publish (push) Successful in 9m47s
android / android (push) Successful in 12m25s
windows-host / package (push) Successful in 16m43s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Canceled after 52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m26s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m24s
deb / build-publish-host (push) Successful in 23m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 21m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m22s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m50s
1f59498c added pf-frame to tools/cursor-probe (a workspace member) without
the Cargo.lock entry. The pinned 1.96 toolchain shrugs, but Arch's cargo
1.97 refuses to resolve under --locked — every arch build died with
'cannot update the lock file' right after the windows-rs fetch, which made
it look like a cache problem. One line, written by the pinned toolchain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:53:18 +02:00
enricobuehlerandClaude Opus 5 a94b1d3ccc fix(client/android): the stream keeps its aspect instead of stretching to the panel
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
ci / web (push) Successful in 58s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 49s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / docs-site (push) Successful in 1m37s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 31s
ci / rust-arm64 (push) Failing after 4m29s
android / android (push) Canceled after 4m35s
ci / rust (push) Canceled after 4m39s
MediaCodec scales whatever it decodes to fill the Surface it renders into,
and the Surface filled the screen — so a stream whose resolution didn't
match the panel's aspect came out stretched. Nothing downstream of the
Surface can correct that; the Surface itself has to carry the aspect.

Size the video to the negotiated mode's ratio, centred, with the remainder
black. The mode is known from the handshake before the first frame arrives,
via a new `nativeVideoSize` (the same `client.mode()` the HUD already
reports as `w×h@hz`); an older native lib returning nothing falls back to
filling, exactly as before.

Input follows the picture. Direct-pointer touch, multi-touch passthrough
and the pen lane all map positions against the size of the node they sit
on, so the gesture layer moves onto the same rect as the video and all
three stay correct by construction instead of each needing an offset
threaded through it. The physical-mouse path can't work that way — its
events arrive from the activity in WINDOW coordinates — so it now measures
against the SurfaceView's rect on screen, subtracting the letterbox origin
and clamping into the picture: a pointer out on a bar has no host position
of its own, and the edge is the honest answer for it.

One deliberate consequence: trackpad swipes that START inside a letterbox
bar no longer register. Trackpad input is relative and could have kept the
whole panel, but one rule — input lands on the picture — beats a mode-
dependent input surface, and the pen lane rides inside trackpad mode too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:48:38 +02:00
enricobuehlerandClaude Fable 5 1f59498c5c fix(host/linux): a no-channel session composites the metadata cursor — Mutter never embeds on a virtual stream
ci / web (push) Successful in 3m26s
ci / docs-site (push) Successful in 3m32s
arch / build-publish (push) Failing after 4m29s
apple / swift (push) Successful in 4m38s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / rust-arm64 (push) Failing after 4m50s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
deb / build-publish (push) Failing after 5m6s
ci / rust (push) Failing after 5m47s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 34s
android / android (push) Canceled after 8m16s
apple / screenshots (push) Canceled after 3m46s
deb / build-publish-host (push) Canceled after 4m15s
deb / build-publish-client-arm64 (push) Canceled after 4m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m58s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 8m21s
The capture-latched client (console.rs latched_mouse) never advertises
CLIENT_CAP_CURSOR, so its session resolved cursor_blend=false and asked
Mutter to EMBED the pointer. On a Mutter virtual stream that is a
fiction: since Mutter 48 (7ff5334a, hw-cursor inhibition removed) the
software cursor overlay is suppressed stage-globally whenever any
physical head realizes a HW cursor — dmabuf-recorded frames blit the
view without a pointer, and cursor-only motion schedules no re-record
either (mutter#4939). Probed on-glass on Mutter 50.3: embedded +
relative motion = frozen frame counter; SPA_META_Cursor positions kept
flowing in the same setup.

So the no-channel session now takes the path that was verified end to
end: cursor-as-metadata + the host composites, permanently — the same
arm a channel session lands in after its capture-model flip. Embedded
remains only the can't-blend fallback (libav VAAPI/NVENC, software).

- session_plan::cursor_blend_for grows the no-channel arm (codec +
  depth in, the same CUDA-payload prediction handshake makes);
  gamescope excluded so patch-2+ keeps its native-NV12 zero-copy shape
- the encode loop's composite refresh + one-shot breadcrumbs now cover
  the no-channel session; the park schedule keeps retrying while its
  composite is starved (relative-only clients cannot park themselves)
- the compositor retarget re-applies set_hw_cursor — the rebuilt
  display used to come up EMBEDDED even for channel sessions
- the GameStream virtual source takes the same rule (it never has a
  channel); its stream_body blend flag mirrors the request
- punktfunk-probe grows --cursor-nochannel (the latched-capture client,
  headless); cursor-probe grows --dump (PPM frames + a content-change
  counter, the pixel evidence the embedded A/B lacked)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:35:25 +02:00
enricobuehlerandClaude Opus 5 9ed967cbaf feat(client/android): the host card wears its OS mark where the initial was
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m7s
android / android (push) Canceled after 2m56s
ci / rust (push) Canceled after 2m37s
ci / rust-arm64 (push) Canceled after 2m25s
ci / web (push) Canceled after 2m11s
ci / docs-site (push) Canceled after 2m11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
The OS mark rode along at 12 dp in front of the address, competing with the
text it prefixed. The avatar circle above it was showing the host's first
letter — which says nothing the name underneath doesn't already say, twice
over on a row of home-worker-N boxes.

Put the mark in the circle instead, at 24 dp in the avatar's own
onPrimaryContainer tint, and drop it from the address line. A host that
advertises no OS chain — or one we ship no mark for — keeps the initial, so
those cards look exactly as they did and a mixed row still reads as one set.

On glass: the two Arch hosts wear the Arch mark; steamdeck and the Windows
runner, both predating the `os=` TXT, keep their letters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:32:22 +02:00
enricobuehlerandClaude Fable 5 be2fabcfba feat(ci/windows-host): the console and the drivers stop rebuilding what nobody changed
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
android / android (push) Canceled after 1m24s
ci / rust (push) Canceled after 1m25s
ci / rust-arm64 (push) Canceled after 1m19s
ci / web (push) Canceled after 1m19s
ci / docs-site (push) Canceled after 1m18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 44s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m30s
windows-host / package (push) Canceled after 4m34s
windows-host / winget-source (push) Canceled after 0s
The job's two cache-shaped tails, now actually cached (the runner just got
wired to the central cache server — it had none): web/.output restores and
skips the ~2.5 min bun build+smoke whenever web/ and sdk/ are untouched,
and the UMDF drivers' in-tree target/ (which checkout's clean wiped every
run because wdk-build can't relocate it) restores so cargo's fingerprints
declare it fresh. Typical run: ~13.6 min -> ~10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:30:59 +02:00
enricobuehlerandClaude Opus 5 6a6be17ce7 fix(client/android): a session that receives no video at all now asks, and says so
The keyframe backstop added for the black LG TV only arms while AUs are
actually going into the decoder (`fed > fed_at_output`) — deliberately, so
an idle stream never asks for anything. That leaves its mirror image
uncovered: a session that receives NOTHING. A decoder cannot be starved of
output when it was handed no input, so no signal in either loop fires, and
the session sits connected — audio, input and the control plane all alive —
behind a black surface.

That state is what a user just reported as "the stats are all basically 0":
fps and Mb/s are counted at AU receipt (`note_received`), so all-zero stats
with a drawn overlay means the decode thread started and received nothing.
Same bug as the black screen, seen from the HUD.

Both loops now watch for it: nothing received 1.5 s into a session ⇒ request
a keyframe and log it, re-asking every 2 s while it lasts. Where it can help
it does — the host encoding fine while every picture references an IDR this
client never saw is precisely a keyframe request away. Where it can't, the
log line is the point: "no video received N ms into the session" separates
"the host never sent a picture" from "we received AUs and lost them", which
no previous black-screen report could distinguish.

Not a root cause. The remaining occurrences are still unattributed — this
makes the next report diagnosable and recovers the case that is ours to
recover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:30:59 +02:00
enricobuehlerandClaude Opus 5 bb7baef20b fix(client/android): the menus keep their safe area after a stream
Returning from a stream left every menu laid out against the wrong safe
area: content shoved right, the profile row sliding under the status bar,
the tab labels crowding the gesture pill. Dumped on the reporter's phone,
the window's real insets were bars=[0,162,0,72] cutout=[0,162,0,0] while
the layout was using the landscape immersive set — cutout left=162
(Material3 lays out against systemBars.union(displayCutout)), bars all
zero. No rotation and no IME animation could shake it loose.

Compose attaches its OnApplyWindowInsets and WindowInsetsAnimation
callbacks when the first composable reads an inset and removes them when
the last reader goes away (WindowInsetsHolder.increment /
decrementAccessors). StreamScreen reads no insets at all, so a stream
drops that count to zero for its whole duration.

Survivable on its own — but a session that ends while the app is
BACKGROUNDED is the common case (leaving the app ends the session), and
then the entire window restore runs on a stopped activity. The corrected
insets arrive while Compose has no listener attached; when the menus
recompose, incrementAccessors re-attaches and asks for a fresh pass, but a
stopped window produces no dispatch and on resume nothing has changed any
more, so none ever comes. Compose keeps serving the landscape,
bars-hidden values for the rest of the process.

Hold one inset reader at the root for the activity's whole life, so the
listeners survive the stream and every dispatch lands. It subscribes to no
inset VALUE, only the holder object, so it costs one DisposableEffect and
no recomposition.

Verified on the reporter's device by replaying the real teardown sequence
(forced landscape + immersive, composition swapped to a screen that reads
no insets, torn down while stopped) and reading the layout back through
uiautomator: 3 of 4 runs wrong without this, 4 of 4 correct with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:30:59 +02:00
enricobuehlerandClaude Opus 5 91def82219 fix(client/android): the menus keep their safe area after a stream
Returning from a stream left every menu laid out against the wrong safe
area: content shoved right by the landscape side inset, the profile row
sliding under the status bar, the tab labels crowding the gesture pill.

Compose attaches its OnApplyWindowInsets AND WindowInsetsAnimation
callbacks when the first composable reads an inset, and tears both down
when the last reader goes away (WindowInsetsHolder.increment /
decrementAccessors). The immersive stream reads no insets at all — it is
a bare full-screen surface — so entering one dropped the reader count to
zero right in the middle of the hide(systemBars()) animation StreamScreen
had just started. With the animation callback gone, that animation's
onEnd never arrived, so the listener kept runningAnimation = true for the
rest of the process, and from then on every onApplyWindowInsets was
swallowed (it defers to an onProgress that can no longer come). The
values froze at the last animation frame — landscape, bars hidden — and
that is what the menus got when they came back.

Hold one inset reader at the root for the activity's whole life: the
listeners now survive the stream, the landscape lock and the bar
animations, and every animation gets its onEnd. It subscribes to no inset
VALUE, so it costs nothing per frame. As a backstop, request a fresh
insets pass from onConfigurationChanged — the activity declares
configChanges=orientation|screenSize, so a rotation re-lays out in place
and a dropped dispatch would otherwise go unnoticed until the layout is
already wrong on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:30:59 +02:00
enricobuehlerandClaude Opus 5 1a18ae1fae fix(packaging/bazzite): the feed publisher signed a redirect page, not the manifest
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / builders-arm64cross (push) Successful in 4s
docker / deploy-docs (push) Successful in 25s
ci / web (push) Successful in 2m46s
ci / docs-site (push) Successful in 2m45s
ci / rust-arm64 (push) Successful in 11m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
ci / rust (push) Canceled after 18m14s
Every Bazzite install on the stable channel has been refusing the feed:

  !! the feed's SHA256SUMS is NOT signed by packages@unom.io (AF245C506F4E4763).

The client was right and the feed was wrong. The registry answers a file GET
with a 303 See Other pointing at presigned object storage, and `curl -f` does
not treat a 3xx as an error — so the publisher's two un-`-L`'d manifest reads
"succeeded" holding the redirect's HTML body, `<a href="…">See Other</a>.`, and
handed that to callers as the manifest.

That broke both of them:

  * --seal signed the HTML page. Its presigned URL is regenerated per request
    and expires 300s later, so the published .asc covers bytes that exist
    nowhere and can never verify. Stable feeds only publish on a tag, so they
    are only ever sealed — f43 and f44 were re-broken at 19:54Z on 2026-07-29
    by the seal step of a canary run, and every canary push re-broke them.
    f43-canary survived by accident: on the publish path the same polluted
    bytes get both signed and uploaded, so it is at least self-consistent.

  * the publish merge read took the page for the previous manifest, and
    `grep -v " $FNAME$"` kept it — so each publish prepended a stale redirect
    page and dropped every prior image line. f43 runs KEEP=0 (keep all) and
    holds exactly one line, with 0.20.0 and 0.19.2 still in the registry and
    no longer listed. This has been quietly eating feed history since long
    before signing existed; nobody noticed because the client's latest() only
    matches ^punktfunk-.*-x86-64\.raw$, so an HTML line is invisible to it.

Both reads now go through one read_manifest that follows redirects AND keeps
only well-formed "<sha256>  <filename>" lines, so a manifest is never again
whatever the transport happened to return. --seal re-publishes a manifest that
normalizing changed, since the signature has to cover the bytes a client
downloads — that is what repairs the live feeds — and refuses outright when a
manifest lists no images, rather than sealing an empty feed that would read as
"up to date" to `punktfunk-sysext update`. The publish path now logs its
carry-over count; silence there is what let the history loss run for months.

Verified end to end against a fake registry that 303s to a fresh URL per
request, with a throwaway key and the real scripts: HEAD reproduces the
injected page, the lost image line and the client's VERIFY-FAIL; the fix keeps
both images and verifies; and the new --seal over the broken state normalizes
the manifest and turns it back to VERIFY-OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:09:23 +02:00
enricobuehlerandClaude Fable 5 2e6cd0e235 feat(capture): stall attribution phases A.2+A.3 — micro-probes and DxgKrnl ETW name the class
ci / web (push) Successful in 2m30s
ci / docs-site (push) Successful in 2m49s
apple / swift (push) Successful in 4m36s
ci / rust-arm64 (push) Successful in 10m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
android / android (push) Successful in 12m44s
deb / build-publish (push) Successful in 11m34s
docker / builders-arm64cross (push) Successful in 23s
windows-drivers / probe-and-proto (push) Successful in 29s
docker / deploy-docs (push) Successful in 30s
arch / build-publish (push) Successful in 15m48s
ci / rust (push) Successful in 16m21s
windows-drivers / driver-build (push) Successful in 1m46s
deb / build-publish-client-arm64 (push) Successful in 9m22s
deb / build-publish-host (push) Successful in 19m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m40s
apple / screenshots (push) Successful in 20m22s
windows-host / package (push) Successful in 14m39s
windows-host / winget-source (push) Skipped
Phase A.2: a refcounted micro-probe engine (fence round-trip per adapter,
DwmGetCompositionTimingInfo tick, watchdogged DwmFlush, Level-Zero
D3DKMTGetScanLine, CPU jitter sentinel) samples continuously on detached
sacrificial threads; each stall report reads the window back and the verdict
matrix folds it with the driver telemetry into a named class: ours-worker /
ours-delivery / CLASS-1 adapter freeze / CLASS-2 compositor blocked /
frame-generation / unattributed. The metronomic WARN carries the per-class
session tally.

Phase A.3: an event-id-filtered real-time ETW session on
Microsoft-Windows-DxgKrnl (QueryChildStatus 150/151, SetPowerState 154/155,
IndicateChildStatus 272, SetTimingsFromVidPn 430, DisplayDetectControl
1096/1097) rides every stall line as a DDI bracket summary — naming the
servicing call and its duration instead of 'below Windows'. Degrades to
etw=unavailable without admin; probes degrade per-leg (absence is stated,
never guessed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 23:44:35 +02:00
enricobuehlerandClaude Fable 5 b2168dae6a feat(vdisplay): stall attribution v1 — the driver testifies which leg lost the frames
Every IDD-push capture stall now carries a VERDICT instead of a hypothesis list.
The shared ring header grows a v2 telemetry tail (drain-loop heartbeat QPC,
last-acquire QPC, full-width offered counter) the driver stamps on every drain
pass; the host samples it between fresh frames and attributes each stall:
worker-stalled (our thread starved) / compose-silence (DWM composed nothing —
the disturbance is below capture) / delivery-leg (frames existed, our
publish/ring/consume lost them). The metronomic WARN prints the running tally,
so one pasted log line settles the Branch-1/Branch-2 fork of the
vdisplay-disturbance-immunity program per session, per box.

Both directions stay version-safe: the tail is gated on the HOST-stamped header
version (a v2 driver never writes past a v1 host's 64-byte layout — it maps the
whole section instead of a fixed 88 bytes), and a v2 host reads a zero heartbeat
as pre-telemetry driver, no verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 23:44:35 +02:00
enricobuehlerandClaude Opus 5 75e4f00e50 Merge branch 'chore/windows-rerender-semantics' into main
ci / docs-site (push) Failing after 31s
ci / web (push) Successful in 54s
apple / swift (push) Successful in 1m17s
ci / rust-arm64 (push) Failing after 7m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 9m13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish (push) Successful in 15m0s
android / android (push) Canceled after 15m44s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 15m46s
ci / rust (push) Canceled after 15m45s
deb / build-publish-host (push) Canceled after 15m12s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 44s
windows-host / package (push) Canceled after 15m35s
windows-host / winget-source (push) Canceled after 0s
release / apple (push) Successful in 17m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m12s
flatpak / build-publish (push) Failing after 12m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
Windows 11 tray theming + per-connect device-name announcement, and the
pairing approve button no longer escapes the canvas on portrait phones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:27:36 +02:00
enricobuehlerandClaude Fable 5 a86b4c18ee feat(ci): arch gets its builder image too — the last per-run gigabyte, and sccache through makepkg
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
ci / docs-site (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m3s
docker / deploy-docs (push) Successful in 30s
docker / builders-arm64cross (push) Successful in 17s
ci / web (push) Successful in 3m1s
ci / rust-arm64 (push) Successful in 10m7s
ci / rust (push) Successful in 13m56s
arch / build-publish (push) Successful in 12m30s
The arch job was the one still paying full freight every run: ~1 GB of
pacman across its two install steps (never cached — container layers die
with the job) and cold cargo builds (arch was skipped in the sccache
rollout). punktfunk-arch-ci bakes base-devel + both makepkg legs' deps +
bun + node + sccache; the in-job installs become --needed no-op guards for
the one push where :latest lags. Rolling-release note in the Dockerfile:
packages now build against the image's snapshot, the same staleness the
gamescope cache already embraces, re-snapshotted on any ci/ edit.

sccache reaches makepkg by crossing the sudo boundary explicitly —
env_reset strips ambient env, so the wrapper env rides the existing
`sudo -u builder env ...` list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 22:49:00 +02:00
enricobuehlerandClaude Fable 5 43a9cf741b fix(client/android): the build resolves cargo from CARGO_HOME before guessing ~/.cargo
ci / web (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m3s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 3m27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 49s
ci / rust-arm64 (push) Successful in 11m57s
android / android (push) Successful in 15m54s
ci / rust (push) Successful in 18m7s
The kts resolves cargo by ABSOLUTE path on purpose (a GUI Android Studio
launch has no ~/.cargo/bin on PATH), but user.home is the wrong anchor in
the CI image, where the shared toolchain lives at CARGO_HOME=/usr/local/cargo
— gradle died starting /root/.cargo/bin/cargo. CARGO_HOME/bin is where
rustup puts binaries whenever the variable is set, so it wins; the
~/.cargo fallback keeps GUI launches working.

Also: android.yml's path filter learns ci/android-ci.Dockerfile — an image
change must exercise its consumer instead of needing a manual rerun to
prove itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:47:07 +02:00
enricobuehlerandClaude Fable 5 12b0ce9b2d fix(ci): the android image learns node, and msix finally builds the CLI it packs
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
ci / docs-site (push) Successful in 1m6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m26s
ci / web (push) Successful in 1m48s
ci / rust (push) Canceled after 2m12s
ci / rust-arm64 (push) Canceled after 2m12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
docker / builders-arm64cross (push) Successful in 1m33s
docker / deploy-docs (push) Successful in 1m30s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m36s
Two first-run discoveries. actions/checkout (and every JS action) execs
`node` inside the job container — the android-ci image didn't ship it, so
the job died at checkout with exit 127 (flatpak.yml documents the same
lesson for fedora:43); a trailing layer keeps the fat SDK/NDK layers
cache-valid. And windows-msix has been red since bf981027 required
punktfunk.exe in the package without adding punktfunk-cli to the build —
the same gap 90c84ef4 already closed for deb, now closed here (rpm and
arch already build it; the CLI has no features, so the arm64 leg's
--no-default-features changes nothing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:36:57 +02:00
enricobuehlerandClaude Fable 5 171f08184f fix(ci/android): cargo-ndk v4 refuses direct invocation — probe it as the subcommand it is
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m45s
docker / builders-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 38s
ci / rust (push) Canceled after 3m42s
ci / web (push) Successful in 2m29s
ci / docs-site (push) Successful in 2m45s
apple / swift (push) Successful in 6m46s
android / android (push) Failing after 3m54s
ci / rust-arm64 (push) Canceled after 6m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m35s
release / apple (push) Successful in 35m40s
apple / screenshots (push) Successful in 20m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:30:46 +02:00
enricobuehlerandClaude Fable 5 395daa8e98 fix(packaging/rpm): the CLI's %files entry had wandered into %install
bf981027 added the punktfunk CLI to the spec, but its `%{_bindir}/punktfunk`
files entry landed one section too early — inside %install, where rpm's
shell dutifully executed /usr/bin/punktfunk (which doesn't exist on a
builder) and killed both rpm legs at %install ever since. Move it where it
was headed: out of the script, into %files client — which was missing it,
so the fixed %install would otherwise have died again on an unpackaged
/usr/bin/punktfunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:30:07 +02:00
enricobuehlerandClaude Fable 5 9cda05dffb feat(ci): android gets a baked builder image; apple gets sccache and a pinned DerivedData
Android was the last job still downloading its world every run: ~3 GB of
SDK/NDK/CMake from Google, a from-source cargo-ndk build, and one monolithic
cache key where a gradle edit invalidated the cargo registry and the Rust
target/ with it. punktfunk-android-ci (content-keyed, LAN registry) bakes
JDK 21 + SDK + NDK + cargo-ndk + sccache; the workflow shrinks to checkout →
two caches → gradle. The cargo-home cache joins the fleet-wide namespace
(same lockfile, same layout — it was the same bytes under a private key),
gradle gets its own key shared with android-screenshots (which stored the
identical content under a second name), and target/ leaves the cache —
sccache covers the three ABI builds now.

Apple: sccache (self-healing ~/.local/bin install + already provisioned on
the mini) covers every cargo invocation build-xcframework.sh makes across
the swift job, the screenshots job and release.yml — three jobs that each
recompiled the same core. screenshots.sh learns PF_SHOT_DERIVED_DATA so CI
pins one stable DerivedData root instead of cold-building into two throwaway
mktemp trees per run (release.yml's disease, same cure), and the Simulators
get shut down after capture (the 846-leaked-sims lesson).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:20:59 +02:00
51 changed files with 2729 additions and 336 deletions
+9 -18
View File
@@ -22,25 +22,15 @@ jobs:
screenshots:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
# JDK 21 + SDK baked (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 1721).
# The tests are pure JVM (no NDK), but sharing android.yml's image means one image to
# keep warm instead of a per-run setup-java + sdkmanager download pair.
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: JDK 21 (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 1721)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Android SDK
# SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
# if the platform channel lacks it (same note as android.yml).
- name: platform-tools + platform 36 + build-tools
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0"
- name: Cache (gradle)
uses: actions/cache@v4
with:
@@ -49,9 +39,10 @@ jobs:
~/.gradle/wrapper
# gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
# Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
# restore a key that can never hold the new one.
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: android-screenshots-
# restore a key that can never hold the new one. Namespace shared with android.yml —
# it is the same content; two keys just stored it twice in the central cache.
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
# the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so.
+52 -50
View File
@@ -2,11 +2,11 @@
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
#
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
# distribution in-job). If android-actions/setup-android is not mirrored on this Gitea instance,
# replace that step with a manual cmdline-tools download, or bake an `android-ci` image like
# ci/rust-ci.Dockerfile. Emulator instrumentation tests are deferred until a KVM-capable runner
# exists (they self-skip otherwise, like apple.yml's RemoteFirstLightTests).
# Runs in the punktfunk-android-ci builder image (ci/android-ci.Dockerfile, content-keyed on
# the LAN registry): JDK 21, the Android SDK/NDK/CMake pins, cargo-ndk and sccache are all
# baked, so the multi-GB per-run Google downloads this job used to make are gone. Emulator
# instrumentation tests are deferred until a KVM-capable runner exists (they self-skip
# otherwise, like apple.yml's RemoteFirstLightTests).
name: android
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
@@ -26,6 +26,9 @@ on:
paths:
- 'crates/**'
- 'clients/android/**'
# The builder image is part of what this artifact is built from — an image
# change must exercise its consumer.
- 'ci/android-ci.Dockerfile'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
@@ -39,6 +42,9 @@ on:
paths:
- 'crates/**'
- 'clients/android/**'
# The builder image is part of what this artifact is built from — an image
# change must exercise its consumer.
- 'ci/android-ci.Dockerfile'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
@@ -46,66 +52,62 @@ on:
- '.gitea/workflows/android.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). The NDK clang targets get their own key universes automatically (keys embed
# compiler hash + target), so the three ABI builds share the bucket with everything else.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
android:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: JDK 21 (AGP 9.3 runs on JDK 1721, not the host default)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# Everything below the checkout used to be four download steps (JDK, SDK,
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
# into the image now. This guard only re-asserts the Android targets so a
# rust-toolchain.toml pin bump keeps working against an older image (:latest lags
# one image rebuild, same bootstrap note as ci.yml's dep steps).
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- name: Rust toolchain + Android targets (self-healing on a fresh runner)
run: |
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal
fi
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- name: Android SDK
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
# anymore — sccache covers recompilation without shipping multi-GB tars per run.
- name: Cache (cargo registry)
uses: actions/cache@v4
with:
# Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain
# drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred).
# That download was the single flakiest piece of this job: the shared runner fleet drops
# packets under parallel-job load and sdkmanager's streamed unzip turns a truncated
# stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice).
packages: platform-tools
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
# auto-download it if needed during the build.
# retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the
# helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed
# attempt leaves no partial package behind, so a plain re-invoke is safe.
run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- name: Caches (cargo + gradle)
- name: Cache (gradle)
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.gradle/caches
~/.gradle/wrapper
target
# gradle-wrapper.properties is in the key on purpose — see android-screenshots.yml.
key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: android-
- name: cargo-ndk
run: command -v cargo-ndk >/dev/null || cargo install cargo-ndk
# gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
# Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
# restore a key that can never hold the new one. Namespace shared with
# android-screenshots.yml — same content, one copy in the central store.
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
+57
View File
@@ -44,6 +44,21 @@ on:
- '.gitea/workflows/apple.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
@@ -70,6 +85,18 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
@@ -128,6 +155,18 @@ jobs:
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
@@ -144,6 +183,20 @@ jobs:
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Pin + prune DerivedData (same disease release.yml already cures)
# screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two
# fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA,
# honored by the script) so repeat runs are incremental, and GC anything a week old
# in the default DerivedData root that no pin owns.
run: |
DD="$HOME/ci/derived-data/screenshots"
mkdir -p "$DD"
echo "PF_SHOT_DERIVED_DATA=$DD" >> "$GITHUB_ENV"
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
@@ -157,6 +210,10 @@ jobs:
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
echo "Produced:"; ls -la screenshots || true
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
if: always()
run: xcrun simctl shutdown all || true
- name: Upload screenshots (zip artifact)
if: always()
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
+44 -8
View File
@@ -53,27 +53,41 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io). NOTE: makepkg runs
# behind `sudo -u builder env ...`, which strips ambient env — the makepkg step
# re-exports these explicitly.
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
CARGO_INCREMENTAL: "0"
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
image: docker.io/library/archlinux:base-devel
# Everything the two pacman steps below used to download (~1 GB/run) is baked in,
# plus bun, sccache and node (ci/arch-ci.Dockerfile). The steps stay as --needed
# no-op guards for the one push where :latest lags an image-content change.
image: 192.168.1.58:5010/punktfunk-arch-ci:latest
timeout-minutes: 90
env:
CARGO_HOME: /usr/local/cargo
steps:
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
- name: Install build + runtime-dev deps
- name: Build + runtime-dev deps (no-op guard — baked into arch-ci)
# No -Syu: the image's snapshot IS the build environment (see the Dockerfile's
# rolling-release note); with everything installed this resolves locally and
# does nothing. It only matters on the push that adds a dep before the image
# rebuild lands — same bootstrap note as ci.yml's GTK4 step.
run: |
pacman -Syu --noconfirm --needed \
pacman -S --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
# the official binary.
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
@@ -98,11 +112,26 @@ jobs:
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
#
# The run number is ZERO-PADDED to a fixed width, and that padding is load-bearing.
# pacman's own vercmp compares numeric segments numerically and gets this right either
# way, but Gitea's Arch registry picks the version it advertises in `punktfunk-canary.db`
# by STRING order. Unpadded, the run counter crossing a power of ten inverts that order
# ("0.9907" > "0.10095" because '9' > '1'), so the db pins itself to the last build
# before the rollover and every later canary becomes invisible to `pacman -Syu` — the
# packages publish fine, the index just never names them. That is exactly what happened
# on 2026-07-29 when run #10000 landed; it cost an evening and needed a manual purge of
# every 4-digit `0.22.0-0.9xxx` version to unstick. Padding keeps string order and
# numeric order in agreement, so the two can never disagree again.
#
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
# it to `1.` would sort canaries ABOVE the release and is not an option.)
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
esac
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
@@ -132,9 +161,15 @@ jobs:
sudo -u builder git config --global --add safe.directory "$PWD"
mkdir -p dist && chown builder: dist
cd packaging/arch
# sudo env_reset strips the ambient env, so the sccache wiring must cross the
# boundary explicitly (same values as the workflow env block).
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
RUSTC_WRAPPER="$RUSTC_WRAPPER" CARGO_INCREMENTAL="$CARGO_INCREMENTAL" \
SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
SCCACHE_REGION="$SCCACHE_REGION" \
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
@@ -158,6 +193,7 @@ jobs:
# failure building gamescope must not cost the packages this workflow exists to publish.
run: |
set -x
# Baked into arch-ci — a no-op guard, like the dep step above.
pacman -S --noconfirm --needed \
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
+9
View File
@@ -65,6 +65,15 @@ jobs:
dockerfile: ci/fedora-rpm.Dockerfile
buildargs: --build-arg FEDORA_VERSION=44
keysuffix: -f44
# Android builder (JDK + SDK/NDK + cargo-ndk + sccache) — android.yml and
# android-screenshots.yml run in it; ~3 GB of per-run Google downloads became
# image layers.
- image: punktfunk-android-ci
dockerfile: ci/android-ci.Dockerfile
# Arch builder (base-devel + both makepkg legs' deps + bun + sccache) —
# arch.yml runs in it; ~1 GB of per-run pacman traffic became image layers.
- image: punktfunk-arch-ci
dockerfile: ci/arch-ci.Dockerfile
steps:
- uses: actions/checkout@v4
+12 -1
View File
@@ -64,8 +64,19 @@ jobs:
container:
# Fedora ships a recent flatpak + flatpak-builder + the kernel userns support.
# --privileged is required for bubblewrap inside the Docker executor (see header).
#
# --network host is what finally fixed the years-long "Could not resolve
# hostname" on every flathub fetch. MEASURED 2026-07-30 on home-runner-2, all
# in ONE container: `getent hosts dl.flathub.org` resolved, `curl` got HTTP
# 200 (both auto and -4), and flatpak still failed error [6] — so it was never
# DNS config, the resolver, the docker version, or the per-job network. It is
# ostree's own resolver refusing to work through Docker's embedded 127.0.0.11
# (proven: rewriting resolv.conf to a real nameserver did NOT help, and the
# default bridge failed too, while the host netns — no embedded resolver in the
# path at all — works every time). Host networking also means this job no
# longer needs the nsswitch surgery below to be lucky.
image: fedora:43
options: --privileged
options: --privileged --network host
steps:
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: the `resolve`
+27
View File
@@ -88,6 +88,21 @@ on:
required: false
default: "true"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs:
apple:
runs-on: macos-arm64
@@ -157,6 +172,18 @@ jobs:
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
- name: Pin + prune Xcode DerivedData
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
+24
View File
@@ -276,6 +276,18 @@ jobs:
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
Pop-Location
# The console output is fully self-contained (Nitro noExternals) and most pushes
# don't touch web/ or sdk/ — restore it from the central cache and skip the ~2.5 min
# bun build+smoke entirely on a hit. First workflow on this runner to use the
# actions cache at all (the runner's config.yaml needed cache.external_server —
# see unom/infra runners/ci-core/README.md).
- name: Cache web console output
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-win-${{ hashFiles('web/**', 'sdk/**') }}
- name: Fetch portable bun runtime (build tool + bundled to run the console)
shell: pwsh
run: |
@@ -295,6 +307,7 @@ jobs:
& $bun --version
- name: Build + smoke-boot web console (bun)
if: steps.webconsole.outputs.cache-hit != 'true'
shell: pwsh
env:
# PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
@@ -355,6 +368,17 @@ jobs:
}
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# The UMDF drivers build IN-TREE inside the pack step (a relocated CARGO_TARGET_DIR
# breaks wdk-build's manifest walk), and checkout's clean wipes that tree every run —
# ~1 min of rebuild for crates that rarely change. Cache the in-tree target; cargo's
# own fingerprints decide what's still fresh after a restore.
- name: Cache drivers workspace target (built in-tree by the pack step)
uses: actions/cache@v4
with:
path: packaging/windows/drivers/target
key: drivers-target-${{ hashFiles('packaging/windows/drivers/**', 'crates/pf-driver-proto/**') }}
restore-keys: drivers-target-
- name: Pack + sign installer
shell: pwsh
env:
+4 -1
View File
@@ -127,7 +127,10 @@ jobs:
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release)
shell: pwsh
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
# punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1
# requires (bf981027 added the requirement without the build — same gap 90c84ef4
# closed for deb).
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
- name: Pack + sign MSIX
shell: pwsh
Generated
+31 -30
View File
@@ -947,10 +947,11 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"pf-capture",
"pf-frame",
"pf-inject",
"pf-vdisplay",
"punktfunk-core",
@@ -1035,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2220,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.21.0"
version = "0.22.0"
[[package]]
name = "lazy_static"
@@ -2325,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"bindgen",
"cmake",
@@ -2360,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"punktfunk-core",
]
@@ -2849,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2870,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ash",
@@ -2895,7 +2896,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2913,7 +2914,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ash",
@@ -2934,7 +2935,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ash",
@@ -2958,7 +2959,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"ash",
"bindgen",
@@ -2967,7 +2968,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"libc",
@@ -2979,7 +2980,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2993,11 +2994,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.21.0"
version = "0.22.0"
[[package]]
name = "pf-inject"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3026,14 +3027,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ash",
@@ -3048,7 +3049,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3081,7 +3082,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3093,7 +3094,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ash",
@@ -3301,7 +3302,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3312,7 +3313,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"android_logger",
"jni",
@@ -3328,7 +3329,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3345,7 +3346,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3365,7 +3366,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3385,7 +3386,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3417,7 +3418,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3501,7 +3502,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3515,7 +3516,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"ksni",
@@ -3538,7 +3539,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -51,7 +51,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.21.0"
version = "0.22.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+67
View File
@@ -0,0 +1,67 @@
# Android CI builder: JDK 21 + Android SDK/NDK/CMake + pinned Rust with the three shipping
# Android targets + cargo-ndk + sccache. Everything android.yml used to download per run
# (~3 GB of NDK + SDK packages from Google, plus a from-source cargo-ndk build) is baked
# here instead; the image is content-keyed and rebuilt only when the ci/ tree changes
# (docker.yml `builders`).
#
# docker build -f ci/android-ci.Dockerfile -t punktfunk-android-ci ci
#
# Version pins mirror what android.yml installed via sdkmanager: AGP 9.3 wants JDK 1721;
# cmake;3.22.1 because kit/build.gradle.kts prepends $ANDROID_SDK/cmake/3.22.1/bin to PATH
# for cargo-ndk's audiopus_sys (libopus) CMake build; platforms;android-37 is deliberately
# absent (AGP auto-downloads it if a build ever needs it — same note as the old workflow).
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git unzip zip python3 openjdk-21-jdk-headless \
build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
# Android SDK: cmdline-tools must land under cmdline-tools/latest for sdkmanager to
# find its own root.
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk
ARG CMDLINE_TOOLS=13114758
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& curl -fsSL -o /tmp/clt.zip "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \
&& unzip -q /tmp/clt.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm /tmp/clt.zip
ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH
RUN yes | sdkmanager --licenses >/dev/null \
&& sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" \
"ndk;30.0.14904198" "cmake;3.22.1" \
&& chmod -R a+rX "$ANDROID_HOME"
# Toolchain shared across CI users (jobs may run as different uids) — same shape as
# rust-ci.Dockerfile, plus the Android cross targets and cargo-ndk. The registry/git
# download caches are stripped after the cargo-ndk install: jobs restore those from the
# shared actions cache, and baking them would only bloat every pull.
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
&& cargo install cargo-ndk --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo ndk --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
# the job container — no node, no checkout (exit 127; same lesson flatpak.yml documents for
# fedora:43). A separate trailing layer on purpose: appending here keeps the fat SDK/NDK
# layers above cache-valid instead of invalidating the whole build.
RUN apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version
+42
View File
@@ -0,0 +1,42 @@
# Arch CI builder: base-devel + every dependency arch.yml's two makepkg legs used to
# pacman-install per run (~1 GB of mirror traffic each time) + bun + sccache + nodejs
# (JS actions exec node INSIDE the job container — the same lesson as android-ci).
# Content-keyed and rebuilt only when the ci/ tree changes (docker.yml `builders`).
#
# docker build -f ci/arch-ci.Dockerfile -t punktfunk-arch-ci ci
#
# ROLLING-RELEASE TRADEOFF, on purpose: packages now build against the Arch snapshot
# from the last image rebuild instead of a fresh -Syu per run. That is the same staleness
# the gamescope cache already embraces ("a stale binary against newer system libs is the
# same risk the distro's own package carries between rebuilds"), and any ci/ edit — or
# bumping the date in this line (refreshed: 2026-07-29) — re-keys and re-snapshots it.
FROM docker.io/library/archlinux:base-devel
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
# deps (second list) — both copied verbatim from what arch.yml installed in-job, where
# they now no-op as `--needed` guards.
RUN pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive \
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
hwdata luajit seatd sdl2-compat vulkan-icd-loader \
xcb-util-errors xcb-util-wm xorg-xwayland \
meson glm wayland-protocols benchmark libxcursor \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
# bootstrap the official binary — once, here, instead of per run.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& rm -rf /root/.bun \
&& bun --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
@@ -18,10 +18,14 @@ import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.Gamepad
@@ -36,6 +40,42 @@ import io.unom.punktfunk.kit.security.KnownHostStore
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
/**
* Keeps ONE window-insets reader alive for as long as the app's UI exists — the fix for the menus
* coming back from a stream laid out against the WRONG safe area.
*
* Compose attaches its `OnApplyWindowInsets` and `WindowInsetsAnimation` callbacks when the first
* composable reads an inset, and removes them again when the last reader goes away
* (`WindowInsetsHolder.increment/decrementAccessors`). [StreamScreen] reads no insets at all — it's
* a bare full-screen surface — so a stream drops the reader count to zero for its whole duration.
*
* That alone is survivable; what isn't is a session that ends while the app is BACKGROUNDED, which
* is the common case (leaving the app ends the session — see StreamScreen's ON_STOP observer). The
* whole window restore — `show(systemBars())`, releasing the landscape lock — then runs on a stopped
* activity, and the corrected insets that follow arrive while Compose has no listener attached. When
* the menus recompose, `incrementAccessors` re-attaches and asks for a fresh pass, but a stopped
* window produces no dispatch, and on resume nothing has *changed* any more, so none ever comes.
* Compose keeps serving what it last saw: the landscape, bars-hidden values.
*
* That's exactly what the reporter's phone showed (on-glass 2026-07-29, verified by dump): the
* platform reported `bars=[0,162,0,72] cutout=[0,162,0,0]` for the window while the layout was still
* using the landscape immersive set — cutout `left=162` (Material3 lays out against
* `systemBars.union(displayCutout)`), bars all zero. Content shoved right by the landscape cutout,
* nothing kept clear of the status bar or the gesture pill, and no rotation or IME animation could
* shake it loose. A/B'd over eight runs of the real teardown sequence: 3 of 4 wrong without this,
* 4 of 4 correct with it.
*
* Reading an inset here holds the count above zero for the activity's whole life, so the listeners
* survive the stream and every dispatch lands. It subscribes to no inset VALUE (only the holder
* object), so it triggers no recomposition — the cost is one DisposableEffect.
*/
@Composable
private fun HoldWindowInsetsListeners() {
// The read itself IS the registration (the accessor is scoped to this composable, which never
// leaves the composition); `remember` is only what keeps it from being a value nobody uses.
remember(WindowInsets.systemBars) {}
}
class MainActivity : ComponentActivity() {
/**
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
@@ -207,6 +247,7 @@ class MainActivity : ComponentActivity() {
}
setContent {
PunktfunkTheme {
HoldWindowInsetsListeners()
// Focus hook for the SC2's synthetic navigation (see [sc2MoveFocus]). `Next` is
// the bootstrap: directional moves need an already-focused node, while one-
// dimensional traversal assigns initial focus when there is none.
@@ -32,7 +32,13 @@ class MouseForwarder(
private val handle: Long,
private val invertScroll: Boolean,
private val captureWanted: Boolean,
private val surfaceSize: () -> Pair<Int, Int>,
/**
* The picture's rect in WINDOW coordinates — where the letterboxed video actually sits, which is
* the frame absolute positions must be measured against. Events arrive from the activity's
* dispatch overrides in window coordinates, so a stream narrower than the panel needs the origin
* subtracted as well as the size divided; `null` while the surface isn't laid out yet.
*/
private val videoRect: () -> android.graphics.Rect?,
) {
/** Capture plumbing, owned by StreamScreen (the focusable capture view). */
var onRequestCapture: (() -> Unit)? = null
@@ -152,12 +158,16 @@ class MouseForwarder(
}
private fun sendAbs(ev: MotionEvent) {
val (w, h) = surfaceSize()
val r = videoRect() ?: return
val w = r.width()
val h = r.height()
if (w <= 0 || h <= 0) return
// Clamped into the picture: a pointer out on a letterbox bar has no host position of its
// own, and the edge is the honest answer for it.
NativeBridge.nativeSendPointerAbs(
handle,
ev.x.roundToInt().coerceIn(0, w - 1),
ev.y.roundToInt().coerceIn(0, h - 1),
(ev.x - r.left).roundToInt().coerceIn(0, w - 1),
(ev.y - r.top).roundToInt().coerceIn(0, h - 1),
w,
h,
)
@@ -26,6 +26,7 @@ import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -197,6 +198,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// below so the capture callbacks can reach the view once it exists.
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
// The video SurfaceView, hoisted for the same reason: the pointer paths built below map WINDOW
// coordinates onto the picture, and with a letterboxed stream that rect is the video's, not the
// panel's. Set when the view is created.
var videoView by remember { mutableStateOf<SurfaceView?>(null) }
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
wifiLocks.forEach { lock ->
@@ -256,7 +262,15 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
handle,
invertScroll = initialSettings.invertScroll,
captureWanted = initialSettings.mouseMode == MouseMode.CAPTURE,
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
// The picture's rect in window coordinates (see MouseForwarder.videoRect) — read live,
// so it is right from the frame the SurfaceView is first laid out.
videoRect = {
videoView?.takeIf { it.width > 0 && it.height > 0 }?.let { v ->
val loc = IntArray(2)
v.getLocationInWindow(loc)
android.graphics.Rect(loc[0], loc[1], loc[0] + v.width, loc[1] + v.height)
}
},
)
mouse.onRequestCapture = {
// The grab needs the (focusable) capture view: focus it, then ask. Posted so a
@@ -275,7 +289,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val remote = if (isTv) {
RemotePointer(
handle,
surfaceWidth = { decor?.width ?: 1920 },
surfaceWidth = { videoView?.width?.takeIf { it > 0 } ?: decor?.width ?: 1920 },
onActiveChanged = { on -> remotePointerOn = on },
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
)
@@ -423,11 +437,33 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
activity?.mouseForwarder?.engageFromStart()
}
Box(modifier = Modifier.fillMaxSize()) {
// Fit the picture to the stream's own aspect, letterboxing the rest in black. MediaCodec scales
// whatever it decodes to fill the Surface it renders into, so a 16:9 stream on a 20:9 panel came
// out stretched — the surface has to carry the aspect, because nothing downstream of it can.
// The mode is the negotiated one (known from the handshake, before the first frame); 0/absent —
// an older native lib — falls back to filling, i.e. exactly the previous behaviour.
val videoAspect = remember(handle) {
val size = NativeBridge.nativeVideoSize(handle)
val w = size?.getOrNull(0) ?: 0
val h = size?.getOrNull(1) ?: 0
if (w > 0 && h > 0) w.toFloat() / h.toFloat() else 0f
}
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
// One rect for the picture AND for the input that lands on it. Every absolute mapping —
// direct-pointer touch, multi-touch passthrough, the pen lane — measures against the size of
// the node it sits on, so putting the gesture layer on this same rect keeps all three correct
// by construction rather than by threading an offset through each of them. The cost is that
// trackpad swipes starting inside a letterbox bar don't register; the picture is the surface.
val videoFit = if (videoAspect > 0f) {
Modifier.align(Alignment.Center).aspectRatio(videoAspect)
} else {
Modifier.fillMaxSize()
}
AndroidView(
modifier = Modifier.fillMaxSize(),
modifier = videoFit,
factory = { ctx ->
SurfaceView(ctx).apply {
videoView = this
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
// Low-latency mode: rank MediaCodecList decoders for the negotiated
@@ -517,7 +553,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
}
Box(
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
videoFit.pointerInput(handle, touchMode) {
when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
else -> streamTouchInput(
@@ -69,9 +69,9 @@ data class HostMenuItem(
)
/**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Wake / Edit / Forget plus whatever [menuItems] adds. Tapping
* the card connects.
* A host as an Apple-style card: a colored avatar carrying the host's OS mark (its initial when we
* don't know the OS), name + address, a trust pill, and (for saved hosts) an overflow menu with
* Wake / Edit / Forget plus whatever [menuItems] adds. Tapping the card connects.
*
* [profileLabel] names the settings profile this card connects with. On a host's own card that is
* its default binding, drawn as a quiet chip — the card says what a tap will do. On a **pinned
@@ -84,7 +84,7 @@ fun HostCard(
address: String,
status: HostStatus,
online: Boolean = false,
/** OS-identity chain (mDNS `os` TXT / stored), for the address line's OS mark. "" = none. */
/** OS-identity chain (mDNS `os` TXT / stored), drawn as the avatar's mark. "" = the initial. */
os: String = "",
enabled: Boolean,
onConnect: () -> Unit,
@@ -129,7 +129,7 @@ fun HostCard(
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HostAvatar(name, online)
HostAvatar(name, online, os)
Spacer(Modifier.height(10.dp))
Text(
name,
@@ -138,28 +138,14 @@ fun HostCard(
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
Row(verticalAlignment = Alignment.CenterVertically) {
// The OS mark leads the address line; absent entirely for a host that
// doesn't advertise one, so those cards render exactly as they always did.
val osIcon = resolveOsIcon(os)
if (osIcon != null) {
Icon(
osIcon,
contentDescription = os,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
}
Text(
address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
}
Text(
address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
if (profileLabel != null || reserveProfileSlot) {
Spacer(Modifier.height(10.dp))
Box(
@@ -283,8 +269,9 @@ private val PROFILE_CHIP_SLOT = 26.dp
private val PRESENCE_ONLINE = Color(0xFF4ADE80)
/**
* The host's letter avatar (Apple-contact style) with its presence as a dot on the corner — the
* idiom every contact list already uses, and one fewer labelled badge on a small card.
* The host's avatar (Apple-contact style) with its presence as a dot on the corner — the idiom
* every contact list already uses, and one fewer labelled badge on a small card. It carries the
* host's OS mark when [os] resolves to one we ship, and the host's initial otherwise.
*
* [online] is true when the host advertises on mDNS OR answers the reachability probe, so a
* routed/VPN host that never advertises still reads as up. Online is a FILLED green dot, offline a
@@ -292,9 +279,10 @@ private val PRESENCE_ONLINE = Color(0xFF4ADE80)
* colour-blind reader and a screenshot in greyscale. TalkBack gets the word either way.
*/
@Composable
fun HostAvatar(name: String, online: Boolean = false) {
fun HostAvatar(name: String, online: Boolean = false, os: String = "") {
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val cardColor = CardDefaults.elevatedCardColors().containerColor
val osIcon = resolveOsIcon(os)
Box {
Box(
modifier = Modifier
@@ -303,11 +291,23 @@ fun HostAvatar(name: String, online: Boolean = false) {
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
// The OS mark IS the avatar when we know the OS — it identifies the machine better than
// the initial ever did, and it's the same circle, so a card whose host advertises no OS
// (or one we ship no mark for) keeps the letter and the row still reads as one set.
if (osIcon != null) {
Icon(
osIcon,
contentDescription = os,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
} else {
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Box(
modifier = Modifier
+5 -1
View File
@@ -47,7 +47,11 @@ dependencies {
// /README.md): `cargo install cargo-ndk` + `rustup target add aarch64-linux-android x86_64-linux-android`.
// ------------------------------------------------------------------------------------------------
val repoRoot = rootDir.parentFile.parentFile // clients/android -> clients -> repo root
val cargoBin = "${System.getProperty("user.home")}/.cargo/bin"
// CARGO_HOME first: rustup puts every binary in $CARGO_HOME/bin, and the CI image
// (ci/android-ci.Dockerfile) installs the shared toolchain at /usr/local/cargo — the
// historical ~/.cargo fallback is what a GUI Android Studio launch (no env) still needs.
val cargoBin = System.getenv("CARGO_HOME")?.let { "$it/bin" }
?: "${System.getProperty("user.home")}/.cargo/bin"
// SDK location without depending on AGP's DSL (sdkDirectory isn't in AGP 9's library extension):
// env first (set by Android Studio and by our CLI shell), then local.properties, then the default.
@@ -183,6 +183,14 @@ object NativeBridge {
*/
external fun nativeVideoMime(handle: Long): String
/**
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
* handshake, so it is known before the first frame — the stream view sizes itself to THIS
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
* Cheap; UI-safe.
*/
external fun nativeVideoSize(handle: Long): IntArray?
/**
* A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` /
* `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from
@@ -21,7 +21,10 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
use super::{
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
};
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
@@ -259,6 +262,10 @@ pub(super) fn run_async(
// first frame — the missed opening IDR — is caught by the same window.
let mut last_output = Instant::now();
let mut fed_at_output: u64 = 0;
// Nothing-ever-arrived backstop (see [`NO_VIDEO_PATIENCE`]) — the mirror of the one above, for a
// session whose video plane delivers no AU at all.
let started = Instant::now();
let mut last_no_video_req: Option<Instant> = None;
while !shutdown.load(Ordering::Relaxed) && !fatal {
// Block for the next event (idle wait — excluded from the work tally). The short timeout
@@ -388,6 +395,23 @@ pub(super) fn run_async(
last_output = now; // one request per patience window, not per iteration
fed_at_output = fed;
}
// Nothing has EVER arrived: not an idle stream but a session that never got a picture — the
// `starved` test above cannot see it, because it needs `fed` to have moved. Evaluated after
// `feed_ready`, so an AU that arrived this pass has either been fed or is parked in
// `pending_aus`; both mean video IS flowing.
let no_video_yet = fed == 0 && pending_aus.is_empty();
if no_video_yet
&& now.duration_since(started) >= NO_VIDEO_PATIENCE
&& last_no_video_req.is_none_or(|t| now.duration_since(t) >= NO_VIDEO_RETRY)
{
log::warn!(
"decode: no video received {} ms into the session — requesting a keyframe",
now.duration_since(started).as_millis()
);
last_no_video_req = Some(now);
let _ = client.request_keyframe();
last_kf_req = Some(now); // share the throttle with the loss-recovery path below
}
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0 || starved)
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{
+20
View File
@@ -62,6 +62,26 @@ const RENDERED_CAP: usize = 64;
/// costs half a second before it self-heals is not a bug the user reports.
const NO_OUTPUT_PATIENCE: std::time::Duration = std::time::Duration::from_millis(500);
/// How long a session may deliver NO access unit at all before we ask for a keyframe and say so.
///
/// [`NO_OUTPUT_PATIENCE`] covers "fed but silent", and it deliberately requires `fed` to have moved
/// so an idle stream never asks for anything. That leaves its mirror image uncovered: a session that
/// receives nothing whatsoever. A decoder cannot be starved of output when it was handed no input,
/// so no signal in either loop fires, and the session sits connected — audio, input and the control
/// plane all alive — behind a black surface with a HUD reading `0 fps · 0.0 Mb/s`, which is exactly
/// how it comes back in reports (2026-07-30).
///
/// Asking costs one small control message, and it is the right ask in the case we can actually fix:
/// the host is encoding, but under infinite GOP every picture it sends references an IDR this client
/// never saw. When the host is sending nothing at all, the request changes nothing — but the log line
/// beside it is what separates that from "we received AUs and lost them", which no previous black
/// screen report could tell us.
const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(1500);
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
+25 -1
View File
@@ -24,7 +24,10 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::{DecodeOptions, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
use super::{
DecodeOptions, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE, NO_VIDEO_RETRY,
PENDING_SPLIT_CAP,
};
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
@@ -150,6 +153,10 @@ pub(super) fn run_sync(
// missed opening IDR — is caught by the same window.
let mut last_output = Instant::now();
let mut fed_at_output: u64 = 0;
// Nothing-ever-arrived backstop (see [`NO_VIDEO_PATIENCE`]) — the mirror of the one above, for a
// session whose video plane delivers no AU at all.
let started = Instant::now();
let mut last_no_video_req: Option<Instant> = None;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
@@ -388,6 +395,23 @@ pub(super) fn run_sync(
last_output = now; // one request per patience window, not per iteration
fed_at_output = fed;
}
// Nothing has EVER arrived: not an idle stream but a session that never got a picture — the
// `starved` test above cannot see it, because it needs `fed` to have moved. `pending` holds
// an AU waiting for a free input buffer, so an empty one alongside `fed == 0` means the video
// plane has delivered nothing at all.
let no_video_yet = fed == 0 && pending.is_none();
if no_video_yet
&& now.duration_since(started) >= NO_VIDEO_PATIENCE
&& last_no_video_req.is_none_or(|t| now.duration_since(t) >= NO_VIDEO_RETRY)
{
log::warn!(
"decode: no video received {} ms into the session — requesting a keyframe",
now.duration_since(started).as_millis()
);
last_no_video_req = Some(now);
let _ = client.request_keyframe();
last_kf_req = Some(now); // share the throttle with the loss-recovery path below
}
if (gate.poll(client.frames_dropped(), now) || starved)
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{
+31 -1
View File
@@ -5,7 +5,7 @@ use jni::objects::JObject;
// Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
#[cfg(target_os = "android")]
use jni::objects::JString;
use jni::sys::{jboolean, jdoubleArray, jlong, jsize, jstring};
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
use jni::JNIEnv;
use super::{jni_guard, SessionHandle};
@@ -263,6 +263,36 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
})
}
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
/// on the host build too. Cheap; safe on the UI thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jintArray {
jni_guard(std::ptr::null_mut(), || {
if handle == 0 {
return std::ptr::null_mut();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mode = h.client.mode();
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
let arr = match env.new_int_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
};
if env.set_int_array_region(&arr, 0, &buf).is_err() {
return std::ptr::null_mut();
}
arr.into_raw()
})
}
/// `NativeBridge.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
/// HUD actually being visible: while disabled the decode thread skips the clock read + lock per AU.
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
@@ -54,6 +54,9 @@ private struct HomeTile: Identifiable {
var hasLibrary = false
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
var icon: String?
/// The host's OS-identity chain. When we ship art for it, the badge wears the OS mark instead
/// of the title's initial the same substitution the touch cards make.
var osChain: String?
/// Offline saved host we hold a MAC for (and WoL is available) activating it wakes first.
var canWake = false
let activate: () -> Void
@@ -284,6 +287,7 @@ struct GamepadHomeView: View {
// A pinned card is a shortcut, not a second host Y (library) stays on the
// host's own tile, where the host-level actions live.
hasLibrary: profile == nil,
osChain: host.osChain,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !online && !host.wakeMacs.isEmpty,
activate: {
@@ -297,6 +301,7 @@ struct GamepadHomeView: View {
title: d.name,
subtitle: "\(d.host):\(String(d.port))",
isOnline: true,
osChain: d.osChain,
activate: { connectDiscovered(d) })
}
let add = HomeTile(
@@ -420,6 +425,15 @@ private struct GamepadHostTile: View {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(Color.brand)
} else if let mark = osIconImage(for: tile.osChain) {
// The OS mark stands in for the initial (template asset tints like the text it
// replaces), and carries the label, since nothing else on the tile names the OS.
mark
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? .white : Color.brand)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
@@ -38,9 +38,16 @@ private func monogram(_ name: String) -> String {
return String(first).uppercased()
}
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
/// The squared host tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a tinted
/// outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
///
/// `mark` is the host's OS mark, and it REPLACES the monogram when we have one: it identifies the
/// machine better than its initial ever did, and on a row of similarly-named boxes the initial says
/// nothing the name beneath it doesn't already say. A host that advertises no OS chain or one we
/// ship no art for keeps its letter, so a mixed row still reads as one set.
private func monogramTile(
_ letter: String, osChain: String?, m: CardMetrics, connecting: Bool, filled: Bool
) -> some View {
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
return ZStack {
shape.fill(filled
@@ -50,6 +57,16 @@ private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, fi
: AnyShapeStyle(Color.brand.opacity(0.14)))
if connecting {
ProgressView().tint(filled ? .white : Color.brand)
} else if let mark = osIconImage(for: osChain) {
// Template asset tints from foregroundStyle exactly like the letter it stands in for.
// Labelled, because this is where the OS is now announced: it used to ride the status
// row below, which no longer carries it.
mark
.resizable()
.scaledToFit()
.frame(width: m.monogram, height: m.monogram)
.foregroundStyle(filled ? Color.white : Color.brand)
.accessibilityLabel(osChain ?? "")
} else {
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
@@ -133,7 +150,8 @@ struct HostCardView: View {
let m = CardMetrics.current
return Button(action: onConnect) {
HStack(spacing: m.spacing) {
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
monogramTile(monogram(host.displayName), osChain: host.osChain,
m: m, connecting: isConnecting, filled: true)
VStack(alignment: .leading, spacing: 4) {
// The chip rides the TITLE line, anchored to the card's trailing edge not
// trailing the name, where it read as part of the title, and not on a line of
@@ -295,16 +313,7 @@ struct HostCardView: View {
/// certificate is pinned (the lock state, spelled out).
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
HStack(spacing: 6) {
// The host's OS mark leads the row (template asset tints like an SF Symbol);
// absent entirely for a host that never advertised one, so those cards render
// exactly as they always did.
if let mark = osIconImage(for: host.osChain) {
mark
.resizable()
.scaledToFit()
.frame(width: m.status + 2, height: m.status + 2)
.accessibilityLabel(host.osChain ?? "")
}
// The OS mark used to lead this row; it is the tile's glyph now (see monogramTile).
RoundedRectangle(cornerRadius: 1.5)
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 6, height: 6)
@@ -364,7 +373,8 @@ struct DiscoveredCardView: View {
let m = CardMetrics.current
return Button(action: onConnect) {
HStack(spacing: m.spacing) {
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
monogramTile(monogram(discovered.name), osChain: discovered.osChain,
m: m, connecting: false, filled: false)
VStack(alignment: .leading, spacing: 4) {
Text(discovered.name)
.font(.geist(m.name, .bold, relativeTo: .title3))
@@ -375,14 +385,7 @@ struct DiscoveredCardView: View {
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 6) {
// Same leading OS mark as a saved card's status row live from the advert.
if let mark = osIconImage(for: discovered.osChain) {
mark
.resizable()
.scaledToFit()
.frame(width: m.status + 2, height: m.status + 2)
.accessibilityLabel(discovered.osChain)
}
// The advert's OS mark is the tile's glyph now (see monogramTile).
Image(systemName: discovered.requiresPairing
? "lock.fill" : "antenna.radiowaves.left.and.right")
.font(.system(size: m.status))
+4 -1
View File
@@ -116,7 +116,10 @@ shoot_sim() {
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
log "$prefix — building ($scheme)…"
local dd; dd="$(mktemp -d)"
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
# incremental build instead of cold-building into a throwaway tmpdir — CI pins this
# (apple.yml); local runs keep the self-cleaning mktemp default.
local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd"
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
CODE_SIGNING_ALLOWED=NO build >/dev/null \
+34 -21
View File
@@ -122,6 +122,13 @@ struct Args {
/// pointer-lock client expecting the HOST to composite the cursor into the video. Decode the
/// dump and look for the pointer — a cursorless dump is the bug this flag was built to catch.
cursor_capture: bool,
/// `--cursor-nochannel` — the same relative wiggle WITHOUT the cursor channel: no
/// `CLIENT_CAP_CURSOR`, no render-mode flip. The headless reproduction of a client LATCHED
/// in capture mode at connect (`console.rs` `latched_mouse` — it never advertises the
/// channel), the shape of the 2026-07 "no cursor in Mutter capture mode" field report. The
/// host must composite the metadata cursor on its own; decode the dump and look for the
/// pointer.
cursor_nochannel: bool,
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
/// seconds (default 4), print what's found, and exit. No connection is made.
discover: Option<u64>,
@@ -301,6 +308,7 @@ fn parse_args() -> Args {
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"),
}
}
@@ -845,32 +853,37 @@ async fn session(args: Args) -> Result<()> {
"SPEED TEST complete",
);
});
} else if args.cursor_capture {
// Capture-model cursor repro: flip the negotiated cursor channel to "host composites"
// and drive RELATIVE pointer motion, exactly like a pointer-lock client. Owns BOTH
// control halves: the host may send CursorShape (0x50) messages while the channel is
} else if args.cursor_capture || args.cursor_nochannel {
// Capture-model cursor repro. `--cursor-capture`: flip the negotiated cursor channel to
// "host composites" and drive RELATIVE pointer motion, exactly like a pointer-lock
// client. `--cursor-nochannel`: the same motion with NO channel at all (a
// capture-latched client) — the host must composite unprompted. Owns BOTH control
// halves: the host may send CursorShape (0x50) messages while a negotiated channel is
// still in its initial client-draws state, and dropping our recv half would fail those
// writes host-side.
let flip_channel = args.cursor_capture;
let mut cs = send;
let mut cr = recv;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
match io::write_msg(
&mut cs,
&CursorRenderMode {
client_draws: false,
}
.encode(),
)
.await
{
Ok(()) => tracing::info!(
"cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the host \
must now composite the pointer into the video"
),
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed");
return;
if flip_channel {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
match io::write_msg(
&mut cs,
&CursorRenderMode {
client_draws: false,
}
.encode(),
)
.await
{
Ok(()) => tracing::info!(
"cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the \
host must now composite the pointer into the video"
),
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed");
return;
}
}
}
// Drain (and just log) whatever the host still sends on the control stream.
+4
View File
@@ -47,13 +47,17 @@ windows = { version = "0.62", features = [
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Direct3D_Fxc",
"Win32_Graphics_Dwm",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
"Win32_System_Diagnostics_Etw",
"Win32_System_LibraryLoader",
"Win32_System_StationsAndDesktops",
"Win32_System_Memory",
"Win32_System_Performance",
"Win32_System_Threading",
"Win32_System_Time",
"Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
+225 -3
View File
@@ -54,6 +54,8 @@ use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
@@ -320,11 +322,17 @@ mod cursor_blend;
mod cursor_poll;
#[path = "idd_push/descriptor.rs"]
mod descriptor;
// Stall attribution (vdisplay-disturbance-immunity Phase A): the DxgKrnl ETW watch (A.3), the
// micro-probe engine (A.2), and the stall watch + verdict matrix that folds them (A.1).
#[path = "idd_push/dxgkrnl_etw.rs"]
mod dxgkrnl_etw;
#[path = "idd_push/probes.rs"]
mod probes;
#[path = "idd_push/stall.rs"]
mod stall;
use channel::ChannelBroker;
use descriptor::{DescriptorPoller, DisplayDescriptor};
use stall::StallWatch;
use stall::{StallEvidence, StallWatch};
pub struct IddPushCapturer {
device: ID3D11Device,
@@ -482,6 +490,17 @@ pub struct IddPushCapturer {
/// during active flow and warns when they turn metronomic — the sole-virtual-display
/// periodic-stutter diagnostic.
stall_watch: StallWatch,
/// v2 driver-telemetry trackers feeding [`stall::StallEvidence`]: the header's
/// `offered_total` as of the last fresh frame, and the stalest the driver's drain heartbeat
/// ever read (µs) while the host starved since then. Rolled at every fresh frame.
offered_at_fresh: u64,
max_hb_age_us: u64,
/// The Phase A.2 micro-probe engine (refcounted process singleton) — its window read rides
/// every stall report so the verdict matrix can name the disturbance class.
probes: Arc<probes::ProbeEngine>,
/// The Phase A.3 DxgKrnl ETW watch; `None` when the session can't start (non-admin dev run)
/// — reports then say `etw=unavailable`.
etw: Option<Arc<dxgkrnl_etw::EtwWatch>>,
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
@@ -532,6 +551,47 @@ impl IddPushCapturer {
}
}
/// Age of a driver-stamped QPC value in microseconds (QPC is system-wide, so cross-process
/// comparison is sound); 0 when the stamp reads ahead of us (a benign race with the writer).
fn qpc_age_us(stamp: u64) -> u64 {
static FREQ: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
let freq = *FREQ.get_or_init(|| {
let mut f = 0i64;
// SAFETY: plain FFI; `f` is a valid local out-param. The frequency is fixed at boot and
// cannot fail on any OS we run on; 0 would only mean the call failed — guarded below.
let _ = unsafe { QueryPerformanceFrequency(&mut f) };
f.max(0) as u64
});
if freq == 0 {
return 0;
}
let mut now = 0i64;
// SAFETY: plain FFI; `now` is a valid local out-param.
if unsafe { QueryPerformanceCounter(&mut now) }.is_err() {
return 0;
}
(now as u64).saturating_sub(stamp).saturating_mul(1_000_000) / freq
}
/// The header's v2 telemetry tail — `(drain_heartbeat_qpc, offered_total)`; `None` until a
/// telemetry-capable driver writes its first heartbeat (the host always creates the v2 layout,
/// so a zero heartbeat means the attached driver predates it).
#[inline]
fn telemetry(&self) -> Option<(u64, u64)> {
// SAFETY: like `latest` — the header stays mapped for the capturer's lifetime, both fields
// are 8-aligned `u64`s within the v2 layout the host itself created, and Relaxed suffices
// for best-effort diagnostics (the same contract the driver writes them under).
let (hb, offered) = unsafe {
(
(*(std::ptr::addr_of!((*self.header).drain_heartbeat_qpc) as *const AtomicU64))
.load(Ordering::Relaxed),
(*(std::ptr::addr_of!((*self.header).offered_total) as *const AtomicU64))
.load(Ordering::Relaxed),
)
};
(hb != 0).then_some((hb, offered))
}
/// Log the driver's status once it first reports (the only driver-visibility channel we have).
fn log_driver_status_once(&mut self) {
if self.status_logged {
@@ -1380,6 +1440,14 @@ impl IddPushCapturer {
);
}
}
// Stall-attribution evidence (v2 telemetry): record the STALEST the driver's drain
// heartbeat ever reads between fresh frames. A heartbeat that goes quiet for the hole
// convicts our worker (starved/dead WUDFHost); one that stays fresh through it acquits the
// driver and indicts the compose/present path. Two Relaxed loads + a QPC read per consume
// tick; rolled at every fresh frame below.
if let Some((hb, _)) = self.telemetry() {
self.max_hb_age_us = self.max_hb_age_us.max(Self::qpc_age_us(hb));
}
let latest = self.latest();
// `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish
// whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0
@@ -1529,10 +1597,38 @@ impl IddPushCapturer {
// the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was
// ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two
// counters were capturer fields that nothing else touched.
self.stall_watch.report(&stall, now);
let evidence = StallEvidence {
// A publisher re-attach restarts `offered_total` near zero; a ring recreate resets
// the stall watch before that can matter, but guard the delta anyway (a restarted
// counter reads as "frames offered since the restart", never as a u64 underflow).
offered_delta: self.telemetry().map(|(_, offered)| {
if offered >= self.offered_at_fresh {
offered - self.offered_at_fresh
} else {
offered
}
}),
max_heartbeat_age_ms: self.max_hb_age_us / 1_000,
// The probe + ETW reads span the same window the report's OS-event correlation
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
probes: now
.checked_sub(stall.gap + Duration::from_millis(300))
.map(|from| self.probes.window(from, now)),
etw: self.etw.as_ref().and_then(|w| {
now.checked_sub(stall.gap + Duration::from_millis(300))
.map(|from| w.summary(from, now))
}),
};
self.stall_watch.report(&stall, now, &evidence);
}
if !regen {
self.last_fresh = now; // feeds the driver-death watch
// A fresh driver frame: feed the driver-death watch and roll the stall-evidence
// trackers (a regen re-encodes OLD content — it is not evidence of driver progress).
self.last_fresh = now;
if let Some((_, offered)) = self.telemetry() {
self.offered_at_fresh = offered;
}
self.max_hb_age_us = 0;
}
// Build the frame. For PyroWave the encode input is the Y plane
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
@@ -2037,4 +2133,130 @@ mod tests {
"detection re-armed after the reset"
);
}
/// [`stall::attribute`]'s verdict table — the Branch-1/Branch-2 fork, per evidence shape.
#[test]
fn stall_attribution_verdicts() {
use super::stall::{attribute, StallVerdict};
let verdict = |gap_ms: u64, offered: Option<u64>, hb_age_ms: u64| {
attribute(
Duration::from_millis(gap_ms),
&StallEvidence {
offered_delta: offered,
max_heartbeat_age_ms: hb_age_ms,
probes: None,
etw: None,
},
)
};
// Pre-telemetry driver: no verdict, whatever the heartbeat tracker read.
assert_eq!(verdict(300, None, 500), StallVerdict::NoTelemetry);
// Heartbeat silent for most of the hole → the worker starved, wherever the frames were.
assert_eq!(verdict(600, Some(0), 400), StallVerdict::WorkerStalled);
assert_eq!(verdict(600, Some(50), 300), StallVerdict::WorkerStalled);
// A scheduled worker heartbeats every ≤16 ms — 200 ms of silence on a 300 ms gap is under
// the max(gap/2, 250 ms) bar, so the verdict falls through to the offered-frames fork.
assert_eq!(verdict(300, Some(1), 200), StallVerdict::ComposeSilence);
// The stall-ending frame (+ a small resume burst) does not acquit DWM...
assert_eq!(verdict(300, Some(3), 20), StallVerdict::ComposeSilence);
// ...but sustained composition through the hole does: the frames existed, WE lost them.
assert_eq!(verdict(300, Some(8), 20), StallVerdict::DeliveryLeg);
assert_eq!(verdict(2_000, Some(120), 30), StallVerdict::DeliveryLeg);
// Long holes scale the worker-stalled bar: 900 ms of silence on a 3 s gap is not half.
assert_eq!(verdict(3_000, Some(2), 900), StallVerdict::ComposeSilence);
assert_eq!(verdict(3_000, Some(2), 1_600), StallVerdict::WorkerStalled);
}
/// [`stall::classify`]'s verdict matrix — how the micro-probe window refines (or declines to
/// refine) the driver-telemetry verdict into a named disturbance class.
#[test]
fn stall_classification_matrix() {
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
let gap = Duration::from_millis(600);
let probes = |fence: Option<u64>, dwm: Option<u64>, flush: Option<u64>| ProbeWindow {
fence_max_us: fence,
dwm_tick_frozen_us: dwm,
dwm_flush_max_us: flush,
..ProbeWindow::default()
};
// The driver's own verdicts win outright — probes can't overrule "we lost the frames".
assert_eq!(
classify(
gap,
&StallVerdict::WorkerStalled,
Some(&probes(Some(500_000), None, None))
),
StallClass::OursWorker
);
assert_eq!(
classify(gap, &StallVerdict::DeliveryLeg, None),
StallClass::OursDelivery
);
// No probes: compose-silence alone can't name a class.
assert_eq!(
classify(gap, &StallVerdict::ComposeSilence, None),
StallClass::Unattributed
);
// Fences stalled ≥ gap/2 → the adapter froze — Class 1 (even without driver telemetry).
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(400_000), Some(400_000), None))
),
StallClass::AdapterFreeze
);
assert_eq!(
classify(
gap,
&StallVerdict::NoTelemetry,
Some(&probes(Some(400_000), None, None))
),
StallClass::AdapterFreeze
);
// Fences fine (16 ms round-trips) but DWM's tick froze — Class 2; DwmFlush counts too.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(500_000), None))
),
StallClass::CompositorBlocked
);
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(20_000), Some(450_000)))
),
StallClass::CompositorBlocked
);
// Everything alive + the driver swears E_PENDING → the frame-generation path.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(20_000), Some(30_000)))
),
StallClass::FrameGeneration
);
// Healthy probes but a pre-telemetry driver: delivery-leg is equally possible — honest.
assert_eq!(
classify(
gap,
&StallVerdict::NoTelemetry,
Some(&probes(Some(16_000), Some(20_000), None))
),
StallClass::Unattributed
);
// An absent probe (None) never reads as "stalled" — absence is stated, not guessed.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(None, Some(20_000), Some(30_000)))
),
StallClass::FrameGeneration
);
}
}
@@ -0,0 +1,366 @@
//! Phase A.3 DxgKrnl ETW correlation (stall attribution, `vdisplay-disturbance-immunity.md`
//! §4.3): a tiny real-time ETW session on `Microsoft-Windows-DxgKrnl`, event-id-filtered to the
//! five display-miniport DDI families whose servicing freezes the present path, so a stall report
//! can NAME the DDI (and its duration bracket) instead of saying "below Windows":
//!
//! - 150/151 `QueryChildStatus` start/stop — connector polling (the DDC/child-I/O class),
//! - 272 `IndicateChildStatus` — the miniport itself reporting a connector change,
//! - 154/155 `SetPowerState` start/stop — monitor/link power transitions (Class-1 servicing),
//! - 430 `SetTimingsFromVidPn` — modeset-class commits (Level-Two "hardware is idle" freezes),
//! - 1096/1097 `DisplayDetectControl` start/stop — present on newer builds; filtered in
//! unconditionally, harmless where absent.
//!
//! Kernel-side event-id filtering (`EVENT_FILTER_TYPE_EVENT_ID`) keeps the per-vblank firehose
//! off; the session costs a few events per minute. Starting a real-time session needs admin /
//! Performance Log Users — the packaged host (service, SYSTEM) has it; a plain dev run degrades
//! to `None` and every report says `etw=unavailable` instead of guessing. The session's
//! `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land AFTER that
//! stall's report line — the next report (and the metronomic tally) still carries it.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
use windows::core::{GUID, PWSTR};
use windows::Win32::Foundation::ERROR_SUCCESS;
use windows::Win32::System::Diagnostics::Etw::{
CloseTrace, ControlTraceW, EnableTraceEx2, OpenTraceW, ProcessTrace, StartTraceW,
CONTROLTRACE_HANDLE, ENABLE_TRACE_PARAMETERS, ENABLE_TRACE_PARAMETERS_VERSION_2,
EVENT_CONTROL_CODE_ENABLE_PROVIDER, EVENT_FILTER_DESCRIPTOR, EVENT_FILTER_TYPE_EVENT_ID,
EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, EVENT_TRACE_PROPERTIES,
EVENT_TRACE_REAL_TIME_MODE, PROCESSTRACE_HANDLE, PROCESS_TRACE_MODE_EVENT_RECORD,
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
};
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
/// `Microsoft-Windows-DxgKrnl` (`{802EC45A-1E99-4B83-9920-87C98277BA9D}`).
const DXGKRNL: GUID = GUID::from_u128(0x802EC45A_1E99_4B83_9920_87C98277BA9D);
/// The event ids the session filters IN (see the module docs).
const FILTER_IDS: [u16; 8] = [150, 151, 272, 154, 155, 430, 1096, 1097];
/// Session name — ours, stopped-if-stale at start (a crashed host leaves the session behind;
/// real-time sessions are machine-global named objects).
const SESSION: &str = "punktfunk-stallwatch-dxgkrnl";
/// The consumer callback's destination: `(event QPC, event id)`, capped. A few events per minute
/// in the field; the cap only matters under a detection storm — exactly when the tail is the
/// least interesting part.
static RING: Mutex<VecDeque<(i64, u16)>> = Mutex::new(VecDeque::new());
fn qpc_now() -> i64 {
let mut v = 0i64;
// SAFETY: plain FFI; `v` is a valid local out-param.
let _ = unsafe { QueryPerformanceCounter(&mut v) };
v
}
fn qpc_freq() -> i64 {
static FREQ: OnceLock<i64> = OnceLock::new();
*FREQ.get_or_init(|| {
let mut f = 0i64;
// SAFETY: plain FFI; `f` is a valid local out-param; fixed at boot.
let _ = unsafe { QueryPerformanceFrequency(&mut f) };
f.max(1)
})
}
/// The consumer's per-event callback — record id + QPC timestamp (the session's `ClientContext`
/// is 1, so `TimeStamp` IS a QPC value) and return; runs on the consumer thread.
unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
if record.is_null() {
return;
}
// SAFETY: `record` is the live event the consumer is delivering for the duration of this call.
let (id, ts) = unsafe {
(
(*record).EventHeader.EventDescriptor.Id,
(*record).EventHeader.TimeStamp,
)
};
let mut ring = RING.lock().unwrap();
if ring.len() == 2048 {
ring.pop_front();
}
ring.push_back((ts, id));
}
/// A live DxgKrnl watch: the controller handle (stops the session on drop) + the consumer handle.
pub(super) struct EtwWatch {
session: CONTROLTRACE_HANDLE,
consumer: PROCESSTRACE_HANDLE,
}
// SAFETY: both fields are plain kernel handle VALUES (u64 wrappers) owned by this watch; every
// operation on them (summary reads the static ring; Drop stops/closes) is thread-safe by the ETW
// API contract, and the singleton hands out only `Arc<EtwWatch>`.
unsafe impl Send for EtwWatch {}
// SAFETY: as above — `&EtwWatch` exposes only `summary` (static-ring reads).
unsafe impl Sync for EtwWatch {}
static WATCH: Mutex<Weak<EtwWatch>> = Mutex::new(Weak::new());
/// The process-wide watch, started on first use; `None` when the session cannot start (no admin
/// rights on a dev run) — callers report `etw=unavailable` rather than guessing.
pub(super) fn acquire() -> Option<Arc<EtwWatch>> {
let mut g = WATCH.lock().unwrap();
if let Some(w) = g.upgrade() {
return Some(w);
}
let w = Arc::new(EtwWatch::start()?);
*g = Arc::downgrade(&w);
Some(w)
}
/// An `EVENT_TRACE_PROPERTIES` allocation with the session-name space ETW writes into appended —
/// the canonical controller-buffer pattern.
fn properties_buffer() -> (Vec<u8>, usize) {
let base = std::mem::size_of::<EVENT_TRACE_PROPERTIES>();
let total = base + (SESSION.len() + 1) * 2;
(vec![0u8; total], base)
}
impl EtwWatch {
fn start() -> Option<Self> {
let name: Vec<u16> = SESSION.encode_utf16().chain([0]).collect();
// A stale session from a crashed host blocks StartTrace with ERROR_ALREADY_EXISTS — stop
// it by name first (fails benignly when there is none).
let (mut stop_buf, _) = properties_buffer();
// SAFETY: `stop_buf` is a live, zeroed, correctly-sized properties allocation; the name is
// a live nul-terminated wide string; a session handle of 0 + name = control-by-name.
unsafe {
let props = stop_buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
(*props).Wnode.BufferSize = stop_buf.len() as u32;
let _ = ControlTraceW(
CONTROLTRACE_HANDLE::default(),
PWSTR(name.as_ptr() as *mut _),
props,
EVENT_TRACE_CONTROL_STOP,
);
}
let (mut buf, base) = properties_buffer();
let mut session = CONTROLTRACE_HANDLE::default();
// SAFETY: `buf` is a live, zeroed allocation of base + name bytes; every write below is a
// field of the properties struct at its head; `LoggerNameOffset = base` points at the
// appended name space (ETW copies the name there itself). ClientContext 1 = QPC clock —
// what makes event timestamps comparable to our probe windows.
let rc = unsafe {
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
(*props).Wnode.BufferSize = buf.len() as u32;
(*props).Wnode.Flags = WNODE_FLAG_TRACED_GUID;
(*props).Wnode.ClientContext = 1;
(*props).LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
(*props).BufferSize = 64;
(*props).MinimumBuffers = 2;
(*props).MaximumBuffers = 4;
(*props).FlushTimer = 1;
(*props).LoggerNameOffset = base as u32;
StartTraceW(&mut session, PWSTR(name.as_ptr() as *mut _), props)
};
if rc != ERROR_SUCCESS {
tracing::debug!(
rc = rc.0,
"DxgKrnl ETW session unavailable (needs admin / Performance Log Users) — \
stall reports will say etw=unavailable"
);
return None;
}
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
// vblank/DPC keywords never reach us.
let mut filter = Vec::with_capacity(4 + FILTER_IDS.len() * 2);
filter.extend_from_slice(&[1u8, 0u8]); // FilterIn = TRUE, Reserved
filter.extend_from_slice(&(FILTER_IDS.len() as u16).to_le_bytes());
for id in FILTER_IDS {
filter.extend_from_slice(&id.to_le_bytes());
}
let mut desc = EVENT_FILTER_DESCRIPTOR {
Ptr: filter.as_ptr() as u64,
Size: filter.len() as u32,
Type: EVENT_FILTER_TYPE_EVENT_ID,
};
let params = ENABLE_TRACE_PARAMETERS {
Version: ENABLE_TRACE_PARAMETERS_VERSION_2,
EnableProperty: 0,
ControlFlags: 0,
SourceId: GUID::zeroed(),
EnableFilterDesc: &mut desc,
FilterDescCount: 1,
};
// SAFETY: `session` is the live handle just started; `params`/`desc`/`filter` are live
// locals for this synchronous call (the kernel copies the filter).
let rc = unsafe {
EnableTraceEx2(
session,
&DXGKRNL,
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
TRACE_LEVEL_INFORMATION as u8,
0,
0,
0,
Some(&params),
)
};
if rc != ERROR_SUCCESS {
tracing::debug!(
rc = rc.0,
"DxgKrnl ETW enable failed — stopping the session"
);
let (mut buf, _) = properties_buffer();
// SAFETY: live handle + valid properties allocation, stopped exactly once on this path.
unsafe {
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
(*props).Wnode.BufferSize = buf.len() as u32;
let _ = ControlTraceW(session, PWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
}
return None;
}
let mut log = EVENT_TRACE_LOGFILEW {
LoggerName: PWSTR(name.as_ptr() as *mut _),
..Default::default()
};
log.Anonymous1.ProcessTraceMode =
PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
log.Anonymous2.EventRecordCallback = Some(on_event);
// SAFETY: `log` is a fully-initialized local; `name` outlives the call (OpenTrace copies
// what it needs before returning).
let consumer = unsafe { OpenTraceW(&mut log) };
if consumer.Value == u64::MAX {
tracing::debug!("DxgKrnl ETW OpenTrace failed — stopping the session");
let (mut buf, _) = properties_buffer();
// SAFETY: live handle + valid properties allocation, stopped exactly once on this path.
unsafe {
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
(*props).Wnode.BufferSize = buf.len() as u32;
let _ = ControlTraceW(session, PWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
}
return None;
}
// The consumer: ProcessTrace blocks for the session's lifetime; Drop's STOP unblocks it.
let consumer_value = consumer.Value;
if let Err(e) = std::thread::Builder::new()
.name("pf-etw-dxgkrnl".into())
.spawn(move || {
// SAFETY: `consumer_value` is the live consumer handle opened above; ProcessTrace
// pumps it until the controller stops the session (Drop), then returns.
let rc = unsafe {
ProcessTrace(
&[PROCESSTRACE_HANDLE {
Value: consumer_value,
}],
None,
None,
)
};
tracing::debug!(rc = rc.0, "DxgKrnl ETW consumer exited");
})
{
tracing::debug!(error = %e, "DxgKrnl ETW consumer thread failed to spawn");
let (mut buf, _) = properties_buffer();
// SAFETY: live handles + valid properties allocation, released exactly once on this path.
unsafe {
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
(*props).Wnode.BufferSize = buf.len() as u32;
let _ = ControlTraceW(session, PWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
let _ = CloseTrace(consumer);
}
return None;
}
tracing::debug!("DxgKrnl ETW stall-watch session live (event-id filtered)");
Some(Self { session, consumer })
}
/// Summarize the DDI activity inside `[from, to]` — the correlation line a stall report
/// carries. Brackets that merely SPAN the window count too (a freeze-long `SetPowerState`
/// has both edges outside the hole it caused). `"none"` when the window is clean.
pub(super) fn summary(&self, from: Instant, to: Instant) -> String {
// Instant → QPC: anchor both clocks now and offset backwards.
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
let events: Vec<(i64, u16)> = {
let ring = RING.lock().unwrap();
ring.iter().filter(|(ts, _)| *ts <= to_q).copied().collect()
};
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
let mut parts = Vec::new();
for (start_id, stop_id, label) in [
(150u16, 151u16, "QueryChildStatus"),
(154, 155, "SetPowerState"),
(1096, 1097, "DisplayDetectControl"),
] {
let mut open: Option<i64> = None;
let mut count = 0u32;
let mut max_ms = 0i64;
let mut still_open = false;
for &(ts, id) in &events {
if id == start_id {
open = Some(ts);
} else if id == stop_id {
if let Some(s) = open.take() {
// The bracket [s, ts] counts when it intersects the window.
if s <= to_q && ts >= from_q {
count += 1;
max_ms = max_ms.max(ms(ts - s));
}
}
}
}
if let Some(s) = open {
if s <= to_q {
count += 1;
max_ms = max_ms.max(ms(to_q - s));
still_open = true;
}
}
if count > 0 {
parts.push(format!(
"{label}×{count}(max {max_ms}ms{})",
if still_open { ", open" } else { "" }
));
}
}
for (id, label) in [
(272u16, "IndicateChildStatus"),
(430, "SetTimingsFromVidPn"),
] {
let count = events
.iter()
.filter(|(ts, i)| *i == id && *ts >= from_q && *ts <= to_q)
.count();
if count > 0 {
parts.push(format!("{label}×{count}"));
}
}
if parts.is_empty() {
"none".to_string()
} else {
parts.join(" ")
}
}
}
/// A `Duration` in QPC ticks (saturating; diagnostic precision).
fn duration_qpc(d: Duration, freq: i64) -> i64 {
(d.as_micros() as i64).saturating_mul(freq) / 1_000_000
}
impl Drop for EtwWatch {
fn drop(&mut self) {
let (mut buf, _) = properties_buffer();
// SAFETY: `self.session`/`self.consumer` are the live handles this watch owns; the STOP
// (with a valid properties allocation) ends the session and unblocks ProcessTrace, and
// CloseTrace releases the consumer — each exactly once, here.
unsafe {
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
(*props).Wnode.BufferSize = buf.len() as u32;
let _ = ControlTraceW(self.session, PWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
let _ = CloseTrace(self.consumer);
}
}
}
@@ -633,6 +633,10 @@ impl IddPushCapturer {
last_liveness: Instant::now(),
last_kick: Instant::now(),
stall_watch: StallWatch::new(),
offered_at_fresh: 0,
max_hb_age_us: 0,
probes: super::probes::acquire(),
etw: super::dxgkrnl_etw::acquire(),
out_ring: Vec::new(),
out_idx: 0,
video_conv: None,
@@ -0,0 +1,573 @@
//! Phase A.2 micro-probes (stall attribution, `vdisplay-disturbance-immunity.md` §4.2): a small
//! engine of watchdogged sacrificial threads that continuously measure the legs a capture stall
//! could hide in, so the stall reporter can read back "what was alive during the hole":
//!
//! - **fence** (one thread per hardware adapter): a tiny CopyResource + D3D11 fence round-trip —
//! engine liveness. A Level-Two/Three adapter freeze ("graphics hardware is idle") stalls this
//! for the freeze's duration on EVERY engine of that adapter.
//! - **dwm-tick**: `DwmGetCompositionTimingInfo(NULL)` `cRefresh` advance — the compositor's own
//! clock. Frozen tick with live fences = DWM (or something it waits on) is blocked, not the GPU.
//! - **dwm-flush**: a watchdogged `DwmFlush` — its latency IS the composition-wait measurement.
//! - **scanline**: `D3DKMTGetScanLine` on an active output (Level-Zero reentrant — documented safe
//! against every miniport lock, so a BLOCKED call here convicts the KMD itself). Prefers a
//! physical head; on an exclusive topology only our IDD is active and the call's latency is
//! still the KMD-liveness signal ([`pf_win_display::win_display::active_scanline_target`]).
//! - **cpu**: a high-res sleeper measuring overshoot — the DPC-storm / CPU-starvation
//! discriminator (a machine-wide scheduling hole inflates every other probe too).
//!
//! The blocking of a probe is its measurement — none of these ever run on the capture/encode
//! path. Threads are DETACHED (never joined): a probe wedged inside a kernel call for the stall's
//! duration exits at its next loop turn after the stop flag flips. One engine per process
//! ([`acquire`]), refcounted across parallel capturers; probes sample at 20 Hz or slower and cost
//! microseconds each, so the engine is invisible next to a streaming session.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::{Duration, Instant};
use windows::core::{s, w, Interface};
use windows::Win32::Foundation::{CloseHandle, HANDLE, HMODULE, HWND, LUID};
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, ID3D11Device5, ID3D11DeviceContext4, ID3D11Fence, ID3D11Texture2D,
D3D11_CREATE_DEVICE_FLAG, D3D11_FENCE_FLAG_NONE, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
D3D11_USAGE_DEFAULT,
};
use windows::Win32::Graphics::Dwm::{DwmFlush, DwmGetCompositionTimingInfo, DWM_TIMING_INFO};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
};
use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
use super::stall::ProbeWindow;
/// One probe's sample ring: `(completed_at, span, value_us)` — `value` is the measurement (a call
/// latency or a frozen-span/overshoot), `span` the wall interval it describes ending at
/// `completed_at`. Capped; ~20 Hz per probe → several minutes of coverage.
struct Ring {
samples: Mutex<VecDeque<(Instant, Duration, u64)>>,
}
impl Ring {
fn new() -> Self {
Self {
samples: Mutex::new(VecDeque::with_capacity(512)),
}
}
fn push(&self, completed_at: Instant, span: Duration, value_us: u64) {
let mut s = self.samples.lock().unwrap();
if s.len() == 512 {
s.pop_front();
}
s.push_back((completed_at, span, value_us));
}
/// Max `value` among samples whose described interval `[completed_at - span, completed_at]`
/// intersects `[from, to]`; `None` when the ring holds nothing for the window (probe absent,
/// or it started after the window).
fn window_max(&self, from: Instant, to: Instant) -> Option<u64> {
let s = self.samples.lock().unwrap();
let mut max: Option<u64> = None;
for (end, span, value) in s.iter() {
let start = end.checked_sub(*span).unwrap_or(*end);
if start <= to && *end >= from {
max = Some(max.map_or(*value, |m| m.max(*value)));
}
}
max
}
}
/// A blocking probe's "currently inside the call since" marker: a call still blocked when the
/// reporter reads the window is exactly the interesting case, and its ring sample does not exist
/// yet — the marker's age stands in for it.
struct InFlight {
since: Mutex<Option<Instant>>,
}
impl InFlight {
fn new() -> Self {
Self {
since: Mutex::new(None),
}
}
fn enter(&self) -> Instant {
let now = Instant::now();
*self.since.lock().unwrap() = Some(now);
now
}
fn exit(&self) {
*self.since.lock().unwrap() = None;
}
/// Age (µs) of a call still in flight that started before `to`; 0 when idle.
fn blocked_us(&self, to: Instant) -> u64 {
match *self.since.lock().unwrap() {
Some(since) if since <= to => to.duration_since(since).as_micros() as u64,
_ => 0,
}
}
}
/// A blocking probe = ring + in-flight marker; `window_max` folds both.
struct BlockingProbe {
ring: Ring,
inflight: InFlight,
}
impl BlockingProbe {
fn new() -> Self {
Self {
ring: Ring::new(),
inflight: InFlight::new(),
}
}
fn measure(&self, f: impl FnOnce()) {
let t0 = self.inflight.enter();
f();
let dur = t0.elapsed();
self.inflight.exit();
self.ring.push(Instant::now(), dur, dur.as_micros() as u64);
}
fn window_max(&self, from: Instant, to: Instant) -> Option<u64> {
let ring = self.ring.window_max(from, to);
let blocked = self.inflight.blocked_us(to);
match (ring, blocked) {
(None, 0) => None,
(r, b) => Some(r.unwrap_or(0).max(b)),
}
}
}
struct Inner {
stop: AtomicBool,
/// One per hardware adapter, labelled by LUID (hybrids run two).
fences: Vec<(String, BlockingProbe)>,
dwm_tick: Ring,
dwm_flush: BlockingProbe,
scanline: BlockingProbe,
/// Whether the scanline probe currently targets a PHYSICAL head (see the module docs).
scanline_physical: AtomicBool,
scanline_running: AtomicBool,
cpu: Ring,
}
/// The process-wide micro-probe engine. Hold the `Arc` for the session's lifetime; the last drop
/// stops every thread at its next loop turn.
pub(super) struct ProbeEngine {
inner: Arc<Inner>,
}
impl Drop for ProbeEngine {
fn drop(&mut self) {
self.inner.stop.store(true, Ordering::Relaxed);
}
}
static ENGINE: Mutex<Weak<ProbeEngine>> = Mutex::new(Weak::new());
/// The engine, started on first use and shared across parallel capturers (probe threads are
/// per-PROCESS facts — two capturers asking the same questions twice would only add noise).
pub(super) fn acquire() -> Arc<ProbeEngine> {
let mut g = ENGINE.lock().unwrap();
if let Some(e) = g.upgrade() {
return e;
}
let e = Arc::new(ProbeEngine::start());
*g = Arc::downgrade(&e);
e
}
impl ProbeEngine {
/// What the probes saw across `[from, to]` — the stall reporter's evidence read. Cheap: five
/// short mutex-held ring scans.
pub(super) fn window(&self, from: Instant, to: Instant) -> ProbeWindow {
let i = &self.inner;
ProbeWindow {
fence_max_us: i
.fences
.iter()
.filter_map(|(_, p)| p.window_max(from, to))
.max(),
dwm_tick_frozen_us: i.dwm_tick.window_max(from, to),
dwm_flush_max_us: i.dwm_flush.window_max(from, to),
scanline_max_us: if i.scanline_running.load(Ordering::Relaxed) {
i.scanline.window_max(from, to)
} else {
None
},
scanline_physical: i.scanline_physical.load(Ordering::Relaxed),
cpu_max_overshoot_us: i.cpu.window_max(from, to),
}
}
fn start() -> Self {
let fences = enumerate_hardware_adapters()
.into_iter()
.map(|(label, _)| (label, BlockingProbe::new()))
.collect();
let inner = Arc::new(Inner {
stop: AtomicBool::new(false),
fences,
dwm_tick: Ring::new(),
dwm_flush: BlockingProbe::new(),
scanline: BlockingProbe::new(),
scanline_physical: AtomicBool::new(false),
scanline_running: AtomicBool::new(false),
cpu: Ring::new(),
});
// Fence probes re-enumerate to pair each adapter with its probe slot by index (the two
// enumerations run back-to-back; a hot-plugged GPU between them only skips a probe).
for (idx, (label, adapter)) in enumerate_hardware_adapters().into_iter().enumerate() {
let inner_t = inner.clone();
spawn_detached(&format!("pf-probe-fence-{idx}"), move || {
fence_probe_loop(&inner_t, idx, &label, &adapter);
});
}
let inner_t = inner.clone();
spawn_detached("pf-probe-dwm-tick", move || dwm_tick_loop(&inner_t));
let inner_t = inner.clone();
spawn_detached("pf-probe-dwm-flush", move || dwm_flush_loop(&inner_t));
let inner_t = inner.clone();
spawn_detached("pf-probe-scanline", move || scanline_loop(&inner_t));
let inner_t = inner.clone();
spawn_detached("pf-probe-cpu", move || cpu_sentinel_loop(&inner_t));
tracing::debug!(
fence_probes = inner.fences.len(),
"stall-attribution micro-probes started (fence/dwm-tick/dwm-flush/scanline/cpu)"
);
Self { inner }
}
}
/// Spawn a detached probe thread — never joined by design (see the module docs).
fn spawn_detached(name: &str, f: impl FnOnce() + Send + 'static) {
if let Err(e) = std::thread::Builder::new().name(name.into()).spawn(f) {
tracing::debug!(name, error = %e, "micro-probe thread failed to spawn — probe absent");
}
}
/// Hardware (non-software) DXGI adapters, labelled by LUID. Display-only/indirect adapters are
/// filtered later by their device-creation failure, not here.
fn enumerate_hardware_adapters() -> Vec<(String, IDXGIAdapter1)> {
let mut out = Vec::new();
// SAFETY: plain factory creation; the result is checked.
let Ok(factory) = (unsafe { CreateDXGIFactory1::<IDXGIFactory1>() }) else {
return out;
};
for i in 0.. {
// SAFETY: `factory` is live; enumeration ends at DXGI_ERROR_NOT_FOUND (the Err arm).
let Ok(adapter) = (unsafe { factory.EnumAdapters1(i) }) else {
break;
};
// SAFETY: `adapter` is live; `desc` is a valid out-param.
let Ok(desc) = (unsafe { adapter.GetDesc1() }) else {
continue;
};
if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 != 0 {
continue;
}
out.push((
format!(
"{:08x}:{:08x}",
desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
),
adapter,
));
}
out
}
/// One adapter's engine-liveness loop: submit a trivial copy, signal a fence, wait on its event.
/// The wait latency is the sample. Device-creation failure = probe absent for this adapter
/// (display-only/indirect adapters land here).
fn fence_probe_loop(inner: &Inner, idx: usize, label: &str, adapter: &IDXGIAdapter1) {
let Some((context, ctx4, fence, event, (a, b))) = fence_probe_setup(adapter) else {
tracing::debug!(
adapter = label,
"fence probe: no device on this adapter — absent"
);
return;
};
let mut value = 0u64;
let Some((_, probe)) = inner.fences.get(idx) else {
return;
};
while !inner.stop.load(Ordering::Relaxed) {
value += 1;
probe.measure(|| {
// SAFETY: all COM objects are live for this loop's lifetime (owned above); `a`/`b`
// are same-device, same-desc textures; the event handle is ours. The fence signal
// orders after the queued copy, so the wait measures the engine actually processing
// work; the 10 s ceiling only bounds a truly wedged adapter (the timeout sample still
// records — that IS the measurement).
unsafe {
context.CopyResource(&a, &b);
let _ = ctx4.Signal(&fence, value);
if fence.SetEventOnCompletion(value, event).is_ok() {
let _ = WaitForSingleObject(event, 10_000);
}
}
});
std::thread::sleep(Duration::from_millis(100));
}
// SAFETY: the loop exited; this thread owns `event` and closes it exactly once.
unsafe {
let _ = CloseHandle(event);
}
}
/// The fence probe's D3D plumbing: a device on `adapter`, its fence, the wait event, and two tiny
/// textures kept alive for the copies. `None` when any piece is unavailable (pre-FL11 or
/// display-only adapters, fence-less runtimes).
#[allow(clippy::type_complexity)]
fn fence_probe_setup(
adapter: &IDXGIAdapter1,
) -> Option<(
windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext,
ID3D11DeviceContext4,
ID3D11Fence,
HANDLE,
(ID3D11Texture2D, ID3D11Texture2D),
)> {
let mut device = None;
let mut context = None;
// SAFETY: adapter is live; UNKNOWN driver type is the documented mode for an explicit
// adapter; out-params are valid locals checked below.
unsafe {
D3D11CreateDevice(
adapter,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_FLAG(0),
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.ok()?;
}
let device = device?;
let context = context?;
let device5: ID3D11Device5 = device.cast().ok()?;
let ctx4: ID3D11DeviceContext4 = context.cast().ok()?;
let desc = D3D11_TEXTURE2D_DESC {
Width: 16,
Height: 16,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
..Default::default()
};
let mut a = None;
let mut b = None;
// SAFETY: `device` is live, `desc` fully initialized, out-params valid locals checked below.
unsafe {
device.CreateTexture2D(&desc, None, Some(&mut a)).ok()?;
device.CreateTexture2D(&desc, None, Some(&mut b)).ok()?;
}
let (a, b) = (a?, b?);
let mut fence = None;
// SAFETY: `device5` is live; out-param is a valid local checked below.
unsafe {
device5
.CreateFence(0, D3D11_FENCE_FLAG_NONE, &mut fence)
.ok()?;
}
let fence: ID3D11Fence = fence?;
// SAFETY: plain event creation (auto-reset, unnamed); checked below, closed by the loop.
let event = unsafe { CreateEventW(None, false, false, None) }.ok()?;
Some((context, ctx4, fence, event, (a, b)))
}
/// `cRefresh` advance sampling: each tick records how long the compositor's frame counter has been
/// unchanged. A frozen span covering a stall (with live fences) = DWM blocked, not the GPU.
fn dwm_tick_loop(inner: &Inner) {
let mut last_refresh = 0u64;
let mut last_change = Instant::now();
while !inner.stop.load(Ordering::Relaxed) {
let mut info = DWM_TIMING_INFO {
cbSize: std::mem::size_of::<DWM_TIMING_INFO>() as u32,
..Default::default()
};
// SAFETY: a NULL hwnd asks for composition-wide timing; `info` is a valid local with
// `cbSize` stamped (the API's byte-count contract).
if unsafe { DwmGetCompositionTimingInfo(HWND::default(), &mut info) }.is_ok() {
let now = Instant::now();
if info.cRefresh != last_refresh {
last_refresh = info.cRefresh;
last_change = now;
}
let frozen = now.duration_since(last_change);
inner.dwm_tick.push(now, frozen, frozen.as_micros() as u64);
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// Watchdogged `DwmFlush`: blocks until the next composition — the wait IS the measurement.
fn dwm_flush_loop(inner: &Inner) {
while !inner.stop.load(Ordering::Relaxed) {
inner.dwm_flush.measure(|| {
// SAFETY: no arguments, no state — the call blocks until DWM composes (or fails
// immediately when composition is unavailable; both durations are valid samples).
let _ = unsafe { DwmFlush() };
});
std::thread::sleep(Duration::from_millis(100));
}
}
/// `D3DKMT_OPENADAPTERFROMLUID` (d3dkmthk.h): LUID in, kernel adapter handle out.
#[repr(C)]
struct D3dkmtOpenAdapterFromLuid {
adapter_luid: LUID,
h_adapter: u32,
}
/// `D3DKMT_GETSCANLINE` (d3dkmthk.h): `InVerticalBlank` is a C `BOOLEAN` (u8) — the pad bytes
/// before the 4-aligned `scan_line` mirror the C layout exactly (size assert below).
#[repr(C)]
struct D3dkmtGetScanline {
h_adapter: u32,
vidpn_source_id: u32,
in_vertical_blank: u8,
_pad: [u8; 3],
scan_line: u32,
}
/// `D3DKMT_CLOSEADAPTER` (d3dkmthk.h).
#[repr(C)]
struct D3dkmtCloseAdapter {
h_adapter: u32,
}
const _: () = {
assert!(std::mem::size_of::<D3dkmtOpenAdapterFromLuid>() == 12);
assert!(std::mem::size_of::<D3dkmtGetScanline>() == 16);
assert!(std::mem::size_of::<D3dkmtCloseAdapter>() == 4);
};
type D3dkmtFn<T> = unsafe extern "system" fn(*mut T) -> i32;
/// Resolve a `D3DKMT*` entry point from gdi32 (no stable windows-rs bindings — the same
/// GetProcAddress route as pf-frame's scheduling-priority call).
///
/// # Safety
/// `T` must be the exact d3dkmthk.h argument struct for `name` — the transmute binds the
/// signature, and the layout asserts above pin the three structs this module uses.
unsafe fn d3dkmt<T>(name: windows::core::PCSTR) -> Option<D3dkmtFn<T>> {
// SAFETY: gdi32 is a permanent system module in any process that touched GDI/D3D; the name is
// a static nul-terminated literal from the caller.
let gdi32 = unsafe { GetModuleHandleW(w!("gdi32.dll")) }.ok()?;
// SAFETY: `gdi32` is the live module handle just obtained.
let p = unsafe { GetProcAddress(gdi32, name) }?;
// SAFETY: the caller's contract (doc above) — `p` is the named export, whose ABI is
// `NTSTATUS fn(struct*)` for every D3DKMT entry point this module names.
Some(unsafe { std::mem::transmute::<unsafe extern "system" fn() -> isize, D3dkmtFn<T>>(p) })
}
/// Level-Zero KMD-liveness loop: `D3DKMTGetScanLine` against an active output, retargeted every
/// ~2 s (topology moves mid-session). The call's LATENCY is the signal; a blocked Level-Zero DDI
/// convicts the KMD below every OS lever.
fn scanline_loop(inner: &Inner) {
// SAFETY: `D3dkmtOpenAdapterFromLuid` is the exact d3dkmthk.h struct for this export
// (layout-asserted above) — the `d3dkmt` safety contract.
let open = unsafe { d3dkmt::<D3dkmtOpenAdapterFromLuid>(s!("D3DKMTOpenAdapterFromLuid")) };
// SAFETY: `D3dkmtGetScanline` is the exact d3dkmthk.h struct for this export (asserted above).
let get = unsafe { d3dkmt::<D3dkmtGetScanline>(s!("D3DKMTGetScanLine")) };
// SAFETY: `D3dkmtCloseAdapter` is the exact d3dkmthk.h struct for this export (asserted above).
let close = unsafe { d3dkmt::<D3dkmtCloseAdapter>(s!("D3DKMTCloseAdapter")) };
let (Some(open), Some(get), Some(close)) = (open, get, close) else {
tracing::debug!("scanline probe: D3DKMT entry points unavailable — absent");
return;
};
let mut target: Option<(u32, u32)> = None; // (h_adapter, vidpn_source_id)
let mut ticks = 0u32;
while !inner.stop.load(Ordering::Relaxed) {
if target.is_none() || ticks % 20 == 0 {
if let Some((h, _)) = target.take() {
let mut req = D3dkmtCloseAdapter { h_adapter: h };
// SAFETY: `req` is a valid local for the asserted layout; `h` came from a
// successful open below and is closed exactly once here.
unsafe { close(&mut req) };
}
if let Some((lo, hi, source, physical)) =
pf_win_display::win_display::active_scanline_target()
{
let mut req = D3dkmtOpenAdapterFromLuid {
adapter_luid: LUID {
LowPart: lo,
HighPart: hi,
},
h_adapter: 0,
};
// SAFETY: `req` is a valid local for the asserted layout; the returned handle is
// owned by this loop and closed on retarget/exit.
if unsafe { open(&mut req) } >= 0 && req.h_adapter != 0 {
target = Some((req.h_adapter, source));
inner.scanline_physical.store(physical, Ordering::Relaxed);
}
}
inner
.scanline_running
.store(target.is_some(), Ordering::Relaxed);
}
ticks = ticks.wrapping_add(1);
if let Some((h, source)) = target {
inner.scanline.measure(|| {
let mut req = D3dkmtGetScanline {
h_adapter: h,
vidpn_source_id: source,
in_vertical_blank: 0,
_pad: [0; 3],
scan_line: 0,
};
// SAFETY: `req` is a valid local for the asserted layout; `h` is the live adapter
// handle owned by this loop. A failed status is still a timed (valid) sample.
unsafe { get(&mut req) };
});
}
std::thread::sleep(Duration::from_millis(100));
}
if let Some((h, _)) = target.take() {
let mut req = D3dkmtCloseAdapter { h_adapter: h };
// SAFETY: as the retarget close above — owned handle, closed exactly once at exit.
unsafe { close(&mut req) };
}
}
/// DPC/starvation sentinel: 5 ms sleeps, overshoot aggregated to one max per ~100 ms bucket.
fn cpu_sentinel_loop(inner: &Inner) {
let mut bucket_max = 0u64;
let mut bucket_start = Instant::now();
while !inner.stop.load(Ordering::Relaxed) {
let t0 = Instant::now();
std::thread::sleep(Duration::from_millis(5));
let overshoot = t0.elapsed().saturating_sub(Duration::from_millis(5));
bucket_max = bucket_max.max(overshoot.as_micros() as u64);
let now = Instant::now();
let span = now.duration_since(bucket_start);
if span >= Duration::from_millis(100) {
inner.cpu.push(now, span, bucket_max);
bucket_max = 0;
bucket_start = now;
}
}
}
+244 -3
View File
@@ -16,6 +16,194 @@ pub(super) struct Stall {
pub(super) metronomic: Option<Duration>,
}
/// Driver-telemetry evidence for one stall window (the v2 header tail — see
/// `pf_driver_proto::frame::SharedHeader`), sampled by the capturer between the last pre-gap
/// frame and the frame that ended the stall.
pub(super) struct StallEvidence {
/// Surfaces the driver OFFERED to the ring publisher during the window (delta of
/// `offered_total`); `None` = pre-telemetry driver (it never wrote the tail).
pub(super) offered_delta: Option<u64>,
/// The STALEST the driver's drain heartbeat ever read while the host starved (max of
/// now heartbeat over the window), in milliseconds.
pub(super) max_heartbeat_age_ms: u64,
/// What the micro-probe engine saw across the window (Phase A.2); `None` when the engine
/// isn't running.
pub(super) probes: Option<ProbeWindow>,
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
/// session is unavailable (non-admin dev run).
pub(super) etw: Option<String>,
}
/// The micro-probes' window read (Phase A.2, built by `probes::ProbeEngine::window`): per-leg
/// maxima across one stall window. Every field is `None` when that probe is absent (no adapter
/// device, no active output, thread failed to spawn) — absence is stated, never guessed.
#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct ProbeWindow {
/// Worst engine-liveness fence round-trip (µs) across all hardware adapters.
pub(super) fence_max_us: Option<u64>,
/// Longest span (µs) with no `DwmGetCompositionTimingInfo` `cRefresh` advance.
pub(super) dwm_tick_frozen_us: Option<u64>,
/// Worst watchdogged `DwmFlush` latency (µs).
pub(super) dwm_flush_max_us: Option<u64>,
/// Worst `D3DKMTGetScanLine` CALL latency (µs) — Level-Zero, so blocking convicts the KMD.
pub(super) scanline_max_us: Option<u64>,
/// Whether the scanline probe had a PHYSICAL head to ask (exclusive topology leaves only our
/// IDD active — latency still counts, scanline values don't).
pub(super) scanline_physical: bool,
/// Worst high-res sleeper overshoot (µs) — the DPC-storm / CPU-starvation discriminator.
pub(super) cpu_max_overshoot_us: Option<u64>,
}
impl std::fmt::Display for ProbeWindow {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let ms = |v: Option<u64>| match v {
Some(us) => format!("{:.0}ms", us as f64 / 1_000.0),
None => "absent".to_string(),
};
write!(
f,
"fence={} dwm_tick_frozen={} dwm_flush={} scanline={}({}) cpu_overshoot={}",
ms(self.fence_max_us),
ms(self.dwm_tick_frozen_us),
ms(self.dwm_flush_max_us),
ms(self.scanline_max_us),
if self.scanline_physical {
"physical"
} else {
"virtual"
},
ms(self.cpu_max_overshoot_us),
)
}
}
/// The named disturbance class a stall's combined evidence supports — the [`attribute`] verdict
/// (driver telemetry, Phase A.1) refined by the micro-probe window (Phase A.2). This is the
/// per-stall output of the program's verdict matrix (design doc §4.4).
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(super) enum StallClass {
/// The drain worker starved — ours (CPU/MMCSS/dead WUDFHost).
OursWorker,
/// Frames were composed and offered but never became consumable — ours (ring/publish/consume).
OursDelivery,
/// Engine-liveness fences stalled with the hole: the ADAPTER froze (Level-Two/Three DDI
/// servicing — link train, power transition, mux). Class 1.
AdapterFreeze,
/// Engines alive but DWM's own tick froze: the compositor is blocked on something (DDC/child
/// I/O vendor lock, win32k display-config queue). Class 2.
CompositorBlocked,
/// Engines alive, DWM ticking, driver drained E_PENDING — composition happened for OTHER
/// surfaces but produced no frame for OUR display: the frame-generation path
/// (IddCx/dirty-tracking/divider). Ours to chase with IddCx WPP.
FrameGeneration,
/// Not enough evidence to name a class (pre-telemetry driver and/or probes absent).
Unattributed,
}
impl std::fmt::Display for StallClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::OursWorker => "OURS-worker (drain thread starved)",
Self::OursDelivery => "OURS-delivery (ring/publish/consume lost composed frames)",
Self::AdapterFreeze => {
"CLASS-1 adapter freeze (engines stalled below the OS — link/power/mux servicing)"
}
Self::CompositorBlocked => {
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
}
Self::FrameGeneration => {
"FRAME-GENERATION (DWM ticked, engines alive, no frame for THIS display — IddCx/dirty/divider)"
}
Self::Unattributed => "UNATTRIBUTED (insufficient telemetry)",
})
}
}
/// The verdict matrix: fold the driver-telemetry verdict and the probe window into a class.
/// Pure — unit-tested beside the [`StallWatch`] tests. A leg is "stalled for the hole" when its
/// worst reading covers at least half the gap (the same proportional bar as [`attribute`]).
pub(super) fn classify(
gap: Duration,
verdict: &StallVerdict,
probes: Option<&ProbeWindow>,
) -> StallClass {
match verdict {
StallVerdict::WorkerStalled => return StallClass::OursWorker,
StallVerdict::DeliveryLeg => return StallClass::OursDelivery,
StallVerdict::ComposeSilence | StallVerdict::NoTelemetry => {}
}
let Some(p) = probes else {
return StallClass::Unattributed;
};
let half_gap_us = (gap.as_micros() as u64) / 2;
let covers = |v: Option<u64>| v.is_some_and(|us| us >= half_gap_us);
if covers(p.fence_max_us) {
return StallClass::AdapterFreeze;
}
if covers(p.dwm_tick_frozen_us) || covers(p.dwm_flush_max_us) {
return StallClass::CompositorBlocked;
}
// Engines alive and DWM ticking: only the driver's own E_PENDING testimony can pin the
// frame-generation path — without it (pre-telemetry driver) the delivery leg is equally
// possible, so stay honest.
if matches!(verdict, StallVerdict::ComposeSilence) {
StallClass::FrameGeneration
} else {
StallClass::Unattributed
}
}
/// The attribution a stall's evidence supports — the Branch-1/Branch-2 fork of the
/// vdisplay-disturbance-immunity program, computed per stall instead of argued per field report.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum StallVerdict {
/// Pre-telemetry driver: no verdict, the log says only what the host observed.
NoTelemetry,
/// The drain worker's heartbeat went silent for a large share of the hole — the swap-chain
/// thread starved (CPU/MMCSS) or the WUDFHost died. Ours, host/driver side.
WorkerStalled,
/// The worker drained E_PENDING throughout: DWM composed NOTHING for the hole. The
/// disturbance is below capture (adapter servicing / DDC lock / present clock) — the
/// micro-probe + ETW phases discriminate further.
ComposeSilence,
/// DWM composed frames all through the hole and the driver offered them, but none became a
/// consumable ring slot — OUR publish/ring/consume leg lost them. Fully killable.
DeliveryLeg,
}
impl std::fmt::Display for StallVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::NoTelemetry => "pre-telemetry driver (no verdict)",
Self::WorkerStalled => "driver-worker-stalled (heartbeat silent) — host CPU/MMCSS or a dead WUDFHost, NOT the display path",
Self::ComposeSilence => "compose-silence (driver drained E_PENDING) — DWM composed nothing; the disturbance is below capture",
Self::DeliveryLeg => "delivery-leg (frames were composed + offered but never consumable) — OUR ring/publish/consume leg",
})
}
}
/// Turn one stall window's evidence into a [`StallVerdict`]. Pure — unit-tested beside the
/// [`StallWatch`] tests.
///
/// Thresholds: a heartbeat that was ever `max(gap/2, 250 ms)` stale convicts the worker (its
/// scheduled cadence is ≤16 ms, so 250 ms of silence is real starvation, and gap/2 scales the bar
/// for long holes); `offered_delta ≥ 8` acquits DWM (8 composed frames during the "hole" mirrors
/// [`StallWatch::RECENT`]'s definition of sustained flow — the stall-ending frame plus a resume
/// burst stay well under it).
pub(super) fn attribute(gap: Duration, evidence: &StallEvidence) -> StallVerdict {
let Some(offered) = evidence.offered_delta else {
return StallVerdict::NoTelemetry;
};
let gap_ms = gap.as_millis() as u64;
if evidence.max_heartbeat_age_ms >= (gap_ms / 2).max(250) {
StallVerdict::WorkerStalled
} else if offered >= 8 {
StallVerdict::DeliveryLeg
} else {
StallVerdict::ComposeSilence
}
}
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
/// path BELOW capture and only while no physical output is active).
@@ -35,6 +223,13 @@ pub(super) struct StallWatch {
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
seen: u32,
with_os_events: u32,
/// Running per-verdict tally (worker-stalled / compose-silence / delivery-leg / no-telemetry),
/// in [`StallVerdict`] order — the metronomic WARN prints it, so one pasted line attributes the
/// whole session's beat, not just the stall that tripped the metronome.
verdicts: [u32; 4],
/// Running per-class tally ([`StallClass`] order: ours-worker, ours-delivery, adapter-freeze,
/// compositor-blocked, frame-generation, unattributed) — the verdict matrix's session summary.
classes: [u32; 6],
}
impl StallWatch {
@@ -54,6 +249,8 @@ impl StallWatch {
cadence: pf_frame::metronome::Metronome::new(),
seen: 0,
with_os_events: 0,
verdicts: [0; 4],
classes: [0; 6],
}
}
@@ -92,7 +289,10 @@ impl StallWatch {
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
/// `evidence` is the capturer's driver-telemetry sample for the window; its [`attribute`]
/// verdict rides every stall line, so a field log names which leg lost the frames instead of
/// leaving it to hypothesis.
pub(super) fn report(&mut self, stall: &Stall, now: Instant, evidence: &StallEvidence) {
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
@@ -105,14 +305,36 @@ impl StallWatch {
if !events.is_empty() {
self.with_os_events = self.with_os_events.saturating_add(1);
}
let verdict = attribute(stall.gap, evidence);
self.verdicts[match verdict {
StallVerdict::NoTelemetry => 0,
StallVerdict::WorkerStalled => 1,
StallVerdict::ComposeSilence => 2,
StallVerdict::DeliveryLeg => 3,
}] += 1;
let class = classify(stall.gap, &verdict, evidence.probes.as_ref());
self.classes[match class {
StallClass::OursWorker => 0,
StallClass::OursDelivery => 1,
StallClass::AdapterFreeze => 2,
StallClass::CompositorBlocked => 3,
StallClass::FrameGeneration => 4,
StallClass::Unattributed => 5,
}] += 1;
// debug (not warn): a single hole also happens when content legitimately pauses;
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
// at debug level, and the web-console debug ring captures these.
tracing::debug!(
gap_ms = stall.gap.as_millis() as u64,
os_display_events = %pf_win_display::display_events::summarize(&events),
"IDD-push capture stall — the desktop was composing at speed, then DWM \
delivered no frame for the gap; the present path stalled below capture"
verdict = %verdict,
class = %class,
probes = evidence.probes.as_ref().map(tracing::field::display),
etw = evidence.etw.as_deref().unwrap_or("unavailable"),
offered_during_gap = evidence.offered_delta,
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
"IDD-push capture stall — the desktop was composing at speed, then the ring \
delivered no frame for the gap; the class names the leg that lost them"
);
if let Some(period) = stall.metronomic {
let suspects = pf_win_display::display_events::connected_inactive_physicals();
@@ -122,6 +344,21 @@ impl StallWatch {
suspects.join(", ")
};
let correlated = format!("{}/{}", self.with_os_events, self.seen);
// The session's attribution in one token: which leg the evidence convicted, per stall.
let verdict_tally = format!(
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
);
let class_tally = format!(
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
frame-generation {}, unattributed {}",
self.classes[0],
self.classes[1],
self.classes[2],
self.classes[3],
self.classes[4],
self.classes[5]
);
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
// cascade is OS-visible; otherwise the disturbance never surfaces above the
// driver. Different classes, different cures — say which one this box has.
@@ -130,6 +367,8 @@ impl StallWatch {
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
verdicts = %verdict_tally,
classes = %class_tally,
"capture stalls are METRONOMIC and coincide with Windows monitor \
hot-plug/re-enumeration events a connected display (or its \
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
@@ -145,6 +384,8 @@ impl StallWatch {
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
verdicts = %verdict_tally,
classes = %class_tally,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows: the GPU driver servicing a \
connected-but-asleep sink (standby HPD/DDC/link probing), \
+39 -5
View File
@@ -490,8 +490,17 @@ pub mod frame {
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
/// only attaches to a fully-published ring.
pub const MAGIC: u32 = 0x4456_4650;
/// Frame-plane version (independent bump of the header layout).
pub const VERSION: u32 = 1;
/// Frame-plane version (independent bump of the header layout). v2 appended the stall-attribution
/// telemetry tail (`drain_heartbeat_qpc`/`last_acquire_qpc`/`offered_total`); see
/// [`VERSION_TELEMETRY`] for the compatibility contract.
pub const VERSION: u32 = 2;
/// The header version that grew the telemetry tail. Compatibility is gated on the HOST-stamped
/// `version` field, not on mapping sizes: a v2 driver writes the tail only when
/// `version >= VERSION_TELEMETRY` (a v1 host created a 64-byte layout — never write past it),
/// and a v2 host reads `drain_heartbeat_qpc == 0` as "pre-telemetry driver, no verdict" (the
/// same zero-means-absent convention as [`OPENED_DETAIL_LIVE`]). Neither side rejects the
/// other's version — the tail is diagnostics, not frame-plane semantics.
pub const VERSION_TELEMETRY: u32 = 2;
/// Ring slots. Headroom so the driver's 0 ms-timeout publish always finds a free slot while the host
/// holds one across the convert/copy + the pipelined encode. MUST be identical on both sides — it is,
/// because both read this one constant.
@@ -587,6 +596,22 @@ pub mod frame {
/// token blocks file writes, so this header is how the driver reports state).
pub driver_status: u32,
pub driver_status_detail: u32,
/// v2 telemetry tail (stall attribution, Phase A.1 of the vdisplay-disturbance-immunity
/// program): driver-written on every drain-loop pass, host-read when a capture stall ends.
/// All three are best-effort Relaxed atomic stores (the `driver_status` visibility
/// contract); `0` means a pre-v2 driver never wrote them. QPC of the swap-chain worker's
/// most recent drain-loop iteration — E_PENDING passes included, so a fresh heartbeat over
/// a stale [`Self::last_acquire_qpc`] reads "the worker is running and DWM is composing
/// nothing", while a stale heartbeat reads "our worker starved".
pub drain_heartbeat_qpc: u64,
/// QPC of the most recent SUCCESSFUL swap-chain acquire — the last instant DWM actually
/// composed this display. The Branch-1/Branch-2 fork in one field.
pub last_acquire_qpc: u64,
/// Wrapping count of surfaces offered to the ring publisher — the full-width sibling of
/// the packed 15-bit [`OPENED_DETAIL_LIVE`] counter (which saturates and cannot be
/// delta'd over a stall window). The host snapshots it per consumed frame; the delta
/// across a stall says whether frames existed that never reached the ring.
pub offered_total: u64,
}
/// Why the driver's publisher must NOT attach a delivered channel to its monitor's ring — the
@@ -663,7 +688,7 @@ pub mod frame {
const _: () = {
use core::mem::{offset_of, size_of};
assert!(size_of::<SharedHeader>() == 64);
assert!(size_of::<SharedHeader>() == 88);
assert!(offset_of!(SharedHeader, magic) == 0);
assert!(offset_of!(SharedHeader, version) == 4);
assert!(offset_of!(SharedHeader, generation) == 8);
@@ -678,6 +703,9 @@ pub mod frame {
assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52);
assert!(offset_of!(SharedHeader, driver_status) == 56);
assert!(offset_of!(SharedHeader, driver_status_detail) == 60);
assert!(offset_of!(SharedHeader, drain_heartbeat_qpc) == 64);
assert!(offset_of!(SharedHeader, last_acquire_qpc) == 72);
assert!(offset_of!(SharedHeader, offered_total) == 80);
};
}
@@ -1426,14 +1454,15 @@ mod tests {
}
#[test]
fn shared_header_is_pod_and_64_bytes() {
fn shared_header_is_pod_and_88_bytes() {
let mut h = frame::SharedHeader::zeroed();
h.magic = frame::MAGIC;
h.width = 5120;
h.height = 1440;
h.target_id = 262;
h.drain_heartbeat_qpc = 0x1234_5678_9abc_def0;
let bytes = bytemuck::bytes_of(&h);
assert_eq!(bytes.len(), 64);
assert_eq!(bytes.len(), 88);
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
assert_eq!(back.magic, frame::MAGIC);
assert_eq!(back.width, 5120);
@@ -1441,6 +1470,11 @@ mod tests {
// v3: the monitor binding occupies the old `_pad` slot at offset 28 — byte-compatible (a v2
// host left it zero there).
assert_eq!(bytes[28..32], 262u32.to_le_bytes());
// Header v2: the telemetry tail is appended — the first 64 bytes ARE the v1 layout, so a
// pre-telemetry driver reading its 64-byte view sees exactly what it always saw.
assert_eq!(bytes[64..72], 0x1234_5678_9abc_def0u64.to_le_bytes());
assert_eq!(back.last_acquire_qpc, 0);
assert_eq!(back.offered_total, 0);
}
#[test]
@@ -50,13 +50,19 @@ const APPLY_TEMPORARY: u32 = 1;
/// the frames. The capturer always negotiates the meta (pf-capture `meta_param`) and the encoder
/// blend composites it for sessions where the client does not draw the cursor itself — while a
/// cursor-forwarding session strips the overlay and sends shape/state over the cursor channel.
/// Embedded mode would leave BOTH paths blind: no metadata means nothing to forward AND nothing
/// to blend, and Mutter's own embedded painting is what the pre-channel path relied on.
/// This is the mode for EVERY session whose host plans a composite or forward (`set_hw_cursor`
/// on), channel or not: Mutter's embedded painting is a fiction on a virtual stream — since
/// Mutter 48 (commit `7ff5334a`, hw-cursor inhibition removed) the software cursor overlay is
/// suppressed STAGE-GLOBALLY whenever any physical head realizes a hardware cursor, so
/// dmabuf-recorded frames blit the view without a pointer, and cursor-only motion schedules no
/// re-record either (mutter#4939). Probed on-glass (Mutter 50.3): embedded + relative motion =
/// frozen frame counter; metadata positions kept flowing in the same setup.
const CURSOR_METADATA: u32 = 2;
/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself
/// zero host-side cursor work, the pre-channel path. Chosen for every session WITHOUT the
/// negotiated cursor channel (`set_hw_cursor` off — Phase B, the Windows no-regression gate
/// mirrored); metadata stays the cursor-channel sessions' mode (shapes forwarded / host blend).
/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself.
/// Kept only as the can't-blend fallback (`set_hw_cursor` off — the resolved encode backend
/// cannot composite a metadata cursor, so metadata would strand the pointer in meta nothing
/// draws). Know its limits (above): on a virtual stream it paints only into the MemFd/SHM
/// record path (`FORCE_CURSORS`) and only refreshes on unrelated damage.
const CURSOR_EMBEDDED: u32 = 1;
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
+43
View File
@@ -1524,6 +1524,49 @@ pub fn source_desktop_rect(target_id: u32) -> Option<(i32, i32, i32, i32)> {
None
}
/// Scanline-probe target (stall-attribution Phase A.2): the adapter LUID + VidPn SOURCE id of an
/// ACTIVE path, preferring a real display over one of OUR virtual monitors. `D3DKMTGetScanLine`
/// wants a source that actually scans out, so a physical head (`physical == true`) gives the
/// honest Level-Zero KMD-liveness probe; on an exclusive topology only our IDD is active, and the
/// caller still gets it (`physical == false`) — the CALL's latency is the measurement either way,
/// the flag just keeps the report from over-reading the returned scanline values. Returns
/// `(adapter_luid_low, adapter_luid_high, vidpn_source_id, physical)`; `None` when nothing is
/// active or the query fails.
pub fn active_scanline_target() -> Option<(u32, i32, u32, bool)> {
// SAFETY: `query_active_config` is this module's own CCD helper: it takes nothing and returns owned
// `Vec`s built from a fresh `QueryDisplayConfig`, so it has no caller obligation at all.
let (paths, _modes) = query_active_config()?;
let mut fallback: Option<(u32, i32, u32, bool)> = None;
for p in &paths {
if p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 {
continue;
}
let candidate = (
p.sourceInfo.adapterId.LowPart,
p.sourceInfo.adapterId.HighPart,
p.sourceInfo.id,
false,
);
let mut req = DISPLAYCONFIG_TARGET_DEVICE_NAME::default();
req.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
req.header.size = size_of::<DISPLAYCONFIG_TARGET_DEVICE_NAME>() as u32;
req.header.adapterId = p.targetInfo.adapterId;
req.header.id = p.targetInfo.id;
// SAFETY: `req.header` is a live local whose `size` field was just set to the enclosing
// struct's own `size_of` (the byte-count contract); the struct outlives this synchronous call.
if unsafe { DisplayConfigGetDeviceInfo(&mut req.header) } != 0 {
fallback.get_or_insert(candidate);
continue;
}
if is_our_virtual_display(&utf16z_str(&req.monitorDevicePath)) {
fallback.get_or_insert(candidate);
continue;
}
return Some((candidate.0, candidate.1, candidate.2, true));
}
fallback
}
/// The union of every ACTIVE path's source rect — the virtual-desktop bounds `(x, y, w, h)` in
/// desktop coordinates. Read from CCD rather than `GetSystemMetrics(SM_*VIRTUALSCREEN)` so the
/// answer is the CONSOLE's real layout even when the calling process sits in another session (the
+37 -18
View File
@@ -389,12 +389,14 @@ fn run(
return stream_body(
&mut capturer,
Some(&rebuild),
// The virtual-output source never selects cursor-as-metadata (`set_hw_cursor` is
// never called → the compositor EMBEDS the pointer where it can), so the encoder
// is handed nothing to composite. gamescope remains the pointerless residual —
// its capture carries no cursor either way (the native plane's XFixes source is
// not wired on this plane).
false,
// Mirrors the source's own `set_hw_cursor` request (`open_gs_virtual_source`):
// cursor-as-metadata + host blend wherever the backend composites — the
// compositor-EMBEDS fallback never paints on a Mutter virtual stream (the
// native plane's no-channel rule, `session_plan::cursor_blend_for`). gamescope
// remains the pointerless residual — its capture carries no cursor either way
// (the native plane's XFixes source is not wired on this plane).
compositor != crate::vdisplay::Compositor::Gamescope
&& blend_capable_metadata_cursor(&cfg),
&sock,
cfg,
running,
@@ -415,18 +417,7 @@ fn run(
// `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask
// the portal to EMBED the pointer so no backend × cursor-mode combination streams
// cursorless. Synthetic frames carry no pointer either way.
let metadata_cursor = {
#[cfg(target_os = "linux")]
{
// Same CUDA-payload prediction SessionPlan/`handshake::cursor_forward` make:
// the NVIDIA resolution plus the zero-copy master switch.
let cuda_planned =
!crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr)
}
#[cfg(not(target_os = "linux"))]
false
};
let metadata_cursor = blend_capable_metadata_cursor(&cfg);
// Which screen this stream must show. The host-wide pin (§5.3) applies to the compat plane too:
// the portal chooser cannot name a head, so a pinned host MIRRORS it here the same way the
// virtual source does via `vdisplay::open`. Without this a Moonlight client on a pinned host
@@ -643,6 +634,24 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
/// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to
/// FOLLOW a Desktop<->Game switch: it re-detects the now-live compositor and re-targets at it. Does NOT
/// launch the app (that happens once at stream start; a rebuild must not re-spawn it).
/// Cursor-as-metadata for this plane (GameStream has no cursor channel): only where the encode
/// backend this session resolves to composites `frame.cursor` — the same CUDA-payload
/// prediction `SessionPlan`/`handshake::cursor_forward` make (the NVIDIA resolution plus the
/// zero-copy master switch). Shared by the monitor-mirror and virtual-output sources so their
/// `set_hw_cursor` request and `stream_body`'s blend flag cannot drift.
fn blend_capable_metadata_cursor(cfg: &StreamConfig) -> bool {
#[cfg(target_os = "linux")]
{
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr)
}
#[cfg(not(target_os = "linux"))]
{
let _ = cfg;
false
}
}
fn open_gs_virtual_source(
cfg: StreamConfig,
app: Option<&super::apps::AppEntry>,
@@ -701,6 +710,16 @@ fn open_gs_virtual_source(
}
};
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Out-of-band cursor for the virtual source (the native plane's no-channel rule, mirrored):
// GameStream has no cursor channel, and the compositor-EMBEDS fallback never paints on a
// Mutter virtual stream (stage-global overlay suppression since Mutter 48 — see
// pf-vdisplay `mutter.rs`), so ask for cursor-as-metadata wherever the resolved backend
// composites `frame.cursor`; `stream_body`'s blend flag mirrors this request. gamescope
// stays off: its capture carries no metadata either way, and the request would cost the
// native-NV12 shape for nothing.
vd.set_hw_cursor(
compositor != crate::vdisplay::Compositor::Gamescope && blend_capable_metadata_cursor(&cfg),
);
// Carry the resolved launch command on the backend instance (per-session) rather than a
// process-global env var, so concurrent sessions can't stomp each other's launch target. It is
// the RESOLVED command, so gamescope's bare spawn nests a library title exactly like an
@@ -17,10 +17,13 @@ use super::*;
/// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`,
/// `client_draws = false`) makes the HOST draw the pointer, and on Linux the encoder is that
/// compositing stage — granting the channel over a backend that can't blend (libav
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied, the
/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never
/// draws — never cursorless, never doubled. THE single predicate: the Welcome's
/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back.
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied — or
/// never asked for (a capture-latched client, `console.rs` `latched_mouse`) — the session
/// composites host-side anyway wherever the backend can blend
/// (`session_plan::cursor_blend_for`'s no-channel arm; the compositor-EMBEDS fallback never
/// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the
/// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed
/// from it, and the session wiring reads that bit back.
pub(super) fn cursor_forward(
client_caps: u8,
compositor: Option<crate::vdisplay::Compositor>,
+95 -32
View File
@@ -1076,20 +1076,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
ctx.bit_depth,
ctx.chroma,
ctx.codec,
// Blend CAPABILITY for cursor-FORWARD sessions (Phase B, the Windows gate mirrored):
// their client can flip to the capture mouse model mid-stream (`CursorRenderMode`), and
// the composite side of that flip must not need an encoder rebuild — WHETHER a frame's
// pointer is drawn stays per-tick (the encode loop strips `frame.cursor` while the client
// draws locally, see the forwarder tick). Non-channel NON-gamescope sessions get the
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
// the blend must be built for every gamescope session. (`cursor_forward` is already
// blend-gated: `handshake::cursor_forward` grants the channel only where
// `encode::cursor_blend_capable` says the resolved backend composites.)
// Blend CAPABILITY (the single rule in `cursor_blend_for`): cursor-FORWARD sessions
// need it for the mid-stream capture-mouse flip (`CursorRenderMode` — WHETHER a
// frame's pointer is drawn stays per-tick, the encode loop strips `frame.cursor`
// while the client draws locally); gamescope (Phase C) can't embed a pointer, so the
// host always composites the XFixes-sourced cursor; and a NO-channel session gets
// metadata + host blend too wherever the backend composites — the compositor-EMBEDS
// fallback streams cursorless on a Mutter virtual output (the overlay-visibility
// gate is stage-global since Mutter 48; see `cursor_blend_for`'s doc). Embedded
// remains only the can't-blend fallback.
crate::session_plan::cursor_blend_for(
ctx.cursor_forward,
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
ctx.codec,
ctx.bit_depth,
),
ctx.cursor_forward,
);
@@ -1187,6 +1187,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
if gamescope_composite {
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
}
// No-channel metadata composite: the client never draws the pointer (it did not advertise
// the cursor channel — e.g. a capture-latched client, `console.rs` `latched_mouse`), and
// the compositor-EMBEDS fallback is a fiction on a Mutter virtual stream (the software
// cursor overlay is suppressed stage-globally whenever any physical head realizes a HW
// cursor, Mutter 48+ — dmabuf frames blit the view WITHOUT it, and cursor-only motion
// schedules no update either, mutter#4939). So the plan asked the backend for
// cursor-as-metadata and the HOST composites, permanently — the same arm a channel
// session lands in after its capture-model flip, minus the channel.
// `mut`: recomputed with `gamescope_composite` on a mid-stream compositor retarget.
let mut metadata_composite = cursor_fwd.is_none()
&& plan.cursor_blend
&& compositor != pf_vdisplay::Compositor::Gamescope;
if metadata_composite {
tracing::info!(
"no cursor channel — compositing the metadata cursor into the video (embedded \
fallback is unreliable on virtual streams)"
);
}
if streamed_wire {
// Client capability only — whether AUs actually stream per-slice depends on the encoder
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
@@ -1231,9 +1249,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// it with the HDR flags so nested games get HDR surfaces at all. Decided in the
// Welcome (`capture::capturer_supports_hdr_for`), so it cannot change under us.
vd.set_hdr(bit_depth >= 10);
// Cursor-forward sessions ask the backend for an out-of-band hardware cursor
// (Windows pf-vdisplay / IddCx; no-op on Linux — the portal already separates it).
vd.set_hw_cursor(cursor_forward);
// Out-of-band cursor request: cursor-forward sessions (Windows pf-vdisplay /
// IddCx hardware cursor; Linux metadata mode) AND no-channel host-composite
// sessions (Linux only — `metadata_composite` is `plan.cursor_blend`-gated, so
// it is always false on Windows). The backend keeps the pointer out of the
// pixels; the host blend (or the client) puts it back.
vd.set_hw_cursor(cursor_forward || metadata_composite);
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the
// backend mints — the retry-hold below AND the capturer's — carries the session's quit
// flag, so a user "stop" (⌘D → the QUIT close code) tears the virtual monitor down the
@@ -2378,6 +2399,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
plan.cursor_blend = crate::session_plan::cursor_blend_for(
plan.cursor_forward,
c == crate::vdisplay::Compositor::Gamescope,
plan.codec,
plan.bit_depth,
);
plan.gamescope_cursor =
crate::session_plan::gamescope_cursor_for(
@@ -2385,6 +2408,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
);
gamescope_composite =
plan.gamescope_cursor && cursor_fwd.is_none();
metadata_composite = cursor_fwd.is_none()
&& plan.cursor_blend
&& c != crate::vdisplay::Compositor::Gamescope;
// The retargeted backend starts with `hw_cursor`
// unset — without re-applying the session's
// out-of-band cursor request, the rebuilt display
// would come up EMBEDDED: double-drawn for a
// desktop-model channel client, cursorless for
// every host-composite session.
vd.set_hw_cursor(plan.cursor_forward || metadata_composite);
}
Err(e2) => tracing::warn!(error = %format!("{e2:#}"),
"capture loss: opening the newly-detected compositor failed — retrying"),
@@ -2560,15 +2593,41 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
}
}
}
} else if gamescope_composite {
// gamescope (Phase C): no channel, host always composites. Refresh the (repeat or new)
// frame's overlay from the capturer's LIVE cursor — the XFixes source publishes there
// — so pointer-only motion on a static gamescope UI re-blends at tick rate instead of
// freezing at the last damage frame (the same reason the composite arm above re-reads
// it). A grabbed/hidden pointer arrives `visible: false` and is stripped just below.
} else if gamescope_composite || metadata_composite {
// No channel, host always composites: gamescope (Phase C — the XFixes source
// publishes on `capturer.cursor()`) and the metadata-composite session (the portal
// `SPA_META_Cursor` live overlay publishes there too). Refresh the (repeat or new)
// frame's overlay from the capturer's LIVE cursor so pointer-only motion on a
// static desktop re-blends at tick rate instead of freezing at the last damage
// frame (the same reason the channel's composite arm above re-reads it). A
// grabbed/hidden pointer arrives `visible: false` and is stripped just below.
#[cfg(not(target_os = "windows"))]
if let Some(live) = capturer.cursor() {
frame.cursor = Some(live);
match capturer.cursor() {
Some(live) => {
if !composite_saw_overlay {
composite_saw_overlay = true;
tracing::info!(
x = live.x,
y = live.y,
w = live.w,
h = live.h,
visible = live.visible,
"host-composite: first live cursor overlay handed to the encoder \
blend"
);
}
frame.cursor = Some(live);
}
None => {
if !composite_saw_none {
composite_saw_none = true;
tracing::info!(
"host-composite active but the capture has no live cursor overlay \
yet (no SPA_META_Cursor bitmap) the stream is cursorless until \
one arrives"
);
}
}
}
}
// The overlay surfaces hidden pointers too (for the hint above) — strip them
@@ -2579,10 +2638,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// The seat-pointer park schedule (state + rationale at the declarations above; armed by
// the first frame of every (re)built display and by the capture-model flip). The first
// two attempts run unconditionally — attempt 1 can be swallowed by a cold EIS
// connection. Past those, only a cursor-channel session in the capture model that STILL
// has no live overlay keeps trying: that combination means the pointer has not reached
// the streamed output (the compositor reports cursor metadata only while it is over the
// recorded view), and a relative-only client cannot get it there on its own.
// connection. Past those, only a host-composite session that STILL has no live overlay
// keeps trying — a channel session in the capture model, or a no-channel
// metadata-composite session (both relative-only): no overlay there means the pointer
// has not reached the streamed output (the compositor reports cursor metadata only
// while it is over the recorded view), and a relative-only client cannot get it there
// on its own.
// Armed from the loop's first tick — a static desktop may never deliver a fresh frame
// (`parked_display` is only bookkeeping for rebuild re-arming), and the pointer must be
// parked regardless.
@@ -2591,8 +2652,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
&& park_attempts < PARK_ATTEMPTS_MAX
&& std::time::Instant::now() >= next_park_at
{
let composite_starved = cursor_fwd.is_some()
&& !cursor_client_draws.load(Ordering::Relaxed)
let composite_starved = ((cursor_fwd.is_some()
&& !cursor_client_draws.load(Ordering::Relaxed))
|| metadata_composite)
&& capturer.cursor().is_none();
if park_attempts < 2 || composite_starved {
park_pointer(&input_tx, frame.width, frame.height);
@@ -3406,13 +3468,14 @@ pub(super) fn prepare_display(
bit_depth,
chroma,
codec,
// Blend capability — must MATCH virtual_stream's resolve (Phase B: non-channel
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
// Blend capability — must MATCH virtual_stream's resolve. Windows-only path, where
// the rule is a constant `false` (the IDD capturer composites itself); passed through
// the shared rule anyway so the two resolves cannot drift.
crate::session_plan::cursor_blend_for(
cursor_forward,
compositor == pf_vdisplay::Compositor::Gamescope,
codec,
bit_depth,
),
cursor_forward,
);
+34 -6
View File
@@ -108,7 +108,9 @@ pub struct SessionPlan {
pub wire_chunk: Option<usize>,
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
/// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
/// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
/// where the ENCODER is the compositing stage (Linux: cursor-forward sessions, gamescope,
/// AND no-channel sessions on a blend-capable backend — the compositor-EMBEDS fallback is
/// broken on Mutter virtual streams, see [`cursor_blend_for`]);
/// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders
/// whose fast path cannot blend (the Vulkan EFC RGB-direct source, native NV12) stay off
/// those shapes when this is set — see [`Self::output_format`] and
@@ -234,20 +236,46 @@ pub(crate) fn resolve_topology() -> SessionTopology {
/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and
/// the mid-stream compositor re-gate) so they can't drift:
/// * **Linux**: the encoder is the compositing stage — blend for a cursor-forward session (the
/// capture-mouse flip needs the host composite on demand) and for gamescope (its capture
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video).
/// capture-mouse flip needs the host composite on demand), for gamescope (its capture
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video), AND
/// for a no-channel session whenever the resolved backend can composite. The pre-channel
/// "compositor EMBEDS the pointer" fallback is a fiction on a Mutter virtual stream:
/// cursor-only motion never re-records the stream (probed on-glass, Mutter 50.3 — frames
/// froze the instant motion went relative while `SPA_META_Cursor` kept updating), so a
/// capture-latched client (which never advertises `CLIENT_CAP_CURSOR`, `console.rs`
/// `latched_mouse`) streamed cursorless. Metadata + host blend is the path that was
/// verified end-to-end; embedded remains only the can't-blend fallback (libav
/// VAAPI/NVENC, software).
/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` /
/// DWM), and no Windows encode backend reads `frame.cursor`. Asking the encoder anyway made
/// `open_video`'s blends-cursor backstop fire spuriously on every cursor-channel session.
pub(crate) fn cursor_blend_for(cursor_forward: bool, gamescope: bool) -> bool {
pub(crate) fn cursor_blend_for(
cursor_forward: bool,
gamescope: bool,
codec: crate::encode::Codec,
bit_depth: u8,
) -> bool {
#[cfg(target_os = "windows")]
{
let _ = (cursor_forward, gamescope);
let _ = (cursor_forward, gamescope, codec, bit_depth);
false
}
#[cfg(not(target_os = "windows"))]
{
cursor_forward || gamescope_needs_host_cursor(gamescope)
if gamescope {
// gamescope's capture carries no SPA_META_Cursor; the blend-capable term below
// must not apply, or a patch-2+ gamescope (composites its own pointer) would lose
// its native-NV12 zero-copy shape for a blend that can never receive an overlay.
return gamescope_needs_host_cursor(true);
}
if cursor_forward {
return true;
}
// No cursor channel: the same CUDA-payload prediction `handshake::cursor_forward` and
// the GameStream monitor mirror make — the NVIDIA resolution plus the zero-copy master
// switch — deciding direct-SDK NVENC (blends) vs libav NVENC (doesn't).
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
}
}
-27
View File
@@ -12,23 +12,6 @@ A machine you pin from the host's own configuration shows the choice in the cons
For headless and unattended setups, two new commands help you find and verify the right monitor without a screen attached: `punktfunk-host list-monitors` lists what the machine has (names, geometry, which one is pinned), and `punktfunk-host mirror-test` confirms frames are actually flowing from the one you picked.
## New: HDR on the gamescope path (opt-in)
Streaming Steam Gaming Mode from a Linux box has always been SDR — not because the encoder couldn't
do better, but because gamescope hands its picture to Punktfunk already tone-mapped down to 8-bit.
That gap is now closed, with a small companion package: install **`punktfunk-gamescope`** (gamescope
plus a patch that adds the 10-bit BT.2020 PQ formats to its capture output — offered upstream) and
set `PUNKTFUNK_GAMESCOPE_HDR=1`, and games render in real HDR while the stream carries HDR10 to an
HDR-capable client.
It sits *beside* your system's gamescope rather than replacing it — your own Gaming Mode is
untouched — and Punktfunk only uses it for the sessions it starts itself. On Bazzite it rides in
the Punktfunk sysext; there's an Arch package, a NixOS option, and a build script for everything
else. `punktfunk-host hdr-probe` tells you exactly which pieces are in place.
Opt-in for this release while it soaks: without the knob, or without the extra package, the
gamescope path streams SDR exactly as before.
## Fixed: newer KDE Plasma silently fell back to a slower, less reliable way to arrange displays
On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the desktop about display layout stopped seeing any displays at all, with no visible error — every session quietly fell back to an external helper tool that's known to hang under load, exactly the thing the previous release's Plasma fix was meant to stop relying on. Fixed: Punktfunk now recognizes both the way older and newer Plasma releases announce their displays.
@@ -42,16 +25,6 @@ On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the
- **libei absolute-coordinate resolution changed from matching by mode size to `mapping_id` → origin → size → first**, since two outputs can share a size but never a top-left, and a mirrored head's region is not the client's stream size at all. A named anchor that matches nothing falls back down the ladder rather than stranding input, and both a miss and a match now log once per distinct answer. `punktfunk-host anchor-test` exercises the ladder against a live compositor with two same-sized outputs — the one case a unit test can only simulate.
- **`SWAYSOCK` is now derived rather than required to be inherited** — by the compositor's known PID, then the newest socket owned by the host's user — closing the last session variable a `systemd --user` host lacked for sway. A new drop-in (`PartOf`/`WantedBy` on `graphical-session.target`, opt-in, shipped under `/usr/share`) restarts a desktop-login host with its desktop, so a Wayland socket and portal connection that die with a Plasma/GNOME restart don't leave the daemon silently unable to recover.
- **KWin's in-process output-management path now also binds `kde_output_device_registry_v2`**, not just the per-output `kde_output_device_v2` globals it originally shipped with — KWin 6.7 stopped advertising the latter, so the module written specifically to avoid shelling out to `kscreen-doctor` (0.19.x) saw zero devices and silently degraded to it on every session. Both models are supported now; registry-sourced devices arrive one Wayland round-trip later, so the handshake gains one conditional extra barrier.
- **gamescope HDR is decided statically, before the display exists.** The punktfunk/1 Welcome fixes a session's bit depth up front and cannot take it back (PQ frames on an 8-bit encoder are a deliberate hard error), so the capability answer is the identity of the gamescope binary the host will spawn — a `+pfhdr` marker in its `--version` banner, probed once per boot — never an optimistic negotiation. The capture-side gate became source-aware (`capturer_supports_hdr_for(compositor)`), the HDR negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa), and the keep-alive reuse key gained `hdr` so a display brought up SDR can never be handed to an HDR session. The GameStream plane's live BT.2100 monitor probe is now scoped to the portal source — a headless gamescope box has no monitor to be in HDR mode.
- **The gamescope patch mirrors code already in gamescope's tree.** Its PipeWire node additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 properties, mapped to `DRM_FORMAT_XRGB2101010`/`XBGR2101010` — the same 10-bit capture texture the HDR AVIF screenshot path allocates — and `paint_pipewire()` composites into them with the HDR screenshot LUT set and `EOTF_PQ`, which is exactly the `bHDRScreenshot` branch. The new formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit, and the fixed PQ container is deliberately *not* conditional on the focused app being HDR (a stream's colourimetry must not follow what the game happens to render).
- **Vulkan Video learned 10-bit**, which is what keeps AMD/Intel HDR on the good path: an HDR session opens a 10-bit video profile (HEVC Main10 / AV1 Main at 10 bits) with a `G10X6…3PACK16` picture and DPB, headers carrying the depth and the BT.2020/PQ CICP triplet — an SPS `bit_depth_*_minus8 = 2` for HEVC, `high_bitdepth` plus the matching sequence-header OBU bits for AV1 — and a new `rgb2yuv10.comp`: the 8-bit BT.709 shader's twin, doing a pure BT.2020 NCL matrix on the already-PQ-encoded samples (there is no transfer function to apply, and applying one would be wrong) and writing 10-bit values into the high bits of `R16`/`RG16` scratch planes that are size-compatible with the picture's. That keeps the two things HDR would otherwise cost on this vendor: real RFI loss recovery, and the compute CSC's cursor blend — the only way a gamescope pointer reaches the stream at all.
- **GameStream advertises its 10-bit codec bits per codec.** `ServerCodecModeSupport` layered `SCM_HEVC_MAIN10` and never `SCM_AV1_MAIN10` — a blanket omission on the theory that the GameStream AV1 path was unconfirmed, even though the SDR baseline has always offered AV1 Main8, so the depth was never the uncertain part. Each 10-bit bit is now gated on that codec's own `can_encode_10bit` probe AND the baseline already advertising it, and the RTSP honor degrades a session whose NEGOTIATED codec can't carry 10 bits rather than labelling an 8-bit stream PQ. `host_hdr_capable` became codec-agnostic to match (any 10-bit-capable codec makes the host HDR-capable; which one a session gets is the session's question).
- **The Vulkan encode backend is now capability-probed per codec AND depth**, mirroring what the direct-SDK NVENC path already does with its GUID probe. `vkGetPhysicalDeviceVideoCapabilitiesKHR` is asked against the very profile the session open will build, so the dispatcher's prediction cannot disagree with reality: a device that can encode 10-bit keeps the Vulkan path, one that can't routes to libav VAAPI *before* burning a failed session open, and `can_encode_10bit` reports the union of what VAAPI and Vulkan Video can do rather than VAAPI's answer alone (which was under-reporting 10-bit on hardware that could do it).
- **A gamescope session can now be zero-copy end to end.** gamescope keeps the pointer out of its PipeWire node (it lives on a hardware plane for scanout), so the host always reconstructed it from XFixes and blended it into every frame — and *that blend* is what forced the encode path onto its compute colour-conversion arm, since the zero-copy RGB-direct source hands the captured buffer to a fixed-function front end with no blend stage. A second carried gamescope patch adds `--pipewire-composite-cursor`, which paints it in using the same `MouseCursor::paint` call the scanout composite uses; the repaint test grows the cursor's state alongside the commit ids, so a pointer-only move still produces a frame and a hidden cursor is erased. The host reads a monotonic `+pfhdr<N>` patch level from the `--version` banner — one probe now answering both "can it do HDR" and "does it paint the cursor" — and stops attaching the XFixes reader and blending when the answer is yes.
- **The two indirect spawn modes now verify that their flags arrived.** A host-managed `gamescope-session-plus` receives the HDR and cursor flags through `GAMESCOPE_BIN` + `PF_HDR_ARGS`, and SteamOS through a PATH shim — conventions, not guarantees. A session that ignored either would exec the distro's gamescope with none of them, and while the HDR half fails loudly (capture negotiation times out against the bit depth the Welcome already fixed), a lost `--pipewire-composite-cursor` was **silent**: the host had been told the compositor would paint the pointer, so it painted none, and the stream simply had no cursor. Both managed paths now read the running compositor's `/proc/<pid>/cmdline` once its node appears and refuse the session on a missing flag; since the plan is fixed by then (`cursor_blend` feeds the encoder open, which precedes the display), the capability is latched off for the process and the retry resolves a correct SDR host-composited session — one rejected attempt per boot, then it converges. The check fails **open** at every ambiguity: no flags expected, or no readable gamescope in `/proc`, says nothing.
- **`punktfunk-gamescope` is now built by CI on the channels that ship it.** `rpm.yml` builds it in the matching Fedora container and hands it to `build-sysext.sh --gamescope`; `arch.yml` builds `packaging/gamescope/PKGBUILD` into the same pacman repo. Both are cached on `packaging/gamescope/**` (that tree depends on nothing else in the repo, so a normal push restores a binary rather than spending ten minutes on someone else's C++) and both are best-effort, because the packages those workflows exist to publish must not hinge on a gamescope build. The Arch PKGBUILD and the nix derivation had each drifted to a stale patch list — the PKGBUILD naming two patches when there are three, the nix override naming the level-1 banner patch after level 2 landed — so both now read the patch *directory*, and the PKGBUILD delegates the whole build to the shared script (asserting its own pinned rev matches) instead of re-deriving the meson invocation, which had already lost the `force_fallback_for=wlroots` that keeps the binary startable off the build host. The nix override's `gamescope.unwrapped` requirement was wrong on current nixpkgs, where `gamescope` *is* the buildable derivation; it now prefers `.unwrapped` where it exists and checks the result is something `overrideAttrs` can actually patch.
- **The zero-CSC RGB-direct (EFC) source works in HDR too.** The `VK_VALVE_video_encode_rgb_conversion` probe now asks for the BT.2020 model and the captured 10-bit packed-RGB format instead of assuming BT.709/BGRA, and the session selects the matching model — so an HDR session with no pointer to composite (the GameStream desktop mirror) hands the captured buffer straight to the encoder's fixed-function front end and runs no host CSC at all. Sessions that DO composite a pointer keep the compute CSC, as before: the EFC cannot blend.
- **NVIDIA gained a zero-copy HDR leg**, and the VAAPI fallback needed no new encoder code. The VAAPI path already ingested XR30 dmabufs into `format=p010:out_color_matrix=bt2020`. On NVIDIA the packed 10-bit frame now travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 CSC itself: no host CSC pass, no depth loss, and the cursor-blend compute shader gained two 10-bit modes so the pointer survives. The tiled EGL de-tile blit is still 8-bit and HDR never routes through it. A host without the direct-SDK NVENC backend keeps HDR on the CPU path, since libav's HDR route swscales into a P010 hardware frame that a packed-10-bit CUDA buffer cannot fill.
- **CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere.
See [v0.20.1](v0.20.1.md) for the fixes carried by that release (Windows install/winget, GameStream opt-in default, the gamescope Game Mode takeover hardening, a laptop-panel stall fix, and the PyroWave latency-creep bundle) — all included here too, since this release supersedes it.
+127
View File
@@ -0,0 +1,127 @@
Update whenever it suits you — the app and the machine you stream from can be updated one at a time, and everything already paired keeps working. One exception, on Windows only: the host and its virtual-controller drivers now have to match each other. Installing or updating the host the normal way takes care of both; if you ever end up with a mismatched pair, controllers stop attaching until the drivers are updated too.
The headline is **settings profiles**. Until now every client setting was global, so the 4K@120 you picked for the desktop upstairs followed you to the retro box in the basement. You can now make named profiles — Game, Work, Couch — and bind one to each host, and they arrive on every client at once: Linux, Windows, Mac, iPhone, iPad, Apple TV and Android. Alongside them: `punktfunk://` links and double-clickable shortcuts that open a stream on a host you already trust, a new `punktfunk` command for scripts and plugins, and — on Linux — real HDR when you stream Steam's Gaming Mode.
## New: settings profiles — your settings, per host
A profile is a named bundle of the settings you want to be different, and only those. Anything you don't touch keeps following your defaults, so fixing a global setting once fixes it everywhere; anything you do touch stays put even if you later change the default. The only way back to inheriting is an explicit **Reset**, which every changed row offers.
There is one settings screen, not two. A switcher at the top swaps the whole screen between **Default settings** and one profile — same categories, same rows, same explanations — and every row shows the value that is actually in effect. Rows a profile changes are marked, so which settings a profile touches is legible without reading it against your defaults. Rows that are facts about *this device* rather than about Game-vs-Work — which decoder, which speakers, which controller is forwarded, auto-wake — simply aren't offered in a profile.
On the host side, three separate things you can do with a profile:
- **Bind one to a host.** The card wears a chip naming it, so what a plain click will do is visible without opening anything.
- **Connect with one, just this once.** A one-off from the card's menu that never changes the binding — connecting with Work today doesn't mean Work tomorrow.
- **Pin a host + profile as its own card.** *Desktop · Work* sits in the grid next to *Desktop*, one click away. It's a shortcut, not a second host, so pairing, Wake-on-LAN and renames stay on the primary card. On Apple TV and Android TV the same pins are tiles, which is what makes profiles usable where menus are not.
Profiles get a colour you pick when you name them, and it follows them everywhere the profile is named — the switcher, the card chips, the in-stream overlay. Deleting a profile never breaks anything: hosts bound to it fall back to your defaults and its pinned cards stop appearing.
Two long-standing annoyances go away with this. The **speed test** now writes its result into the layer the host you tested actually reads its bitrate from — measuring the slow box downstairs used to quietly re-tune your desktop — and every button says where it will write before you press it. And **Android finally has a speed test at all**, along with a per-host clipboard switch and full parity with the settings the other clients had.
## New: links and shortcuts that open a stream
`punktfunk://` links now work on every client. A browser prompt, a wiki link, an `xdg-open`, a Playnite entry, a Stream Deck macro or a shortcut on your desktop can open a stream on a host this device already trusts — optionally launching a specific game and using a specific profile.
Host and pinned cards can **copy their own link**, and on Linux and Windows they can **write a double-clickable shortcut** straight to your applications folder or desktop. The link carries the host's stable id *and* its address and fingerprint, so a shortcut keeps working after the host moves to a new address or your client is reinstalled.
A link can only ever do what clicking one of your own cards could do, minus trust decisions. It carries *references* to things that already exist on this device — never values, so no web page can dictate your resolution, bitrate or codec. It can never pair with or trust a host on its own: a link naming a host you don't know opens the ordinary PIN ceremony, under your eyes. A link that names a profile you don't have, or a fingerprint that contradicts what you already pinned, is refused by name rather than quietly connecting with the wrong thing. And a link arriving while you're already streaming never cuts that stream off.
## New: the `punktfunk` command
One command-line front end over the same brain the apps use, for scripts, plugins and headless boxes:
```
punktfunk pair | hosts list/add/forget | wake | library | launch | open
reachable | speed-test | profiles list | reset
```
Because it runs the same connect plan a card click runs, `launch` and `open` **wake a sleeping host** and wait for it — the older shell flag never did; it fired a packet and dialled into the void. Exit codes are distinct enough to branch on without parsing prose, and anything that genuinely needs a person (pairing, reset) refuses rather than hanging a CI job on an invisible prompt. It's installed by the deb, rpm, Arch, nix, flatpak and Windows packages.
## New: HDR when you stream Steam's Gaming Mode on Linux (opt-in)
Streaming Steam Gaming Mode from a Linux box has always been SDR — not because the encoder couldn't do better, but because gamescope hands its picture to Punktfunk already tone-mapped down to 8-bit. That gap is now closed, with a small companion package: install **`punktfunk-gamescope`** (gamescope plus a patch that adds the 10-bit BT.2020 PQ formats to its capture output — offered upstream) and set `PUNKTFUNK_GAMESCOPE_HDR=1`, and games render in real HDR while the stream carries HDR10 to an HDR-capable client.
It sits *beside* your system's gamescope rather than replacing it — your own Gaming Mode is untouched — and Punktfunk only uses it for the sessions it starts itself. On Bazzite it rides in the Punktfunk sysext; there's an Arch package that also installs on a Steam Deck, a NixOS option, and a build script for everything else. `punktfunk-host hdr-probe` tells you exactly which pieces are in place. Verified end to end on Bazzite and on SteamOS 3.8.16.
Opt-in for this release while it soaks: without the knob, or without the extra package, the gamescope path streams SDR exactly as before. The same package also lets gamescope draw the mouse pointer into the stream itself, which removes a full-frame conversion pass the host used to pay every frame just to add a cursor.
AMD and Intel hosts benefit from this work even outside gamescope: an HDR stream now stays on the faster encode path instead of dropping to a slower one that also lost loss-recovery, and NVIDIA hosts keep an HDR capture on the zero-copy path rather than falling back to the CPU.
## New: smaller things worth knowing about
- **Host cards show which system the host runs.** A small mark for Windows, macOS, Steam Deck, Bazzite, Arch, Fedora and the rest, on every client and in the web console — and a plain Tux for a distribution nothing recognizes. On the cards and tiles it takes the place of the host's initial, which never said anything the name beside it didn't already say. Hosts that predate this, or run something we ship no mark for, keep their letter, so a mixed row still reads as one set.
- **Name your host whatever you like.** `PUNKTFUNK_HOST_NAME=Living Room` renames it everywhere a human sees it, in Punktfunk's clients and in Moonlight, without renaming the machine. Spaces and accents are fine.
- **Every connect introduces the device by name.** An access request now arrives as *"MacBook Pro wants to connect"* rather than as a fingerprint fragment, and approving one no longer saves that placeholder forever.
- **The Windows tray fits Windows 11** — dark menu, crisp at any scaling, icons — and pops a notification naming the device and mode when a stream starts.
- **The Windows console can list the machine's real monitors.** It previously showed nothing and explained itself with Linux troubleshooting advice. Note that *streaming* one of them is still Linux-only; the picker now says so plainly instead of saving a setting that did nothing.
- **Full chroma (4:4:4)** is now a switch on the Linux and Windows clients, not just on Apple — it's what makes small text and thin lines crisp, so it's a good thing to turn on in a "Work" profile.
- **A frame limiter for the game, not for the stream.** `PUNKTFUNK_MAX_FPS` caps how fast the game renders while the stream keeps its full rate, so the GPU time goes to capture and encode instead — and on a laptop or handheld, to less heat and more battery.
- **A smoother virtual display.** `PUNKTFUNK_VDISPLAY_HZ_MULT=2` runs the virtual display at twice the session's rate without putting one extra frame on the wire, which halves the worst-case wait for a freshly finished frame. Opt-in, since it costs the compositor the extra work.
- **Apple's About page is worth opening**, and the host grid can be sorted and grouped — by name, date added or last connected, and grouped by profile or status.
- **Every download now ships a checksum** next to it on the release page, so `sha256sum -c` is all it takes to verify one.
- **The stream's on-screen stats scale with your display**, instead of rendering at half size on a HiDPI laptop.
- **The Linux app finally has its own icon** in the launcher, taskbar and window switcher on deb, rpm, Arch and nix installs — they had all been shipping a generic monitor glyph.
## Improved
- **Android's settings read like every other client's.** Same categories, same sub-sections, one-line explanations instead of desktop paragraphs, and an About page that names the app and its version.
- **The Windows app's shell got a round of real polish**: proper nested menus on host tiles, one native control for the profile switcher instead of three glued together, sheets that close on Escape or a click outside, and a host editor that is a centred sheet rather than a tile whose controls could end up below the fold.
- **The Linux client's settings rows behave.** A button that appears when you change something no longer slides the control you just clicked out from under the pointer, long explanations stop squeezing the value next to them, and undoing one override changes that one row in place instead of closing and reopening the whole dialog.
- **Host cards in a row are the same height again** on Android, whether or not they carry a profile chip or a long trust label.
- **Unsaved display settings in the web console are visible and recoverable.** The Custom block's Save button sat below the fold, so people edited, navigated away and lost the lot. There's now a badge in the card header, a highlight on the block, and a sticky bar that stays with you — plus a warning if you try to leave or overwrite pending edits.
- **The web console counts paired devices correctly.** Punktfunk's own clients pair on a different plane than Moonlight, and only Moonlight's was being counted — so a perfectly normal setup showed "0 paired".
- **The Steam Deck plugin works with a natively installed client**, not only the flatpak. Pairing, the library and launching all failed on a Deck whose client came from a sysext, a package or a nix profile.
## Fixed
- **Bazzite hosts couldn't update at all.** Every install on the stable channel had started refusing the update feed, correctly — the publisher had been signing a redirect page instead of the actual file list, and had also been quietly dropping older entries from that list for months. Both are fixed and the live feeds are repaired.
- **Android: the picture was stretched whenever the stream didn't match the screen's shape.** Streaming a 16:10 desktop to a 20:9 phone, or anything to a tablet in the wrong orientation, filled the panel and distorted everything in it. The video is now sized to the stream's own proportions, centred, with black bars for the remainder — and touch, multi-touch and pen input land on the picture rather than on the panel, so a tap goes where you aimed it. One deliberate consequence: a trackpad swipe that *starts* on a black bar no longer registers, because input landing on the picture is the rule that has to win.
- **Android: menus were laid out wrong after every stream.** Content shoved to one side, rows sliding under the status bar. Coming back from a stream that ended while the app was in the background left the app using the stream's full-screen measurements for the rest of its life.
- **Android: a black screen with a perfectly healthy overlay.** A box handed the stream mid-picture never received a full frame to start from, and nothing asked for one. Now it asks. A session that receives no video at all also says so in the log, which is what makes the remaining reports diagnosable.
- **Android: a setting changed on the wrong layer.** Switching to a profile and changing a row wrote the change to your defaults instead, and switching back sent the next edit into the profile — which read as "the default settings can't be changed any more".
- **Android: opening a link could end the stream it was meant to leave alone.**
- **Mouse side buttons and iPad keyboards.** On Android, back/forward were dead on mice that report them the way Bluetooth mice and TV boxes tend to. On iPad, every mouse button past the first two clicked *left* on the host, holding a key deleted exactly one character, and scrolling ignored the system's Natural Scrolling setting.
- **The mouse pointer was missing from GNOME streams.** Two separate causes, both found on real hardware: the pointer was never moved onto the screen being streamed, and current GNOME versions never draw a pointer into a virtual stream even when asked to. Punktfunk now draws it itself for those sessions.
- **KDE Plasma: asking for the streamed display to be primary did nothing.** The desktop stayed on the physical monitor while the log claimed success. Current Plasma ignores the request Punktfunk was making; it now sets display order the way Plasma's own tools do, and reads the answer back instead of echoing its own request.
- **A mirrored monitor streamed soft and stuttery.** Mirroring a 4K panel to a client asking for 1080p encoded four times the pixels at the 1080p bitrate. An Automatic bitrate now follows the pixels actually being encoded.
- **Pinning a monitor to stream broke Steam Gaming Mode on the same box.** The pin is host-wide, so booting into a Game Mode session with no monitors to mirror made the host refuse to stream at all instead of streaming normally.
- **Streams looked washed out on some TVs.** Three encode paths shipped video with no colour information at all. Punktfunk's own clients guess right, so this went unnoticed; TV decoders guess from resolution, and an LG webOS panel read a 4K SDR stream as wide-gamut.
- **NVIDIA hosts offered codecs their GPU can't encode.** An older card advertised HEVC, and a client that believed it got about fifteen seconds of blank video and a disconnect. Both platforms now ask the driver what it actually supports.
- **The Linux client demanded a sound-server replacement it never used.** On Arch it was a hard dependency that conflicts with PulseAudio, so anyone running real PulseAudio could not install the client at all; the deb and rpm proposed the same swap more politely. Neither the client nor the host speaks that protocol — only games do, and real PulseAudio serves them fine.
- **The tray crashed at every launch on Debian and Ubuntu.** A build-time detail, fixed where four other packagings already had it right.
- **Streaming on sway or Hyprland destroyed your desktop-portal configuration** — the whole file, on first connect, silently. Punktfunk now changes the one line it needs and leaves everything else byte-for-byte, with a one-time backup.
- **Windows: several ways to end a session left the desk dark.** Recovery paths that turn your panels back on were gated behind the wrong condition, so a failed step meant nothing ever turned them back on. Related: Punktfunk's own virtual display was being counted among your real monitors, which disabled the very last-resort "never leave the desk dark" backstop.
- **Windows: a mid-stream resolution change could leave a phantom monitor behind**, or silently not change the refresh rate it reported changing.
- **Windows: a low-privilege local program could hijack a virtual controller's input channel** and forge input into your desktop. The host now asks Windows itself which process is serving the device instead of trusting a value any local program could write. This is why the host and drivers must now match.
- **Deleting a profile crashed the Windows app**, and pinning appeared to do nothing because the switch was below the fold.
- **Linux: the About dialog silently dropped its third-party licence notices**, printing 16,000 lines of errors instead.
- **Apple: the licence list stranded you with no way back on iPad**, the app icon drew with square corners, profile colours never appeared in menus, and the app described itself as "free software" on a page you reach after paying for it.
- **`nix build .#punktfunk-web` had been broken** since a lockfile refresh, and needed a manual hash round-trip on a Linux machine to fix. It no longer has a hash that can go stale.
- **Fedora and Windows releases can no longer ship unsigned.** Both had a silent fallback: a rotated or missing signing key would have published packages that every user's updater rejects, or a release signed with a throwaway certificate nobody can pin. On a release tag both now fail the build instead.
- **Steam Gaming Mode sessions now prove Punktfunk's settings reached them.** A session that ignored them produced a stream that was correct in every respect except that it had no mouse pointer, and nothing in the logs said so.
## Under the hood (for developers)
- **One protocol number moves.** Streaming protocol stays at **2**, the embeddable C ABI at **13**, and the Windows virtual-display driver protocol at **6** — 0.180.22 hosts and clients keep mixing freely. The Windows **virtual-gamepad channel protocol goes 2 → 3** and fails closed in both directions by design, with the existing "update host + drivers together" diagnostic; the installer ships both, so only a hand-assembled pair can mismatch. `pf-dualsense` is renamed `pf-gamepad` (package identity only — crate, INF/CAT/DLL, UMDF service, log file, env var); the four hardware IDs, the bootstrap mailbox name and `PAD_MAGIC` are wire contract and unchanged, and `driver install --gamepad` retires the pre-rename store package by matching `pf_dualsense.dll` rather than the hardware ids.
- **The gamepad channel's trust root moved from a mailbox to the device stack.** The host duplicated each pad's shared DATA section into the driver's `WUDFHost` using a `driver_pid` read from a LocalService-writable bootstrap mailbox, gated only on the target's image being `%SystemRoot%\System32\WUDFHost.exe` — which is world-executable, so a LocalService principal (notably the de-privileged plugin runner) could spawn its own suspended `WUDFHost`, publish that pid and be handed `SECTION_MAP_READ|WRITE` on a live section: forged HID input into the interactive desktop, and for `pf-mouse` a real absolute pointer. The pid now comes from the devnode the host `SwDeviceCreate`'d, looked up by the instance id PnP handed back, so the kernel does the routing. Three transports, because `HidD_GetIndexedString` is not forwarded to a UMDF HID minidriver at all and a private device interface cannot be opened on a devnode hidclass owns `IRP_MJ_CREATE` for: a private IOCTL for `pf-xusb`, the HID serial string for `pf-mouse`, and HID feature report `0x85` for `pf-gamepad` (no report-descriptor change — the captured descriptors already declared it).
- **Profiles are a sparse override overlay, resolved once.** `pf-client-core::profiles` holds `SettingsOverlay` (sparse `Option`s) and a catalog in its own `client-profiles.json` — deliberately not the settings file, which has five whole-file load-modify-save writers with no merge. Resolution has one implementation, `trust::effective_settings()`: `overlay(profile).apply(global)` where `profile = one-off ?? host binding ?? none`. `absorb` serves per-control shells (compare the effective settings before and after one control fired; the comparison is against what the control was *showing*, so a value equal to today's global still records a pin) and `clear` is the only removal, keyed by the overlay's own field names with `resolution` aliasing the width/height/match-window tri-state. `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`; `upsert` now preserves user-set state against refreshes that carry none of it; all three client stores write temp+rename. Apple mirrors the model field-for-field with unknown-key carry-through, appending `profileID`/`pinnedProfileIDs`/`osChain`/`addedAt` last because that JSON is a frozen app↔widget contract; Android re-keys its host store from `addr:port` to a minted UUID in one migration pass tested against a verbatim pre-migration blob.
- **`punktfunk://` is one grammar with one vector file.** `punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…]`, with a 2 KB cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is a refusal, not a U+FFFD), control characters refused after decoding, and `fp=` held to 64 hex. `pair` parses and is refused so a link can never start a trust ceremony; `pf://` parses as an input alias and is never emitted or registered. Resolution is stable id → unique host name → `addr[:port]`, with `host=`+`fp=` as the reinstall recovery path. `clients/shared/deeplink-vectors.json` is the cross-language contract — 44 cases including every refusal code — run by the Rust, Swift and Kotlin suites from the source tree, so three parsers cannot drift into three security postures.
- **A brain layer now sits under the front-ends.** `ConnectPlan` is a resolved intent with one constructor per door (card click, CLI verb, URL); `ConnectPlan::resolve` is pure, which is what lets the URL router be tested without a config directory. `plan_from_link` holds the deep-link security rules once instead of per shell. `WakeWait` is Apple's `HostWaker` cadence as a pure step function (packet at 0 s and every 6 s, presence polled every second, 90 s budget, then a park rather than an error). Session spawn, its argv and its stdout contract moved here too. `punktfunk-session --resolved-spec <path>` is a new spec mode in which the renderer performs **zero** store reads — it had been re-deriving effective settings, the clipboard decision and the profile name inside the thing that draws pixels — and the match-window write-back is now reported on stdout for the spawner to persist rather than being a sixth concurrent writer of the settings file. `punktfunk-session --pair` prints a deprecation notice and forwards; Decky's `--list-hosts`/`--reachable` flags remain a frozen compat contract.
- **The `os=` advert is additive on two carriers.** The host detects its OS once per process and emits an icon-friendly specificity chain, generic → specific: `windows`, `macos`, `linux[/<family>][/<id>]` (e.g. `linux/fedora/bazzite`, `linux/arch/steamos`), the middle token being the first recognized `ID_LIKE` ancestor and the leaf `ID` verbatim, sanitized to TXT-safe `[a-z0-9._-]`. Clients walk it most-specific-first, so an unknown distro degrades to its family's mark and finally to Tux with zero distro→parent knowledge on the client. Carried by a new advisory mDNS `os=` TXT key (same trust posture as `mac`) and by `HostInfo.os` + `HostInfo.os_name` on the mgmt API; GameStream serverinfo and the QUIC Welcome are untouched. Android's JNI discovery record appends `os` as its eighth `␟`-field, append-only and pinned by tests in both directions. `assets/os-icons/` holds the ten master SVGs each platform derives from (Font Awesome Free brands CC BY 4.0 + Simple Icons CC0, folded into `THIRD-PARTY-NOTICES.txt`).
- **Vulkan Video learned 10-bit, and stopped guessing what it can do.** An HDR session opens a Main10 profile with 10-bit component depths, a `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture + DPB, and an SPS carrying `bit_depth_*_minus8 = 2` with the BT.2020/PQ CICP triplet; AV1 carries `high_bitdepth` plus the matching sequence-header OBU bits, which sit *before* the CICP bytes, so getting them wrong puts every following field one bit out of phase (the new test reads the packed bits back). `rgb2yuv10.comp` is a pure BT.2020 NCL 3×3 matrix — the samples arrive already PQ-encoded, so there is no transfer function to apply and applying one would be wrong — writing into the high bits of `R16`/`RG16` scratch planes merely size-compatible with the picture's. `probe_encode_support` became `VulkanEncodeCaps { supported, eight_bit, ten_bit }`, answered by `vkGetPhysicalDeviceVideoCapabilitiesKHR` against the very profile chain the session open builds, so an incapable device routes to VAAPI *before* burning a failed open; `can_encode_10bit` now reports the union of VAAPI's and Vulkan Video's answers rather than VAAPI's alone.
- **NVIDIA gained a zero-copy HDR leg and an honest codec advertisement.** A packed 10-bit frame travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 conversion itself: no host CSC pass, no depth loss. HDR never routes through the tiled EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture), and a host without the direct-SDK backend keeps the CPU path, since libav's HDR route swscales into a P010 hardware frame a packed-10-bit CUDA buffer cannot fill. Separately, both platforms now probe `nvEncGetEncodeGUIDs` on one throwaway direct-SDK session instead of advertising a static H.264|HEVC|AV1 superset, wired into `host_wire_caps` *and* the GameStream serverinfo mask; it fails open, so it can only ever narrow. The HEVC 4:4:4 answer rides the same session rather than a libav `hevc_nvenc` FREXT probe — that open is the prime suspect for the field bug where one probe wedges NVENC process-wide with `NV_ENC_ERR_INVALID_VERSION`.
- **Four encode paths were emitting no colour description.** Vulkan Video HEVC built an SPS with no VUI at all (the default backend for AMD/Intel Linux hosts), Vulkan AV1 packed `color_description_present_flag = 0`, openh264 wrote nothing, and the libav-NVENC Linux path excluded packed-RGB 4:2:0 on the belief that NVENC writes its own VUI (libavcodec derives it from the `AVCodecContext` colour fields). All four now signal BT.709 limited, which is what every host CSC actually produces. Two tests parse the real emitted bitstream rather than re-asserting constants. GameStream's `ServerCodecModeSupport` gains `SCM_AV1_MAIN10`, each 10-bit bit gated on that codec's own `can_encode_10bit` **and** the SDR baseline already advertising it; `host_hdr_capable` became codec-agnostic and the RTSP honor degrades a session whose *negotiated* codec can't carry 10 bits.
- **D3D11 multithread protection is now enabled before libav sees the device.** libav turns it on in `d3d11va_device_create`; we take `d3d11va_device_init` because we hand it the capturer's existing `ID3D11Device`, and nothing enabled it on our side. Two consequences, one shipping for a long time: `av_hwdevice_ctx_create_derived(QSV ← D3D11VA)` was rejected by MFX as `MFX_ERR_UNDEFINED_BEHAVIOR (-16)` (reported as the uninformative "Error setting child device handle"), and AMF — the default Windows zero-copy path — was running with libav's `d3d11va_default_lock`, whose `ID3D11Multithread::Enter`/`Leave` are documented no-ops while protection is off, so the lock serialising our capture thread against its encode thread had never serialised anything. Measured on Intel UHD 750: protection is the only variable that matters, and it read back `was=false` every time. QSV still defaults off.
- **gamescope carries three patches and a monotonic marker.** The PipeWire node additionally offers `xRGB_210LE`/`xBGR_210LE` with mandatory SMPTE ST.2084 + BT.2020 props (spelled out numerically — PipeWire 1.4.11 on Fedora 43 has no `SPA_VIDEO_TRANSFER_SMPTE2084`), `paint_pipewire()` composites into them with the HDR screenshot LUT and `EOTF_PQ`, and `--pipewire-composite-cursor` paints the pointer with the same `MouseCursor::paint` the scanout composite uses. New formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit. The `--version` banner stamps `+pfhdr<N>` as a **patch level**, because punktfunk fixes a session's bit depth in the Welcome before the display exists and PQ frames on an 8-bit encoder are a deliberate hard error — so the capability answer must be a static property of the binary that will be spawned, never an optimistic negotiation. Level 1 = HDR formats, level 2 = the cursor flag; `cursor_blend_for` and the `gamescope_cursor` resolver consult it through one helper because the reader without the blend is a wasted X11 connection and the blend without the reader is a stream with no pointer. The build forces `force_fallback_for=libliftoff,vkroots,wlroots` — Fedora 44's `builddep` pulls in `wlroots-devel`, and meson then links it shared, producing a binary that starts only inside the build container. For the same reason the **C++ runtime is linked statically**: the Arch container builds against gcc 16.1.1 while SteamOS 3.8.16 ships libstdc++ 3.4.34, so the pacman package died at `--version` with `GLIBCXX_3.4.35 not found` on the gamescope backend's most important platform. It is safe here because gamescope links no shared C++ library at all — its `NEEDED` list is all C, and glslang/SPIRV are build-time only — so no C++ ABI crosses a shared boundary; the cost is ~1 MB. The flags are appended to `LDFLAGS` rather than passed as `-Dcpp_link_args`, which would replace meson's environment-derived value and silently drop makepkg's `-z relro`/`-z now`/`--as-needed`, and the build now asserts no `libstdc++` in `NEEDED`, since a static runtime is otherwise invisible in a passing build and surfaces only as a binary that will not start somewhere else. Both managed spawn modes (`gamescope-session-plus` via `GAMESCOPE_BIN`+`PF_HDR_ARGS`, SteamOS via a PATH shim) now read the running compositor's `/proc/<pid>/cmdline` once its node appears and refuse the session on a missing flag, latching the capability off for the process so the retry converges on a correct SDR host-composited session; it fails open at every ambiguity.
- **The cursor decision is now source- and session-scoped.** `capturer_supports_hdr_for(compositor)` replaced the flat `false`; the HDR-negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa); the keep-alive reuse key gained `hdr`, since gamescope cannot turn HDR on live. `cursor_blend_for` grew a **no-channel** arm: the capture-latched console client never advertises `CLIENT_CAP_CURSOR`, so its session asked Mutter to *embed* the pointer — a fiction since Mutter 48 removed hw-cursor inhibition, where the software overlay is suppressed stage-globally whenever any physical head realizes a HW cursor, and cursor-only motion schedules no re-record (mutter#4939). Probed on Mutter 50.3: embedded + relative motion froze the frame counter while `SPA_META_Cursor` positions kept flowing. Those sessions now take cursor-as-metadata + host composite permanently; embedded survives only as the can't-blend fallback. The stream loop also parks the seat pointer at the streamed surface's centre through the session's own input pipeline, retried on a schedule because the first park can land on a still-cold EIS connection — a pointer-lock client sends only relative deltas, so nothing else ever moves the pointer into a freshly created virtual output.
- **libei absolute-coordinate resolution gained a scale rung.** A display scale *s* shrinks an output's EI region to logical pixels (Mutter advertises 853×533 for a 1280×800 output at 1.5), so the exact-size rung missed every scaled output and absolute input fell through to `regions.first()`. A new rung between exact and first requires one consistent factor (1..=4, fractional included) to map region onto mode on both axes, with per-axis rounding slack.
- **`GET /display/monitors` answers on Windows.** `monitors::list` was a per-compositor dispatch whose non-Linux arm bailed, and `detect()` wasn't `cfg`-gated, so a Windows host returned an empty list plus a verbatim Linux error string about `PUNKTFUNK_COMPOSITOR`. `target_inventory()` already walked the CCD database and now reports the geometry it had in hand. Two fields are reported honestly rather than invented: `scale` is always 1.0 (Windows scaling is per-application per-monitor DPI, not a compositor-global logical scale, so the geometry is pixels) and an inactive head gets zeroed geometry, because CCD mode indices are only valid for active paths. `MonitorsResponse.pin_supported` is a new **capability** reported by the build that would have to honor a pin — per-monitor capture is Linux/portal only, since `pf-capture`'s sole Windows entry point is `open_idd_push` from our own IddCx display and DXGI Desktop Duplication was deliberately removed — and a non-Linux `capture_monitor` in the whole-object PUT is coerced away with a log line rather than 400'd, so a stored pin self-heals instead of failing every later settings save. It defaults to `true` when absent, so an older host isn't retroactively locked out.
- **The `pf-vdisplay` sweep landed its contained half.** A gamescope AB/BA lock inversion between a connect and the restore worker; `observe_session_instance` holding `LAST_INSTANCE` across `invalidate_backend` and a 10 s `systemctl` shell-out; `admit` holding the live-session table across budget checks that block on the manager `state` lock (itself held across DDC round trips and 3 s activation ladders); GameStream never registering its display, so both Windows budgets were blind to it; and `ensure_exclusive_watch` panicking on a failed thread spawn while holding both `exclusive_watch` and `state`, poisoning the two locks the manager runs on. Mutter's `create` timeout used to drop its stop flag *without setting it*, leaving a thread that had already made the virtual output primary parked forever holding the D-Bus connection that is the monitor's lifetime — under the default topology that orphan applies a sole-monitor `APPLY_TEMPORARY` config Mutter only reverts once the virtual monitor disappears, with no in-process recovery. The gamescope sub-mode travels as a `GamescopeRoute` return value carried on the backend instance instead of being published into `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` and read back with the lock released in between; `ENV_LOCK` now covers detection's readers too, sampled into one `EnvProbe` (glibc `setenv` can realloc `environ` and free the old string, and the session watcher reads those five keys every second). Helper processes are enrolled in a Job object, since `Child::kill` is one `TerminateProcess` and every Windows helper is reached through a shell — the hanging process is a grandchild, which is why a green `cargo test -p pf-vdisplay` still failed its CI job by orphaning a 60-second `ping.exe` that held the build step's stdout pipe.
- **`query_active_config` treats zero active paths as an answer.** Windows rejects a zero-count `QueryDisplayConfig` rather than returning an empty set, so "every panel off, a KVM switched away, a headless box between adapter and first monitor" came back as "the query failed" — which is exactly the teardown-gate condition whose recovery legs exist to stop the operator's panels being left dark. Measured on an RTX 4090 / Win11 26200 with the TV powered off: `numPaths = 0`, then `0x57 ERROR_INVALID_PARAMETER` from a console session (`0x5 ERROR_ACCESS_DENIED` from session 0). Related: targets carrying our own EDID manufacturer id (`PNK`, matched on the monitor **device path** in both `#` and `\` spellings) are now classified `external_physical = false`, so `restore_displays_ccd`'s last-resort `force_extend_topology` can actually fire — it never could, because the restore runs before the virtual is REMOVEd and our own display kept `lit >= 1`.
- **KWin's `set_primary_output` handler is literally `// intentionally ignored`.** Output order is driven by per-output `set_priority` (management ≥ 3; we bind up to v22 and never called it) — exclusive topology only ever *looked* right because disabling every other output leaves KWin nothing else to promote. Ours now takes priority 1 with every other enabled output renumbered uniquely behind it, and `primary_taken` (which echoed the request) became `primary_verified`, read back after one sync barrier.
- **The unsafe-proof program finished.** `clippy::undocumented_unsafe_blocks` cannot see an unsafe operation sitting directly in an `unsafe fn` body, so `unsafe_op_in_unsafe_fn` is denied workspace-wide and the three previously exempt crates (`punktfunk-core` 167 items, `pf-presenter` 123, `pf-client-core` 91) now deny both — with the recurring shapes stated once per crate (the C ABI contract in `abi.rs`, the Vulkan contract in `pf-presenter`'s `lib.rs`, split into CREATE/RECORD/DESTROY because only DESTROY carries a real precondition) rather than 141 restatements of a signature. Eleven hand-written `#[repr(C)]` mirrors of external C structs — the sharpest remaining memory-safety risk, since a wrong field offset compiles and doesn't reliably crash — are now `const _: () = assert!(..)` layout-checked on every build: `AVCUDADeviceContext`, the `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` pair (duplicated verbatim in two crates that can't depend on each other), the six cuda.h structs, Darwin's `msghdr_x`, and `IPolicyConfigVtbl`, which mirrors an undocumented COM interface called by slot index through a ten-entry unnamed `_reserved` gap. ⚠️ A Linux-only survey of a cross-platform crate undercounts by whatever `cfg` hides — here by 3× — so every crate had to be re-verified on Windows after being "done".
- **Supply chain and release integrity.** Per-release SBOM, full-tree `cargo`/`bun` audits and a real license gate; every release asset now gets a `<asset>.sha256` sidecar written in `upsert_asset` (sidecars rather than one `SHA256SUMS`, because eight workflows attach to the same release object concurrently and a shared manifest would be a read-modify-write race — and the PowerShell twin writes LF with no BOM, since GNU `sha256sum` folds a trailing CR into the filename). The Bazzite sysext feed carries `SHA256SUMS.asc`, a detached OpenPGP signature verified against a **baked-in** `packages@unom.io` key (`AF245C506F4E4763`, the same key that signs the RPMs) — a key fetched from the feed you're authenticating authenticates nothing — with `PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1` as the informed way through and a `--seal` mode that re-signs an existing manifest without rebuilding an image. The publisher's bug is worth recording: the registry answers a file GET with a 303 to presigned storage and `curl -f` does **not** treat 3xx as an error, so two un-`-L`'d reads "succeeded" holding `<a href="…">See Other</a>.``--seal` signed that HTML page (whose presigned URL expires in 300 s, so the `.asc` covered bytes that exist nowhere), and the publish merge kept it while `grep -v` dropped every prior image line. Both reads now go through one `read_manifest` that follows redirects and keeps only well-formed `<sha256> <filename>` lines. The Windows drivers gained one stable publisher identity (thumbprint `4B8493E7CD565758D335F8F4F05C5A7261A13E02`, RSA 3072, valid to 2036) delivered via `DRIVER_CERT_PFX_B64`; `driver install` purges stale `CN=punktfunk-driver` certs before adding and `driver uninstall` removes them entirely, closing the leak where every upgrade added two more self-signed machine roots that nothing ever removed. The decoded `.pfx` is now deleted after the last signing step and from a script-scope trap. Fail-closed guards on `refs/tags/v*` cover the MSIX cert, the host installer cert and the RPM GPG key. This does **not** authenticate the driver download — a self-signed leaf is its own root, so the installer must trust it for PnP to proceed; attestation signing remains the real fix.
- **New knobs.** `PUNKTFUNK_HOST_NAME` overrides the GameStream serverinfo `<hostname>` element and the mDNS service instance name; `dns_label()` sanitizes the A-record target separately and passes an already-legal name through byte-for-byte, because `mdns-sd` rejects the whole `ServiceInfo` on an illegal target (which would take discovery down rather than merely look wrong), and the display name loses `.` since clients derive the name from the first label. `PUNKTFUNK_MAX_FPS` becomes gamescope's `--nested-refresh` on all three sessions we own, landing on `PF_HZ` alone and not `CUSTOM_REFRESH_RATES` so the advertised mode stays the client's; the attach path has no lever and is untouched. `PUNKTFUNK_VDISPLAY_HZ_MULT` multiplies the display's rate while the pacing rate becomes the session's rate floored by the achieved one — the same value as before whenever the knob is unset. `PUNKTFUNK_OSD_SCALE` scales the stream chrome, which now reads SDL's window display scale per frame and quantizes it into the damage key, re-deriving the typeface at the scaled size rather than transforming the canvas.
- **Capture-stall attribution v1.** The driver's shared ring header grows a v2 telemetry tail (drain-loop heartbeat QPC, last-acquire QPC, full-width offered counter) that the host samples between fresh frames to attribute each stall as worker-stalled / compose-silence / delivery-leg, version-safe in both directions (gated on the host-stamped header version; a zero heartbeat reads as a pre-telemetry driver). A refcounted micro-probe engine (per-adapter fence round-trip, `DwmGetCompositionTimingInfo` tick, watchdogged `DwmFlush`, `D3DKMTGetScanLine`, a CPU jitter sentinel) samples on detached threads, and an event-id-filtered real-time ETW session on `Microsoft-Windows-DxgKrnl` rides every stall line as a DDI bracket summary. Degrades to `etw=unavailable` without admin; absence is stated, never guessed. Also: a capture's meta now records the encoder and GPU that produced it, read once per capture from `pf_gpu::active()`.
- **CI restructure.** The five builder images moved to a LAN registry under content keys (a hash of the `ci/` tree), built only when the key has no manifest, and releases pin them by copying the key manifest to a `vX.Y.Z` tag via the registry API — no rebuild, no bytes moved. sccache backs every Rust job through one S3 bucket with `CARGO_INCREMENTAL=0`; Android and Arch got baked images (Android was downloading ~3 GB of SDK/NDK per run, Arch ~1 GB of pacman); Apple got sccache plus a pinned `DerivedData` root. Path filters stop docs-only pushes lighting up the whole fleet, concurrency groups let a newer push supersede a queued run, and the report-only bench job moved to its own nightly workflow. The nix flake's two bun fixed-output derivations were replaced with bun2nix 2.1.2, so there is no aggregate hash left to go stale.
- **Two packaging traps recorded.** `scripts/ci/gitea-release.sh` is sourced under POSIX `sh` (dash) on the deb and decky attach steps, so no bash-isms; and the tray must be built in its **own** cargo invocation, because cargo feature unification hands it a tokio-flavoured `zbus` with no tokio runtime anywhere near it when co-built with the host.
+55 -9
View File
@@ -17,7 +17,9 @@
# <feed> e.g. f43, f43-canary, f44 (Fedora major x channel)
# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all
# --seal re-sign a feed's EXISTING manifest without publishing an image. For feeds published
# before signing existed, and after a key rotation. Idempotent.
# before signing existed, and after a key rotation. Idempotent. Also re-publishes the
# manifest if normalizing it changed anything, because the signature has to cover the
# bytes a client downloads.
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name),
# RPM_GPG_PRIVATE_KEY (armored private key; absent => unsigned, fatal on a v* tag)
set -euo pipefail
@@ -40,6 +42,28 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
SUMS="$WORK/SHA256SUMS"
SIG="$WORK/SHA256SUMS.asc"
FETCHED="$WORK/SHA256SUMS.fetched" # exactly what the registry served, before normalization
# read_manifest -> the feed's current manifest, normalized into $SUMS and kept verbatim in
# $FETCHED. Returns non-zero iff the feed has no manifest at all.
#
# -L is not optional here. The registry answers a file GET with a 303 See Other pointing at
# presigned object storage, and `curl -f` does NOT treat a 3xx as an error — so without -L the call
# "succeeds" and hands back the redirect's HTML body ('<a href="…">See Other</a>.'). Both callers
# then took that page for the manifest: every publish prepended a stale redirect page and dropped
# every prior image line, and --seal signed a page whose presigned URL expired 300 seconds later —
# a signature over bytes that exist nowhere. Clients fetch WITH -L, so they checked the real
# manifest against that signature and refused the feed, which from a Bazzite box is indistinguishable
# from someone having tampered with it.
#
# The line filter is the second layer, and the one that does not depend on getting curl's flags
# right: whatever the transport hands back, only well-formed "<sha256> <filename>" lines are ever
# signed or re-published. It also scrubs a feed that already carries an injected page.
read_manifest() {
: > "$SUMS"; : > "$FETCHED"
curl -fsSL "${AUTH[@]}" -o "$FETCHED" "$BASE/SHA256SUMS" || return 1
grep -E '^[0-9a-f]{64} [^ ]+$' "$FETCHED" > "$SUMS" || :
}
# sign_manifest — detached-sign $SUMS into $SIG with RPM_GPG_PRIVATE_KEY. Prints nothing and
# returns 1 if no key is available; the caller decides whether that is survivable.
@@ -88,14 +112,29 @@ require_signature() {
# --seal: re-sign whatever manifest the feed already has, no image, no pruning.
if [ "$SEAL" = 1 ]; then
curl -fsS "${AUTH[@]}" -o "$SUMS" "$BASE/SHA256SUMS" \
|| { echo "no SHA256SUMS at $BASE — nothing to seal" >&2; exit 1; }
sign_manifest || require_signature
if [ -f "$SIG" ]; then
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
echo "sealed $BASE ($(wc -l <"$SUMS") image(s))"
read_manifest || { echo "no SHA256SUMS at $BASE — nothing to seal" >&2; exit 1; }
# An empty result means the manifest was ALL junk. Signing that would hand clients a feed that
# verifies and offers no images, which reads as "up to date" to `punktfunk-sysext update`.
if [ ! -s "$SUMS" ]; then
echo "$BASE/SHA256SUMS lists no images — refusing to seal it (feed needs a republish)" >&2
exit 1
fi
if ! sign_manifest; then
require_signature # non-release: warn and leave the live feed exactly as it was
exit 0
fi
# The signature must cover the bytes a client actually downloads, so a manifest that normalizing
# changed gets re-published with it — otherwise the .asc would describe a file the registry does
# not have, which is the very failure this is repairing. Manifest first, signature second, and
# neither when the stored copy was already clean.
if ! cmp -s "$SUMS" "$FETCHED"; then
echo "normalizing $BASE/SHA256SUMS: $(grep -c '' <"$FETCHED") line(s) served, $(grep -c '' <"$SUMS") kept"
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
fi
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
echo "sealed $BASE ($(grep -c '' <"$SUMS") image(s))"
exit 0
fi
@@ -103,7 +142,14 @@ FNAME="$(basename "$RAW")"
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
# Merge into the existing manifest: drop any prior line for this filename, append ours.
curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
if read_manifest; then
sed -i "\| $FNAME\$|d" "$SUMS"
# Said out loud on purpose. A manifest that silently shrinks is how a feed loses its rollback
# history, and that went unnoticed precisely because nothing ever reported the carry-over.
echo "carrying forward $(grep -c '' <"$SUMS") image(s) from the existing manifest"
else
echo "no manifest at $BASE yet — starting a new feed"
fi
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry.
@@ -95,6 +95,19 @@ echo "==> configuring"
# there would not start on the host. Pinning the fallback makes the outcome the same everywhere.
# (gamescope's own meson.build hard-errors if libliftoff/vkroots are missing from this list, so
# all three go together.)
#
# The C++ runtime goes STATIC for the same reason wlroots does: this binary is built on a ROLLING
# distro and has to start on a FROZEN one. Arch's gcc (16.1.1 when this was written) makes the
# compositor require `GLIBCXX_3.4.35`, and SteamOS 3.8.16 ships libstdc++ 3.4.34 — so the published
# Arch package died on the very platform the gamescope backend matters most on ("version
# GLIBCXX_3.4.35 not found"), while every other soname resolved and glibc was never close to the
# limit (the binary asks for 2.38 at most; SteamOS has 2.41). Safe because
# gamescope links NO shared C++ library (its `NEEDED` list is all C — glslang/SPIRV are build-time
# only), so no C++ ABI ever crosses a shared boundary; it costs ~1 MB.
# Appended to LDFLAGS rather than passed as `-Dcpp_link_args`, because that option would REPLACE
# the value meson derives from the environment and silently drop makepkg's hardening flags
# (`-z relro`, `-z now`, `--as-needed`).
export LDFLAGS="${LDFLAGS:-} -static-libstdc++ -static-libgcc"
meson setup "$BUILD" "$SRCDIR" \
--prefix="$PREFIX" \
--buildtype=release \
@@ -112,6 +125,17 @@ ninja -C "$BUILD" ${JOBS:+-j "$JOBS"}
# distro's gamescope package — and we need none of them: the host only ever execs the compositor.
BIN="$BUILD/src/gamescope"
[ -x "$BIN" ] || { echo "build produced no $BIN" >&2; exit 1; }
# The static C++ runtime above is invisible in a successful build and only shows up as a binary
# that will not start on an older distro — so assert it here, where a mistake is a build failure
# instead of a package that dies at `--version` on SteamOS. No `libstdc++.so.6` in NEEDED is the
# whole invariant (and it needs no version threshold to check).
if command -v objdump >/dev/null; then
objdump -p "$BIN" 2>/dev/null | grep -q 'NEEDED.*libstdc++' && {
echo "built binary links libstdc++ dynamically — the static C++ runtime did not take, and this" >&2
echo "package would not start on a distro older than the build host" >&2
exit 1
}
fi
DEST="${DESTDIR}${PREFIX}/bin/punktfunk-gamescope"
echo "==> installing $DEST"
install -Dm755 "$BIN" "$DEST"
+1 -1
View File
@@ -326,7 +326,6 @@ done
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
# The session streamer the shell execs for a connect (resolved as its sibling in %{_bindir}).
install -Dm0755 target/release/punktfunk-session %{buildroot}%{_bindir}/punktfunk-session
%{_bindir}/punktfunk
# The headless CLI (design/client-architecture-split.md §4).
install -Dm0755 target/release/punktfunk %{buildroot}%{_bindir}/punktfunk
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
@@ -450,6 +449,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%{_bindir}/punktfunk-client
%{_bindir}/punktfunk-session
%{_bindir}/punktfunk
%{_datadir}/applications/io.unom.Punktfunk.desktop
%{_datadir}/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg
%{_udevrulesdir}/70-punktfunk-client.rules
@@ -35,6 +35,7 @@ features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Memory",
"Win32_System_Performance",
"Win32_System_Threading",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
@@ -31,7 +31,8 @@ use std::time::Instant;
use pf_driver_proto::control::SetFrameChannelRequest;
use pf_driver_proto::frame::{
AttachReject, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
DRV_STATUS_TEX_FAIL, FrameToken, RING_LEN, SharedHeader, check_attach, pack_opened_detail,
DRV_STATUS_TEX_FAIL, FrameToken, RING_LEN, SharedHeader, VERSION_TELEMETRY, check_attach,
pack_opened_detail,
};
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Graphics::Direct3D11::{
@@ -43,6 +44,7 @@ use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex;
use windows::Win32::System::Memory::{
FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, UnmapViewOfFile,
};
use windows::Win32::System::Performance::QueryPerformanceCounter;
use windows::Win32::System::Threading::SetEvent;
use windows::core::Interface;
@@ -292,6 +294,13 @@ pub struct FramePublisher {
/// "DWM never composed" from "every compose mismatched the ring".
offered: u32,
mismatch_drops: u32,
/// Whether the HOST created a telemetry-capable (v2, 88-byte) header — stamped `version >=
/// VERSION_TELEMETRY` at attach. Gates every write to the telemetry tail: a v1 host's header
/// is 64 bytes, and the tail fields would land past the layout it reads.
telemetry: bool,
/// Full-width wrapping sibling of `offered` (which packs to 15 saturating bits) — mirrored
/// into `SharedHeader::offered_total` so the host can delta it across a stall window.
offered_total: u64,
/// The slot of the most recent successful publish + when it happened — what [`Self::harvest_into`]
/// reads when this publisher is superseded. `None` until the first publish.
last_published: Option<(u32, Instant)>,
@@ -327,20 +336,16 @@ impl FramePublisher {
// 1. Map the header from the duplicated section handle (ours from here on).
let map = FrameChannel::take(&mut channel.header);
// SAFETY: `map` is the live section handle the host duplicated into this process; mapping
// size_of::<SharedHeader>() bytes of it (the host created the mapping at >= that size). The null
// `view.Value` is checked below.
// SAFETY: `map` is the live section handle the host duplicated into this process; a byte
// count of 0 maps the WHOLE section — a v1 host created a 64-byte (pre-telemetry) header,
// so requesting size_of::<SharedHeader>() (the v2 88 bytes) could exceed what that host
// declared, while 0 always fits and always covers the layout the host actually built (the
// `telemetry` version gate keeps our writes inside it). The null `view.Value` is checked below.
let view = unsafe {
// Read/write only — the host now duplicates the header handle with least access
// (`SECTION_MAP_READ | SECTION_MAP_WRITE`), so `FILE_MAP_ALL_ACCESS` would exceed the
// granted rights and fail. We read the layout + write status/publish-token fields; RW covers it.
MapViewOfFile(
map,
FILE_MAP_READ | FILE_MAP_WRITE,
0,
0,
core::mem::size_of::<SharedHeader>(),
)
MapViewOfFile(map, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0)
};
if view.Value.is_null() {
let err = windows::core::Error::from_win32();
@@ -370,15 +375,16 @@ impl FramePublisher {
// into another client's ring. The shared `check_attach` (unit-tested in pf-driver-proto)
// owns the precedence: staleness first, binding second.
// SAFETY: `header` is the mapped host header; `magic`/`generation` live within it and are read
// atomically (Acquire) to pair with the host's Release publishes; `target_id` is a plain
// in-bounds u32 read, stamped before the magic the Acquire load ordered us behind.
let (magic, header_gen, header_target) = unsafe {
// atomically (Acquire) to pair with the host's Release publishes; `target_id`/`version` are
// plain in-bounds u32 reads, stamped before the magic the Acquire load ordered us behind.
let (magic, header_gen, header_target, header_version) = unsafe {
(
(*(core::ptr::addr_of!((*header).magic) as *const AtomicU32))
.load(Ordering::Acquire),
(*(core::ptr::addr_of!((*header).generation) as *const AtomicU32))
.load(Ordering::Acquire),
(*header).target_id,
(*header).version,
)
};
match check_attach(
@@ -518,14 +524,46 @@ impl FramePublisher {
mismatch_logged: false,
offered: 0,
mismatch_drops: 0,
telemetry: header_version >= VERSION_TELEMETRY,
offered_total: 0,
last_published: None,
render_luid_low,
render_luid_high,
})
}
/// v2 telemetry tail, drain side (stall attribution): stamp the heartbeat on EVERY drain-loop
/// pass — and the last-acquire on a pass that actually acquired a composed frame — so the host
/// can split a capture stall into "our worker starved" (heartbeat went stale) vs "the worker
/// drained E_PENDING the whole hole — DWM composed nothing" (heartbeat fresh, last-acquire
/// stale). Gated on the host's stamped header version (see the `telemetry` field docs);
/// best-effort Relaxed stores, the `driver_status` visibility contract.
pub fn note_drain(&self, acquired: bool) {
if !self.telemetry {
return;
}
let mut qpc = 0i64;
// SAFETY: plain FFI; `qpc` is a valid local out-param. QPC cannot fail on any OS we load on.
if unsafe { QueryPerformanceCounter(&mut qpc) }.is_err() {
return;
}
// SAFETY: `self.header` stays mapped for the publisher's lifetime and the version gate above
// proves the host built the v2 (88-byte) layout; both fields are naturally-aligned u64s
// within it, valid for `AtomicU64` views (the same pattern as `latest_cell`).
unsafe {
(*(core::ptr::addr_of!((*self.header).drain_heartbeat_qpc) as *const AtomicU64))
.store(qpc as u64, Ordering::Relaxed);
if acquired {
(*(core::ptr::addr_of!((*self.header).last_acquire_qpc) as *const AtomicU64))
.store(qpc as u64, Ordering::Relaxed);
}
}
}
/// Mirror the live diagnostic counters into the header's detail word (proto
/// `pack_opened_detail`) — read by the host's first-frame timeout to name a no-frames failure.
/// `pack_opened_detail`) — read by the host's first-frame timeout to name a no-frames failure
/// plus, on a telemetry-capable (v2) header, the full-width `offered_total` the host deltas
/// across a stall window (the packed 15-bit counter saturates, so it can't be delta'd).
#[inline]
fn write_opened_detail(&self) {
// SAFETY: `self.header` stays mapped for the publisher's lifetime (unmapped only in Drop);
@@ -534,6 +572,14 @@ impl FramePublisher {
(*self.header).driver_status_detail =
pack_opened_detail(self.offered, self.mismatch_drops);
}
if self.telemetry {
// SAFETY: the version gate proves the host built the v2 (88-byte) layout;
// `offered_total` is a naturally-aligned u64 within it (the `latest_cell` pattern).
unsafe {
(*(core::ptr::addr_of!((*self.header).offered_total) as *const AtomicU64))
.store(self.offered_total, Ordering::Relaxed);
}
}
}
#[inline]
@@ -624,6 +670,7 @@ impl FramePublisher {
// header's detail word — what lets the host's first-frame timeout tell "DWM never composed"
// from "every compose mismatched the ring". Written once per call, after the outcome is known.
self.offered = self.offered.saturating_add(1);
self.offered_total = self.offered_total.wrapping_add(1);
if desc.Format.0 as u32 != self.ring_format || desc.Width != rw || desc.Height != rh {
self.mismatch_drops = self.mismatch_drops.saturating_add(1);
self.write_opened_detail();
@@ -485,20 +485,22 @@ pub fn set_cursor_channel(
/// genuine teardown, not a flap: the entry was already removed) so the caller drops it, closing the ring
/// handles. Replacing an already-stashed publisher (should not happen — one worker exits at a time)
/// drops the old one, so it can never accumulate. Returning the publisher in the `Err` makes the
/// `Result` itself `#[must_use]`, so a caller can't silently drop the not-preserved publisher.
/// `Result` itself `#[must_use]`, so a caller can't silently drop the not-preserved publisher; it
/// rides boxed (the struct outgrew clippy's 128-byte `result_large_err` bar with the v2 telemetry
/// fields, and this path runs once per worker exit — the allocation is free).
pub fn preserve_publisher(
target_id: u32,
publisher: crate::frame_transport::FramePublisher,
) -> Result<(), crate::frame_transport::FramePublisher> {
) -> Result<(), Box<crate::frame_transport::FramePublisher>> {
if target_id == 0 {
return Err(publisher);
return Err(Box::new(publisher));
}
let mut lock = lock_monitors();
if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) {
m.preserved_publisher = Some(publisher);
Ok(())
} else {
Err(publisher)
Err(Box::new(publisher))
}
}
@@ -408,6 +408,15 @@ impl SwapChainProcessor {
)
};
// v2 telemetry (stall attribution): stamp the drain heartbeat — plus the last-acquire
// on a pass that got a composed frame — into the shared header EVERY pass, E_PENDING
// included (the wait below is ≤16 ms, so the heartbeat cadence bounds how stale it can
// read while this thread is scheduled). What lets the host split a capture stall into
// worker-starved / DWM-composed-nothing / our-delivery-leg.
if let Some(p) = publisher.as_ref() {
p.note_drain(hr_success(hr));
}
if (hr as u32) == E_PENDING {
if !logged_pending {
dbglog!(
+1
View File
@@ -16,6 +16,7 @@ repository.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
pf-vdisplay = { path = "../../crates/pf-vdisplay" }
pf-capture = { path = "../../crates/pf-capture" }
pf-frame = { path = "../../crates/pf-frame" }
pf-inject = { path = "../../crates/pf-inject" }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
anyhow = "1"
+58 -1
View File
@@ -153,6 +153,19 @@ mod linux {
}
});
let dump = args.iter().any(|a| a == "--dump");
let dump_dir = format!(
"/tmp/pf-probe-frames-{}",
if embedded { "embedded" } else { "metadata" }
);
if dump {
let _ = std::fs::remove_dir_all(&dump_dir);
let _ = std::fs::create_dir_all(&dump_dir);
}
let mut prev: Vec<u8> = Vec::new();
let mut changed = 0u64;
let mut dumped = 0u32;
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut frames, mut with_overlay) = (0u64, 0u64);
let mut last_report = Instant::now();
@@ -161,6 +174,24 @@ mod linux {
// Damage-driven source: timeouts (gaps) are normal, hence the missing error arm.
if let Ok(f) = cap.next_frame_within(Duration::from_millis(500)) {
frames += 1;
if let pf_frame::FramePayload::Cpu(data) = &f.payload {
if !prev.is_empty()
&& prev.len() == data.len()
&& prev.as_slice() != data.as_slice()
{
changed += 1;
}
prev.clear();
prev.extend_from_slice(data);
if dump && frames % 15 == 1 && dumped < 16 {
dumped += 1;
let path = format!("{dump_dir}/frame-{frames:05}.ppm");
match dump_ppm(&path, f.width, f.height, f.format, data) {
Ok(()) => println!("cursor-probe: dumped {path}"),
Err(e) => eprintln!("cursor-probe: dump failed: {e:#}"),
}
}
}
if let Some(c) = &f.cursor {
with_overlay += 1;
if with_overlay == 1 {
@@ -195,9 +226,13 @@ mod linux {
let _ = injector.join();
println!("---");
println!(
"cursor-probe: content-changed frames: {changed}/{frames} (a moving embedded \
cursor over a static desktop should change nearly every frame)"
);
if embedded {
println!(
"cursor-probe: EMBEDDED run — no metadata expected; check the stream visually. \
"cursor-probe: EMBEDDED run — no metadata expected; check the dumped frames. \
frames={frames}"
);
} else if live_seen || with_overlay > 0 {
@@ -214,4 +249,26 @@ mod linux {
}
Ok(())
}
/// Write a packed 4-bpp frame as a PPM, swapping B/R for the BGR layouts. Enough fidelity
/// to answer "is the pointer in the pixels".
fn dump_ppm(
path: &str,
w: u32,
h: u32,
format: pf_frame::PixelFormat,
data: &[u8],
) -> Result<()> {
use std::io::Write;
let mut out = std::io::BufWriter::new(std::fs::File::create(path)?);
write!(out, "P6\n{w} {h}\n255\n")?;
let (ri, gi, bi) = match format {
pf_frame::PixelFormat::Rgbx | pf_frame::PixelFormat::Rgba => (0usize, 1usize, 2usize),
_ => (2usize, 1usize, 0usize),
};
for px in data.chunks_exact(4).take((w as usize) * (h as usize)) {
out.write_all(&[px[ri], px[gi], px[bi]])?;
}
Ok(())
}
}