Compare commits

..
Author SHA1 Message Date
enricobuehler 46390739d8 fix(pf-vdisplay): the box's OWN session unit needs the gamescope bind too
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m40s
nix / flake (pull_request) Failing after 19m15s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m1s
ci / web (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m28s
ci / rust (pull_request) Successful in 6m26s
`launch_session` spawns a transient unit and can hand `systemd-run` the
`BindReadOnlyPaths` directly, but a box that owns an autologin
`gamescope-session-plus@<client>.service` is RESTARTED IN PLACE instead — no `systemd-run`,
so that path kept running Nobara's hardcoded `/usr/bin/gamescope` and the previous commit
fixed only half the problem. Found on the box: after a reboot the host took the
`ensure_box_gamescope_mode` path (the autologin unit was live) rather than the managed one.

Deliver the same two fixes as a drop-in on that unit — the bind, and the WSI opt-out when the
box's layer was built for a different gamescope — plus `PF_HZ`/`PF_HDR_ARGS`, which the
wrapper reads and would otherwise default to 60 Hz. `daemon-reload` before the restart or
systemd runs the old unit. Best-effort: a failure to write it must not block a restart that
would otherwise work, and it is a no-op on a box already resolving to `/usr/bin/gamescope`.

⚠ REMOVED on restore, deliberately. Leaving it would put the patched build — and our HDR and
cursor flags — under the user's ORDINARY game mode, which is exactly what
`packaging/gamescope/README.md`'s "sits BESIDE the distro package" rule exists to prevent. The
bind is ours only for as long as we are driving the session.

`ensure_box_gamescope_mode` grows an `hdr` param to build those args; both call sites already
had it in scope (`self.hdr`, and `create_managed_session`'s parameter).

Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
2026-08-09 20:28:00 +02:00
enricobuehler 3500e95660 fix(pf-vdisplay): make Nobara's session run the patched gamescope, and stop the WSI layer killing every client
Two independent reasons a Nobara box could never stream from a gamescope session,
both found on glass (VM 123, Nobara 44, RTX 5070 Ti).

**1. The session ran a stock gamescope, so the host refused it.**

Nobara's `gamescope-session-plus` builds its command as

    GAMESCOPECMD="/usr/bin/gamescope \

and reads `GAMESCOPE_BIN` NOWHERE. All three of our spawn levers miss at once: the env
var is ignored, and an absolute path cannot be redirected by a PATH shim. So the session
ran stock gamescope, the capability probe rejected it, and every session died with
"pipeline build failed (out of retries) … it ignored GAMESCOPE_BIN / the PATH shim".
`~/.gamescope-cmd.log` — which the script writes with the exact command it ran — settles
that in one line, and is the first thing to read on any such report.

Fixed by binding our wrapper over `/usr/bin/gamescope` inside the transient unit's mount
namespace (`BindReadOnlyPaths`). Deliberately a bind, not a replacement: punktfunk-gamescope
ships under its own name precisely so it sits BESIDE the distro package, and the bind is
scoped to the session — nothing outside it sees the redirect and nothing is written to
`/usr`. Skipped when the resolved binary already IS `/usr/bin/gamescope`.

**2. With the patched gamescope finally running, every Vulkan client died — black screen.**

The box's `VkLayer_FROG_gamescope_wsi` ships with the DISTRO's gamescope and speaks its
`gamescope_swapchain` protocol. Ours disagrees, so the compositor rejects the client's
`swapchain_feedback` ("message too short") and drops it. Steam never paints; there is no
other symptom, which is what makes it expensive to find.

Measured with `vkcube` under each build, layer on:

    ours 3.16.25-17  ON  -> 1 rejected client
    ours 3.16.25-17  OFF -> 0
    OLD pin 3.16.25-4 ON -> 1 rejected client
    stock 3.16.23.2  ON  -> 0

 The upstream protocol XML is BYTE-IDENTICAL between the distro's commit (5cdb5b0) and
our pin — same interface version, same `uuuuuus` signature — so this is the distro patching
gamescope, not a version bump. Hence the gate is "do the upstream triples differ", not a
floor, and an unreadable version on either side leaves the layer alone rather than degrading
a box that works (Bazzite/SteamOS, where it has always been fine).

⚠⚠ The old pin fails identically, so REVERTING the pin bump fixes nothing here — this is
pre-existing, not a regression from 5fb8dce4.

Verified against the UNPATCHED distro script, reproducing exactly what this code emits:
the session's own log reports `punktfunk-gamescope version 3.16.25-17-ga87390d+pfhdr4`,
with 0 swapchain_feedback errors, 0 client-communication errors and 0 aborts.

Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
Non-vacuity re-verified per the xcheck note — a planted type error in the new function
produced 3 errors, and removing it went back to Finished.

Still open, deliberately NOT addressed here: a 10-bit HDR stream aborts gamescope in
`destroy_buffer` (upstream `pipewire.cpp:88`), which is a separate defect.
2026-08-09 20:15:52 +02:00
enricobuehler bc9201d136 fix(packaging/gamescope): bump the pin past upstream's capture-format probe, and sign the RPM
Three things, one delivery path — a Fedora/Nobara box getting the patched gamescope.

**The pin moves 8c676c39 -> 5fb8dce4** (3.16.25-1 -> 3.16.25-11). The commit that matters
is ff6b924, `rendervulkan: fall back to XBGR2101010 when XRGB2101010 is unsupported`: it
probes `linearTilingFeatures` for STORAGE+SAMPLED and captures as XBGR2101010 where
A2R10G10B10 linear storage is unavailable — which is every NVIDIA. That covers the paths
that are upstream's rather than ours: the RGB intermediate `paint_pipewire()` acquires when
the stream is YCbCr, and AVIF screenshots. #143 fixed our own node host-side; this is the
other half, and its commit message asked for exactly this bump.

All six patches rebased. Only 0006 conflicted: upstream's f8be7ee added
`vulkan_has_drm_modifiers_for_features()` immediately above the `g_device` declaration our
patch turns into a reference — both kept. 0003 and 0005 come out byte-identical; 0006 also
picks up the `--zero-commit --no-signature` form 0001-0005 already used.

**Patch 0001 now offers `xBGR_210LE` BEFORE `xRGB_210LE`**, mirroring the host-side
`HDR_FORMAT_ORDER` rationale on the producer end. A consumer takes the first pod it can use,
and we were handing third-party consumers (OBS and friends) the one format NVIDIA fills
byte-reversed under a correct-looking label. Deliberately NOT done by calling upstream's
`vulkan_get_rgb10_capture_format()`, which is what pw_pods.rs proposes: that symbol landed
after 3.16.25, so it would break `packaging/nix/gamescope.nix` — which applies these patches
to whatever gamescope nixpkgs pins — with an opaque C++ error instead of a patch conflict.
The reorder gets the same outcome on any base. Note added there so the next reader does not
"fix" it.

**And the RPM was never signed.** `Sign RPMs` runs right after `Build RPM`; the gamescope
RPM is built ~90 steps later, behind its own ~10-minute cache, so it missed the signing pass
entirely — every punktfunk-gamescope RPM ever published went out unsigned. The repo file we
tell users to install carries `gpgcheck=1`, so `dnf install punktfunk-gamescope` failed with
"The package is not signed" on every Fedora and Nobara box. The package was in the channel
the whole time and could not be installed from it, which is worse than absent: the notes and
the docs-site both say it is there. `sign-rpms.sh` now takes explicit paths (defaulting to
`dist/*.rpm` as before) and a second pass signs this one before publish, fail-closed on a tag
like the first.

Verified on Nobara 44 (VM 123, RTX 5070 Ti passthrough), canary 0.27.0-0.ci12611.g516a2954:

* Builds clean in the fc44 CI image; banner `3.16.25-17-ga87390d+pfhdr4` (11 upstream + our
  6), so the marker the host probes still reads 4 — no capability moved, hence pkgrel 3 and
  `.pfhdrN` staying put.
* `pw-cli enum-params` on the live node: BGRx, NV12, **xBGR_210LE (81), xRGB_210LE (80)** —
  8-bit consumers still negotiate bit-for-bit, 10-bit now leads with the safe one.
* All four patched flags present, `--pipewire-composite-external-overlay` included.
* Patch 0006 confirmed working by comparison, which is the only way to see it: the new build
  exits 0 where both the pre-0006 `+pfhdr2` build and the stock 3.16.23.2 abort with 134.
* Signing fix proven with a throwaway key: `Signature: (none)` -> `digests signatures OK`.
* Host health on the canary: synthetic spike 300/300 encoded, loopback 300 recovered, 0
  mismatches.

One unexplained one-off: the very first headless run after install segfaulted at exit
(SIGSEGV, after "Primary child shut down!"). Not reproduced in 11 subsequent runs across
every flag combination, so it is recorded rather than diagnosed — the binary is stripped and
there is no symbolised core.
2026-08-09 18:00:28 +02:00
enricobuehler 003ce8bea7 Merge pull request 'Every NVIDIA gamescope HDR stream had red and blue swapped — and a sysext step added in a release was unreachable forever' (#143) from worktree-hdr-rb-swap-nvidia into main
arch / build-publish (push) Failing after 3s
ci / rust (push) Failing after 2s
ci / rust-arm64 (push) Failing after 2s
deb / build-publish (push) Failing after 3s
deb / build-publish-host (push) Failing after 0s
deb / build-publish-client-arm64 (push) Failing after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
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 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m16s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
docker / deploy-docs (push) Failing after 1m41s
android / android (push) Successful in 5m38s
apple / screenshots (push) Successful in 5m53s
windows-host / package (push) Successful in 16m59s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m1s
Reviewed-on: #143
2026-08-09 15:34:02 +00:00
enricobuehler 235b8e55d4 Merge pull request 'chore(web): console onto @unom/ui 0.9.2' (#142) from worktree-console-unom-092 into main
audit / license-gate (push) Failing after 2s
audit / cargo-audit (push) Failing after 2s
audit / docs-site-audit (push) Successful in 22s
audit / bun-audit (web) (push) Failing after 22s
audit / bun-audit (sdk) (push) Successful in 27s
audit / bun-audit (plugin-kit) (push) Successful in 29s
audit / pnpm-audit (push) Successful in 20s
ci / rust-arm64 (push) Failing after 2s
deb / build-publish-client-arm64 (push) Failing after 2s
ci / rust (push) Failing after 2s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
arch / build-publish (push) Canceled after 1m29s
ci / web (push) Canceled after 1m12s
ci / docs-site (push) Canceled after 1m9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
deb / build-publish (push) Canceled after 1m14s
deb / build-publish-host (push) Canceled after 1m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1m3s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 25s
docker / deploy-docs (push) Canceled after 0s
windows-host / package (push) Canceled after 1m1s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
nix / flake (push) Failing after 4m16s
Reviewed-on: #142
2026-08-09 15:32:35 +00:00
enricobuehler 0b252403cd fix(web): fix the card inset at the root, not at the call sites
ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m35s
ci / web (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 9m12s
nix / flake (pull_request) Failing after 19m50s
The broken inset on the Displays configuration card was the symptom. The cause is
structural, and it had already been diagnosed at least twice in-tree without being fixed.

Two faults, both in components/ui/card.tsx:

1. The padding was a RESPONSIVE COMPOUND: `p-4 pt-0 sm:p-6 sm:pt-0`. tailwind-merge
   resolves conflicts only within a variant, so any call-site override won at the base
   and lost at `sm:` — correct on a phone, wrong on every desktop. Measured on the
   Displays card before this change: padding-top 24px at 500px, 0px at 1440px.

2. `pt-0` encoded an assumption about a SIBLING that nothing enforced — "a CardHeader is
   above me and supplies the top inset". Delete the header, which is exactly what tabbing
   a page does since the tab label replaces the card title, and the top inset silently
   vanishes at ≥640px.

Fix:

- One single-variant utility, `p-padding-card` — the same `--spacing-padding-card` token
  @unom/ui's own Card uses, so nested cards finally agree on their inset. A single
  variant cannot half-lose an override.
- Top inset is now self-correcting: `[&:not(:first-child)]:pt-0`. Ask the DOM instead of
  the author. A headerless CardContent keeps its inset with nothing to remember.

Seven call sites had grown their own compensation in five dialects — `p-6`,
`p-card pt-card sm:pt-card` (×3), `p-4 sm:pt-6` (×3), `pt-4 sm:pt-6`, and my own `pt-6`
from the tabs commit. All removed; they are the symptom-fixes this replaces. LogsCard
even carried a six-line comment correctly describing the trap and working around it
locally — that comment is now three lines saying it no longer needs saying.

`flush` stays: full-bleed content is a real intent, expressed as a prop the component
honours rather than a utility that has to out-argue the one already there.

Guarded by UI/Card → "Inset with and without header", a headered/headerless pair that has
to look identical on every side. It must be checked at BOTH widths — a single width
cannot show this class of bug, which is why it kept surviving.

Verified by measuring computed padding at 500px and 1440px: first child 20px on all four
sides, after-a-header 0px top and 20px elsewhere, identical at both widths. tsc clean,
biome clean on every touched file, 9/9 server tests, build + i18n clean, 32/32 screenshots.
2026-08-09 17:23:09 +02:00
enricobuehler 0ab17ee81d fix(packaging): a post_merge step added in a release was unreachable forever
ci / bun-nix (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m52s
ci / rust (pull_request) Failing after 8m7s
A sysext upgrade is driven by the script from the OLD image -- /usr/bin/punktfunk-sysext
is replaced by the very `systemd-sysext refresh` that runs mid-upgrade -- so a
post_merge step ADDED in the new release is executed by nobody. The old script
does not have it, and the new script never gets a turn: from then on `update`
matches the "already on $cur" branch and returns before post_merge. The step is
permanently unreachable on exactly the installs that need it, and nothing says so.

Field-proven on the Bazzite host that took 0.25.0 -> 0.26.0 (2026-08-09). The
casualty was the `punktfunk` group, which post_merge learned to create in 0.26.0
(62a6fa9f): 0.25.0's script ran the upgrade, so the group was never created, and
every `punktfunk-sysext update` since has said "nothing to do". `pf-dm-helper`
gates on membership in that group, so it refused every caller -- pkexec authorised
it and the helper then declined itself -- and every managed gamescope takeover fell
back to "stopping the display manager needs privilege", leaving sddm's autologin
Relogin loop churning logind sessions for the whole stream.

Re-run post_merge when already current. Everything in it is idempotent (guarded
getent/groupadd, `install` of /etc mirrors, udevadm reload/trigger, sysctl,
modprobe), so convergence is the honest behaviour and "nothing to do" was a lie
about host state. Add an explicit `reapply` verb too, so the steps a sysext image
cannot carry can be re-applied without reinstalling the image.

Also print the membership hint. Creating the group is necessary but NOT sufficient
and the difference is invisible until a stream fails: joining stays opt-in by
design (writing vhci `attach` materialises an arbitrary emulated USB device), so
post_merge now names the exact usermod when SUDO_USER is not a member. Matched with
`grep -qx` so `punktfunk-update` does not read as `punktfunk`.

bash -n clean; shellcheck clean apart from the pre-existing SC1091 on
`. /etc/os-release`, which fires on the unmodified file too.
2026-08-09 17:08:24 +02:00
enricobuehler 31aef4b09f feat(web): tab the Virtual displays page
ci / rust-arm64 (pull_request) Successful in 2m6s
ci / docs-site (pull_request) Successful in 3m59s
ci / bun-nix (pull_request) Successful in 4m39s
ci / web (pull_request) Successful in 5m1s
ci / rust (pull_request) Failing after 13m18s
nix / flake (pull_request) Failing after 23m11s
Same pill strip the plugin UIs use, via @unom/ui's Tabs: Configuration | Live displays.

The page was two stacked cards, and the configuration card ALONE is taller than the
viewport — the existing comment on the unsaved badge says as much, because that height
is how pending edits went unnoticed. The live-display list sat below all of it, so in
practice it was off screen.

Two details that are not cosmetic:

- The dirty marker moved from the card header onto the Configuration TRIGGER. Behind a
  tab the old badge would vanish entirely while Live was open — a strictly worse version
  of the problem it was added to solve. On the trigger it survives both tabs, and the
  Custom block keeps its own inline badge for when the tab IS open.
- The strip is extracted as a presentational `DisplayTabs` rather than inlined in
  `DisplaySection`. The container calls `useBlocker`, which needs a router, so it cannot
  render in Storybook — and this page's story exists specifically to pin the MOTION
  NESTING of the preset grid (a card sets no delayChildren, so tiles nested one level
  deeper stop staggering). Inserting tabs changes that ancestor chain, so the story has
  to render the real one or it passes for the wrong reason.

Adds Pages/Displays → "Unsaved on other tab", which switches to Live with a dirty draft:
if the marker ever goes silent there, the warning is gone exactly when it matters.

Verified: tsc clean, biome clean, `bun test server/` 9/9, vite build + i18n check clean,
Storybook builds, 32/32 screenshots.
2026-08-09 16:58:46 +02:00
enricobuehler 97928516a0 fix(pf-capture): every NVIDIA HDR stream had red and blue swapped
gamescope's capture textures are mappable, hence linear-tiled, and NVIDIA does
not implement linear-tiled STORAGE for A2R10G10B10_UNORM_PACK32. Upstream says
it plainly in rendervulkan.cpp: "imageStore lands in XBGR order there, swapping
R/B". So the composite writes XBGR bytes into a buffer still LABELLED
XRGB2101010, and our patch's spa_format_to_drm() derives that label from the
negotiated SPA format alone, never asking the hardware what it can actually
write.

The host then believed the label, correctly at every step:
xRGB_210LE -> PixelFormat::X2Rgb10 -> NV_ENC_BUFFER_FORMAT_ARGB10. DRM
XRGB2101010 really is "B in the low 10 bits" and NVENC ARGB10 really is "B in
the lowest 10 bits"; the Windows twin (R10G10B10A2 -> ABGR10) is correct by the
same rule. Every mapping audits clean because the label was right and only the
CONTENT was wrong -- which is why this survived a full trace of both ends.

Fix the preference host-side: offer xBGR_210LE FIRST. The first compatible
consumer pod wins, so that is what a gamescope session lands on, and an
XBGR2101010 texture is one NVIDIA writes in its own order -- label and content
agree. It costs nothing elsewhere: A2B10G10R10_UNORM_PACK32 is the universally
supported packed-10 format, it is what upstream's own fallback picks, and
X2Bgr10 has a first-class encoder path (NVENC ABGR10, VAAPI X2BGR10LE).
xRGB_210LE stays as the second pod so a producer offering only it can still
negotiate HDR instead of dropping to the SDR downgrade.

Doing it here rather than in the patch set is deliberate: the real fix is for
spa_format_to_drm() to offer only what vulkan_get_rgb10_capture_format()
reports, but that function landed after 3.16.25 and the pin is
3.16.25-7-g60561e2+pfhdr4 (0 "2101010" strings in the shipped binary), so the
deployed gamescope cannot self-correct. This ships in the host binary with no
gamescope rebuild.

Field-confirmed on the RTX 5070 Ti Bazzite host with 0.26.0, and confirmed
host-side rather than client-side by reproducing the identical swap from two
unrelated clients (16" MacBook Pro and Mac Studio). SDR was never affected --
it takes no packed-10 path.

Gate (pf-lxcheck2, linux/amd64): fmt clean, clippy --all-targets -D warnings
clean, cargo test -p pf-capture 60 passed / 0 failed incl. the new
hdr_offers_xbgr_before_xrgb order pin.
2026-08-09 16:58:15 +02:00
enricobuehler d13d253c2f chore(web): @unom/ui 0.8.16 → 0.9.2
ci / rust-arm64 (pull_request) Failing after 31s
ci / docs-site (pull_request) Successful in 3m1s
ci / bun-nix (pull_request) Successful in 3m27s
ci / web (pull_request) Successful in 4m29s
ci / rust (pull_request) Failing after 13m16s
nix / flake (pull_request) Failing after 19m47s
Brings the console onto the current design system. 0.9.x adds the Badge, Spinner,
Skeleton, Switch, Table, EmptyState and CodeBlock primitives, and 0.9.2 carries the
form fixes found while overhauling the rom-manager plugin UI:

- Select's border and focus ring resolved to `--main`, which is the FOREGROUND here
  (`--main: var(--foreground)` in web/src/styles.css), so the trigger wore a near-white
  border and a 3px near-white focus ring. Its chevron and placeholder were painted
  `--secondary`, a SURFACE colour, and all but vanished. Now on `--input`/`--ring`, the
  same tokens InputText already used.
- InputNumber declares a color-scheme, so the browser-drawn spinner arrows stop being
  near-black on a near-black field.

Both defects were live in this console too — the console palette is what exposes them.

Verified: codegen + vite build clean, `tsc --noEmit` clean, `bun test server/` 9/9,
Storybook builds, 31/31 screenshots. A probe over all 61 stories reports ZERO page
errors, and the two stories containing a Select now render it at h-input-height with
`border: rgb(42, 33, 72)` (the input token) and a muted-foreground chevron.

Note: the console's components/ui/ wrapper layer is unchanged and still required —
@unom/ui's DialogContent remains a surface with no Portal or placement, which is
exactly what web/src/components/ui/dialog.tsx supplies.
2026-08-09 16:27:08 +02:00
enricobuehler 516a295432 Merge pull request 'My gamescope gate withheld the host .deb it was meant to protect — the release still ships the KDE-breaking one' (#140) from worktree-gamescope-gate-placement into main
ci / web (push) Successful in 1m5s
ci / bun-nix (push) Successful in 17s
ci / rust-arm64 (push) Successful in 1m38s
ci / docs-site (push) Successful in 2m36s
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
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
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
deb / build-publish-client-arm64 (push) Successful in 1m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
deb / build-publish (push) Successful in 3m45s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
deb / build-publish-host (push) Successful in 6m39s
ci / rust (push) Successful in 10m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
Reviewed-on: #140
2026-08-09 09:12:55 +00:00
enricobuehler 2c190b27b4 Merge pull request 'Switching audio device mid-stream killed the sound for the rest of the session — AVAudioEngine stops itself, and nothing ever restarted it' (#141) from worktree-audio-device-switch-silence into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (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/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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
apple / swift (push) Successful in 1m41s
release / apple (push) Successful in 10m9s
apple / screenshots (push) Successful in 6m10s
Reviewed-on: #141
2026-08-09 09:10:52 +00:00
enricobuehler 3cfa5ca194 Merge pull request 'The capability-hint test asserted the environment, not the code — main is red on a machine where nothing is wrong' (#139) from worktree-kwin-capability-test-env into main
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (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/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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
deb / build-publish (push) Canceled after 1m55s
deb / build-publish-host (push) Canceled after 1m10s
deb / build-publish-client-arm64 (push) Canceled after 50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
android / android (push) Successful in 6m20s
windows-host / package (push) Successful in 11m23s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
arch / build-publish (push) Successful in 11m50s
Reviewed-on: #139
2026-08-09 09:10:33 +00:00
enricobuehler bf913c5706 fix(apple): switching audio device mid-stream killed the sound for the rest of the session
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / rust (pull_request) Failing after 9m43s
Field report, macOS client, host-independent: start a stream with AirPods in, take them
out — nothing on the speakers; put them back in — nothing in the AirPods either. Only
restarting the whole stream brought audio back.

An AVAudioEngine does not follow the audio hardware. When the output device changes under
a running engine, its IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and it posts
AVAudioEngineConfigurationChange. It stays stopped until somebody starts it again, and
nothing here ever did — no error, no log line, just a session rendering silence from that
moment on. Putting the AirPods back in is a second stop, not a recovery, which is exactly
why that half of the report looked so strange.

Measured on the client's own playback topology (source node -> main mixer, 48 kHz stereo)
by moving the default output device programmatically: render callbacks go from ~94/s to
zero the instant the device changes, and both restarting the same engine and building a
fresh one resume them.

The fix watches the hardware and rebuilds the topology the session was started with, on
whatever device is there now. Three triggers, because no single one covers the ground:

  - the engine's own configuration-change notification, every platform — the direct
    signal, but it can only be posted BY an engine, so it cannot report a rebuild that
    failed to start;
  - a CoreAudio HAL default-output-device listener on macOS — independent of any engine
    and of the engine's topology. This is what makes the recovery work for the
    voice-processing engine, which is the DEFAULT macOS configuration (mic and echo
    cancellation both default on) and whose notification behaviour could not be verified:
    no Mac in the fleet can initialize VPIO at all;
  - route-change and media-services-reset on iOS/tvOS, where the session rather than the
    device is what moves. The route observer is now installed for mic-off (.playback)
    sessions and on tvOS too — it used to be iOS-and-mic-only, for the earpiece steer,
    but every platform has engines a route change can stop.

They collapse into one debounced rebuild (one switch produces a burst), with a floor
between rebuilds so a device that renegotiates in a loop cannot spin the session, and a
short retry ladder for a device caught mid-transition — a rebuild that fails leaves no
engine to post the next notification, so that path must not simply give up. The ring is
deliberately carried across: the drain thread keeps decoding through the switch, and the
ring's overflow policy has already dropped whatever went stale while the engine was down.

A rebuild is only ever done when it concerns us. A healthy engine that followed the change
on its own is left alone, and somebody changing the system default while this session is
pinned to a named speaker is none of our business — rebuilding for that would cost an
audible gap for nothing.

The trigger wiring is split into AudioDeviceWatcher for one reason: an end-to-end test of
the recovery needs a live session, which needs a host, and punktfunk-host does not build
on macOS — so the part where a silent failure costs the session ALL of its audio would
otherwise ship unverified. On its own the watcher is pointed at the real hardware from a
unit test: a real default-output-device move must reach the owner, our engine's
notification must get through, a foreign engine's must not. Neutralizing the wiring fails
both positive tests and neither negative one.

AudioDeviceSwitchTests drives the real SessionAudio through the out-and-back switch
against the loopback host; it skips wherever that fixture cannot run (which is every Mac,
today) and the open host's frame budget is raised so it outlives the switch.
2026-08-09 11:03:10 +02:00
enricobuehler 5bd92dac5d fix(ci): my gamescope gate withheld the host .deb it was supposed to protect
ci / bun-nix (pull_request) Successful in 25s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / docs-site (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 8m9s
The gate #135 added fails the job at the gamescope BUILD step. In deb.yml that
step runs before "Publish to the Gitea apt registry" and "Attach the host .deb
to the Gitea release", so failing it skipped both.

Consequence on the v0.26.0 tag, and it is the worst thing in this release so
far: the host .deb on the release is from 00:17 — re-point #1, BEFORE #136
revoked CAP_SYS_NICE. Every other .deb is from 08:29-08:31. So the published
Debian host still runs `setcap cap_sys_nice=ep` in its postinst, which is
exactly what makes the host unidentifiable to KWin and kills every KDE desktop
session. A gate meant to protect the release withheld the fix for it and left
the broken artifact in place.

rpm.yml has the identical latent bug and only escaped it because Fedora went
green: a gamescope failure there would skip the sysext image, the feed publish
and the release attach, withholding the punktfunk RPMs and .raw images too.

Both now warn at the build/package steps and gate as the LAST step of the job,
after everything has published. A missing EXTRA must never stop a good artifact
shipping — go red afterwards instead.

Also: name noble's dependencies outright. `apt-get build-dep gamescope` gives it
almost nothing (the distro has no comparable package), which is why this peeled
one dep per CI cycle — wayland-protocols, then xdamage. The full set is derived
from the Arch package's depends+makedepends, which is the build that demonstrably
works, plus wlroots' own (it is a forced fallback subproject).

One `apt-get` per name on purpose: a single transaction aborts wholesale on one
unknown package, installing NOTHING and hiding the real gap behind a name typo.
Per-package, best-effort, with the missing name echoed; the end-of-job gate is
what actually decides.

⚠ Verification: both YAML files parse; every gamescope-touching `run:` block is
`bash -n` clean with matrix placeholders substituted (9 blocks); the .deb glob
matches build-gamescope-deb.sh's documented output
(`dist/punktfunk-gamescope_<version>_<arch>.deb`) and the RPM glob excludes
debuginfo/debugsource exactly as the attach loop above it does. The noble dep
NAMES cannot be proven from macOS — that is what the next tag run decides, and
it now decides it without holding the host .deb hostage.
2026-08-09 10:52:42 +02:00
enricobuehler e8a4f54c07 fix(pf-vdisplay): the capability-hint test asserted the environment, not the code
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m0s
ci / bun-nix (pull_request) Successful in 42s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m48s
ci / rust-arm64 (pull_request) Successful in 2m47s
ci / rust (pull_request) Successful in 6m57s
`silent_without_capabilities` called the real `capability_denial_hint()` and
asserted it returns "", on the strength of a doc comment that read "The test
process has no capabilities."

That is true on a dev box and false in CI, where the runner container is root
with a full permitted set. main went red on 0f79587d with:

    left: " — NOTE: this process carries capabilities (CapPrm=0x000001ffffffffff) …"
   right: ""

Nothing was wrong: the hint fired correctly, on a process that really did hold
every capability. The test was reading the ambient environment and calling it a
property of the code.

`permitted_caps_from_status` had already been split out for exactly this reason
— "so that shape is testable without a capability-carrying process to point at"
— but only the PARSE half. The message half still went to /proc/self/status.
This finishes the split: `capability_denial_hint_for(Option<u64>)` holds the
formatting and takes the mask, `capability_denial_hint()` reads /proc and
delegates. Both keep their callers, so neither is dead code.

Also adds `names_the_mask_and_the_repair_when_capped`. Without it the silent
case passes just as well against a function that returns "" unconditionally —
which is the failure mode this repo has been bitten by before, and the reason
every decode fix carries a counterfactual.

No behaviour change: the three error paths call the same function and get the
same string.

⚠ Verification is CI. `kwin.rs` is `#[cfg(target_os = "linux")]`, so it does not
compile on the macOS host this was written from; `cargo fmt --all --check` is
clean and a Linux container check was attempted but the stock rust image has no
cmake for audiopus_sys, so it never reached the test. ci.yml going green on main
is the proof — and unlike the case it replaces, this test now fails or passes
for reasons that have nothing to do with the machine running it.

Does not touch the v0.26.0 tag: ci.yml runs on `push: branches: [main]` and
`pull_request` only, and no tag leg runs cargo test.
2026-08-09 10:39:41 +02:00
enricobuehler f80636f901 Merge pull request 'The release notes advertise a privilege 0.26.0 deliberately does not grant' (#138) from worktree-notes-capsysnice-correction into main
android-screenshots / screenshots (push) Successful in 1m29s
release / apple (push) Successful in 12m13s
decky / build-publish (push) Successful in 37s
windows-host / package (push) Successful in 11m48s
windows-host / canary-manifest (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 1m27s
deb / build-publish (push) Successful in 4m18s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
linux-client-screenshots / screenshots (push) Successful in 2m54s
sbom / sbom (push) Successful in 20s
deb / build-publish-host (push) Failing after 5m18s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows-host / winget-source (push) Successful in 21s
docker / builders-arm64cross (push) Successful in 9s
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 18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / deploy-docs (push) Successful in 28s
android / android (push) Successful in 10m26s
arch / build-publish (push) Successful in 11m24s
web-screenshots / screenshots (push) Successful in 5m5s
flatpak / build-publish (push) Successful in 16m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m6s
ci / rust-arm64 (push) Successful in 1m40s
ci / web (push) Successful in 2m5s
ci / docs-site (push) Successful in 1m12s
ci / bun-nix (push) Successful in 38s
ci / rust (push) Canceled after 1m31s
2026-08-09 08:13:45 +00:00
enricobuehler 0f79587dd6 docs(release): the notes claimed a privilege 0.26.0 deliberately does not grant
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m51s
ci / bun-nix (pull_request) Successful in 35s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 8m47s
The user-facing v0.26.0 notes said, of the PyroWave GPU-priority lever:

    "it is now, and the package grants the host the permission that switch needs"

That was true of 0.26.0-1 and is now the opposite of true. Granting CAP_SYS_NICE
made the host unidentifiable to KWin and killed desktop streaming on every KDE
box across all five Linux channels, so 0.26.0-2 revokes it everywhere and must
keep doing so. The lever is wired natively on Linux for the first time — that
part stands — but it is dormant on an ordinary install, and the notes have to
say so rather than advertise a speed-up nobody gets.

CHANGELOG.md was already corrected in #136 (the 0.26.0-2 note under PW1 and the
qualifier on the owed A/B). This is the user-facing half, which #136 did not
touch:

  * the PyroWave bullet now leads with what DID land (two encoder handles, the
    capture buffer headroom) and describes the priority switch as present but
    dormant, with the reason.
  * a new Fixed entry for the KDE breakage itself. Worth telling users even
    though the release was never announced: 0.26.0-1 packages did reach the
    registries, and anyone who pulled one has a desktop session that fails with
    a missing-screencast error surviving a clean reinstall. It also explains the
    dormancy the bullet above now refers to.

Deliberately NOT written as a "Before you update" action: upgrading strips the
capability by itself on every channel, so there is nothing for a reader to do.

Commit count 47 -> 52.

Voice check clean (0 internal-vocabulary hits above "## For developers"); notes
67 lines.
2026-08-09 10:12:58 +02:00
enricobuehler 651a7a82a1 Merge pull request '0.26.0-1 gave the host CAP_SYS_NICE, which made it invisible to KWin — every KDE desktop session died, on five packaging channels' (#136) from worktree-kwin-capability-identification into main
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Failing after 2m20s
ci / rust (push) Failing after 2m21s
ci / docs-site (push) Successful in 1m18s
ci / bun-nix (push) Successful in 26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 1m56s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
android / android (push) Successful in 6m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
apple / screenshots (push) Successful in 5m58s
deb / build-publish (push) Successful in 5m32s
deb / build-publish-host (push) Successful in 6m11s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
arch / build-publish (push) Successful in 11m40s
windows-host / package (push) Successful in 12m30s
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m58s
nix / flake (push) Successful in 18m34s
windows-host / canary-manifest (push) Successful in 14s
Reviewed-on: #136
2026-08-09 08:04:43 +00:00
enricobuehler 4d383811c0 fix(packaging): the same CAP_SYS_NICE broke KDE on FIVE channels, not one — Bazzite included
ci / bun-nix (pull_request) Successful in 17s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m38s
ci / rust-arm64 (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m31s
ci / rust (pull_request) Failing after 9m2s
nix / flake (pull_request) Successful in 12m24s
The Arch fix in the previous commit was incomplete. 0.26.0-1 granted the host CAP_SYS_NICE through
every Linux channel we ship, and each one breaks KWin identification the same way:

  * packaging/rpm/punktfunk.spec .......... %caps(cap_sys_nice=ep) in %files  <- Fedora AND Bazzite
                                            via rpm-ostree layering
  * packaging/bazzite/build-sysext.sh ..... setcap on the staging tree, recorded by mksquashfs
  * packaging/debian/build-deb.sh ......... setcap in the postinst
  * packaging/nix/nixos-module.nix ........ security.wrappers with capabilities = "cap_sys_nice=ep"
  * scripts/steamdeck/install.sh .......... setcap on $BIN, six lines after writing the .desktop
                                            whose Exec= it thereby voids

Bazzite was NOT a separate fault, as first reported here — it is this one. Verified by mounting the
published punktfunk-0.26.0-1-x86-64.raw: `getcap usr/bin/punktfunk-host` reports cap_sys_nice=ep,
stored as security.capability in the squashfs. The claim in packaging/arch/build-sysext.sh that
"file capabilities don't survive this squashfs path" is false and is corrected here; mksquashfs
records them, which is exactly why the image shipped one.

NixOS deserves its own note: a security.wrappers entry does not dodge the problem. The wrapper
raises the capability into its AMBIENT set before exec'ing the store binary, precisely so it
survives — which lands CAP_SYS_NICE in the exec'd process's permitted set and fails the readlink
identically to a file capability. ExecStart now points at the store path directly, which is also the
path packages.nix substitutes into the .desktop's Exec=, so the two finally agree.

Measured blast radius of holding a capability, same-uid reader, CachyOS kernel 7.1.6:

    /proc/PID/exe ....... EPERM   <- KWin's identification. Desktop sessions die.
    /proc/PID/root/* .... EPERM   <- xdg-desktop-portal reads .flatpak-info here to resolve an
                                     app id; the wlroots and Hyprland backends go through it
    /proc/PID/environ ... EPERM
    /proc/PID/cgroup .... OK
    /proc/PID/status .... OK
    /proc/PID/cmdline ... OK

Compositor backends, by exposure: KWin is broken outright (proven, field-confirmed). gamescope has
no identity gate and was never affected, which matches the field — only Desktop mode was reported.
Mutter drives Mutter's own D-Bus API, not the portal, and looks unaffected. wlroots and Hyprland go
through the ScreenCast portal, whose app-id resolution reads a path the capability blocks — a real
exposure, not something I reproduced end to end.

The sysext build now HARD-FAILS if a capability is staged, rather than trusting that the RPM payload
never carries one: a merged sysext's /usr is read-only squashfs, so a bad image cannot be repaired
on the box, and the spec was one %caps() away from baking one in again.

Docs corrected, because they advertised the capability as a feature:
  * docs-site running-as-a-service "GPU scheduling priority" — rewritten: the host carries no
    capability, why it must not, and how to clear a 0.26.0-1 install (Bazzite needs a new image)
  * docs-site configuration.md — the PYROWAVE_QUEUE_PRIORITY row no longer claims the packages grant it
  * packaging/bazzite/README.md — §6.5 still described the kde-desktop-setup.sh behaviour from
    before it stopped writing KWIN_WAYLAND_NO_PERMISSION_CHECKS and started REMOVING it; plus a
    note that 0.26.0-1 Desktop mode cannot be repaired in place
  * packaging/arch/README.md — the false "capabilities don't survive the sysext" line
  * CHANGELOG v0.26.0 PW1 — annotated with the 0.26.0-2 correction rather than rewritten, and the
    owed PyroWave-under-load A/B now says it needs a gamescope-only box

Verified: bash -n on all five changed shell files; nix-instantiate --parse on nixos-module.nix and
packages.nix; the published 0.26.0-1 sysext mounted and its capability read; getcap on an uncapped
file exits 0 with empty output, so the new build assertion cannot false-positive.
2026-08-09 09:56:36 +02:00
enricobuehler 42ee6c5628 fix(packaging): the host's CAP_SYS_NICE made it invisible to KWin, killing every KDE session
0.26.0-1 setcap'd `cap_sys_nice=ep` on /usr/bin/punktfunk-host so the encoder could open an
elevated global-priority Vulkan queue. On every KDE box that ended desktop streaming outright:

    KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client

reported from CachyOS on NVIDIA and on AMD, surviving a clean reinstall of host and client, and
worked around only by KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.

The two cannot coexist. KWin hands out its restricted protocols — zkde_screencast_unstable_v1,
which mints our virtual output, and org_kde_kwin_fake_input, which injects input — only to a client
it can IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
.desktop's Exec=. The kernel refuses that readlink to any reader whose effective set is not a
superset of the target's PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities.
So the instant the binary carries one, KWin's executablePath() is empty, nothing matches, and the
global is never advertised — presenting exactly as a missing or mis-installed .desktop file.

Measured on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:

    no capability .............................. readlink /proc/<pid>/exe OK
    capability ................................. EPERM
    capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM   <- dumpable is NOT the gate
    capability dropped + PR_SET_DUMPABLE(1) .... OK      <- only an uncapped process works

The third row also rules out the reflex fix of moving the grant to systemd AmbientCapabilities=,
which lands CAP_SYS_NICE in the very same permitted set. Nothing short of not holding the
capability restores identification, so the host does not get one.

The cost is pacing only. pf-zerocopy's device create already walks REALTIME -> HIGH -> default when
a priority class is refused, and pf-frame's thread nice is a documented best-effort no-op without
the capability — so this is 0.25.0's behaviour exactly, which is the behaviour that worked.

  * packaging/arch/punktfunk-host.install: grant -> revoke. post_upgrade strips the capability from
    boxes that already ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode and file
    capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade paths.
  * pf-vdisplay kwin.rs: all three "KWin does not expose zkde_screencast" errors now read
    /proc/self/status and, if this process holds ANY capability, name it with its CapPrm mask and
    the `setcap -r` that repairs it. The failure stays impossible to diagnose from the Wayland side
    otherwise, and it is not unique to our own packaging — a hand-rolled setcap does it too.

Verified on 192.168.1.21 (CachyOS): the capability/dumpable matrix above; cargo check and
cargo clippy --all-targets -- -D warnings clean for pf-vdisplay; both new unit tests pass; and the
hint itself exercised end-to-end, silent uncapped and firing with CapPrm=0x0000000000800000 under
cap_sys_nice=ep. The shipped punktfunk-host-0.26.0-1-x86_64.pkg.tar.zst was unpacked to confirm its
.INSTALL carries the setcap on both post_install and post_upgrade.

Ships as 0.26.0-2 — packaging plus one crate, no version bump.
2026-08-09 09:37:30 +02:00
enricobuehler 08eaf337e8 Merge pull request 'v0.26.0 promised a Fedora and an apt gamescope that were never built' (#135) from worktree-gamescope-rpm-deb-builddeps into main
ci / web (push) Successful in 1m6s
ci / rust-arm64 (push) Successful in 1m35s
ci / bun-nix (push) Successful in 27s
ci / docs-site (push) Successful in 1m12s
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 12s
deb / build-publish-client-arm64 (push) Successful in 1m30s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
deb / build-publish-host (push) Successful in 5m53s
deb / build-publish (push) Successful in 6m9s
docker / builders-arm64cross (push) Successful in 7s
ci / rust (push) Successful in 11m22s
docker / deploy-docs (push) Failing after 6m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m20s
2026-08-09 07:22:59 +00:00
enricobuehler 39869031be fix(ci): the gamescope RPM and .deb never built, and a warning let the tag ship anyway
ci / bun-nix (pull_request) Successful in 23s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 11m20s
v0.26.0's notes and docs-site say the patched gamescope is now installable on
Fedora and on Debian/Ubuntu. Neither package exists on the release. Both builds
failed inside best-effort steps that emit `::warning::` and return 0, so every
job stayed green and the only evidence was a warning nobody reads. Arch built
fine, which is why it is the sole gamescope package attached.

Two distinct missing build deps, same root cause: `dnf builddep gamescope` /
`apt-get build-dep gamescope` resolve the DISTRO'S OLDER PACKAGED gamescope,
which does not need what the pinned master tree needs.

  Fedora (f43 AND f44)
    /usr/sbin/ld: cannot find -lstdc++
    have you installed the static version of the stdc++ library ?
    ERROR: Compiler sccache c++ cannot compile programs.

  build-punktfunk-gamescope.sh appends `-static-libstdc++ -static-libgcc` to
  LDFLAGS deliberately, so the binary still starts on SteamOS's older libstdc++.
  Without libstdc++-static that trips meson's very FIRST sanity check, so
  nothing builds at all.

  Debian/Ubuntu noble
    protocol/meson.build:7:17: ERROR: Neither a subproject directory nor a
    wayland-protocols.wrap file was found.

  The tree carries no wrap fallback for wayland-protocols.

Both proven deps are installed WITHOUT `|| true` so a rename is loud. The
remaining Arch makedepends the older packaged gamescope may not pull (glm,
cmake, libXcursor, wayland-protocols-devel on Fedora) stay best-effort, since
meson finds fallbacks and a name that moves between releases should not fail
the job.

And the part that actually matters: on `refs/tags/v*` a missing gamescope is
now an ERROR, not a warning. A release must not be able to make a claim its own
CI silently dropped. Gated in two places per platform — the build step, and the
packaging step that is authoritative and also covers the cache path (the build
step is skipped entirely on a cache hit, so a stale cache would otherwise reach
packaging and skip in silence). Canary keeps the old best-effort behaviour.

Deliberately NOT gated: the sysext leg. The notes make no claim about gamescope
inside the sysext, and with the build fixed gs-cache is populated so it gets the
binary anyway — gating it would add release-blocking risk with no matching
promise.

⚠ Verification is CI itself: both YAML files parse, and every gamescope-touching
`run:` block is `bash -n` clean with the matrix placeholders substituted. The
dep names cannot be proven from macOS; the rpm and deb legs on the next tag are
the proof, and they are now hard-gated, so a wrong name fails loudly instead of
shipping another empty promise.
2026-08-09 09:22:20 +02:00
enricobuehler 55f361cb92 Merge pull request 'The v0.26.0 tag went red on Windows — a Linux-only reader tripped dead_code' (#134) from worktree-pyrowave-wire-dead-code into main
apple / swift (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 4m54s
ci / web (push) Successful in 1m47s
release / apple (push) Successful in 10m42s
ci / rust (push) Successful in 8m51s
ci / docs-site (push) Successful in 1m44s
ci / bun-nix (push) Successful in 32s
apple / screenshots (push) Successful in 5m55s
android-screenshots / screenshots (push) Successful in 2m16s
deb / build-publish (push) Successful in 3m56s
decky / build-publish (push) Successful in 23s
deb / build-publish-client-arm64 (push) Successful in 2m39s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m32s
deb / build-publish-host (push) Successful in 6m49s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
linux-client-screenshots / screenshots (push) Successful in 2m55s
android / android (push) Successful in 11m36s
arch / build-publish (push) Successful in 13m24s
flatpak / build-publish (push) Successful in 8m4s
windows-host / winget-source (push) Successful in 35s
windows-host / package (push) Successful in 11m45s
windows-host / canary-manifest (push) Skipped
sbom / sbom (push) Successful in 35s
docker / deploy-docs (push) Failing after 6m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
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 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m57s
web-screenshots / screenshots (push) Successful in 4m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m38s
2026-08-08 23:44:44 +00:00
enricobuehler 2079411f4f fix(pf-encode): the Windows host could not compile — a Linux-only reader tripped dead_code
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m24s
android / android (pull_request) Successful in 5m55s
ci / rust-arm64 (pull_request) Successful in 4m5s
ci / bun-nix (pull_request) Successful in 33s
ci / docs-site (pull_request) Successful in 1m32s
ci / rust (pull_request) Successful in 21m44s
The v0.26.0 tag went red on windows-host at the clippy step, after a clean
build:

  error: function `wire_sequence` is never used
    --> crates\pf-encode\src\enc\pyrowave_wire.rs:68:15
     = note: `-D dead-code` implied by `-D warnings`

`pyrowave_wire` is cfg'd for linux OR windows and is genuinely shared —
`packet_boundary` and `stamp_color_bits` each have callers on both backends.
`wire_sequence` does not: every call site is in `enc/linux/pyrowave.rs`, which
is `#[cfg(all(target_os = "linux", feature = "pyrowave"))]`. Alternating
encoder handles are a Linux-side concern (PW5); the Windows backend drives
pyrowave's compat device with a single handle and never needs the counter. The
module's own `#[cfg(test)]` block does not reference it either, so on Windows
the item has zero callers in every target and dead_code is correct — it is the
`-D warnings` promotion to a hard error that stops the lib compiling.

Scoped to the one item rather than the file, and expressed as
`cfg_attr(not(target_os = "linux"), ...)` rather than a bare `allow`, so
dead_code stays LIVE on Linux — where the caller lives, and where this function
quietly losing its last caller would be a real finding rather than noise.

⚠ Not reproducible off a Windows box: cross-compiling to
x86_64-pc-windows-msvc from macOS dies in openh264-sys2's build script
(clang++ rejects `-fPIC` for that target) long before the lint stage. The
mechanism is nonetheless exact — one item, one cfg, zero callers behind it —
and the windows-host and windows-msix legs are the proof.

No behaviour change on any platform: this adds a lint attribute and eight
lines of comment.
2026-08-09 01:43:54 +02:00
enricobuehler 4d1a1348c0 Merge pull request 'chore(release): bump workspace version to 0.26.0' (#133) from worktree-release-0260 into main
apple / swift (push) Successful in 1m41s
audit / bun-audit (plugin-kit) (push) Successful in 1m1s
audit / bun-audit (sdk) (push) Successful in 33s
audit / bun-audit (web) (push) Failing after 36s
audit / docs-site-audit (push) Successful in 27s
audit / pnpm-audit (push) Successful in 30s
ci / web (push) Successful in 2m25s
audit / license-gate (push) Successful in 4m21s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m26s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 6m20s
android-screenshots / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 24s
deb / build-publish-client-arm64 (push) Canceled after 17s
decky / build-publish (push) Canceled after 5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (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/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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
linux-client-screenshots / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 1m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
sbom / sbom (push) Canceled after 0s
web-screenshots / screenshots (push) Canceled after 1s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m21s
audit / cargo-audit (push) Successful in 2m17s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m13s
nix / flake (push) Successful in 20m20s
flatpak / build-publish (push) Successful in 21m23s
2026-08-08 23:29:38 +00:00
enricobuehler e5180a5b7d chore(release): bump workspace version to 0.26.0
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m15s
ci / bun-nix (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 3m39s
ci / rust-arm64 (pull_request) Successful in 7m12s
android / android (pull_request) Successful in 9m5s
ci / rust (pull_request) Successful in 20m42s
nix / flake (pull_request) Failing after 20m28s
47 commits since v0.25.0, most of them from field reports on 0.25.0 itself,
plus Wave 2 of the PyroWave Linux host-performance program.

Nothing breaks: the wire protocol stays at 2 and the C ABI stays at 17, so
this release adds no call, no message and no capability bit. pf-driver-proto
is byte-for-byte identical to v0.25.0 and to v0.24.0.

Four new environment variables (PUNKTFUNK_OVERLAY_MASK,
PUNKTFUNK_GAMESCOPE_REFRESH_RATES, PUNKTFUNK_PYROWAVE_CHUNK_KIB,
PUNKTFUNK_PYROWAVE_STREAMED_AU), verified new by git grep at the v0.25.0 tag
rather than assumed. plugin-kit goes 0.3.2 -> 0.4.0 for the `plugin` launch
kind; the SDK goes 0.1.2 -> 0.1.4; gamescope patch level +pfhdr2 -> +pfhdr4.

Two behaviour changes make a client advertise LESS than it used to, both
deliberate: VIDEO_CAP_444 is now probed against the driver rather than ridden
off the setting alone (every Steam Deck with "Full chroma" on was losing HEVC
entirely, not crispness — no AMD silicon decodes HEVC 4:4:4), and the Decky
client-update check now reports a failure instead of dressing it up as
"up to date".

Bump is the same four files as 0.25.0: Cargo.toml, Cargo.lock,
docs/releases/v0.26.0.md, docs/releases/whatsnew/v0.26.0.txt — plus the
CHANGELOG.md section, which the split at 0.25.0 made part of the ritual.

Gates run locally, all green:
  * cargo fmt --all --check          clean
  * cargo metadata --locked          resolves
  * Cargo.lock diff                  versions-only, 70/70 changed lines, 35 crates
  * Play whatsnew gate               398/500 chars, not byte-identical to any other release
  * notes voice check                0 internal-vocabulary hits above "## For developers"

Notes are 66 lines against 0.25.0's 83, covering 47 commits.

Still owed on glass and recorded in the CHANGELOG's verification table:
iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the
PyroWave-under-game-load A/B with CAP_SYS_NICE actually granted.
2026-08-09 01:28:39 +02:00
enricobuehler 4070d043d6 Merge pull request 'PyroWave on Linux: the GPU-priority knob never fired, a dmabuf timeout condemned the host, and the jumbo grow was dead code' (#132) from worktree-wave2-pyrowave into main
apple / swift (push) Successful in 1m35s
android / android (push) Canceled after 3m13s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m9s
ci / rust (push) Canceled after 2m42s
ci / rust-arm64 (push) Canceled after 1m10s
ci / web (push) Canceled after 3s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 2s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (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/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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
flatpak / build-publish (push) Canceled after 8s
nix / flake (push) Canceled after 7s
release / apple (push) Canceled after 5m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14s
windows-host / package (push) Canceled after 3m27s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #132
2026-08-08 23:23:12 +00:00
enricobuehler ebf61cb448 Merge branch 'worktree-wave2-pw5-encode-overlap' into worktree-wave2-pyrowave
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m9s
ci / bun-nix (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
ci / web (pull_request) Successful in 3m25s
android / android (pull_request) Successful in 5m4s
ci / rust-arm64 (pull_request) Successful in 6m29s
ci / rust (pull_request) Successful in 11m37s
nix / flake (pull_request) Successful in 18m17s
# Conflicts:
#	crates/pf-encode/src/enc/linux/pyrowave.rs
2026-08-09 01:14:59 +02:00
enricobuehler 4a92c64144 Merge branch 'worktree-wave2-pw7a-jumbo-shard' into worktree-wave2-pyrowave 2026-08-09 01:13:30 +02:00
enricobuehler 2426056465 Merge branch 'worktree-wave2-pw6-streamed-au' into worktree-wave2-pyrowave 2026-08-09 01:13:25 +02:00
enricobuehler d3aaa16a7d Merge branch 'worktree-wave2-pw3-dmabuf-latch' into worktree-wave2-pyrowave
# Conflicts:
#	packaging/arch/punktfunk-host.install
#	scripts/steamdeck/install.sh
2026-08-09 01:13:23 +02:00
enricobuehler 2dd65bdd41 Merge pull request 'Opening the Steam menu on the Deck moved the game too — the pad is now held neutral while an overlay owns it' (#131) from worktree-deck-overlay-input-mask into main
apple / swift (push) Successful in 1m38s
audit / cargo-audit (push) Successful in 1m45s
audit / bun-audit (plugin-kit) (push) Successful in 37s
audit / bun-audit (sdk) (push) Successful in 30s
audit / bun-audit (web) (push) Failing after 26s
audit / docs-site-audit (push) Successful in 26s
audit / pnpm-audit (push) Successful in 11s
arch / build-publish (push) Successful in 9m2s
ci / rust-arm64 (push) Successful in 3m6s
android / android (push) Successful in 10m27s
audit / license-gate (push) Successful in 5m59s
ci / web (push) Successful in 1m30s
ci / bun-nix (push) Successful in 58s
ci / docs-site (push) Successful in 1m56s
release / apple (push) Successful in 9m51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m52s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
apple / screenshots (push) Successful in 5m48s
deb / build-publish-client-arm64 (push) Successful in 6m51s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m13s
deb / build-publish-host (push) Successful in 11m34s
ci / rust (push) Canceled after 18m48s
deb / build-publish (push) Canceled after 12m54s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 8m0s
nix / flake (push) Canceled after 8m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m37s
windows-host / canary-manifest (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m30s
Reviewed-on: #131
2026-08-08 22:59:30 +00:00
enricobuehler 5cbaca7789 feat(client/pads): stop forwarding the pad while the Steam overlay owns it
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 5m50s
android / android (pull_request) Successful in 7m56s
ci / rust (pull_request) Successful in 15m55s
nix / flake (pull_request) Failing after 16m33s
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the SAME
physical controller the client forwards, so opening either one moved the game
on the host as well as Steam's UI — a second, invisible player. Steam Input
masks a normal game here; it cannot mask us, because masking happens on Steam
Input's virtual pad and we deliberately forward the REAL one (28DE:1205 — the
virtual pad has no gyro, trackpads or paddles).

SDL ships the exact behaviour we want and it is on by default: presses are
dropped while the process has windows but no keyboard focus, releases still get
through. It CANNOT fire on a Deck. gamescope resolves focus per Xwayland ctx
and the client sits alone in its own, so the Steam overlay — which lives in the
root ctx — never takes our X focus away and no FocusOut is ever generated.
Measured on glass: with the QAM open, X input focus inside the client's ctx
stayed on its window for the whole 4 s, while GAMESCOPE_FOCUSED_APP flipped to
769 (Steam) and GAMESCOPE_FOCUSED_APP_GFX stayed on the app.

So the signal is explicit. `overlay_focus` watches those two atoms on the
gamescope root ctx — which is NOT our own $DISPLAY under `--xwayland-count 2`,
hence the socket-directory walk and the flatpak filesystem line — and the
presenter ORs it with window focus into one `set_masked`.

Masking is deliberately not `set_forwarding`: that closes the slot and sends
GamepadRemove, so the game would see a controller UNPLUG every time somebody
opened the QAM. This keeps every slot open and only stops the transitions,
after flushing what the host believes is held so a stick deflected at
overlay-open stops steering instead of freezing at its last value. On the way
back, held buttons are adopted rather than replayed — the A that picked a QAM
row must not fire in the game as it closes — while axes are re-sent, since a
stick has no press to ghost and SDL only speaks on change.

Fails open throughout: no gamescope, no X, or an unreadable signal all leave
forwarding exactly as it was. `PUNKTFUNK_OVERLAY_MASK=0` opts out.
2026-08-09 00:55:16 +02:00
enricobuehler 7d37fe450d test(pf-encode): run PyroWave at depth 2 on real hardware — without shipping depth 2
Wave-2 PW5, the stage-6 experiment. Shipped behaviour is UNCHANGED: `max_inflight` is still 1.

Stage 6 is the frame-corruption stage, and its gate is an on-glass tear-hunt with a live compositor,
a real client and ten minutes of moving content. That is not runnable from here. But the depth-2
risk has two halves, and one of them lives entirely in this crate — the per-slot resources
(`cmd`/`fence`/`csc_set`/y/uv/cursor) and the alternating encoder handles — so that half can be
answered now, on the GPU, and the answer is worth having before anyone attempts the other.

The experiment drives the backend with two frames genuinely in flight (submit N+1, then poll N) and
compares the result against the encoder's OWN synchronous output over the same 16 moving frames.
Its own depth-1 decode is the honest reference: pyrowave's raw AU bytes are not reproducible
run-to-run (see the stage-3 commit), but its decoded planes are.

RESULT, .21 / RTX 5070 Ti (GPU idle at 180 MHz of 3090 — the slow-clock worst case on this card):

  depth-2 vs depth-1 over 16 frames: worst-case PSNR identical (inf)

Bit-identical luma, every frame, in order. So stages 4 and 5 between them are sufficient for the
encoder side: doubling the six single-slot resources and alternating two `pyrowave_encoder` handles
under one monotonic wire sequence really does make overlap invisible to the decoder.

The test is built to fail rather than to pass. Content MOVES every frame (flat fills are the
documented false-green trap — a torn frame stitched from two halves of a static card is invisible),
it asserts two frames were ACTUALLY in flight rather than silently proving nothing, it asserts the
AU count is unchanged, and it carries an off-by-one discriminator that raw PSNR would miss: each
overlapped frame must match its own reference BETTER than it matches the previous one, so a
pipeline delivering frames one position late fails even though every individual PSNR looks fine.

It reaches `max_inflight` directly instead of through a shipped knob, precisely so the shipped
value stays 1.

⚠ WHAT THIS DOES NOT COVER, stated here so the next person does not read it as a green light for
stage 6: the CAPTURE side. `.process` hands the SPA buffer back to the compositor at callback
return while the encode thread holds only a dup of its dmabuf fd, so a second frame in flight
widens the window in which the producer may overwrite a buffer we are still reading by a full frame
period. Nothing in this crate can test that — it needs a live producer. Stages 1 and 2 are what
make it answerable (the pool census says how deep the producer's ring is; the Choice range asks for
headroom), and the on-glass hunt is what would settle it.

Gates green at CI parity.
2026-08-09 00:30:25 +02:00
enricobuehler 077db416ec feat(pf-encode): two PyroWave encoder handles, and the 3-bit landmine that makes them work
Wave-2 PW5 stage 5. Depth is STILL 1 — the handles alternate per frame, one in flight.

PyroWave's `Encoder` cannot hold two frames. Not "probably not" — structurally not. `Encoder::Impl`
owns ONE each of `wavelet_img_high_res`, `bucket_buffer`, `meta_buffer`, `block_stat_buffer`,
`payload_data` and `quant_buffer`, and `Impl::encode` OPENS by discarding them: an image barrier
with `VK_IMAGE_LAYOUT_UNDEFINED` as the old layout — a written promise that nothing else is reading
it — plus three `fill_buffer` clears. Two encodes recorded into two command buffers and submitted
to one queue have no execution dependency in Vulkan (submission order orders the START, not the
completion), so N+1's DWT would overwrite the wavelet bands and zero the RDO buckets while N's
block packing still reads them. Content-dependent, silent.

So overlap means TWO handles on one device, alternated — one per slot. Every resource above is
then private per handle, and within a handle the encodes stay strictly serialized (a slot's next
frame is recorded only after that slot's previous one retired), which leaves patch 0004's
scratch-pool invariant intact without touching it.

THE LANDMINE, and it is the reason this stage is its own commit: `sequence_count` ALSO lives on
`Impl`, and it is the 3-bit counter stamped into every block header. Two handles each count
1,2,3... alone, so the wire sees 1,1,2,2,3,3.... The decoder restarts a frame only when the value
CHANGES (`diff = (hdr.sequence - last_seq) & 0x7; restart = diff != 0`), so a repeat reads as MORE
BLOCKS OF THE SAME FRAME: `clear()` never runs, `decoded_frame_for_current_sequence` stays true,
and the second frame of each pair is swallowed. Half frame rate, occasional mixed-frame blocks, no
error anywhere — on every client, since pf-client-core and the Apple Metal hand-port parse the same
field.

`patches/0007-encoder-sequence-override.patch` (new, ~38 lines) exposes
`Encoder::set_next_sequence` + a `pyrowave_encoder_set_next_sequence` C entry + a
`PYROWAVE_SEQUENCE_MASK` define, so ONE monotonic counter on the Rust side is stamped regardless of
which handle encodes. The setter stores `(seq - 1) & mask` because `Impl::encode` pre-increments —
its contract is about the next ENCODE, not the next store. Inert when unused, so the whole Windows
backend is untouched. No `.def` change: the C API is a static archive.

PREDICTED, THEN OBSERVED. A negative control on .21 (the override call removed, nothing else) reads
the wire out at exactly:

  [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 0, 0, 1, 1, 2, 2]

which is the analysis's prediction character for character, and with the override:

  +1 mod 8, all 20 frames, through the 3-bit wrap.

THE GATE, `wire_sequence_increments_across_alternating_handles`, checks three things over 20 frames
because any one alone could pass while the stream is broken: the wire counter advances by 1 mod 8;
ONE persistent decoder (its `last_seq` carried across every push, exactly like a client's) reports
every AU decodable; and consecutive decoded pictures DIFFER. Content moves every frame — and the
first run caught a trap in the harness itself rather than the encoder: `test_card` starts its LCG
at `seed | 1`, so seeds 2 and 3 build a byte-identical card and the test faked the very repeat it
hunts. Odd seeds only now, with the reason written down.

A runtime self-check backs the test up where the test cannot reach: after packetize, the stamped
sequence is compared against what we asked for, and a mismatch logs once per process naming patch
0007. A re-vendor that loses the patch would not fail to build — it would fail on glass, subtly,
and this makes it loud instead. Two byte reads per frame.

`reset()` rebuilds both handles and `Drop` destroys both, each with the same null-immediately
discipline the single handle had (`pyrowave_encoder_destroy` is a bare `delete` with no null
check, so a stale pointer left in the field is a double free).

Vendored-patch discipline: patch 0007 re-applies clean to a pristine vendor checkout (verified by
stashing the vendor tree and re-applying), and `git diff crates/pyrowave-sys/vendor/` touches
exactly the four intended files.

VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090): all 8 `#[ignore]`d GPU tests
pass, including the new gate and the 4:2:0 / 4:4:4 / 24-bpp PSNR smokes.

Gates green at CI parity.
2026-08-09 00:27:28 +02:00
enricobuehler 29248dcab9 feat(pf-encode): PyroWave had six single-slot resources, not the two the plan named
Wave-2 PW5 stage 4. Pure capacity — `max_inflight` is STILL 1, nothing overlaps yet.

The plan named the y/uv images as the thing to double. Reading the backend found five more, and
each is a correctness problem under overlap rather than a performance one:

  * `csc_set` — ONE descriptor set, rewritten every frame by `bind_rgb`. Updating a set still bound
    by a PENDING command buffer violates VUID-vkUpdateDescriptorSets-None-03047, and on most
    drivers that is a wrong picture rather than an error.
  * `y_img`/`uv_img` — the CSC of N+1 storage-writes exactly the images pyrowave is still sampling
    for N. The barrier comment ("the previous frame's encode already completed under our
    synchronous fence") was load-bearing and said so.
  * `cursor_img` + `cursor_stage` — the struct comment stated the assumption outright: *"Single
    (not ring) because PyroWave encodes one frame synchronously — no in-flight overlap to race."*
  * `cmd` + `fence` — you cannot record into a PENDING command buffer at all.
  * `cpu_img`/`cpu_stage` (software capture / tests) — the host writes staging while the previous
    frame's copy is still pending.

All of it moves into a `Slot`, and the encoder now owns `SLOTS` of them. Two, because Granite caps
the overlap at two for us: the pyrowave device defaults to `init_frame_contexts(2)` and
`next_frame_context()` — called at the top of every `encode_gpu_synchronous` — waits the context it
rotates into. A third slot would need a vendored `init_frame_contexts(3)` that is not exposed.

`bitstream` and `import_cache` are deliberately NOT per-slot, and the `Slot` doc says why so a
later sweep does not "fix" it: `bitstream` is only touched during packetize, i.e. only on the poll
side one frame at a time, and `import_cache` retaining the VkImage/VkDeviceMemory per dmabuf inode
is precisely what makes it safe for two slots to sample the same imported buffer. `cpu_expand` is
shared for the same reason — it is copied into staging before `submit_frame` returns, so no GPU
work ever reads it.

Each frame carries its slot index in `InFlight` rather than recomputing it, so `wait_and_packetize`
cannot wait the wrong fence — the failure that would look like corruption rather than an error.
`reset()` now waits EVERY in-flight fence, not just one, which matters the moment depth rises.

WHAT IT COSTS, measured from the driver's own memory requirements rather than estimated (.21,
RTX 5070 Ti, and there is now an `#[ignore]`d test that prints it on any GPU):

  1080p 4:2:0   3872 KiB per slot    7744 KiB for both
  4K    4:2:0  12992 KiB per slot   25984 KiB for both
  4K    4:4:4  24992 KiB per slot   49984 KiB for both

So the extra slot costs ~3.8 MiB at 1080p and ~24 MiB at 4K 4:4:4 — an order of magnitude under
the plan's ~25-35 MB / 100-150 MB estimate, because that estimate included pyrowave's internal
wavelet and scratch buffers, which stage 5's second encoder handle will add and this stage does
not. Affordable on an iGPU. The open line now logs `slots`, `slot_kib` and `slots_kib` so this is
visible per session and not only in a test.

VERIFIED ON GLASS (.21, GPU idle at 195 MHz of 3090 — slow-clock, the worst case on this card):
all 6 `#[ignore]`d GPU tests pass, and all NINE decoded-plane hashes (`ref-dense-{y,cb,cr}`,
`ref-chunked-*`, `ref-dense444-*`) are bit-identical to the pre-PW5 base. Decode identity is the
meaningful gate here — the raw AU bytes are not reproducible run-to-run even from an unmodified
binary, which stage 3's message documents.

Gates green at CI parity.
2026-08-09 00:18:38 +02:00
enricobuehler 95962f55d0 refactor(pf-encode): PyroWave waited its fence inside submit — the one backend that did
Wave-2 PW5 stage 3. Depth is STILL 1; this is the shape change alone.

`encode_frame` recorded CSC+encode, queue-submitted, waited the fence and packetized, all inside
`Encoder::submit`. Every other backend in this crate puts the wait on the POLL side. That
difference is the whole reason the host loop's cadence folds around this encoder: with the wait
inline, `submit` returns only after the GPU is done, so the arrival-anchored floor absorbs the
encode only while it stays under 0.9x the frame interval.

Split into `submit_frame` (ingest -> CSC -> pyrowave encode -> queue-submit -> return) and
`wait_and_packetize` (fence wait -> packetize -> AU), with an `InFlight` deque between them capped
by `max_inflight`, which is 1. **One is the only value the resources can support today** — `cmd`,
`fence`, `csc_set` and the y/uv images are one each, so a second concurrent frame would record into
a PENDING command buffer and storage-write images pyrowave is still sampling. `submit` therefore
drains to `max_inflight - 1` before recording, which states that invariant in one place instead of
leaving it implicit in "the encode is synchronous".

The subtle part is the command-buffer state machine, and it is unchanged: the record-and-submit
closure still resets `cmd` on every PRE-submit failure (RECORDING/INVALID/EXECUTABLE, never
PENDING), and the fence wait still does NOT reset on failure, because a timeout leaves the buffer
PENDING where a reset violates VUID-vkResetCommandBuffer-commandBuffer-00045. What changed is that
a failed wait now also leaves the entry IN FLIGHT — which is precisely what tells `reset()` there
is live GPU work to re-wait before the pyrowave encoder object may be destroyed. `gpu_pending` is
gone; `!inflight.is_empty()` is the same fact, and cannot drift from it.

The split opened two windows that did not exist when everything ran inline, both closed here:
`reconfigure_bitrate` and `set_wire_chunking` can now land BETWEEN a submit and its poll, so the
packetize boundary and the bitstream cap are snapshotted into `InFlight` at submit time. Reading
the live fields would have let a mid-flight bitrate drop turn a perfectly good frame into
"unexpected packet count", and a mid-flight chunking change into an AU with the wrong
`chunk_aligned` flag.

`flush()` is no longer a no-op — it drains the in-flight frame, so the trait's poll-until-None
contract still returns every AU (the `spike` subcommand and the hardware smoke tests are the real
users).

The perf instrument still measures submit->AU, stamped at submit and taken when the AU becomes
readable, so `92326312`'s numbers stay directly comparable; the log line now carries `depth` and
says plainly that above depth 1 the number legitimately grows by about one loop period.

VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090 — so these are slow-clock runs,
which is the worst case on this card, not the best): all 6 `#[ignore]`d GPU tests pass — the
4:2:0, 4:4:4 and 24-bpp PSNR smokes, the mode-mismatch refusal, the fd-leak check and the golden
dump.

Byte-identity, honestly: the AU bytes are NOT reproducible, and were not before this commit
either. Three runs of the SAME unmodified binary produced three different `au-dense.bin` hashes
(ab7ecaf6 / 8735700e / 933b3d40) — the vendored 4:2:0 encoder emits run-varying bytes that the
decoder ignores. So the meaningful gate is DECODE identity, and that holds exactly: every decoded
plane (`ref-dense-{y,cb,cr}`, `ref-chunked-{y,cb,cr}`, `ref-dense444-{y,cb,cr}`) is bit-identical
between the pre-split base and this commit, across four runs. 4:4:4 AUs are additionally
bit-stable and match the checked-in Apple fixture exactly.

Gates green at CI parity.
2026-08-09 00:11:22 +02:00
enricobuehler c3ecc29117 feat(pf-capture): the zero-copy path never asked the compositor for buffer headroom
Wave-2 PW5 stage 2, on the number stage 1 just made visible.

`build_dmabuf_buffers` set `SPA_PARAM_BUFFERS_dataType` and stopped there — no
`SPA_PARAM_BUFFERS_buffers` at all, so the pool depth the whole zero-copy safety argument rests on
was entirely the producer's choice, and we never even expressed a preference. This asks for 8
(min 2, max 16).

A **Choice Range**, deliberately, not a fixed count. SPA intersects the consumer's and producer's
Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection and
the link stalls in "negotiating" with no error anywhere — the exact trap that cost this codebase
the entire Linux cursor channel once, when a 256^2 cursor-meta max failed to intersect Mutter's
fixed 384^2 offer. With a range the producer clamps into it and negotiation still succeeds; the
min stays at 2 so nothing that works today stops working.

The numbers, and what they are not: 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz,
well past the ~3-4 ms capture-to-fence latency PW3/PW4 measured, with room for a second frame in
flight. 16 is a ceiling rather than a request — a 4K 4:4:4 buffer is ~25 MB, so 16 of them is
~400 MB of compositor allocation. These are the values we ASK for; what a producer actually
allocates is what stage 1's census line reports, and that line is the one to trust.

Scoped to the dmabuf pod only. The mappable and SHM-only builders are untouched: their consumers
copy out of the buffer inside `.process`, so pool depth is not part of their correctness argument.

A test pins the pod SHAPE — Choice, Range, Int children, values default-first — so a later
simplification cannot quietly turn the range back into a number and take the negotiation down with
it.

Gates green at CI parity; on-glass negotiation on each producer is stage 2's own gate and is
reported with the stage-1 census numbers.
2026-08-09 00:01:24 +02:00
enricobuehler 6d550530fe feat(pf-capture): nothing had ever counted the compositor's buffer pool — the number every zero-copy safety argument rests on
Wave-2 PW5 stage 1, and the one stage with no risk at all.

The zero-copy capture path dups the dmabuf fd, publishes the frame, and hands the SPA buffer
straight back to the producer at `.process` return — while the encode thread has not yet imported
it, let alone read it. The code says so itself ("content stability across the brief import/encode
window relies on the compositor's buffer-pool depth, like any zero-copy capture"). That depth is
therefore load-bearing: it is the ONLY thing standing between us and the producer overwriting a
buffer mid-read.

And it had never been measured. Not logged, not asserted, not even requested — `build_dmabuf_buffers`
set `SPA_PARAM_BUFFERS_dataType` and nothing else, so whatever the producer picked is what we got,
silently.

This adds the `add_buffer`/`remove_buffer` stream callbacks PipeWire has always offered and logs the
count once per distinct depth: `pool_depth`, `high_water`, and the latest-frame-only `drained`
count beside it. One line per session on a stable pool (`.process` runs at the capture rate — an
unconditional log would be 240 lines a second of the same number), a second line if a
renegotiation changes the depth.

`high_water` is tracked separately from `live` because a renegotiation frees the pool before
re-allocating it: any decision keyed on the live count would read that dip as "the pool shrank".
`remove` saturates at zero rather than wrapping, so an unmatched remove cannot report `u32::MAX`
buffers.

Measurement only — no behaviour change, and no consumer of the number yet. PW5's later stages need
it (a deeper encode pipeline widens the overwrite window by a full frame period), but the number is
worth having regardless of whether those stages ever land: it is the answer to "is our zero-copy
capture actually safe on this compositor", and until now the honest answer was "nobody knows".

3 tests pin the once-per-depth logging, the renegotiation dip, and the saturating remove.

Gates green at CI parity.
2026-08-09 00:01:08 +02:00
enricobuehler 4f5ca5f9bc docs(pf-capture): KWin/RADV was the last untested producer — it has no fence either
Closes PW4's one remaining gap. The Steam Deck switched to Desktop Mode gives KWin on RADV, the
combination none of the earlier legs covered, and it reports no implicit fence like every other:

  gamescope + NVIDIA (RTX 5070 Ti)   NoFence
  Mutter    + NVIDIA (RTX 5070 Ti)   NoFence
  gamescope + RADV   (Deck VANGOGH)  300/300 NoFence, mean 23us, p99 <=100us
  KWin      + RADV   (Deck desktop)  no fence  (older build's wording: waited=false)

That is every compositor x vendor this fleet has. PW4 retires with no outstanding doubt rather
than "probably fine except one box we never tried".

Measured with the Deck's OWN already-authorized binary rather than a scratch build, because KWin
grants zkde_screencast_unstable_v1 per EXECUTABLE PATH: it resolves /proc/<pid>/exe against a
.desktop's Exec= and caches the grant on first connect, so an unregistered path is refused outright
and registering one needs a re-login. The fence probe is pre-existing capture-path code, so a build
from July answers the outcome question perfectly well — and nothing of the user's was modified to
get it.

Comment-only; no behaviour change. fmt + pf-capture clippy -D warnings green.
2026-08-08 23:52:54 +02:00
enricobuehler 744bcb468b feat(host/wire): a jumbo path can now be PROVEN — and the shipped grow never could
Wave-2 PW7a: a PyroWave session on a proven-jumbo LAN should START at the big shard, because it
is the one codec that can never be re-keyed mid-stream (its client parses chunk-aligned AUs in
windows of the `Welcome` value, read once over the C ABI). At an 8908-byte shard that is ~6×
fewer datagrams per frame — ~49k → ~8k pps at 550 Mb/s — and proportionally less window-tail
padding.

THE BLOCKER FOUND FIRST: the whole jumbo leg was dead code, not just the missing half. quinn
caps a peer's MTU-discovery search at `min(MtuDiscoveryConfig::upper_bound, the OTHER side's
advertised max_udp_payload_size)` (`quinn_proto::connection::mtud::SearchState::new`), and
`EndpointConfig::max_udp_payload_size` defaults to 1472. Nothing in the repo had ever touched
`EndpointConfig`, so raising the host's PROBE ceiling — all `stream_transport_idle` did — could
never make discovery settle above 1472, and the shipped mid-session grow's
`settled >= sealed_datagram_bytes(target)` gate was unreachable on every path that has ever
existed. Two smaller contributors, fixed here too: the watcher stopped sampling the moment
`settled >= 1472`, discarding the very climb the proof needs, and a session sealed ABOVE the
1500-byte default was never checked against the path at all.

The advertisement is raised on the CLIENT endpoint, under the same `jumbo_wire_mtu()` opt-in as
the probe ceiling, because it is not free: quinn sizes its endpoint receive buffer
`max_udp_payload_size × max_receive_segments × BATCH_SIZE`, so on a GRO-capable Linux/Android
client that is ~2.9 MiB at the default and ~18 MiB at jumbo (47 KiB → 288 KiB on Apple/Windows).
Consequence: jumbo now needs the opt-in on BOTH ends. Without it, every byte on the wire and
every byte of buffer is exactly what it was.

WHY THE GROW IS AS SAFE AS THE CLAMP, which is not obvious — the failure modes are opposite. A
stale clamp only makes datagrams smaller than they had to be; a stale grow seals an oversized
datagram onto a 1500-byte path, where it is silently dropped, and a PyroWave session cannot
recover from that for its whole life. Mirroring the clamp's keying is therefore NOT sufficient.
So the memory is demoted: the persisted verdict only decides whether it is worth WAITING for a
proof, and what authorises the grow is a LIVE re-proof on the very connection being welcomed —
`conn.stats().path.current_mtu` ≥ the sealed target, i.e. a datagram of exactly that size acked
by this client, on this connection, seconds ago. The moved laptop cannot inherit anything: its
new path's live MTU is 1472 and the grow does not happen, whatever the memory says.

The remembered half is keyed strictly anyway — `(local_ip, peer_ip)`, so a verdict earned over
the host's 10 GbE NIC does not apply to the same peer over Wi-Fi or a VPN — and carries the
operator target it was proven under plus a 6 h TTL. It is erased by any contrary evidence: a
lower settle, a session that ended before the window closed (what a client staring at black
does), a changed opt-in, or a constrained-path clamp that disagrees.

The proof-wait is on the bring-up critical path (`handshake.rs` sends the `Welcome` and only
then kicks the display prep), so it is bounded at 300 ms, exits the instant the proof lands, and
is entered ONLY for a path a previous session already proved. Its worst case is the moved
laptop, and that is self-limiting: that session's watcher erases the verdict.

MEASURED, NOT ARGUED: `mtu_discovery_climbs_only_as_high_as_the_peer_advertises` (`#[ignore]`d,
loopback — whose own MTU is 64 KiB, so configuration is the only thing that can stop the search),
on .21:

  leg A (server opted in, client NOT): settled at 1472 B UDP payload   <- the dead-code proof
  leg B (both opted in):               reached 8972 B in 5 ms          <- the fix, and its speed

Leg A is the finding restated as an experiment. Leg B says the climb costs ~5 ms once both sides
advertise it, so the 300 ms proof-wait is ~60x the loopback convergence time — enough headroom
for a real LAN's RTT and per-probe ack delay across the ~11 probes the search takes.

Still owed: the A/B on a real jumbo LAN segment (9000-MTU NIC + switch on both ends) — pps per
frame, wire/pin ratio, and a PyroWave session observed starting at 8908. Not runnable without
the hardware.
2026-08-08 21:53:02 +02:00
enricobuehler 20f4d23f2d test(pw6): the streamed-AU trap is real — and at 2 % loss it costs exactly nothing
PW6 shipped behind a knob because one pre-registered risk was unmeasured: a
streamed frame whose FINAL block is lost has no totals, so where the whole-AU
path hands the consumer a usable blurred partial, a streamed frame may deliver
nothing. PyroWave clients opt into partial delivery unconditionally, so this
would have been a live behaviour change for every one of them. Measured now,
three ways, instead of reasoned about.

`tools/loss-harness` gains a partial-delivery leg: FEC pinned OFF, chunk-aligned
AUs, deliver_partial ON, realistic 1408/200 geometry, and AU sizes swept across
the whole 1..=200-shard range of FINAL-block sizes — because the final block's
size is what bounds the exposure. Loss is injected per packet from a seeded
xorshift rather than through `loopback_drop_period`, whose deterministic 1-in-N
would systematically always-or-never hit the final block, which is the entire
question. `tc netem` on `lo` was deliberately not used: the in-process model
gives exact per-frame attribution, needs no sudo, cannot disturb a box running a
live desktop session, and — decisively — can drop precisely the final block.

Leg 1, deterministic (drop exactly the last block, 200 frames): whole-AU
delivers 200 partials and 0 losses; streamed delivers 0 partials and 200 total
losses. The trap is real and, when it fires, total.

Leg 2, random loss, 20 000 frames per cell, same seed and sizes for both shapes.
At 2 % the two are indistinguishable — 20000/20000 partials and ZERO vanished
frames on both, matching the analytic bound E[loss^k] over final-block sizes k
(~1e-4). The gap only appears at 30 % (99.94 % vs 100 % rescue) and 50 %
(99.79 %). `complete` is 0 throughout by construction: with FEC off and ~500
packets per AU, essentially every frame is damaged — which is the regime the
partial path exists for.

The spike gains `--wire-chunk` and a streamed loopback path, so the wire shape
is reachable end to end outside a real client: `poll_chunk` drains the AU,
`begin_streamed_frame_at`/`seal_streamed_chunk`/`seal_streamed_finish` seal each
piece, and the client byte-compares the reassembly. On 120 real PyroWave AUs the
streamed legs (56.5 and 2.0 chunks/AU) and the whole-AU control emit a
byte-identical 47 373 568-byte stream with 0 mismatches — the cut changes the
wire shape and not one byte of content, and with the knob unset it does not
engage at all.

A new `#[ignore]`d GPU test closes the picture question on real hardware with a
BUSY card (gradients + checker + noise), never a flat fill: chunks are whole
windows, exactly one `first` and one `last`, the AU decodes through the client's
own window walk, and luma PSNR lands at 40.2 dB. Unset the knob and the test
refuses to run, which is the default-off claim verified rather than asserted.

Verdict recorded in the plan: KEEP IT OFF. The 2 % tie is an argument about
typical loss, but the failure is not graceful when it fires and the measured win
is host send-side pipelining that nobody has yet put a millisecond number on.
2026-08-08 21:26:25 +02:00
enricobuehler 49f5c815ea feat(pf-encode): PyroWave can stream its AU to the wire — and newest-wins was never in the way
PW6 was gated on one question: what happens to the client's newest-wins
draining when a PyroWave AU arrives in pieces, given that
`Session::set_deliver_frame_parts` refuses to combine with an all-intra
stream. The answer is that the doc and the plan conflated two different
axes, and the question never applied to this package.

Host STREAMED_AU chunks change only the WIRE shape. The reassembler
completes such a frame exactly like a whole one (`block_count != 0 &&
blocks_ok == block_count`) and hands up ONE Frame, so the frame channel
still sees one entry per AU and the drain is untouched.

What newest-wins genuinely cannot survive is the client's SEPARATE prefix
delivery, and the mechanism is sharper than "assumes whole AUs" said:
`FrameChannel::pop` counts QUEUE ENTRIES and takes one entry to be one AU.
With parts on, one AU pushes several, so `len > 1` stops meaning "the
consumer is behind" — the drain fires mid-AU, returns a SUFFIX and clears
that same AU's prefixes. For PyroWave that is fatal rather than lossy: the
sequence header lives in window 0 of every AU (`au_dims` reads it there), so
every frame would arrive headerless, and `FramePart`'s own orphan contract
would have a correct consumer abandon essentially all of them. Written into
`pop`, `set_deliver_frame_parts` and the handshake, together with what a fix
would take (skip whole SUPERSEDED AUs, never split one).

That answer shrinks what this package may claim, so the code says so
plainly. `encode_frame` is synchronous: the whole AU exists before the first
chunk can be polled, so `poll_chunk` is not "emit as produced" and there is
no encode/send overlap here (PW6 ⟂ PW5, confirmed). And with the client
still receiving one whole Frame there is no decode-while-arriving either —
the "~7 ms, decouple e2e latency from AU size" framing needs client work
this commit does not do. What IS left is real and host-side: the whole-AU
path FEC-protects, packetizes and seals the entire ~830 KB AU before its
first datagram may leave the socket, while the streamed path seals and paces
each FEC block as it completes.

All of the cutting lives in the shared `pyrowave_wire` helper, which
compiles and unit-tests on every platform, so both backends' `poll_chunk` /
`supports_chunked_poll` are thin delegations — the Windows backend cannot be
compiled from a Linux box, and logic written into it directly would ship
unverified. Chunks are whole numbers of framing windows because `build_au`
gives each window exactly ONE kind; that also makes them shard-aligned for
free, which is what the sealer's sentinel bases require. Dense mode never
streams (no window framing to cut on). `poll()` now errors while a chunk
cursor is live — the trait's one-drain-method-per-AU contract, where
double-emitting would put the same bytes on the wire twice under one frame
index — and `reset()` drops the cursor so a rebuild cannot splice a dead
AU's tail onto a fresh one. No new Encoder trait method, so neither the
TrackedEncoder forwarding trap nor the EncoderCaps default trap is in play.

Shipped OFF: `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` arms it,
`PUNKTFUNK_PYROWAVE_CHUNK_KIB` tunes the 256 KiB target. The pre-registered
partial-delivery trap is real and now has a named cost — an unpinned
streamed frame (final block lost) is excluded from partial delivery, where
the whole-AU path still hands the consumer a usable blur, and PyroWave
clients opt into partials unconditionally. The netem loss-harness leg is the
prerequisite for default-on and has not been run.
2026-08-08 19:23:05 +02:00
enricobuehler 9232631299 feat(pf-encode): PyroWave had no encode split — so the one cost this program protects was unmeasurable
Wave-2 PW1's exit criterion, and the instrument it needed.

VAAPI and direct NVENC both log a PUNKTFUNK_PERF submit split. PyroWave did not — which meant the
single encoder the GPU-priority work exists to defend was the one you could not put a number on.
Adds per-frame timing of the synchronous encode (whole `submit`: CSC + encode + fence wait +
packetize, which for this backend IS the encode), summarised every 2 s as mean/p50/p99/max.

p99 rather than mean-only on purpose. The failure patch 0005 describes is a TAIL event — frames
going ~2 ms to 15-18 ms at 95 % game load while the mean barely moves — so a mean-only readout
would report "fine" straight through the thing being measured.

WHAT IT MEASURED — .21, RTX 5070 Ti (610.57.04), GRID 2 benchmark loop saturating the GPU at
54-87 %, PyroWave 1080p, same binary both arms (only CAP_SYS_NICE differs), 30-frame windows with
the warm-up window dropped:

  arm                        p50        p99        worst frame
  default priority (refused) ~2.6 ms    ~6.4 ms    9.5 ms
  REALTIME granted           ~3.2 ms    ~4.4 ms    5.4 ms
  REALTIME granted (repeat)  ~3.35 ms   ~4.8 ms    5.1 ms

p99 down ~30 %, worst frame roughly halved, for ~0.6 ms on the median. For a streaming encoder
that is the right side of the trade — the tail is what becomes a visible hitch.

This CONTRADICTS the patch's only prior datum (RTX 4090 / Windows / WDDM: "did not reduce the
spikes"), so patch 0005's header now records the Linux/NVIDIA result beside it, with an explicit
"do NOT delete this patch on the strength of the WDDM result — the two stacks disagree". Header
prose only; the diff hunks stay byte-identical and `git diff crates/pyrowave-sys/vendor/` is
untouched by this commit.

Caveats recorded rather than buried: the arms were not interleaved and the game load drifted
between them, capture was frame-starved (~2.5 fps) so this is encode latency under contention and
not a full-rate stream, and it is two granted runs against one refused run. The direction held
across all 25 windows.

Also worth knowing for anyone repeating this: `encode_fps` is a VACUOUS metric on this rig. A
headless gamescope with no real content emits ~12 fps, so both arms simply report the capture rate.
Measure latency, not throughput.

Gates green at CI parity.
2026-08-08 18:40:52 +02:00
enricobuehler 8387e48ac6 docs(pf-capture): the fence wait is already free — PW4 retires into this comment
Wave-2 PW4's outcome. The package proposed moving the producer-fence wait off the PipeWire loop
thread, and was pre-registered to be ABANDONED if the wait turned out to already be free. It is,
on every producer and vendor measured — including the one where implicit sync actually exists.

Steam Deck, RADV VANGOGH, gamescope producer (built in distrobox pf2, run on the host):

  samples=300  mean_us=23  max_us=48  p50=<=100us  p99=<=100us
  signaled=0   no_fence=300  timed_out=0  failed=0

p99 in the first bucket is the plan's own abandonment condition, and the outcome split explains
why: 300 of 300 buffers reported NoFence. Same on both NVIDIA producers (gamescope and Mutter's
virtual output — the exact no-explicit-sync case the comment cites as the reason the wait exists).

So `wait_read_ready` here is one ioctl and a return, not a block. Moving it to the consumer side
would buy nothing measurable and would take on the hazard the package itself names — a slot holding
a not-yet-ready dmabuf, and `repeat_last` re-waiting a fence it already consumed. Not a trade worth
making for 23 microseconds.

The 100 ms budget stays: it guards a producer that DOES fence, which is a real thing even if
nothing in this fleet does it. KWin/AMD is the one combination still unmeasured, and the histogram
from the previous commit is deliberately kept as the way to re-check — run with PUNKTFUNK_PERF=1
and read the p99 bucket.

Comment-only; no behaviour change. Gates green at CI parity.
2026-08-08 16:19:34 +02:00
enricobuehler fb60bf653e feat(pf-capture): instrument the fence wait PW4 wants to move, before moving it
Wave-2 PW4, step one of one-so-far. The package's own first line is "investigation step first
(measure, then decide)", and it is pre-registered to be ABANDONED if the wait's p99 is ~0 — so the
instrument ships before the change, not after.

A per-session histogram of `wait_read_ready`, taken on the PipeWire loop thread, which is exactly
where the wait is expensive: that thread is the compositor's consumer, so time blocked there delays
buffer recycling for the NEXT frame. Logged under PUNKTFUNK_PERF at the same cadence and gate the
encode backends use for their submit splits, so a perf run reads as one instrument: samples, mean,
max, p50/p99 bucket, and the Signaled/NoFence/TimedOut/failed split.

Buckets are coarse on purpose (100us -> 10ms, plus overflow). The decision this feeds is binary —
a p99 in the first bucket means the wait is already free and PW4 becomes a comment correction; a
p99 past 1ms is a real stall against a 16.6ms frame budget. Edges are placed so those two worlds
cannot be confused, and anything past the last edge reports as overflow rather than clamping into
the top bucket ("worse than 10ms" is a distinct finding).

The outcome split sits next to the timings because "the wait is short" and "there is nothing to
wait for" are different results with different consequences, and one data point already shows the
second: on gamescope/NVIDIA the probe reports NoFence, i.e. that producer attaches no implicit
fence at all.

5 tests pin the arithmetic, including that an empty histogram reports "no answer" rather than a
decisive-looking zero — the failure mode that would retire the package on no evidence.

No behaviour change: the wait still happens where it always did. Gates green at CI parity.
2026-08-08 15:47:25 +02:00
enricobuehler b815e00a87 fix(pf-zerocopy): one dmabuf timeout condemned every later capture on the host, forever
Wave-2 PW3.

The raw-dmabuf passthrough has two very different reasons to switch itself off, and they shared one
`AtomicBool`:

  * the encoder repeatedly failed to import what this compositor allocates — unrecoverable, a
    driver fact, and the reason this latch was written (it stops the encode-stall recovery
    rebuilding the same doomed encoder five times and then ending the session, on every connection,
    forever);
  * the dmabuf-only capture offer never negotiated — which can simply mean the compositor was
    mid-restart.

Sharing the flag made the second as permanent as the first. One timeout, and EVERY later session on
the host captured CPU frames until the process was restarted — including sessions against a
completely different compositor and a different node, which had never failed at anything. Nothing
said so; the arm line PW2 added would have shown `cpu` with no explanation.

Now the two causes have the lifetimes they should have, in a `RawDmabufLatch` that owns both:

  * Import failures stay sticky. Unchanged threshold (3 consecutive), unchanged hazard coverage.
  * Negotiation timeouts get a retry budget of 2 — one retry, deliberately small: each failure
    costs a ~10 s stall, so a larger budget is paid by the user in dead air. One retry survives the
    mid-restart transient; a compositor that genuinely never accepts keeps the same identity, so it
    latches on the second try, one extra stall per host lifetime versus the old behaviour.
  * A capture that negotiates credits the budget back, so an evening of reconnects against a
    compositor that failed once cannot accumulate its way into a latch.
  * BOTH are keyed to a capture identity (node id + portal bit). A new node — fresh virtual output,
    compositor restart, the Bazzite Gaming↔Desktop switch — is a genuinely different question and
    earns a fresh dmabuf attempt instead of inheriting a verdict about something else. The SAME
    capture keeps its verdict, which is what preserves the 10 s-stall protection the latch exists
    for.

The session-open line now carries the latch state, so `cpu` is no longer ambiguous between "this
host was never going to do dmabuf" and "something failed earlier and we are still living with the
verdict" — only the second is a bug worth chasing, and only the second is now visible as one.

Atomics rather than a lock because `note_import_ok` is on the per-frame import path; everything
else runs at pipeline build or on failure. The state machine is tested against a local instance
rather than the process-wide static — seven tests covering both lifetimes, the identity clear, the
same-identity hold, the budget credit, and the cause naming.

One honest note on the identity: it is the PipeWire node id, not the "(compositor-id, modifier
list)" pair the design sketched. Node id is what capture actually has at that point, and it changes
on exactly the events that matter here (new virtual output, compositor restart, session switch).
Keying on the modifier list too would need the list before the importer is built, which is the
wrong order.
2026-08-08 15:40:55 +02:00
enricobuehler 767e67caf4 feat(packaging): grant the host CAP_SYS_NICE, without which the GPU-priority lever does nothing
Wave-2 PW1, second half. The companion commit wires `PYROWAVE_QUEUE_PRIORITY` into the Linux
PyroWave device; this is what makes it work on a packaged host.

Measured on .21 (RTX 5070 Ti, NVIDIA 610.43.02), same binary in both arms:

  as packaged (no capability)     every class refused, REALTIME *and* HIGH -> default priority
  same binary, cap_sys_nice+ep    granted REALTIME on the FIRST attempt, no downgrade

RADV behaves the same way. So this is not the RADV-specific "expect one downgrade to HIGH" the
plan predicted — without the capability there is no elevated priority at all, on any vendor, and
the knob is decoration.

Worth being precise about what is being granted, because it is a network-facing daemon.
CAP_SYS_NICE permits raising scheduling priority (nice, ioprio, affinity, RT class) and nothing
else: no filesystem access, no network privilege, no user switching, and it is NOT setuid. The
repo already ships exactly this capability on its gamescope binary for the same reason. Two side
effects that will otherwise confuse someone debugging: a capability-carrying binary is AT_SECURE,
so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for it (note this box was propped up by exactly
such a shim during the ffmpeg-9 soname break — that workaround would now be silently ignored), and
core dumps are suppressed by default.

Per packaging path, because none of them are the same:

- Arch: a `_grant_sched_capability` in the scriptlet, called from post_install AND post_upgrade —
  a replaced binary is a new inode, so the capability does not survive an upgrade by itself.
- Debian: the same setcap in the postinst `configure` branch.
- RPM: `%caps(cap_sys_nice=ep)` on the binary in `%files`, which is the rpm-native form — rpm then
  applies it on install, restores it on upgrade, and verifies it. A `%post setcap` does none of
  those.
- NixOS: `security.wrappers`, because a store path is read-only and shared and cannot be setcap'd.
  The unit's ExecStart moves to `config.security.wrapperDir` — without that the wrapper exists and
  the service still runs the uncapped store path, which is the whole failure this fixes.
- Steam Deck: setcap in the installer's sudo block. That box needs it most (one small Van Gogh GPU
  shared between the game and the encode). The binary lives under $HOME, so unlike the /etc
  drop-ins it survives a SteamOS A/B update on its own and needs no atomic-keep entry — but it
  does need re-applying after each rebuild, which re-running the installer does.
- Bazzite sysext: at IMAGE BUILD time, before mksquashfs. It cannot be done in the merge hook (a
  merged sysext's /usr is read-only squashfs) and it cannot ride in from the RPM either — rpm keeps
  capabilities in its own header and `rpm2cpio | cpio` carries only the payload, so the staged file
  arrives with none. mksquashfs does record security.capability (only security.selinux is
  excluded), so a setcap on the staging tree is what lands in the image. Needs root/CAP_SETFCAP;
  a plain-user CI build warns and ships without it rather than failing a release over a
  performance lever.

Every one of them is best-effort and cannot fail an install: a box without libcap, or a filesystem
that cannot store capabilities, simply runs at default priority exactly as it does today.

Documented in the same PR — the configuration row now says the packages grant it, and
running-as-a-service gets a section explaining what it is, how to check it (`getcap`), and how to
remove it (`setcap -r`, or just `PYROWAVE_QUEUE_PRIORITY=off`), including the two debugging side
effects.

Verified: the Arch scriptlet grants the capability from a fake package root exactly as pacman
would invoke it, and the resulting binary reaches REALTIME end to end on the RTX 5070 Ti; the RPM
spec's %caps line parses under rpmspec in a Fedora 41 container; the NixOS module parses under
nix-instantiate; all five edited shell scripts pass `bash -n`. No Rust file changed in this
commit, so the CI-parity Rust gates from the companion commit still stand.
2026-08-08 15:24:54 +02:00
enricobuehler 2bf571a5ad feat(pf-encode): PyroWave's Linux encode device never asked for the priority its own patch requests
Wave-2 PW1, first half = Wave-1 WP14 step 4, executed as specced.

PyroWave encodes on the same GPU shader cores a game saturates, and that is measured to hurt:
patch 0005's header records `encode_gpu_synchronous` going from ~2 ms to 15-18 ms at 95 % game
load, with the stream frame rate collapsing. NVENC is immune because it has its own ASIC. The
lever for a compute workload is an elevated global-priority QUEUE — a process-priority raise only
reorders submission, not hardware preemption.

The vendored patch requests exactly that. It is gated `if (!inherit_info)`, and only Windows
leaves `inherit_info` null (`pyrowave_create_device_by_compat`, where Granite builds the device
itself). Linux passes its own create-infos into `pyrowave_device_create_info`, Granite's
`get_existing_create_info()` hands them back, `create_device` takes the inherit branch — and the
whole block is skipped. On Linux the knob has never done anything at all. Meanwhile pf-zerocopy's
VkBridge has shipped the identical ladder on Linux for some time and calls it "the actual NVIDIA
compute-preemption lever"; the encoder that needs it most did not have it.

This wires it natively in `open_inner`'s `DeviceHold`:

- The extension probe reuses the `dev_ext_props` already fetched for queue_family_foreign, and
  takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
- `queue_priority_candidates` is a pure fn with the grammar copied from the C patch: unset →
  realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to
  the ladder rather than to off. One env var must not mean two things on two platforms — that is
  the documentation trap this package exists to close — so the grammar is unit-tested against the
  patch's, including where they are both deliberately un-clever (neither trims).
- The create ladder is REALTIME → HIGH → no-priority, stepping only on a refusal. A refused class
  can never fail the open, which matters more here than on Windows: this path is reached only by a
  NEGOTIATED PyroWave session, so a hard error is a dead stream, not a fallback to another encoder.

The subtle part is the write-back. `pyrowave_create_device` RETAINS `device_create_info` for the
device's lifetime and Granite reads the chain back. If the ladder ends on the no-priority attempt
while `_queue_ci[0].p_next` still points at the global-priority struct, Granite is handed a chain
the device was not created with. The `None` arm therefore nulls `p_next` before the final create,
and the field's doc says why. The enabled extension deliberately STAYS in the list: it really is
enabled on the device, it just carries no request.

One deviation from the plan, stated because it is a deviation: the ladder also steps down on
`ERROR_INITIALIZATION_FAILED`, not only `ERROR_NOT_PERMITTED_KHR`. The plan and the C patch handle
only the latter; pf-zerocopy's shipped ladder accepts both. Given a hard error here kills a
negotiated session, treating one extra driver-specific refusal as a downgrade is the cheap side of
that asymmetry.

Also corrects the two vendored notes, which claimed a Linux behaviour the gate made impossible,
and records that patch 0005's negative RTX-4090 result is Windows/WDDM and does not transfer to a
different driver stack. Patch hunks are byte-identical (header prose only) and
`git diff crates/pyrowave-sys/vendor/` is PUNKTFUNK-VENDOR.txt alone.

`PYROWAVE_QUEUE_PRIORITY` is now reachable on Linux, so it is documented in the same PR.

MEASURED ON GLASS, and it changes what this package is worth on its own — .21, RTX 5070 Ti,
NVIDIA 610.43.02, same binary in both arms:

  as packaged (no capability)     every class refused, REALTIME *and* HIGH -> default priority
  same binary, cap_sys_nice+ep    granted REALTIME on the FIRST attempt, no downgrade

So the lever is INERT on an unprivileged host, and that is not the RADV-specific downgrade the
plan predicted — on NVIDIA it is a downgrade to nothing at all. The ladder itself is proven good
across all three legs (unset / high / off): a refused class never fails the open, and `off`
enables no extension and logs nothing. It simply has nothing to grant yet.

The privilege needed is CAP_SYS_NICE on the host binary, which is NOT what Wave-1 WP3 ships
(RLIMIT_NICE, PAM limits, CPUWeight — all different things). That grant is a security-posture
change on a network-facing daemon, so it is deliberately NOT in this commit; the warn line now
names the capability so an operator is not left guessing, and the docs row says the setting has no
effect on most hosts today rather than implying it works.

The loaded-GPU encode_us p99 A/B is therefore not run: it needs a GPU-saturating game (hence a
desktop session the box does not currently have) and it is pointless before the capability lands,
since the unprivileged arm has no priority to measure.

NO unit test is possible for the device-create ladder itself — it needs a real Vulkan device. Its
coverage is the clippy pass, the grammar tests, and the on-glass log line. Stated here rather than
left for a reviewer to wonder about.
2026-08-08 14:37:41 +02:00
enricobuehler 9c24569db6 fix(spike): --codec pyrowave encoded PyroWave off a capture negotiated for somebody else
Found while taking PW2's on-glass measurement, and it is what made the measurement possible.

`spike` built its capture request from `OutputFormat::resolve`, the constructor shared with the
GameStream path, which hard-codes `pyrowave: false` ("GameStream never negotiates PyroWave").
On Linux that flag is not cosmetic: `capture_virtual_output` feeds it to `zero_copy_policy` as
`ZeroCopyPolicy::pyrowave_session`, which is what puts the capture on the raw-dmabuf passthrough.
So `--codec pyrowave` opened a PyroWave encoder over a capture negotiated for a different
consumer, and the only way to exercise the real path was the host-global
`PUNKTFUNK_ENCODER=pyrowave` lever.

That lever cannot stand in for the per-session flag, which is the part that matters here: it
resolves the backend to `Pyrowave`, and `linux_zero_copy_is_vaapi_for` returns true for that —
so it ALSO flips `backend_is_vaapi` on. A per-session PyroWave negotiation on an auto/NVENC host,
where `backend_is_vaapi` is false, was therefore unreachable from the CLI — and that is exactly
the configuration whose CPU downgrade logged nothing at all.

The spike now sets the flag from its own codec, the same comparison `session_plan::output_format`
makes for a real session. With it, the before/after on .21 is unambiguous: origin/main logs zero
capture-path lines on that configuration, this branch logs two (the resolved arm, and the named
downgrade with its cause and fix).
2026-08-08 14:08:27 +02:00
enricobuehler 2aa763ce70 feat(pf-capture): a PyroWave session could drop to CPU capture and log nothing at all
Wave-2 PW2 (design/linux-host-performance-wave2-pyrowave.md). Observability only — no
behaviour change to any capture decision — and it lands first because every later package
in the program is measured by an A/B whose "before" is currently unreadable.

The defect: the capture path's CPU-fallback warning was gated on `backend_is_vaapi`, which
reads the HOST-GLOBAL encoder pref. A PyroWave session is negotiated PER SESSION, so on an
NVIDIA/auto host that gate is false — and the session then fell out of every arm of the
negotiation log chain, emitting nothing whatsoever while paying a full-resolution CPU pixel
touch on every frame. A degraded host and a healthy one produced identical logs.

Four sites, matching PW2.1-2.4:

1. The CPU-path warning now asks the per-session question (`consumer_kind`) instead of the
   pref, and names the consumer. Its gate widened to every GPU consumer and excludes only
   the software encoder, whose native input IS CPU frames — an NVENC session silently on
   the CPU path is the same defect, not a different one. `pyrowave_session` deliberately
   outranks `backend_is_vaapi`, because a PyroWave pref flips `backend_is_vaapi` on too
   (`linux_zero_copy_is_vaapi_for`'s `Pyrowave` arm), so testing vaapi first would swallow
   every PyroWave session.

2. The raw-passthrough block in `consume_frame` had four silent exits — no format, an
   SHM/MemFd buffer, no DRM fourcc, a failed `F_DUPFD_CLOEXEC` — each falling out of three
   nested `if`s into the CPU de-pad path. It is now a labeled block that breaks with a named
   `PassthroughFallback`, logged once per distinct reason per session with a running count,
   so a persistent downgrade is distinguishable from a hiccup at renegotiation. `.process`
   runs per frame, so the rate limit is the shippable part and is what the tests pin.

   Note `NoFormat` does NOT fall back — the CPU path needs `ud.format` too and returns — so
   the line says DROPPED for that one. Three of four downgrade; one loses the frame.

3. `force_cpu_for_nvenc_444` told a 4:4:4 PyroWave session it was "on the NVENC path", which
   is false in every particular: the wavelet encoder never touches NVENC, never swscales to
   YUV444P, and what it actually loses is the raw-dmabuf passthrough its design assumes.

4. One INFO line at pipeline build states the resolved arm and consumer
   (`capture pipeline resolved: dmabuf-passthrough → pyrowave`). Nothing stated it before;
   the 2026-08-08 triage reconstructed it from four files, and for the arm that matters most
   there was no detail line to reconstruct it from.

Also: `spike --codec pyrowave`, so a PyroWave capture→encode pass can be driven without a
client. That is the harness the rest of this program measures on, and it did not exist.

Gates on .21 at CI parity: fmt, workspace clippy -D warnings, pf-encode clippy with
nvenc,vulkan-encode,pyrowave and without, workspace tests.
2026-08-08 13:36:51 +02:00
81 changed files with 7403 additions and 759 deletions
+56
View File
@@ -344,10 +344,45 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
apt-get build-dep -y gamescope || true
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
# gamescope — where noble has one at all — so it misses what the master tree needs, and
# wayland-protocols is the gap that actually stops the build: meson dies in
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
# file was found", because the tree has no wrap fallback for it. That is what happened on
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
# shipped with no gamescope .deb while the notes said it had one.
apt-get install -y --no-install-recommends wayland-protocols
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
# releases should not fail the job. (No libstdc++ static package is needed here — g++
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
# purpose: a single transaction aborts wholesale on one unknown package, which would
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
# the missing one named; the end-of-job gate below is what actually decides.
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
cmake xwayland; do
apt-get install -y --no-install-recommends "$p" \
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
done
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
# skips the host .deb's own publish + release-attach steps below, which is how the
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
# meant to protect the release. Never let a missing EXTRA stop a good artifact
# shipping; go red afterwards instead.
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
fi
@@ -357,6 +392,7 @@ jobs:
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
fi
@@ -387,6 +423,26 @@ jobs:
upsert_asset "$RID" "$DEB"
done
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
# because every failure on this path was a `::warning::` that returned 0.
#
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
# gate protecting the release withheld the fix for it. Everything good ships first; the job
# goes red afterwards.
- name: A stable tag must ship the gamescope .deb
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope_*.deb)
if [ ${#built[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope .deb present: ${built[*]}"
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
+65
View File
@@ -206,10 +206,26 @@ jobs:
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
# without the static library meson's very FIRST sanity check dies with
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
# the job stayed green, and the release shipped with no gamescope RPM while the notes
# said it had one. A rename here must be LOUD, hence no `|| true`.
dnf -y install libstdc++-static
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
# does without, and a name that moves between Fedora releases should not fail the job.
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
@@ -227,9 +243,35 @@ jobs:
--binary gs-cache/punktfunk-gamescope \
--release "$PF_RELEASE"
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
fi
# A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build
# time, long before this RPM existed — the gamescope build sits behind its own ~10-minute
# cache and deliberately runs after the host RPMs are already published. So every
# punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to
# install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is
# not signed" on every Fedora and Nobara box. The package was in the channel the whole time
# and could not be installed from it — which is worse than absent, because the release notes
# and the docs-site both say it is there.
#
# Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org
# secret is missing, rather than republishing something a user's dnf will reject.
- name: Sign punktfunk-gamescope
env:
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: |
shopt -s nullglob
rpms=(dist/punktfunk-gamescope-*.rpm)
# No RPM here is the best-effort skip above, already warned about — not a signing failure.
if [ "${#rpms[@]}" -eq 0 ]; then
echo "no punktfunk-gamescope RPM to sign (see the packaging step above)"
exit 0
fi
bash packaging/rpm/sign-rpms.sh "${rpms[@]}"
- name: Publish punktfunk-gamescope to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -310,3 +352,26 @@ jobs:
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
#
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
- name: A stable tag must ship the gamescope RPM
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope-*.rpm)
keep=()
for r in "${built[@]}"; do
case "$r" in *debuginfo*|*debugsource*) continue;; esac
keep+=("$r")
done
if [ ${#keep[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope RPM present: ${keep[*]}"
+420
View File
@@ -12,6 +12,426 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.26.0
52 commits since v0.25.0.
### Versions
| | v0.25.0 | v0.26.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 17 | **17** | unchanged — no symbol added, removed or changed |
| Workspace crate dirs | 26 | **26** | unchanged (40 workspace members) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.24.0 | **0.25.0** | tracks API edits, lags one release by convention |
| gamescope patch level (`+pfhdrN`) | 2 | **4** | 3 patches → 6; `pkgrel` 1 → 2 |
| `@punktfunk/host` (SDK) | 0.1.2 | **0.1.4** | |
| `@punktfunk/plugin-kit` | 0.3.2 | **0.4.0** | the `plugin` launch kind |
`crates/pf-driver-proto` is byte-for-byte identical to v0.25.0 and to v0.24.0 — if you ship the
virtual-display driver or the gamepad channel, the last two releases have not touched you.
### ⚠ Breaking changes
**None.** This is a fixes release. Every embedder, packager and plugin that works against v0.25.0
works against v0.26.0 unchanged. Two behaviour changes are worth knowing about anyway, because both
make a client advertise *less* than it used to — see **Capability advertisement** below.
### Capability advertisement
- **`VIDEO_CAP_444` is now probed, not asserted.** It rode the "Full chroma" setting alone. That was
safe while a software HEVC decoder sat underneath it; M8 removed one (there is no permissively
licensed HEVC CPU decoder, so `software_decodable_codecs()` is `H264|AV1`). The host grants 4:4:4
on HEVC **only** and answers the resolved chroma in the `Welcome` *before* the client builds a
decoder — so on a device with no 4:4:4 decode the toggle did not cost crispness, it cost the whole
codec: the Vulkan rung refuses the shape at construction, VAAPI refuses it too, there is no CPU
rung, and the session reconnects on H.264. No AMD silicon has HEVC 4:4:4 decode, so every Steam
Deck with that switch on lost HEVC. Per-profile and default-off, which is why it read as
intermittent.
Now gated on `hevc_444_hardware_decodable`, which asks the driver through the same code the rung
uses at construction (`VkH265Decoder::probe_stream_support`). **Both depths are required**, not
either: with HDR the host may resolve 4:4:4 10-bit, and a device offering `YUV444_8` but not
`YUV444_10` lands in the same hole. Answering from the Vulkan rung alone is exact rather than
approximate — it is the only rung in this build that implements 4:4:4 at all
(`pf_vaadec::profile_for` errors on `chroma_format_idc 3`, pf-dxvadec refuses anything but 4:2:0,
the CPU rung is 8-bit 4:2:0).
⚠ Deliberately **not** extended to `VIDEO_CAP_10BIT`/HDR: all three rungs implement 10-bit 4:2:0,
so a Vulkan-only probe there would withdraw HDR from boxes whose VAAPI/DXVA rung decodes it
perfectly — a regression against a case never observed.
The bit arithmetic moved into `video::video_caps_for` so the part that was wrong is testable
without a GPU, a host or a `Hello`; the test is verified non-vacuous against the planted defect.
### Host and client environment variables
Four new, one clarified. Verified new by `git grep` at the v0.25.0 tag, not assumed —
`PUNKTFUNK_JUMBO`, `PUNKTFUNK_WIRE_MTU`, `PUNKTFUNK_STREAMED_AU`, `PUNKTFUNK_LIBRARY_ART_ROOTS`,
`PUNKTFUNK_RECOVER_SESSION_CMD`, `PUNKTFUNK_GAMESCOPE_SDR_NITS`, `PUNKTFUNK_MAX_FPS` and
`PUNKTFUNK_ON_CONNECT_CMD` all already existed.
- **`PUNKTFUNK_OVERLAY_MASK`** *(new, client)* — controls the Steam-overlay input mask below.
- **`PUNKTFUNK_PYROWAVE_CHUNK_KIB`** *(new)* and **`PUNKTFUNK_PYROWAVE_STREAMED_AU`** *(new)*
PyroWave AU chunking and the streamed-AU path.
- **`PYROWAVE_QUEUE_PRIORITY`** *(existed, but was inert on Linux — see below)* — grammar: unset →
realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to
the ladder rather than to off. ⚠ **One env var must not mean two things on two platforms**, so
the Rust grammar is unit-tested against the C patch's, including where both are deliberately
un-clever (neither trims).
- **`PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120`** *(new)* — widens the set a gamescope session
offers in Steam's in-session display settings. The rate the session actually runs at is always
included, so it can only add options; junk entries are skipped rather than failing the host.
Requires gamescope patch level 3+.
- **`PUNKTFUNK_COMPOSITOR`** *(behaviour clarified, not changed)* — documented as "which backend to
drive", it also silently discarded `game_session=dedicated`: `resolve_compositor` gated the
dedicated route on `!overridden` and logged nothing either way. The pin still wins — it is the
operator's explicit knob — but it now says so and names itself. Two further holes closed with it:
the pin put its backend into `available()` unconditionally *and* skipped `apply_session_env`'s
`XDG_CURRENT_DESKTOP` scrub, so `pick_compositor` could never return `None` — the one call site of
`try_recover_session()`, which left `PUNKTFUNK_RECOVER_SESSION_CMD` unreachable behind that arm.
Liveness is now read on both paths. `needs_live_session()` exempts gamescope, which stands up its
own session, so pinning it on a headless box stays supported.
### Client settings keys
All additive; an older client ignores what it does not know, and a newer value can never trap an
older client.
- **`gamepad_ui_mode`** — `"connected"` (default, and exactly what the previous lone Bool meant) or
`"always"`. Splits *whether* the controller UI is offered from *when* it appears.
`GamepadUIEnvironment.isActive` takes the mode with **no default argument** on purpose: a call
site that forgot it would silently strand everyone who chose Always. An unrecognized value waits
for a controller.
- **`ui_palette`** gains `oled` at **index 1**, directly after the brand default — keeping
`PALETTES[0]` the unknown-id fallback and the dark-to-pale cycling order intact. Hand-mirrored in
three languages (`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`); each
port carries an `oled_is_actually_black` test that measures the claim (mean cell luminance 0.019
against Violet's 0.254) rather than restating the table.
- **`library-hidden.json`** — per-title hide list, mirroring how `library-scanners.json` holds
disabled sources. Deliberately **not** stored on the entry: a scanner's and a plugin's titles are
rebuilt from scratch on every scan and reconcile, so a flag written onto one would be erased
minutes later. Applied in `all_games`, the single funnel every play surface already goes through
(client grid, native clients, the GameStream app list, launch resolution).
### gamescope patches
Three → six, and the marker patch moves last so the banner is stamped after the capabilities it
advertises.
- **0003 — headless: advertise the virtual display's mode and refresh rates.** `CHeadlessConnector`
returned empty spans from `GetModes()` and `GetValidDynamicRefreshRates()` and reported
`GAMESCOPE_SCREEN_TYPE_INTERNAL`, so `update_mode_atoms` **deleted** the mode-list atom and
wlserver fell through to a one-entry refresh list built from `g_nOutputRefresh` — which, with
`--nested-refresh` absent, is `Init()`'s 60 Hz default. That is why a 1920x1080@120 client saw
"gamescope only shows 60hz" and Overwatch capped itself to 60 while the stream ran at 120. Now
populates both from the resolved mode, reports `EXTERNAL`, and adds `--custom-refresh-rates`.
gamescope-session-plus has probed for that flag for years; upstream never had it, so the
`CUSTOM_REFRESH_RATES` env it plumbs was a no-op everywhere.
- **0004 — pipewire: optionally composite the external overlay into the capture stream.** That layer
is mangoapp. `paint_pipewire` has never referenced it on any version. Behind
`--pipewire-composite-external-overlay`, off by default.
- **0006 — never destroy the Vulkan device or output.** `g_device` (`CVulkanDevice`) and `g_output`
(`VulkanOutput_t`) were plain globals, so glibc ran their destructors from `__run_exit_handlers`
once `main()` returned — calling back into an ICD that had already been torn down and unloaded.
Faulting address equalling the instruction pointer is the signature. Reproducible with
`gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true` (exit 139, every
time). Both globals get storage constructed exactly as before but never destroyed; pinning only
the device relocated the fault into `~VulkanOutput_t`, hence a shared `CNoDestroy<T>`.
**`+pfhdrN` deliberately does not move for 0006.** The marker is a capability tier the host
probes via `gamescope_patch_level()` *before* it spawns; this patch adds no capability, so bumping
it would advertise a tier that does not exist. Ships as a `pkgrel` bump instead.
⚠ gamescope CI legs are best-effort — a broken patch is a **missing package**, not a red run.
### Virtual-display handle ownership (Windows)
The control-device sharing contract was "bare `HANDLE` copies, never closed for the process
lifetime": retired handles were kept alive because pinger/linger threads and capture closures held
raw copies whose soundness depended on no-close. An open control handle is exactly what vetoes the
PnP disable — and can wedge the `pnputil` restart — that wake-from-sleep recovery leans on, so every
post-wake adapter reload came back REFUSED. `reset-pf-vdisplay.ps1` stops the whole host service
precisely to get those handles closed; the in-process recovery could not.
Ownership is now `Arc` all the way out: `ensure_device` / `device_handle` / `control_device_handle`
hand out `Arc<OwnedHandle>` clones, every consumer holds its clone across its IOCTLs (ending the
`isize` smuggling — `Arc<OwnedHandle>` is `Send + Sync`), and retiring drops only the manager's
reference. `DeviceSlot::retired` is gone.
**Nothing may store a bare control `HANDLE` again.** The whole fix is that the handle closes when
the last in-flight user drains.
### Presenter — points are not pixels
`SDL_GetDesktopDisplayMode` reports a mode in **screen coordinates** and hands the pixels-per-point
ratio back separately as `pixel_density`; `m.w`/`m.h` were read raw. KDE advertises a 2560x1600 panel
at 150 % as 1707x1067 points with a density of ~1.4997, `render_scale::apply` even-floors both odd
axes, and 1706x1066 went on the wire. Multiplying by the density recovers 2560x1600 to the pixel.
⚠ Inert on X11 and Windows: SDL never sets a density there and `SDL_video.c` normalizes the unset
0.0 to 1.0. **This bug needed a compositor doing fractional scaling.**
Second, independent defect: the SDL window was created without `HIGH_PIXEL_DENSITY`, so the Wayland
surface stayed at buffer scale 1 and the swapchain was built at 1707x1067 for KWin to upscale. That
one also silently shrank "Match window", which asks the host for `size_in_pixels()`.
### Apple audio session
`micEnabled` and `echoCancel` both default to `true`, so the **default** iOS session is
`.playAndRecord` — and that branch set `.defaultToSpeaker`. That option is an output **override**,
not a preference, and it outranks an A2DP route. ⚠ **Wired headphones beat it, Bluetooth does not**,
so testing with a cable returns the wrong answer — which is what the comment sitting on it asserted.
Now solved against the route actually given: after activation, if the current output is
`.builtInReceiver`, override to speaker; anything external (Bluetooth, wired, CarPlay, AirPlay) is
left strictly alone. The override is a property of the current route — iOS drops it on every route
change, which is what lets a newly-connected headset win — so it is re-applied per route via an
observer, registered only for `.playAndRecord`, removed in `stop()` before deactivate, `deinit` as
backstop. Without it, dropping Bluetooth mid-stream lands on the earpiece.
⚠ Deliberately **not** adding `.allowBluetooth`: it would make a headset's mic usable but drag the
whole route onto HFP/SCO and collapse game audio to narrowband.
### Audio jitter policy
`JitterPolicy` (`punktfunk-core/src/audio.rs`, used by Linux/Windows/Android) and its mirror in
Swift `AudioRing`. The policy learned exclusively from audible failures on both sides: growth needed
**three** audible underruns; the A/V sync loop re-tested a shallower ring every five quiet seconds
and paid an audible starvation event every time it was wrong, forever; and a grown target was never
re-banked (growth raises a threshold — only a re-prime deepens the ring), so a bunching link rode
the knife edge with the "grown" target sitting inert.
Three mechanisms: **near-miss** (a read served with less than one protocol frame left over is the
same evidence as an underrun, heard by no one — grows one step per window, *before* the click);
**shrink probes** (every shrink armed for 5 s, undone on the spot if answered by an underrun or
near-miss, with a doubling backoff 60 s → 8 min on a failed sync-driven shrink; a surviving probe
resets it); **hollow re-prime** (an underrun while the depth *average* runs more than a step below
target re-primes immediately — the average, not the instant, separates a hollow ring from one late
packet, and it is seeded on prime so a fresh ring is never spuriously hollow).
Measured on a ten-minute simulation of the Wi-Fi power-save pattern (25 ms gaps / 300 ms, 50 ppm
skew): **~2000 audible events → 9.**
### Plugins, SDK and the runner
- **`category` never shipped.** The console correctly keeps `category: "library"` plugins out of the
nav; the host reported no category for them at all. `defineLibraryPlugin` sets it and
`sdk/src/ui.ts` forwards it — what shipped did not: `@punktfunk/host` was bumped to 0.1.2 on
2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's 0.1.2 is the
pre-category build. ⚠ **Inert until published.** `serveUi` now reads its own directory entry back
and warns once when a requested category did not land.
- **Local art sync failed on a `file://` disagreement.** `local_art_bytes` decodes a `file://` value
before testing containment; `validate_art_paths` handed the raw value to `Path::new`. Same defect
produced both the unreachable settings and `sync (startup) failed: HostRequestError`.
- **The runner now carries SDK updates.** The copy each installed plugin runs was pinned at install
time, so an SDK fix could never reach it.
- **`bun publish` runs `prepare`, and `prepare` needs bun2nix** — the SDK could not be published at
all. Also fixed: a corrupt committed `bun.lock` in plugin-kit.
- **Decky client update.** `flatpak remote-info punktfunk-origin io.unom.Punktfunk` names no branch;
the remote publishes `stable` **and** `canary`, so the ref is ambiguous and flatpak refuses it —
⚠ one branch being *installed* does not disambiguate, the ambiguity is on the remote. The call
failed on every box, every time, and returned `available=False`, which the panel rendered as good
news. Every query now names the ref in full via `_flatpak_ref()` (no subprocess), carrying the
**scope** too, so a system-wide install is no longer invisible to a check that hardcoded `--user`.
A check that cannot run now reports `client_error`.
### Packaging
- **The `punktfunk` group is created everywhere the udev rule needs it.** `60-punktfunk.rules`
chgrp's the usbip vhci attach/detach nodes to a dedicated group (security review 2026-08-05 M-4:
writing `attach` materialises an arbitrary emulated USB device, so it must not ride on `input`).
**Four of six install paths shipped that rule in 0.25.0 without creating the group** — chgrp
failed, nodes stayed `root:root 0644`, the virtual Deck pad silently never attached, and
`usermod -aG punktfunk` failed outright. Fixed in arch `post_upgrade()` (only `post_install` was
correct, so every box that reached 0.25.0 by `pacman -Syu` missed it), nix (`users.groups.punktfunk`
did not exist), the bazzite sysext (a group is host state and cannot ride an image), and the Steam
Deck scripts. deb and rpm were correct throughout.
- **`punktfunk-gamescope` now builds for RPM and apt**, not Arch only.
- **Arch release-rebuild prune** called a helper that cannot exist in a release rebuild. Together
with the FFmpeg 9 repackage this closes the 0.25.0-1 → 0.25.0-2 episode in the pipeline rather
than by hand.
- **Steam Deck `update.sh` / `install.sh`.** The web step ran `bun install --frozen-lockfile` with
no `--ignore-scripts`, so web's `postinstall` (`bun2nix -o bun.nix`) rewrote a **tracked** file on
every update; the SDK step below it had always passed `--ignore-scripts`, and that asymmetry is
the whole bug. Now `--ignore-scripts` plus an explicit `bun run codegen` — provably equivalent,
since web's `prepare` is literally `"bun run codegen"` and `src/api/gen`, `src/paraglide` and
`src/routeTree.gen.ts` are gitignored. `--pull` restores `web/bun.nix` and `sdk/bun.nix` before
pulling, which is lossless by construction. ⚠ Deliberately **not** `git reset --hard`: `$SRC`
defaults to the operator's own checkout. Also: `web.env` secret hygiene — `chmod 600` sat inside
the create-only branch, so an install set up once and only updated since kept it world-readable.
`packaging/debian/build-web-deb.sh`, `packaging/arch/PKGBUILD` and `packaging/rpm/punktfunk.spec`
still lack `--ignore-scripts` for web — harmless (throwaway build trees), left as follow-up.
### Triage tooling
**`--probe-decode` described a different device from the one that streams.** The RADV
video-decode opt-in sat *after* the `--list-adapters` / `--probe-decode` / `--list-audio` / `--pair`
early exits, so the triage tool never had it. Measured on a Deck, same binary back to back: bare
`--probe-decode` printed "vulkan video decode: no", "driver decode ops: none (0x0)", "no queue
family advertises VIDEO_DECODE"; with `RADV_PERFTEST=video_decode` in the environment, "YES" and
"H.264, H.265, AV1, VP9". ⚠ **Any Deck triage that consulted it reached the opposite of the truth.**
Hoisted to the top of `run`, ahead of every early exit.
### PyroWave on Linux — Wave 2
The program's own measurement, from patch 0005's header: `encode_gpu_synchronous` goes from ~2 ms
to **1518 ms at 95 % game load**, with the stream frame rate collapsing. PyroWave encodes on the
same shader cores a game saturates; NVENC is immune because it has its own ASIC.
- **PW1 — the GPU-priority lever had never fired on Linux.** The vendored patch requests an elevated
global-priority queue, gated `if (!inherit_info)` — and **only Windows leaves `inherit_info` null**
(`pyrowave_create_device_by_compat`, where Granite builds the device itself). Linux passes its own
create-infos, Granite's `get_existing_create_info()` hands them back, `create_device` takes the
inherit branch, and the whole block is skipped. Now wired natively in `open_inner`'s `DeviceHold`,
ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the
open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
**Needs `CAP_SYS_NICE`**, which the packaging granted in `0.26.0-1`; without it the lever does
nothing.
🛑 **Corrected in `0.26.0-2`: the packaging no longer grants it, and must not.** Every channel that
did (Arch `.install`, RPM `%caps()`, the Bazzite sysext image, the deb postinst, the NixOS
`security.wrappers` entry) broke desktop streaming on KDE outright — field-reported on CachyOS and
Bazzite as `KWin does not expose zkde_screencast_unstable_v1 to this client`. KWin identifies a
client by resolving its `/proc/<pid>/exe` against an installed `.desktop`, and the kernel refuses
that readlink to any reader whose effective set is not a superset of the target's **permitted**
set (`cap_ptrace_access_check`) — KWin has no capabilities, so a capability-carrying host is
unidentifiable and the restricted globals are never advertised. Neither `prctl(PR_SET_DUMPABLE, 1)`
nor systemd `AmbientCapabilities=` rescues it; only an uncapped process is identifiable. The lever
therefore stays wired but unexercised on a stock install (the ladder degrades to default priority),
and is opt-in for gamescope-only hosts, which have no such identity check.
- **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`,
`bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and
`Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the
old layout, plus three `fill_buffer` clears). Two encodes submitted to one queue have **no**
execution dependency in Vulkan — submission order orders the start, not the completion — so N+1's
DWT would overwrite N's wavelet bands while N's block packing still reads them. Content-dependent
and silent. Overlap therefore means two handles alternated, one per slot. ⚠⚠ **The landmine:**
`sequence_count` also lives on `Impl`, and it is the **3-bit** counter stamped into every block
header. Two handles each counting 1,2,3… put 1,1,2,2,3,3… on the wire, and the decoder restarts a
frame only when the value *changes* — so a repeat reads as more blocks of the same frame. Depth is
**still 1**; the handles alternate with one in flight.
- **PW3 — the fence wait moved out of submit.** PyroWave was the one backend waiting its fence inside
`submit`.
- **PW7a — the jumbo leg was dead code.** quinn caps a peer's MTU-discovery search at
`min(MtuDiscoveryConfig::upper_bound, the other side's advertised max_udp_payload_size)`, and
`EndpointConfig::max_udp_payload_size` **defaults to 1472**. Nothing in the repo had ever touched
`EndpointConfig`, so raising the host's probe ceiling could never make discovery settle above 1472
— and the shipped mid-session grow's `settled >= sealed_datagram_bytes(target)` gate was
unreachable on **every path that has ever existed**. Two smaller contributors fixed with it: the
watcher stopped sampling the moment `settled >= 1472`, discarding the very climb the proof needs;
and a session sealed above the 1500-byte default was never checked against the path at all.
The advertisement is raised on the **client** endpoint under the same `jumbo_wire_mtu()` opt-in,
because it is not free: quinn sizes its endpoint receive buffer
`max_udp_payload_size × max_receive_segments × BATCH_SIZE` — on a GRO-capable Linux/Android client
that is ~2.9 MiB at the default and **~18 MiB at jumbo** (47 KiB → 288 KiB on Apple/Windows).
PyroWave is the codec that most wants this: it can never be re-keyed mid-stream (its client parses
chunk-aligned AUs in windows of the `Welcome` value, read once over the C ABI), so it should
*start* at the big shard. At an 8908-byte shard that is ~6× fewer datagrams per frame — **~49k → ~8k
pps at 550 Mb/s**.
### Zero-copy capture
- **The dmabuf latch conflated two causes with different lifetimes.** One `AtomicBool` served both
"the encoder repeatedly failed to import what this compositor allocates" (unrecoverable, a driver
fact) and "the dmabuf-only capture offer never negotiated" (which can just mean the compositor was
mid-restart). Sharing it made the second as permanent as the first: **one timeout, and every later
session on that host captured CPU frames until the process restarted** — including sessions against
a different compositor and a different node that had never failed at anything, with nothing said.
Now a `RawDmabufLatch` owning both: import failures stay sticky (unchanged 3-consecutive threshold);
negotiation timeouts get a retry budget of **2** — deliberately small, since each failure costs a
~10 s stall the user pays in dead air; a capture that negotiates credits the budget back; and both
are keyed to a capture identity (node id + portal bit).
- **The zero-copy path never asked for buffer headroom.** `build_dmabuf_buffers` set
`SPA_PARAM_BUFFERS_dataType` and stopped — no `SPA_PARAM_BUFFERS_buffers` at all, so the pool depth
every zero-copy safety argument rests on was entirely the producer's choice and we never expressed
a preference. Now asks for 8 (min 2, max 16) as a **Choice Range, deliberately not a fixed count**:
SPA intersects consumer and producer params, so a fixed 8 against a producer that can only afford 4
empties the intersection and the link stalls in "negotiating" with no error anywhere — ⚠ the exact
trap that once cost this codebase the entire Linux cursor channel, when a 256² cursor-meta max
failed to intersect Mutter's fixed 384². 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz;
16 is a ceiling, not a request (a 4K 4:4:4 buffer is ~25 MB).
- **A PyroWave session could drop to CPU capture and log nothing.** The CPU-fallback warning was gated
on `backend_is_vaapi`, which reads the **host-global** encoder pref — but a PyroWave session is
negotiated **per session**, so on an NVIDIA/auto host that gate is false and the session fell out of
every arm of the negotiation log chain while paying a full-resolution CPU pixel touch every frame.
A degraded host and a healthy one produced identical logs. Now asks the per-session question
(`consumer_kind`), widened to every GPU consumer and excluding only the software encoder, whose
native input *is* CPU frames. ⚠ `pyrowave_session` must outrank `backend_is_vaapi`, because a
PyroWave pref flips `backend_is_vaapi` on too.
### Steam-overlay input masking (Steam Deck)
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the **same physical controller** the
client forwards, so opening either moved the game on the host as well — a second, invisible player.
Steam Input masks a normal game here; it cannot mask us, because masking happens on Steam Input's
virtual pad and we deliberately forward the **real** one (the virtual pad has no gyro, trackpads or
paddles).
**SDL's own gate cannot fire on a Deck.** SDL drops presses while a process has windows but no
keyboard focus, and it is on by default — but gamescope resolves focus per Xwayland ctx and the client
sits alone in its own, so the Steam overlay (which lives in the root ctx) never takes our X focus and
no `FocusOut` is ever generated. Measured on glass: with the QAM open, X input focus inside the
client's ctx stayed on its window for the whole 4 s while `GAMESCOPE_FOCUSED_APP` flipped to 769
(Steam) and `GAMESCOPE_FOCUSED_APP_GFX` stayed on the app. **That pair of atoms is the signal.**
`overlay_focus` watches them on the gamescope **root** ctx, which is *not* our own `$DISPLAY` under
`--xwayland-count 2` — hence the socket-directory walk and the flatpak filesystem line.
⚠⚠ Masking is deliberately **not** `set_forwarding`: that closes the slot and sends `GamepadRemove`,
so the game would see a controller **unplug** every time somebody opened the QAM. Every slot stays
open and only transitions stop, after flushing what the host believes is held (so a stick deflected at
overlay-open stops steering instead of freezing at its last value). On the way back, held buttons are
**adopted rather than replayed** — the A that picked a QAM row must not fire in the game as it closes
— while axes *are* re-sent, since a stick has no press to ghost and SDL only speaks on change.
### The `plugin` launch kind
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses on the
**first** offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`, stopped
putting anything in the library at all. Playnite hit the same wall and was rescued with a typed kind
the host resolves itself; there is no fixed scheme for "whichever emulator the operator configured,
with the core and flags they chose", so that trick does not generalise.
The entry now carries an **opaque key and nothing executable**, and the host asks the owning plugin
what to run at launch time, over the loopback UI port and per-boot secret it already registered.
**A stolen plugin token stops being command execution:** planting an entry is not enough, because
the live plugin answers 404 for a key it never published. Nothing executable is persisted or served to
a client, and an emulator that moved is picked up on the next launch rather than leaving a dead tile
(same reasoning as `xbox` resolving its AUMID at launch time).
**The host still spawns it**, because only the host can put the process where the stream can see it:
on Linux that is either gamescope's own argv or a spawn carrying the session's compositor env, and the
returned child is what session-game-lifetime tracks to know the game exited. A plugin spawning the
emulator itself would land it outside both.
### Verification status
| | |
|---|---|
| gamescope 0006 | 6/6 exit 0 on a release build at the real spawn shape (`2752x2064@120 --steam --xwayland-count 1`); distro control SIGSEGVs |
| Decky client update | on the Deck against the real install — pre-fix `available=False remote=''`, post-fix `available=True remote=ca010668` |
| `--probe-decode` | on a Deck, same binary back to back, with and without the RADV opt-in |
| Apple audio | builds on arm64-apple-ios17.0 (the triple that compiles the `#if os(iOS)` blocks — a plain `swift build` is macOS and skips them), arm64-apple-tvos17.0, macOS; 257 Swift tests |
| Audio jitter | 10-minute Wi-Fi power-save simulation, ~2000 → 9 audible events |
| 4:4:4 gate | test verified non-vacuous against the planted original defect |
| Steam Deck scripts | `bash -n` + shellcheck 0.11.0 clean at `-S warning`; exec bits preserved |
| Steam-overlay masking | on glass on a Deck — atom flip and X-focus non-flip both measured over a 4 s QAM open |
| PyroWave depth 2 | exercised on real hardware **without shipping depth 2** (dedicated test, shipped depth stays 1) |
| PW6 streamed AU | the trap is real, and at 2 % loss it costs exactly nothing |
**Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at. ⚠ That
last one now needs a **gamescope-only** host, or a hand-granted capability on a box you are not
streaming the KDE desktop from: see the `0.26.0-2` correction under PW1 above.
---
## v0.25.0
407 commits since v0.24.0.
Generated
+36 -35
View File
@@ -994,7 +994,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2358,7 +2358,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.25.0"
version = "0.26.0"
[[package]]
name = "lazy_static"
@@ -2463,7 +2463,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"bindgen",
"cmake",
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"punktfunk-core",
]
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3017,7 +3017,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3047,11 +3047,12 @@ dependencies = [
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
"x11rb",
]
[[package]]
name = "pf-clipboard"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3069,7 +3070,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3090,7 +3091,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3100,7 +3101,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3124,7 +3125,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"libc",
@@ -3136,7 +3137,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3150,11 +3151,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.25.0"
version = "0.26.0"
[[package]]
name = "pf-inject"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3183,14 +3184,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3205,7 +3206,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"serde",
"serde_json",
@@ -3213,7 +3214,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"base64",
@@ -3225,7 +3226,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3234,7 +3235,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3267,7 +3268,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3278,7 +3279,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3290,7 +3291,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3513,7 +3514,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3524,7 +3525,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"android_logger",
"jni",
@@ -3542,7 +3543,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3559,7 +3560,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3574,7 +3575,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3593,7 +3594,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3625,7 +3626,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3710,7 +3711,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3724,7 +3725,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ksni",
@@ -3747,7 +3748,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.25.0"
version = "0.26.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -57,7 +57,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.25.0"
version = "0.26.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
@@ -0,0 +1,129 @@
// "The audio output moved under us" the one signal `SessionAudio` needs to survive a device
// change, and the one piece of it that can be tested without a stream.
//
// Split out of SessionAudio deliberately. An end-to-end test of the recovery needs a live session,
// which needs a host, and punktfunk-host does not build on macOS so the wiring that matters most
// (is the observer actually installed? does the identity check let the notification through?) would
// otherwise ship unverified, and a silent failure in it costs the session ALL of its audio. On its
// own this can be pointed at the real hardware from a unit test: see AudioDeviceWatcherTests.
//
// What it does NOT own: anything with session semantics. The iOS route-change steer and the
// media-services-reset re-activation stay in SessionAudio, next to the AVAudioSession they act on.
import AVFoundation
import os
#if os(macOS)
import CoreAudio
#endif
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
final class AudioDeviceWatcher {
/// Why the owner is being told. Only for the log line every reason leads to the same
/// question, "is playback still on the device it should be on".
enum Reason: String {
/// An engine stopped itself because its IO hardware changed underneath it.
case engineConfiguration = "the audio hardware configuration changed"
/// The system's default output device moved (macOS).
case defaultOutputDevice = "the default output device changed"
}
/// Does this configuration change belong to an engine the session still owns? A retired engine
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
#if os(macOS)
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
deinit { stop() }
/// Idempotent.
func start() {
lock.lock()
let already = configObserver != nil
lock.unlock()
guard !already else { return }
let token = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: nil, queue: nil
) { [weak self] note in
// Posted from whatever thread the IO unit noticed on. The engine is the notification's
// object; it is only ever compared by identity, never resurrected.
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
}
}
lock.lock()
configObserver = token
lock.unlock()
#if os(macOS)
// The engine notification is the direct signal, but it is delivered BY an engine useless
// in the two places it is needed most: after a rebuild that could not start (no engine left
// to notify anyone) and on an engine topology whose notification behaviour is unverified
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue registered against it below
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block)
guard status == noErr else {
log.warning("""
could not watch the default output device (\(status)) an output device change \
mid-stream may need a reconnect
""")
return
}
lock.lock()
defaultOutputListener = block
lock.unlock()
#endif
}
/// Idempotent, and safe from any thread. After it returns, no further `onChange` is delivered
/// except one already in flight on the main queue which the owner's own stopped-flag catches.
func stop() {
lock.lock()
let token = configObserver
configObserver = nil
#if os(macOS)
let listener = defaultOutputListener
defaultOutputListener = nil
#endif
lock.unlock()
if let token { NotificationCenter.default.removeObserver(token) }
#if os(macOS)
guard let listener else { return }
var address = Self.defaultOutputAddress()
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener)
#endif
}
#if os(macOS)
/// Freshly built per call rather than held in a mutable static: the HAL takes the address
/// `inout` and copies it, so there is nothing to share and a shared one would only be a
/// mutable global.
private static func defaultOutputAddress() -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
}
#endif
}
@@ -43,8 +43,21 @@ public enum AudioDevices {
}
private static func defaultInputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
}
/// The device the system is currently playing to what an engine with no pinned speaker UID
/// follows, and so what `SessionAudio` compares its live output device against when the
/// default moves (AirPods in or out, a headset unplugged).
static func defaultOutputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultOutputDevice)
}
private static func systemDevice(
_ selector: AudioObjectPropertySelector
) -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = AudioDeviceID(0)
@@ -21,6 +21,10 @@
//
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
// concrete device and follows default-device changes).
//
// Surviving the hardware. An AVAudioEngine does NOT follow the audio hardware: when the output
// device changes underneath a running engine, the engine stops itself and stays stopped. The
// session therefore watches for that and rebuilds its engines see "Device changes" below.
import AVFoundation
import os
@@ -79,14 +83,49 @@ public final class SessionAudio {
/// session's activate.
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
#endif
#if os(iOS)
/// Live only for a `.playAndRecord` session: the token for the route-change observer that
/// keeps the BUILT-IN output on the speaker rather than the earpiece (see
/// `steerBuiltInOutputToSpeaker`). A `.playback` session already prefers the speaker and
/// never needs steering, so the mic-off path installs nothing. Guarded by `stateLock`.
#if !os(macOS)
/// Token for the route-change observer: it revives an engine the route change stopped, and on
/// iOS re-applies the earpiece steer (see `installRouteObserver`). Guarded by `stateLock`.
private var routeObserver: NSObjectProtocol?
/// Token for the media-services-reset observer the audio server restarting takes the
/// session's configuration and every engine with it. Guarded by `stateLock`.
private var mediaResetObserver: NSObjectProtocol?
#endif
// MARK: - Device changes (see `installDeviceChangeRecovery`)
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
/// started with. Main-thread confined, like the start paths that read it.
private var startConfig: StartConfig?
private struct StartConfig {
let speakerUID: String
let micUID: String
let micChannel: Int
let micEnabled: Bool
let echoCancel: Bool
}
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
private var deviceWatcher: AudioDeviceWatcher?
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
/// Main-thread confined.
private var enginesAttempted = false
/// A rebuild is already on the main queue one device switch produces a burst of triggers
/// and they must collapse into one restart. Main-thread confined.
private var rebuildQueued = false
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
/// the session. Main-thread confined.
private var lastRebuildAt: TimeInterval = 0
/// Let the burst of triggers from one switch land before rebuilding.
private static let rebuildDebounce: TimeInterval = 0.15
/// Floor between two rebuilds.
private static let rebuildFloor: TimeInterval = 0.5
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
/// switch and a silent session.
private static let rebuildAttempts = 3
public init(connection: PunktfunkConnection) {
self.connection = connection
}
@@ -96,10 +135,14 @@ public final class SessionAudio {
/// Engine teardown still belongs to stop().
deinit {
flag.stop()
#if os(iOS)
// The observer only holds self weakly, so we can be deinited with it still registered;
// drop the token here too rather than leaking it when an owner skips stop().
// The observers only hold self weakly, so we can be deinited with them still registered;
// drop them here too rather than leaking them when an owner skips stop().
deviceWatcher?.stop()
#if !os(macOS)
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
if let mediaResetObserver {
NotificationCenter.default.removeObserver(mediaResetObserver)
}
#endif
}
@@ -120,6 +163,12 @@ public final class SessionAudio {
videoLatency: LatencyMeter? = nil
) {
self.videoLatency = videoLatency
// Before any engine exists: the recovery watches the hardware, not the engines, and the
// config it rebuilds from has to be recorded whether or not this start succeeds.
startConfig = StartConfig(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
installDeviceChangeRecovery(micEnabled: micEnabled)
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
@@ -189,10 +238,10 @@ public final class SessionAudio {
#if os(iOS)
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
// output override so the mic-off (`.playback`) path deliberately does neither.
if micEnabled {
steerBuiltInOutputToSpeaker(session)
installRouteObserver()
}
// (The route OBSERVER that re-applies this per route is installed by
// `installDeviceChangeRecovery`, for every session a `.playback` session steers
// nothing but still has engines a route change can stop.)
if micEnabled { steerBuiltInOutputToSpeaker(session) }
#endif
} catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
@@ -220,11 +269,20 @@ public final class SessionAudio {
}
}
#endif
#if !os(macOS)
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
/// the stream back to the built-in output. iOS drops an output override whenever the route
/// changes which is what lets a newly-connected headset win so the earpiece steer is a
/// property of the CURRENT route and has to be re-applied per route. Without this, dropping
/// Bluetooth mid-stream would land the game on the earpiece.
/// the stream back to the built-in output. Two things follow from that.
///
/// iOS drops an output override whenever the route changes which is what lets a newly-
/// connected headset win so the earpiece steer is a property of the CURRENT route and has to
/// be re-applied per route. Without it, dropping Bluetooth mid-stream lands the game on the
/// earpiece.
///
/// And on every platform a route change can take the engines down with it (see
/// `installDeviceChangeRecovery`), which is why this is installed for `.playback` sessions and
/// on tvOS too, where there is no earpiece to steer away from.
private func installRouteObserver() {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.routeChangeNotification,
@@ -235,7 +293,10 @@ public final class SessionAudio {
// other call into it.
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
#if os(iOS)
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
#endif
DispatchQueue.main.async { self.reviveStoppedEngines("the audio route changed") }
}
}
stateLock.lock()
@@ -252,6 +313,7 @@ public final class SessionAudio {
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
enginesAttempted = true // even if every path below fails see `reviveStoppedEngines`
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
@@ -325,33 +387,27 @@ public final class SessionAudio {
public func stop() {
flag.stop() // before taking the engines see stateLock's comment
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
let wasDraining = drainStarted
drainStarted = false
#if os(iOS)
let watcher = deviceWatcher
deviceWatcher = nil
#if !os(macOS)
let route = routeObserver
routeObserver = nil
let mediaReset = mediaResetObserver
mediaResetObserver = nil
#endif
stateLock.unlock()
#if os(iOS)
// Before the deactivate below, so a route change during teardown can't re-steer a session
// we are in the middle of releasing.
// Every watcher goes before the engines do: a device change landing during teardown must
// not schedule a rebuild of a session we are in the middle of releasing. (`flag` already
// guards that, but not arming the trigger is better than catching it.) On iOS this is
// also ahead of the deactivate below, so a route change cannot re-steer a dying session.
watcher?.stop()
#if !os(macOS)
if let route { NotificationCenter.default.removeObserver(route) }
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
#endif
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
tearDownEngines()
#if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
// activation, setActive is synchronous/blocking run it on the shared serial session queue
@@ -372,6 +428,234 @@ public final class SessionAudio {
}
}
/// Stop and release every engine we own, leaving the ring, the drain thread, the observers and
/// the audio session alone the teardown half shared by `stop()` and a rebuild. Safe from any
/// thread; the engines are taken under the lock before any of them is touched.
private func tearDownEngines() {
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
stateLock.unlock()
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
}
// MARK: - Device changes
/// An AVAudioEngine does not follow the audio hardware. When the output device changes under a
/// running engine AirPods taken out of an ear, a headset unplugged, the default switched in
/// System Settings the engine's IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and
/// it posts `AVAudioEngineConfigurationChange`. It stays stopped until somebody starts it
/// again. Nothing here ever did, so from that moment the session rendered silence: no audio on
/// the speakers the stream had just moved to, and none in the AirPods when they went back in
/// (that is a second stop, not a recovery), until the whole stream was restarted. Measured on
/// this exact topology: render callbacks go from ~94/s to zero the instant the default output
/// device changes, and both restarting the same engine and building a fresh one resume them.
///
/// Three triggers feed one rebuild, because no single one of them covers the ground:
///
/// - the engine notification, everywhere the direct signal, but only an engine that still
/// EXISTS can post it, so it cannot report a rebuild that failed to start;
/// - the HAL default-output-device listener, macOS independent of any engine and of the
/// engine's topology. It is what makes the recovery work for the voice-processing engine
/// (mic + echo cancellation, the DEFAULT macOS configuration) without having to assume that
/// a VPIO engine posts the notification the plain one demonstrably does;
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
/// not the device is what moves.
///
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
/// Main thread.
private func installDeviceChangeRecovery(micEnabled: Bool) {
stateLock.lock()
let already = deviceWatcher != nil
stateLock.unlock()
guard !already else { return } // a second start() on one SessionAudio: keep the first set
let watcher = AudioDeviceWatcher(
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
stateLock.lock()
deviceWatcher = watcher
stateLock.unlock()
watcher.start()
#if !os(macOS)
installRouteObserver()
installMediaResetObserver(micEnabled: micEnabled)
#endif
}
/// Is `posted` one of the engines this session currently owns? A retired engine posts one last
/// configuration change as it is torn down, and another AVAudioEngine in the process is none of
/// our business identity only, the object is never resurrected.
private func ownsEngine(_ posted: AnyObject?) -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
return posted === playbackEngine || posted === captureEngine || posted === combinedEngine
}
/// The hardware moved (main queue, from `AudioDeviceWatcher`). Both reasons ask the same
/// question is playback still where it should be but they answer it differently: an engine
/// that told us it stopped is definitive, while the default device moving might not concern us
/// at all.
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
guard !flag.isStopped else { return }
switch reason {
case .engineConfiguration:
scheduleEngineRebuild(reason: reason.rawValue)
case .defaultOutputDevice:
#if os(macOS)
defaultOutputChanged()
#else
break // the watcher only raises this one on macOS
#endif
}
}
/// Restart the engines if and only if playback is down. The conservative trigger: it is
/// what a route change (iOS/tvOS) and the macOS backstop get to do, since a HEALTHY engine
/// that followed the change on its own must not be interrupted for it.
///
/// Gated on a start having been ATTEMPTED rather than on an engine existing, which is the
/// difference between recovering a session whose very first `startPlayback` failed no
/// output device at the moment it connected and leaving it silent for good. On iOS the same
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
private func reviveStoppedEngines(_ reason: String) {
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
}
/// Is the render side actually running? Both engines can carry it (`combinedEngine` when the
/// voice processor is engaged, `playbackEngine` otherwise). Taken out from under `stateLock`
/// before asking AVAudioEngine anything the lock guards our handles, not the framework.
private var playbackIsLive: Bool {
stateLock.lock()
let playback = playbackEngine
let combined = combinedEngine
stateLock.unlock()
return (playback?.isRunning ?? false) || (combined?.isRunning ?? false)
}
/// Coalesce: one device switch produces a burst the old device leaving, the default moving,
/// the new device settling, and each engine we own posting its own change and one rebuild
/// serves all of it. The floor between rebuilds keeps a device that renegotiates in a loop
/// from spinning the session. Main thread.
private func scheduleEngineRebuild(reason: String) {
guard !rebuildQueued else { return }
rebuildQueued = true
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: 0)
}
}
/// Put back the topology this session was started with, on whatever hardware is there now.
///
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
/// follow too: `installMicTap` reads the input's live format, and the voice processor
/// renegotiates its own. The RING is deliberately not touched it is the one thing carried
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
/// keeps decoding right through the switch and its overflow policy has already dropped
/// everything that went stale while the engine was down.
private func rebuildEngines(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped, let config = startConfig else { return }
lastRebuildAt = ProcessInfo.processInfo.systemUptime
tearDownEngines()
startEngines(
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
micEnabled: config.micEnabled, echoCancel: config.echoCancel)
// Did playback actually come back? A device caught mid-transition can refuse to start, and
// a rebuild that fails leaves no engine to post the next notification so this is the one
// path that must not just give up. (`startEngines` has logged the reason already.)
if playbackIsLive {
log.info("audio engines restarted on the current device")
return
}
guard attempt < Self.rebuildAttempts else {
#if os(macOS)
log.error("""
audio did not come back after the device change the default-output watcher will \
try again when a device appears
""")
#else
log.error("audio did not come back after the route change")
#endif
return
}
rebuildQueued = true // holds off a trigger that would only race this ladder
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: attempt + 1)
}
}
#if os(macOS)
/// The system's output device moved. Rebuild only when it actually concerns this session: the
/// engine is gone or stopped, or it is playing to a device that is no longer the one we should
/// be on. Somebody changing the default while we are pinned to a named speaker is none of our
/// business, and rebuilding for it would cost an audible gap for nothing. Main queue (the
/// listener block is registered against it).
private func defaultOutputChanged() {
guard !flag.isStopped, let config = startConfig else { return }
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine, engine.isRunning, let unit = engine.outputNode.audioUnit,
let playingOn = Self.currentDevice(of: unit)
else {
// Nothing is playing. If an engine was expected at all, this is the backstop firing.
reviveStoppedEngines("the default output device moved")
return
}
// Empty UID = follow the system default; a pinned UID only moves if that device itself
// came or went, which `deviceID(forUID:)` reports by resolving to a different ID or none.
let shouldBeOn = config.speakerUID.isEmpty
? AudioDevices.defaultOutputDevice()
: AudioDevices.deviceID(forUID: config.speakerUID)
guard let shouldBeOn, shouldBeOn != playingOn else { return }
scheduleEngineRebuild(reason: "the output device changed under the session")
}
#endif
#if !os(macOS)
/// The audio server can die and restart. It takes the session's configuration and every engine
/// with it, and the documented recovery is to build all of it again the same rebuild a route
/// change uses, with the session activation back in front of it.
private func installMediaResetObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: nil
) { [weak self] _ in
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.scheduleEngineRebuild(reason: "the audio services were reset")
}
}
}
stateLock.lock()
let stale = mediaResetObserver
mediaResetObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
#endif
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
/// mechanism: the owner composes its reasons the user's in-stream mute and the background
/// keep-alive's privacy mute into one effective state and passes that here, so neither can
@@ -437,6 +721,21 @@ public final class SessionAudio {
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
}
#if os(macOS)
/// Whether playback is rendering, and the device it is rendering to. The device-change
/// recovery has exactly one observable signature from outside "running again, on the device
/// the system just moved to" and nothing else here could tell the two halves apart: a
/// stopped engine can still name the old device, and a retargeted one can still be stopped.
/// Used by `AudioDeviceSwitchTests`.
var playbackState: (running: Bool, device: AudioDeviceID?) {
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine else { return (false, nil) }
return (engine.isRunning, engine.outputNode.audioUnit.flatMap(Self.currentDevice(of:)))
}
#endif
// MARK: - Playback (host speaker)
/// The playback jitter ring + the source node draining it shared by the plain playback
@@ -0,0 +1,102 @@
// The device-switch regression, end to end against a real session.
//
// An AVAudioEngine does not follow the audio hardware: when the output device changes under a
// running engine it STOPS ITSELF and stays stopped. Nothing restarted it, so a stream whose
// output moved mid-session AirPods taken out of an ear, a headset unplugged, the default
// changed in System Settings played silence from that moment on: nothing on the speakers the
// system had just moved to, and nothing in the AirPods when they went back in, since that is a
// second stop rather than a recovery. Only restarting the whole stream brought audio back.
//
// This drives the real `SessionAudio` against the loopback host and moves the system's default
// output device out from under it, twice out and back, the exact shape of the field report.
// Playback-only (mic off): it is the render side that died, and a mic would drag the microphone
// permission and the voice processor into a test that is about neither.
//
// Driven by clients/apple/test-loopback.sh, like its LoopbackIntegrationTests siblings.
#if os(macOS)
import AVFoundation
import CoreAudio
import XCTest
@testable import PunktfunkKit
final class AudioDeviceSwitchTests: XCTestCase {
/// Set the system default output device. Test-local on purpose: nothing in the app ever
/// changes the user's device, it only follows it.
private func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
/// Pump the MAIN runloop until playback is running on `device`, or the deadline passes. The
/// recovery lands on the main queue (a debounced hop, then possibly a retry ladder), so a
/// sleeping test would block the very thing it is waiting for.
private func waitForPlayback(
_ audio: SessionAudio, on device: AudioDeviceID, timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
let state = audio.playbackState
if state.running, state.device == device { return true }
}
return false
}
func testPlaybackFollowsAnOutputDeviceChange() throws {
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
let port = UInt16(portStr)
else {
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
}
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
let conn = try PunktfunkConnection(
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60,
bitrateKbps: 50_000)
let audio = SessionAudio(connection: conn)
// "" speaker UID = follow the system default, which is what the report was running and
// the only configuration a default-device change is supposed to move.
audio.start(
speakerUID: "", micUID: "", micChannel: 0, micEnabled: false, echoCancel: false)
defer {
audio.stop()
_ = setDefaultOutput(original)
}
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 5),
"playback never started on the current default output device")
// Out: the device the stream was playing to goes away underneath it.
XCTAssertEqual(setDefaultOutput(target), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: target, timeout: 10),
"playback did not come back after the output device changed — this is the field "
+ "report: no sound on the device the system moved to, until the stream is "
+ "restarted")
// And back: the second half of the report, where putting the AirPods back in produced a
// second stop rather than a recovery.
XCTAssertEqual(setDefaultOutput(original), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 10),
"playback did not come back after the output device changed back")
}
}
#endif
@@ -0,0 +1,121 @@
// The trigger half of surviving a device change: does the session actually get TOLD?
//
// An AVAudioEngine stops itself when its output hardware changes and never restarts on its own, so
// everything downstream of these notifications is dead code if the notification never arrives. The
// rebuild itself needs a live session to exercise (and so a host, which does not build on macOS),
// but the wiring does not and the wiring is where a silent failure costs a session all of its
// audio, which is exactly the shape of the bug this watcher exists to fix.
import AVFoundation
import XCTest
#if os(macOS)
import CoreAudio
#endif
@testable import PunktfunkKit
final class AudioDeviceWatcherTests: XCTestCase {
/// The callbacks land on the main queue, so a test that slept would block the thing it waits
/// for. Pumps until `predicate` holds or the deadline passes.
private func pump(until predicate: () -> Bool, timeout: TimeInterval = 2) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if predicate() { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
}
return predicate()
}
/// The identity gate is the one line that could swallow every notification silently: get it
/// wrong and the recovery compiles, installs, runs and never fires.
func testAConfigurationChangeFromOurEngineReachesTheOwner() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
XCTAssertTrue(
pump(until: { reasons.contains(.engineConfiguration) }),
"the session was never told its engine's configuration changed")
}
/// A retired engine posts one last change as it is torn down, and other AVAudioEngines in the
/// process are not ours to restart rebuilding for either would interrupt healthy playback.
func testAConfigurationChangeFromAForeignEngineIsIgnored() {
let ours = AVAudioEngine()
let stranger = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: stranger)
// Give it the same grace the positive case gets, then require silence.
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a foreign engine's change was taken for ours")
}
func testStopSilencesTheWatcher() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
watcher.stop()
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a stopped watcher still reported")
}
#if os(macOS)
/// The backstop, against the real HAL: move the system's default output device the thing that
/// happens when AirPods come out of an ear and require that the session hears about it. This
/// is the trigger the recovery leans on for the voice-processing engine, whose own notification
/// behaviour cannot be verified here (no Mac in this project's fleet can initialize VPIO).
func testTheDefaultOutputDeviceMovingReachesTheOwner() throws {
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
watcher.start()
defer {
_ = Self.setDefaultOutput(original)
watcher.stop()
}
XCTAssertEqual(Self.setDefaultOutput(target), noErr)
XCTAssertTrue(
pump(until: { reasons.contains(.defaultOutputDevice) }, timeout: 5),
"the session was never told the default output device moved")
}
/// Test-local on purpose: nothing in the app ever changes the user's device, it only follows it.
private static func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
#endif
}
+5 -2
View File
@@ -26,8 +26,11 @@ mkdir -p "$CFG/open" "$CFG/paired" "$CFG/guess"
trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" "${GUESS_PID:-}" 2>/dev/null || true' EXIT
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
# handshake, so the Swift test can assert the host→client feedback planes end to end.
# The open host outlives the others on purpose: AudioDeviceSwitchTests connects to it and then
# spends tens of seconds moving the system's output device around, long after the 300 frames the
# round-trip test needs.
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 12000 \
--allow-tofu &
HOST_PID=$!
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
@@ -61,4 +64,4 @@ cd clients/apple
PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \
PUNKTFUNK_GUESS_PORT="$GUESS_PORT" PUNKTFUNK_GUESS_PIN="$GUESS_PIN" \
PUNKTFUNK_TEST_FEEDBACK=1 \
swift test --filter LoopbackIntegrationTests
swift test --filter 'LoopbackIntegrationTests|AudioDeviceSwitchTests'
+23
View File
@@ -168,6 +168,10 @@ pub struct PortalCapturer {
/// downgrade ([`pf_zerocopy::note_raw_dmabuf_negotiation_failed`]) so the pipeline rebuild
/// retries on the CPU offer instead of failing identically forever.
vaapi_dmabuf: bool,
/// PW3: this capture's dmabuf offer has been confirmed to negotiate (a frame arrived), so the
/// negotiation retry budget has already been credited back. One-shot — the credit is per
/// capture, not per frame.
negotiation_confirmed: bool,
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
@@ -412,6 +416,7 @@ impl PwHandles {
signals: self.signals,
stall_since: None,
vaapi_dmabuf: self.vaapi_dmabuf,
negotiation_confirmed: false,
hdr_offer: self.hdr_offer,
hdr_source,
node_id,
@@ -468,6 +473,13 @@ fn spawn_pipewire(
} else {
want_hdr
};
// PW3: tell the raw-dmabuf latch which capture this is BEFORE reading its verdict below. A
// different node id is a different question — a fresh virtual output, a compositor restart,
// the Bazzite Gaming↔Desktop switch — and inheriting "dmabuf does not work here" from an
// unrelated capture is how one transient timeout used to cost a host CPU capture until it was
// restarted. The portal bit is in the key because a portal-fd capture and a virtual-output
// capture with the same node number are genuinely different sources.
pf_zerocopy::note_raw_dmabuf_capture(u64::from(node_id) | (u64::from(fd.is_some()) << 32));
// THE negotiation decision, resolved once here and handed to the thread — no mirror (L3/F1).
// Every environment/latch read the decision depends on happens at this single point.
let plan = pipewire::negotiation_plan(pipewire::NegotiationInputs {
@@ -705,6 +717,7 @@ impl PortalCapturer {
// The slot before the wakeup: a publish that coalesced its edge (or landed while we were
// not waiting) is still visible here.
if let Some(f) = self.take_frame() {
self.note_negotiation_confirmed();
return Ok(f);
}
let slice = Duration::from_millis(500)
@@ -728,6 +741,16 @@ impl PortalCapturer {
self.slot.lock().ok().and_then(|mut s| s.take())
}
/// PW3: a frame arrived, so this capture's dmabuf-only offer DID negotiate — credit the
/// negotiation retry budget back. Only meaningful for a capture that actually made that offer,
/// and only once per capture (the budget counts consecutive failed BUILDS, not frames).
fn note_negotiation_confirmed(&mut self) {
if self.vaapi_dmabuf && !self.negotiation_confirmed {
self.negotiation_confirmed = true;
pf_zerocopy::note_raw_dmabuf_negotiation_ok();
}
}
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
fn next_frame_timed_out(
File diff suppressed because it is too large Load Diff
+155 -6
View File
@@ -121,6 +121,38 @@ pub(super) fn build_dmabuf_format(
/// SDR — the same outcome as not offering HDR.
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
/// The two 10-bit PQ formats an HDR session offers, **in negotiation order**. The order is not a
/// style choice — on NVIDIA it is the difference between correct colour and red/blue swapped.
///
/// `xBGR_210LE` (DRM `XBGR2101010`, Vulkan `A2B10G10R10_UNORM_PACK32`) comes FIRST because the
/// first compatible consumer pod wins, and it is the only one gamescope fills correctly on every
/// vendor:
///
/// * `A2R10G10B10_UNORM_PACK32` **linear-tiled storage** is an optional Vulkan feature that
/// NVIDIA does not implement. gamescope's capture textures are mappable, hence linear, so on
/// NVIDIA its composite `imageStore` into that image lands in XBGR order — the bytes come out
/// byte-reversed while the buffer is still LABELLED `XRGB2101010`.
/// * The host believes the label: `xRGB_210LE → PixelFormat::X2Rgb10 →`
/// `NV_ENC_BUFFER_FORMAT_ARGB10`. Every mapping in that chain is individually correct, which is
/// exactly why the bug is invisible from this side — the *content* is what's wrong.
/// * Upstream gamescope hit the same wall and fixed it with `vulkan_get_rgb10_capture_format()`,
/// which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to `XBGR2101010`.
/// That landed AFTER 3.16.25, so the pinned `punktfunk-gamescope` (3.16.25-7-g60561e2 +pfhdr4)
/// predates it and cannot self-correct — hence fixing the preference host-side, where it ships
/// in the host binary with no gamescope rebuild.
///
/// Preferring xBGR costs nothing anywhere else: `A2B10G10R10_UNORM_PACK32` is the universally
/// supported packed-10 format (it is the standard HDR10 swapchain format), it is what upstream
/// falls back to, and `X2Bgr10` has a first-class encoder path (NVENC `ABGR10`, VAAPI
/// `X2BGR10LE`). `xRGB_210LE` stays as the second pod so a producer that somehow offers only it
/// can still negotiate HDR rather than falling off to the SDR downgrade.
///
/// ⚠ The real fix belongs upstream in the patch set: `spa_format_to_drm()` should offer only the
/// format `vulkan_get_rgb10_capture_format()` reports. Until the gamescope pin moves past that
/// commit, THIS ORDER is what keeps NVIDIA HDR sessions correct — do not "tidy" it.
pub(super) const HDR_FORMAT_ORDER: [VideoFormat; 2] =
[VideoFormat::xBGR_210LE, VideoFormat::xRGB_210LE];
pub(super) fn build_hdr_dmabuf_format(
format: VideoFormat,
preferred: Option<(u32, u32, u32)>,
@@ -288,16 +320,57 @@ pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
})
}
/// Build a Buffers param requesting dmabuf-only buffers.
/// PW5 stage 2: the buffer-pool depth we ASK for on the zero-copy path, as a Choice range.
///
/// The zero-copy path hands the SPA buffer back to the producer at `.process` return, while the
/// encode thread still holds a dup of its dmabuf fd and has not yet imported, let alone read, the
/// contents. Nothing bounds that window — see the `queue_raw_buffer` comment in `pipewire.rs` — so
/// the only thing that keeps capture untorn is the producer round-robining a pool deeper than our
/// import+encode latency. Until PW5 stage 1 nobody had ever counted what that pool was; we never
/// even asked for a size (`build_dmabuf_buffers` set `dataType` and nothing else).
///
/// A **range**, deliberately, not a fixed count: SPA intersects the consumer's and producer's
/// Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection
/// and the link silently stalls in "negotiating" — the exact failure mode the cursor-meta `size`
/// property already cost this codebase once (see `build_cursor_meta_param`). With a range the
/// producer clamps into it and negotiation still succeeds.
///
/// The numbers: `min` stays at 2 so nothing that works today stops working; `default` 8 is ~133 ms
/// of buffer at 60 Hz and ~33 ms at 240 Hz, comfortably past the ~3-4 ms capture→fence latency
/// measured in PW3/PW4 even with a second frame in flight; `max` 16 is a ceiling, not a request
/// (a 4K 4:4:4 buffer is ~25 MB, so 16 is ~400 MB of compositor allocation and worth capping).
/// **What the producer actually picks is logged by the stage-1 census — trust that line, not
/// these constants.**
const POOL_MIN: i32 = 2;
const POOL_DEFAULT: i32 = 8;
const POOL_MAX: i32 = 16;
/// Build a Buffers param requesting dmabuf-only buffers, with pool headroom (see [`POOL_DEFAULT`]).
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
}],
properties: vec![
pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
},
pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_buffers,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(),
pw::spa::utils::ChoiceEnum::Range {
default: POOL_DEFAULT,
min: POOL_MIN,
max: POOL_MAX,
},
),
)),
},
],
})
}
@@ -512,4 +585,80 @@ mod tests {
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
);
}
/// PW5 stage 2: the pool request must be a **Choice Range**, never a fixed Int.
///
/// This is the whole safety argument for asking at all: SPA intersects the two sides' Buffers
/// params, so a fixed count a producer cannot afford empties the intersection and the link
/// stalls in "negotiating" with no error anywhere — the same trap that cost this codebase the
/// entire Linux cursor channel once (see `build_cursor_meta_param`). Asserting the pod shape
/// is what keeps a later "simplify" from turning the range back into a number.
#[test]
fn the_dmabuf_pool_request_is_a_range_not_a_fixed_count() {
let pod = build_dmabuf_buffers().unwrap();
let key = spa::sys::SPA_PARAM_BUFFERS_buffers.to_ne_bytes();
let at = pod
.windows(4)
.position(|w| w == key)
.expect("the dmabuf Buffers pod must carry a buffers count");
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
// Property = { key, flags, value_pod }; value_pod = { size, type, body }. A Choice body
// is { type: u32, flags: u32, child_size: u32, child_type: u32, values… }.
assert_eq!(
word(at + 12),
spa::sys::SPA_TYPE_Choice,
"the buffers count must be a Choice, not a bare Int — a fixed count can fail \
negotiation outright"
);
assert_eq!(
word(at + 16),
spa::sys::SPA_CHOICE_Range,
"the Choice must be a Range (default, min, max)"
);
assert_eq!(word(at + 24), 4, "Choice child pods are 4-byte Ints");
assert_eq!(word(at + 28), spa::sys::SPA_TYPE_Int, "…of type Int");
let vals: Vec<i32> = (0..3)
.map(|i| i32::from_ne_bytes(pod[at + 32 + i * 4..at + 36 + i * 4].try_into().unwrap()))
.collect();
assert_eq!(
vals,
vec![POOL_DEFAULT, POOL_MIN, POOL_MAX],
"Range values are serialized default-first"
);
// The minimum must not exceed what producers already serve, or the ask becomes a demand.
const { assert!(POOL_MIN <= 2) };
}
/// xBGR_210LE must be offered FIRST, and this is a correctness test, not a style one.
///
/// The first compatible consumer pod wins the negotiation. Leading with `xRGB_210LE` makes an
/// NVIDIA gamescope session land on `XRGB2101010`, whose linear-tiled `A2R10G10B10` storage
/// NVIDIA does not support — gamescope's composite `imageStore` writes XBGR bytes under an
/// XRGB label and the whole stream comes out with red and blue swapped. Every format mapping
/// on the host side is individually correct, so nothing downstream can detect it.
///
/// Field-confirmed 2026-08-09 on the RTX 5070 Ti Bazzite host with 0.26.0. See the
/// [`HDR_FORMAT_ORDER`] docs for the upstream fix this predates.
#[test]
fn hdr_offers_xbgr_before_xrgb() {
assert_eq!(
HDR_FORMAT_ORDER[0],
VideoFormat::xBGR_210LE,
"xBGR_210LE must be offered first — leading with xRGB_210LE swaps red and blue on \
every NVIDIA gamescope HDR session"
);
assert_eq!(
HDR_FORMAT_ORDER[1],
VideoFormat::xRGB_210LE,
"xRGB_210LE stays as the fallback pod so a producer offering only it can still \
negotiate HDR instead of dropping to the SDR downgrade"
);
// Both must still build: the order is a preference, never a removal.
for fmt in HDR_FORMAT_ORDER {
assert!(
!build_hdr_dmabuf_format(fmt, None).unwrap().is_empty(),
"{fmt:?} must still produce a format pod"
);
}
}
}
+6
View File
@@ -134,6 +134,12 @@ pf-vaadec = { path = "../pf-vaadec" }
# container can then compile and clippy the whole rung without `libva-dev`, and a machine
# without a VAAPI runtime gets a clean refusal instead of a packaging dependency.
libloading = "0.8"
# The gamescope overlay watcher (`overlay_focus`): read two CARDINAL properties off a
# gamescope root window and block on PropertyNotify. `default-features = false` keeps the
# pure-Rust `RustConnection` — no libxcb link, so no new C dependency on any client package
# — the same stance pf-capture and pf-vdisplay already take on this crate. No extension
# features: root-window properties and an event mask are core X11.
x11rb = { version = "0.13", default-features = false }
[target.'cfg(windows)'.dependencies]
wasapi = "0.23"
+164 -1
View File
@@ -381,6 +381,7 @@ enum Ctl {
PadAudioPrefs(u8),
MenuMode(bool),
MenuRumble(MenuPulse),
Mask(bool),
}
#[derive(Clone)]
@@ -548,6 +549,31 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::Forwarding(on));
}
/// A system overlay owns the controller right now — hold every forwarded pad NEUTRAL
/// until it closes. This is the Steam Input behaviour a streaming client has to
/// reproduce by hand: while the Deck's Steam menu or QAM is up, the same physical
/// sticks and buttons drive Steam's UI, and anything we keep forwarding lands in the
/// game underneath as a second, invisible player.
///
/// **Masking is not [`set_forwarding`](Self::set_forwarding).** Forwarding-off closes the
/// slot and sends the host a [`GamepadRemove`](InputKind::GamepadRemove) — the game sees a
/// controller *unplug*, which is a hardware event with real in-game consequences (pause
/// menus, "reconnect your controller", player-slot churn). Opening the QAM must not look
/// like that. Masking keeps every slot open and merely stops the transitions, after
/// flushing what the host believes is held so a stick held at overlay-open stops steering
/// instead of freezing at its last value.
///
/// SDL has this gate of its own — it drops presses while the process has windows but no
/// keyboard focus — and on a desktop it fires. It CANNOT fire on a Deck in Gaming Mode:
/// gamescope resolves focus per Xwayland ctx, and the client sits alone in its own ctx, so
/// its X input focus never moves when the overlay takes over (measured). That is why this
/// exists as an explicit lever rather than something inherited for free.
///
/// Held state is adopted, not replayed, on the way back — see [`Ctl::Mask`]'s handling.
pub fn set_masked(&self, on: bool) {
let _ = self.ctl.send(Ctl::Mask(on));
}
/// The session's system-button policy, resolved from
/// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]:
/// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay
@@ -1069,6 +1095,9 @@ struct Worker {
menu_mode: bool,
menu_nav: MenuNav,
menu_tx: async_channel::Sender<MenuEvent>,
/// A system overlay owns input ([`GamepadService::set_masked`]): forwarded pads are held
/// neutral and menu translation is paused, with every slot still OPEN.
masked: bool,
}
impl Worker {
@@ -1519,6 +1548,87 @@ impl Worker {
}
}
/// Re-adopt what the pads are physically holding when an overlay mask lifts.
///
/// Buttons are taken back into `held_buttons` **without** a wire press: a button pressed
/// inside the overlay (the A that picked a QAM row) must not fire in the game the instant it
/// closes — releasing it and pressing again is what arms it. Same rule menu mode already
/// applies across a screen handoff ([`MenuNav::reset`]), for the same reason.
///
/// Axes ARE re-sent, because a stick has no press semantics to ghost — it is deflected or it
/// is not. The mask flushed them to zero, and SDL only speaks on *change*, so a stick still
/// held when the overlay closes would stay dead host-side until the user happened to move it.
///
/// Neither half can run against a pad that is gone: this only walks open slots, and every SDL
/// read here is a state query on a handle the slot owns.
fn readopt_held(&mut self) {
use sdl3::gamepad::{Axis, Button};
// Every button `button_bit` maps — the same surface the press path forwards.
const BUTTONS: [Button; 21] = [
Button::South,
Button::East,
Button::West,
Button::North,
Button::Back,
Button::Start,
Button::Guide,
Button::LeftStick,
Button::RightStick,
Button::LeftShoulder,
Button::RightShoulder,
Button::DPadUp,
Button::DPadDown,
Button::DPadLeft,
Button::DPadRight,
Button::Touchpad,
Button::RightPaddle1,
Button::LeftPaddle1,
Button::RightPaddle2,
Button::LeftPaddle2,
Button::Misc1,
];
const AXES: [Axis; 6] = [
Axis::LeftX,
Axis::LeftY,
Axis::RightX,
Axis::RightY,
Axis::TriggerLeft,
Axis::TriggerRight,
];
// Copied out: the slot walk below borrows `self` mutably.
let system_forward = self.system_forward;
let attached = self.attached.clone();
for slot in &mut self.slots {
slot.held_buttons.clear();
for b in BUTTONS {
let Some(bit) = button_bit(b) else {
continue;
};
// The press path returns before `held_buttons` for un-forwarded system
// buttons; tracking them here would invent state it never keeps.
if !system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
continue;
}
if slot.pad.button(b) {
slot.held_buttons.push(bit);
}
}
let Some(c) = &attached else {
continue;
};
for a in AXES {
let (id, v) = axis_value(a, slot.pad.axis(a));
if slot.last_axis[id as usize] != v {
slot.last_axis[id as usize] = v;
send(c, InputKind::GamepadAxis, id, v, slot.index);
}
}
}
// The chord latch was cleared on the way in; drop it again if what we just adopted
// doesn't actually hold it.
self.rearm_escape();
}
/// True when any one forwarded pad holds the entire escape chord (any player can leave).
fn chord_held(&self) -> bool {
self.slots
@@ -1785,6 +1895,34 @@ impl Worker {
.push((pad, bit, Instant::now() + TAP_PRESS));
}
}
Ok(Ctl::Mask(on)) => {
if self.masked == on {
continue;
}
self.masked = on;
if on {
// Neutral NOW, and while the slots stay open: a stick held when the
// overlay opened must stop steering, but the host must not see the pad
// unplug (that is `close_slot_at`'s job, and a game reacts to it).
if let Some(c) = self.attached.clone() {
for slot in &mut self.slots {
Self::flush_slot(&c, slot);
}
}
// Nothing can be mid-chord across the flip: the transitions that would
// complete or break it are about to be dropped.
self.reset_chord();
} else {
// Coming back. Whatever is still physically held was never delivered —
// adopt it silently rather than replay it as a fresh press, the same
// rule menu mode uses across a screen handoff (`MenuNav::reset`). A
// button you pressed *inside* the overlay must not fire in the game the
// instant it closes; releasing and pressing again is what arms it.
self.readopt_held();
self.menu_nav.reset();
}
tracing::info!(masked = on, "overlay input mask");
}
Ok(Ctl::Forwarding(on)) => {
if self.forwarding == on {
continue;
@@ -1846,6 +1984,28 @@ impl Worker {
/// "is a session live".
fn handle_event(&mut self, event: sdl3::event::Event) {
use sdl3::event::Event;
// A system overlay owns the controller ([`GamepadService::set_masked`]): drop every
// input transition. The pads were flushed neutral when the mask went on, so dropping
// the ups as well as the downs is what keeps the two in agreement — `readopt_held`
// rebuilds the held set from the hardware when it lifts.
//
// Device add/remove deliberately still count: a controller genuinely plugged in or
// pulled out behind an overlay is a fact about the world, not an input, and losing it
// would leave the slot table lying about what exists.
if self.masked
&& matches!(
event,
Event::ControllerButtonDown { .. }
| Event::ControllerButtonUp { .. }
| Event::ControllerAxisMotion { .. }
| Event::ControllerTouchpadDown { .. }
| Event::ControllerTouchpadMotion { .. }
| Event::ControllerTouchpadUp { .. }
| Event::ControllerSensorUpdated { .. }
)
{
return;
}
match event {
Event::ControllerDeviceAdded { which, .. } => {
if !self.order.contains(&which) {
@@ -2074,7 +2234,9 @@ impl Worker {
/// on and no session is attached (attach supersedes; SDL events merely wake the loop,
/// so a press is translated the iteration it arrives).
fn menu_poll(&mut self) {
if !self.menu_mode || self.attached.is_some() {
// Masked covers the launcher too: with the Deck's Steam menu up over our console, the
// same stick that scrolls Steam's UI would otherwise also be scrolling ours behind it.
if !self.menu_mode || self.attached.is_some() || self.masked {
return;
}
let Some((_, pad)) = self.menu_open.as_ref() else {
@@ -2301,6 +2463,7 @@ impl Worker {
menu_mode: false,
menu_nav: MenuNav::new(),
menu_tx,
masked: false,
}
}
}
+4
View File
@@ -46,6 +46,10 @@ pub mod orchestrate;
// The host's OS-identity chain (mDNS `os=` TXT): sanitize + icon-walk order. Pure string
// logic, built everywhere (the Apple/Android ports mirror it rather than link it).
pub mod os;
// "A system overlay owns the controller" for gamescope Gaming Mode — the signal behind the
// gamepad input mask, which SDL's own focus gate structurally cannot provide there.
#[cfg(target_os = "linux")]
pub mod overlay_focus;
// Client settings profiles: the override catalog + the one connect-time resolver
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
// the bindings live on.
+284
View File
@@ -0,0 +1,284 @@
//! "A system overlay owns the controller right now" — the gamescope half of the input mask.
//!
//! On a Steam Deck in Gaming Mode the Steam menu and the QAM are drawn by Steam and driven by
//! the *same physical controller* the client is forwarding. Steam does not mask us the way it
//! masks a normal game: masking happens on Steam Input's virtual pad, and the client
//! deliberately forwards the REAL pad instead (28DE:1205 — the virtual one has no gyro,
//! trackpads or paddles). So while the QAM is up, one thumbstick drives Steam's UI *and* the
//! game on the host. This watcher is what tells [`crate::gamepad::GamepadService::set_masked`]
//! to stop that.
//!
//! **Why the free mechanism can't do it.** SDL already drops gamepad presses while the process
//! has windows but no keyboard focus (`SDL_PrivateJoystickShouldIgnoreEvent`, on by default —
//! we never set `SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS`), and on a desktop that fires. It cannot
//! fire here: gamescope resolves focus **per Xwayland ctx** (`determine_and_apply_focus` scans
//! only that ctx's window list), the Steam overlay lives in the root ctx, and the client sits
//! alone in its own. Measured on a Deck 2026-08-08: with the QAM open, X input focus inside the
//! client's ctx never moved off its window, so no `FocusOut` is ever generated. Hence an
//! explicit signal.
//!
//! **The signal.** gamescope publishes two CARDINALs on the ROOT ctx's root window (Steam mode
//! only, i.e. `gamescope -e` — which is what Gaming Mode runs):
//!
//! * `GAMESCOPE_FOCUSED_APP` — appid of the window holding **input** focus
//! * `GAMESCOPE_FOCUSED_APP_GFX` — appid of the window being **displayed**
//!
//! They are equal in normal play and diverge exactly while something else has taken input over
//! the running app. Measured, both for the Steam menu and for the QAM:
//!
//! ```text
//! app=3856846079 gfx=3856846079 ← streaming, we own input
//! app=769 gfx=3856846079 ← overlay open (769 = Steam)
//! ```
//!
//! Note `app != gfx` rather than "app is Steam": anything that takes input away from the
//! displayed app is a thing we should stop forwarding through, and comparing to our own appid
//! would need us to know it (a non-Steam shortcut's appid is assigned by Steam at creation).
//!
//! **Which display.** Not necessarily ours. Gaming Mode runs `gamescope --xwayland-count 2`:
//! Steam and the atoms live on the first server, the app is given the second, and the client's
//! own `$DISPLAY` therefore has none of these properties. So discovery walks candidates — our
//! `$DISPLAY` first (correct for a single-server gamescope), then every socket in
//! `/tmp/.X11-unix` — and keeps the first whose root actually carries both atoms. gamescope's
//! Xwayland accepts unauthenticated local connections (verified: `xprop` against it succeeds
//! with no `.Xauthority` at all), so no cookie plumbing is needed.
//!
//! Everything here is best-effort by construction: no gamescope, no X, a sandbox that cannot
//! see the other socket, or a session that restarts underneath us all end in "no signal", which
//! degrades to exactly the behaviour that shipped before this module existed.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt, EventMask, Window,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
/// How long to wait before rebuilding everything after the X connection drops. Gaming Mode
/// recreates its Xwayland servers across a session restart, so "gone" is not permanent — but it
/// is also not worth a hot retry loop.
const RECONNECT_DELAY: Duration = Duration::from_secs(3);
/// Live "an overlay owns input" flag, updated by a background thread.
///
/// Cheap to poll (one relaxed atomic load), which is what the presenter's event loop wants — it
/// checks once per iteration and only talks to the gamepad service on an edge.
pub struct OverlayFocus {
open: Arc<AtomicBool>,
}
impl OverlayFocus {
/// Start watching, or return `None` when this isn't a gamescope Steam session (the common
/// case — every desktop client) or the user opted out with `PUNKTFUNK_OVERLAY_MASK=0`.
///
/// Returning `None` is not a failure: the caller keeps its window-focus path, which is the
/// right signal everywhere the compositor actually moves focus.
pub fn start() -> Option<OverlayFocus> {
if std::env::var("PUNKTFUNK_OVERLAY_MASK").is_ok_and(|v| v == "0" || v == "false") {
tracing::info!("overlay input mask disabled by PUNKTFUNK_OVERLAY_MASK");
return None;
}
if !gamescope_session() {
return None;
}
let open = Arc::new(AtomicBool::new(false));
let flag = open.clone();
std::thread::Builder::new()
.name("punktfunk-overlay-focus".into())
.spawn(move || watch(&flag))
.map_err(|e| tracing::warn!(error = %e, "overlay focus watcher failed to start"))
.ok()?;
Some(OverlayFocus { open })
}
/// Does something other than the displayed app own input right now?
pub fn is_open(&self) -> bool {
self.open.load(Ordering::Relaxed)
}
}
/// Gaming Mode / any gamescope session — the only place this signal exists. Mirrors the same
/// env checks the shells already use to detect Gaming Mode.
fn gamescope_session() -> bool {
std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|| std::env::var_os("SteamDeck").is_some()
|| std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
}
/// Displays worth trying, in order: ours first (a single-server gamescope publishes the atoms on
/// the display the app is already on), then every other socket present. `/tmp/.X11-unix` is
/// listed rather than probing `:0..:N` blindly so we never connect to a display that isn't there.
fn candidate_displays() -> Vec<String> {
let mut out = Vec::new();
if let Ok(d) = std::env::var("DISPLAY") {
if !d.is_empty() {
out.push(d);
}
}
if let Ok(entries) = std::fs::read_dir("/tmp/.X11-unix") {
let mut found: Vec<String> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let n = name.strip_prefix('X')?;
n.parse::<u32>().ok().map(|n| format!(":{n}"))
})
.collect();
found.sort();
for d in found {
if !out.contains(&d) {
out.push(d);
}
}
}
out
}
/// The two atoms on a root that carries them, or `None` for a display that isn't gamescope's
/// root ctx. `only_if_exists` keeps this from interning atoms into unrelated X servers.
fn gamescope_atoms(conn: &RustConnection) -> Option<(Atom, Atom)> {
let app = conn
.intern_atom(true, b"GAMESCOPE_FOCUSED_APP")
.ok()?
.reply()
.ok()?
.atom;
let gfx = conn
.intern_atom(true, b"GAMESCOPE_FOCUSED_APP_GFX")
.ok()?
.reply()
.ok()?
.atom;
(app != 0 && gfx != 0).then_some((app, gfx))
}
/// Read one CARDINAL appid. gamescope writes these with a length of ZERO when the appid is 0
/// (`focusedAppId != 0 ? 1 : 0`), so "present but empty" is a real state meaning "no app" — it
/// must read as `None`, not as `Some(0)` that would then compare unequal to everything.
fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option<u32> {
let reply = conn
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1)
.ok()?
.reply()
.ok()?;
// Bound rather than returned inline: the iterator borrows `reply`, and as a tail
// expression its temporary would outlive it.
let id = reply.value32()?.next();
id
}
/// The whole decision, separated from X so it can be tested: an overlay is up exactly when
/// input focus and the displayed app are both known and DIFFER.
///
/// Absence is never an overlay. A missing value means "no app focused" (gamescope's zero-length
/// write) or "this display stopped answering" — and a mask that latched on when the signal went
/// away would silently kill the controller for the rest of the session, which is a far worse
/// failure than not masking at all.
fn overlay_open_from(app: Option<u32>, gfx: Option<u32>) -> bool {
matches!((app, gfx), (Some(a), Some(g)) if a != g)
}
/// True when input focus and the displayed app have diverged — an overlay is up.
fn overlay_open(conn: &RustConnection, root: Window, app: Atom, gfx: Atom) -> bool {
overlay_open_from(read_appid(conn, root, app), read_appid(conn, root, gfx))
}
/// Connect, find the root ctx, then block on PropertyNotify for the two atoms. Returns on any X
/// error so the outer loop can rebuild after a session restart.
fn watch(flag: &Arc<AtomicBool>) {
loop {
if let Some((conn, root, app, gfx)) = connect() {
// Seed before the first event: the overlay may already be up when we start.
flag.store(overlay_open(&conn, root, app, gfx), Ordering::Relaxed);
loop {
match conn.wait_for_event() {
Ok(Event::PropertyNotify(e)) if e.atom == app || e.atom == gfx => {
let open = overlay_open(&conn, root, app, gfx);
if flag.swap(open, Ordering::Relaxed) != open {
tracing::debug!(open, "gamescope overlay focus changed");
}
}
Ok(_) => {}
Err(e) => {
tracing::info!(error = %e, "gamescope focus watcher disconnected");
break;
}
}
}
// A dropped connection tells us nothing about the controller — unmask, or a
// gamescope restart mid-overlay would leave the pad dead with nothing to revive it.
flag.store(false, Ordering::Relaxed);
}
std::thread::sleep(RECONNECT_DELAY);
}
}
/// The first candidate display whose root carries both atoms, with PropertyNotify selected.
fn connect() -> Option<(RustConnection, Window, Atom, Atom)> {
for dpy in candidate_displays() {
// `dpy`, not `display`: `display` is one of tracing's own value helpers, and a field
// named after it resolves to the helper inside the macro rather than to this string.
let Ok((conn, screen_num)) = RustConnection::connect(Some(&dpy)) else {
continue;
};
let Some((app, gfx)) = gamescope_atoms(&conn) else {
continue;
};
let root = conn.setup().roots[screen_num].root;
// Both atoms must actually be PRESENT on this root, not merely interned: a second
// gamescope Xwayland knows the atom names (they are per-server strings) but only the
// root ctx publishes the values.
if read_appid(&conn, root, gfx).is_none() {
continue;
}
// Checked rather than fire-and-forget: an event mask that silently failed to apply
// would leave the watcher blocked forever on a display that never speaks to it.
let selected = match conn.change_window_attributes(
root,
&ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
) {
Ok(cookie) => cookie.check().is_ok(),
Err(_) => false,
};
if !selected {
continue;
}
tracing::info!(dpy, "watching gamescope focus for overlay input masking");
return Some((conn, root, app, gfx));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// The measured Deck states, both directions (2026-08-08, Steam menu and QAM alike):
/// equal appids while we own input, divergent while the overlay does.
#[test]
fn divergent_appids_are_an_overlay() {
assert!(!overlay_open_from(Some(3856846079), Some(3856846079)));
assert!(overlay_open_from(Some(769), Some(3856846079)));
}
/// gamescope writes these properties with a length of ZERO when the appid is 0, so "no app"
/// arrives as a missing value rather than `Some(0)`. Reading it as `Some(0)` would make it
/// differ from every real appid and mask the pad on an empty Gaming Mode home screen.
#[test]
fn a_missing_appid_is_never_an_overlay() {
assert!(!overlay_open_from(None, Some(3856846079)));
assert!(!overlay_open_from(Some(769), None));
assert!(!overlay_open_from(None, None));
}
/// The safety property that outranks the feature: if the signal is unreadable we forward as
/// before. A latched mask would leave a streaming session with a dead controller and no way
/// back short of restarting it.
#[test]
fn absence_fails_open_not_closed() {
assert!(!overlay_open_from(None, None));
}
}
File diff suppressed because it is too large Load Diff
+328
View File
@@ -53,6 +53,32 @@ pub(crate) fn stamp_color_bits(bitstream: &mut [u8], seq_offset: usize, bt2020_p
}
}
/// Read the 3-bit wire sequence counter out of a pyrowave block header.
///
/// Every block header is `{ u16 ballot; u16 payload_words:12, sequence:3, extended:1; u32 ... }`
/// (`pyrowave_common.hpp`, `static_assert(sizeof == 8)`), so the counter is bits 12..14 of the
/// little-endian half-word at `packet_offset + 2` — the same word `stamp_color_bits` reaches into
/// from the other end.
///
/// This field is the entire frame-boundary signal on the wire: the decoder restarts a frame only
/// when the value CHANGES (`diff = (hdr.sequence - last_seq) & 0x7; restart = diff != 0`), so a
/// repeated value is read as more blocks of the same frame. That is why PW5's alternating encoder
/// handles need `pyrowave_encoder_set_next_sequence`, and why a test asserts this reader sees
/// +1 mod 8 across the pair.
///
/// Its only caller is the Linux backend — alternating encoder handles are a Linux-side concern, and
/// the Windows backend drives pyrowave's compat device with a single handle. The rest of this module
/// really is shared (`packet_boundary` and `stamp_color_bits` have callers on both), so the exemption
/// is scoped to this one item rather than the file: `dead_code` stays live on Linux, where the caller
/// lives and where its disappearing would be a real finding. Windows builds with `-D warnings`, so
/// without this the host and tray clippy legs fail to compile the lib at all.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn wire_sequence(bitstream: &[u8], packet_offset: usize) -> Option<u8> {
let lo = *bitstream.get(packet_offset + 2)?;
let hi = *bitstream.get(packet_offset + 3)?;
Some(((u16::from_le_bytes([lo, hi]) >> 12) & 0x7) as u8)
}
/// The wavelet block space's total 32x32-block count for a mode — the exact counting walk of
/// upstream `WaveletBuffers::init_block_meta` (also ported to the Apple `WaveletLayout`, whose
/// golden tests pin it against real host AUs). Needed because the vendored RDO pass packs the
@@ -201,6 +227,193 @@ pub(crate) fn build_au(
au
}
// ---------------------------------------------------------------------------
// Streamed-AU chunk cutting (PW6 — latency plan §T3.4, wave-2 plan PW6)
// ---------------------------------------------------------------------------
/// Default per-chunk target — ~34 chunks for a 400 Mb/s 60 fps AU (~833 KB). Deliberately coarse,
/// because the SEALER, not this size, sets how early bytes actually leave:
///
/// * Toward a plain `VIDEO_CAP_STREAMED_AU` client, `Packetizer::push_streamed` flushes only when
/// its pending buffer exceeds one FEC block — `fec.max_data_per_block × shard_payload`, which is
/// 200 × 1408 = 281 600 B on the shipped 1500-MTU IPv4 geometry. Anything smaller than that is
/// simply buffered. (256 KiB sits just under one block, so the first flush lands on the SECOND
/// chunk; the win is intact either way — the whole-AU path seals all ~3 blocks before its first
/// datagram may leave.) Only a client that ALSO negotiated `VIDEO_CAP_MULTI_SLICE` gets the
/// finer `MIN_STREAM_BLOCK_SHARDS` floor (16 shards ≈ 22 KB), where the chunk size does set the
/// flush granularity directly. pf-encode is not told the session's FEC geometry, so this is a
/// fixed byte target rather than a block-derived one.
/// * Chunks are not free: the send thread paces each sealed batch on its own
/// (`stream.rs::pace_sealed`), and every call grants a fresh `max(bytes/4, 128 KiB)` microburst
/// allowance. Cutting an AU into dozens of chunks therefore erodes the pacing this host does to
/// stop line-rate bursts from overrunning the NIC — the failure mode the pacer exists for.
const STREAM_CHUNK_TARGET_BYTES: usize = 256 * 1024;
/// Clamp on the `PUNKTFUNK_PYROWAVE_CHUNK_KIB` override (see [`stream_chunk_step`]).
const STREAM_CHUNK_MIN_KIB: usize = 4;
const STREAM_CHUNK_MAX_KIB: usize = 8192;
/// Whether streamed-AU output is armed for this host process.
///
/// **Default OFF, and deliberately so.** The streamed wire shape costs one PyroWave-specific
/// regression that has not been measured: an UNPINNED streamed frame (its final block never
/// arrived, so `frame_bytes` is still the 0 sentinel) is excluded from partial delivery
/// (`reassemble.rs`, 2026-07 security-review finding 10) — where today's whole-AU path hands the
/// consumer a usable blurred partial, a streamed frame that loses its final block delivers
/// NOTHING. PyroWave clients opt into partial delivery unconditionally
/// (`client/pump/handshake.rs`), so this is a live behaviour change for every one of them. The
/// netem loss-harness leg (2 % on `lo`, FEC pinned off — the Phase-4 recipe) comparing
/// partial-delivery rates streamed vs whole-AU is the prerequisite for flipping the default;
/// until it has run, `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` is how you get it.
///
/// The client's `VIDEO_CAP_STREAMED_AU` and the host's `PUNKTFUNK_STREAMED_AU` remain the outer
/// gates (`stream.rs`) — this only decides whether the ENCODER offers chunks at all.
fn stream_armed() -> bool {
static ARMED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
// Latched once: `supports_chunked_poll` is re-queried per AU, and a knob that could change
// mid-session would flip the wire shape under an open `StreamedAu`.
*ARMED.get_or_init(|| {
matches!(
std::env::var("PUNKTFUNK_PYROWAVE_STREAMED_AU").as_deref(),
Ok("1")
)
})
}
/// Bytes per streamed chunk, rounded DOWN to a whole number of `window`-sized windows (never
/// below one). The rounding is the whole point — see [`AuChunker`].
fn chunk_step(window: usize, target: usize) -> usize {
(target / window.max(1)).max(1) * window.max(1)
}
/// The streamed-AU chunk size for a backend whose wire chunking is `wire_chunk`, or `None` when
/// this session must stay on the whole-AU path — which is the answer whenever the feature is not
/// armed ([`stream_armed`]) or the encoder is in DENSE mode.
///
/// Dense mode is excluded on purpose: there the AU is ONE atomic pyrowave packet with no window
/// framing, so a cut is neither shard-aligned nor a framing boundary. Every real PyroWave session
/// runs datagram-aligned (`stream.rs` sets `plan.wire_chunk = Some(session.shard_payload())`), so
/// nothing is lost — but the invariant this file promises stays true instead of nearly true.
///
/// `PUNKTFUNK_PYROWAVE_CHUNK_KIB` overrides the target (clamped to
/// [`STREAM_CHUNK_MIN_KIB`]..=[`STREAM_CHUNK_MAX_KIB`]); garbage falls back to the default.
pub(crate) fn stream_chunk_step(wire_chunk: Option<usize>) -> Option<usize> {
let window = wire_chunk.filter(|&w| w > 0)?;
if !stream_armed() {
return None;
}
static TARGET: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
let target = *TARGET.get_or_init(|| {
std::env::var("PUNKTFUNK_PYROWAVE_CHUNK_KIB")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|k| (STREAM_CHUNK_MIN_KIB..=STREAM_CHUNK_MAX_KIB).contains(k))
.map(|k| k * 1024)
.unwrap_or(STREAM_CHUNK_TARGET_BYTES)
});
Some(chunk_step(window, target))
}
/// Hands a **finished** datagram-aligned AU out in window-aligned pieces for the streamed-AU wire
/// ([`crate::Encoder::poll_chunk`], `punktfunk_core::quic::VIDEO_CAP_STREAMED_AU`). Shared by both
/// pyrowave backends so the cut rule cannot drift between Linux and Windows — the Windows backend
/// cannot even be compiled from a Linux/macOS dev box, so logic written into it directly ships
/// unverified.
///
/// ## What this does NOT buy (read before quoting PW6 as a latency win)
///
/// pyrowave's `encode_frame` is **synchronous**: `submit` returns only once the whole AU sits in
/// `pending`, so by the time the host can poll a chunk the encode is over. `poll_chunk` is
/// therefore NOT "emit slices as the encoder produces them" — it is "hand the finished AU out in
/// pieces so the wire work pipelines with itself". Concretely, what moves:
///
/// * whole-AU path: `Session::seal_frame_at` FEC-protects, packetizes and AEAD-seals the ENTIRE
/// ~830 KB AU before its first datagram may leave the socket;
/// * streamed path: each FEC block seals and paces as it completes, so the first byte reaches the
/// wire after one block's seal, and the remaining seal work overlaps its own transmission.
///
/// There is NO encode/send overlap here — unlike the H.26x sub-frame slice path, where chunks
/// genuinely appear while the encoder is still working. PW6 and PW5 (encode overlap) are
/// independent packages, not sequential ones.
///
/// It also does **not** give the client decode-while-arriving: the reassembler completes a
/// streamed AU exactly like a whole one (`reassemble.rs` — `block_count != 0 && blocks_ok ==
/// block_count`) and hands up ONE `Frame`. Client-side prefix decode is the separate
/// `Session::set_deliver_frame_parts` opt-in, which PyroWave's newest-wins frame channel cannot
/// take — see the PW6 section of `design/linux-host-performance-wave2-pyrowave.md`.
///
/// ## The cut rule
///
/// A chunk is a whole number of `chunk`-sized WINDOWS. [`build_au`] gives every window exactly ONE
/// `kind` in its 4-byte prefix (`WIN_PACKED` or one link of a `WIN_FRAG_*` chain), so a cut inside
/// a window would split a unit the clients parse atomically. Whole windows are `shard_payload`
/// multiples by construction, which is what makes the sealer's sentinel block bases shard-aligned
/// for free (plan §4.4) — the streamed path's placement contract.
pub(crate) struct AuChunker {
au: Vec<u8>,
/// Bytes already handed out.
cursor: usize,
/// Bytes per chunk — a whole number of windows ([`chunk_step`]).
step: usize,
pts_ns: u64,
keyframe: bool,
recovery_anchor: bool,
chunk_aligned: bool,
/// Set once anything has been emitted, so the degenerate EMPTY AU still owes exactly one
/// chunk and not an infinite stream of them.
emitted: bool,
}
impl AuChunker {
pub(crate) fn new(frame: crate::EncodedFrame, step: usize) -> AuChunker {
AuChunker {
au: frame.data,
cursor: 0,
step: step.max(1),
pts_ns: frame.pts_ns,
keyframe: frame.keyframe,
recovery_anchor: frame.recovery_anchor,
chunk_aligned: frame.chunk_aligned,
emitted: false,
}
}
/// The next piece, or `None` once the AU is spent. The pieces concatenate to exactly the bytes
/// [`crate::Encoder::poll`] would have returned; `first` opens the wire frame and `last` closes
/// it (the host's `handle_chunk` keys its `begin`/`finish` off precisely those two).
pub(crate) fn next(&mut self) -> Option<crate::AuChunk> {
if self.cursor >= self.au.len() {
// A zero-byte AU is not reachable through `build_au` (it always emits at least one
// window), but the host would leak its open `StreamedAu` if a chunked poll returned
// nothing at all — so the degenerate case still owes one self-closing chunk.
if self.emitted {
return None;
}
self.emitted = true;
return Some(self.chunk(Vec::new(), true, true));
}
let first = self.cursor == 0;
let end = (self.cursor + self.step).min(self.au.len());
let data = self.au[self.cursor..end].to_vec();
self.cursor = end;
self.emitted = true;
Some(self.chunk(data, first, end == self.au.len()))
}
/// AU-level metadata rides every chunk (the `AuChunk` contract only makes it authoritative on
/// `first`, but a truthful copy on each one costs nothing and keeps a mid-AU log honest).
fn chunk(&self, data: Vec<u8>, first: bool, last: bool) -> crate::AuChunk {
crate::AuChunk {
data,
pts_ns: self.pts_ns,
keyframe: self.keyframe,
recovery_anchor: self.recovery_anchor,
chunk_aligned: self.chunk_aligned,
first,
last,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -362,4 +575,119 @@ mod tests {
stamp_color_bits(&mut bs, 0, true);
assert_eq!(bs[7], 0x78);
}
// --- streamed-AU chunk cutting (PW6) ------------------------------------
// Appended at module END per the wave plan's ownership rule.
fn frame(data: Vec<u8>) -> crate::EncodedFrame {
crate::EncodedFrame {
data,
pts_ns: 1_234_567,
keyframe: true,
recovery_anchor: false,
chunk_aligned: true,
}
}
/// Drain a chunker into `(concatenated bytes, per-chunk lengths, first flags, last flags)`.
fn drain(mut c: AuChunker) -> (Vec<u8>, Vec<usize>, Vec<bool>, Vec<bool>) {
let (mut bytes, mut lens, mut firsts, mut lasts) = (Vec::new(), Vec::new(), vec![], vec![]);
while let Some(ch) = c.next() {
lens.push(ch.data.len());
firsts.push(ch.first);
lasts.push(ch.last);
bytes.extend_from_slice(&ch.data);
assert_eq!(ch.pts_ns, 1_234_567, "AU metadata rides every chunk");
assert!(ch.keyframe && ch.chunk_aligned && !ch.recovery_anchor);
}
(bytes, lens, firsts, lasts)
}
/// The invariant PW6 rests on: chunks concatenate to EXACTLY the AU, every cut lands on a
/// whole-window boundary (so no window's single `kind` is split across two wire frames), and
/// the reassembled stream still walks back to the same codec packets. A cut inside a window
/// would hand the client a 4-byte prefix whose body arrives in a different chunk — the
/// framing is one-kind-per-window, so there is no way to express that.
#[test]
fn stream_chunks_tile_the_au_on_window_boundaries() {
let bs: Vec<u8> = (0..4000u32).map(|i| (i % 251) as u8).collect();
let packets = [(0, 20), (20, 300), (320, 55), (375, 900), (1275, 40)];
let chunk = 64;
let au = build_au(&packets, &bs, Some(chunk));
assert!(au.len() / chunk > 4, "need several windows to cut between");
let step = chunk_step(chunk, 3 * chunk);
assert_eq!(step, 3 * chunk);
let (bytes, lens, firsts, lasts) = drain(AuChunker::new(frame(au.clone()), step));
assert_eq!(bytes, au, "chunks concatenate to exactly the AU");
assert!(
lens.iter().all(|l| l % chunk == 0),
"every chunk is a whole number of windows: {lens:?}"
);
assert!(
lens[..lens.len() - 1].iter().all(|&l| l == step),
"only the tail chunk may be short: {lens:?}"
);
assert_eq!(
firsts,
(0..lens.len()).map(|i| i == 0).collect::<Vec<_>>(),
"exactly one opening chunk"
);
assert_eq!(
lasts,
(0..lens.len())
.map(|i| i + 1 == lens.len())
.collect::<Vec<_>>(),
"exactly one closing chunk"
);
// And the client's parse is unchanged by the cutting.
let mut expect = Vec::new();
for &(o, s) in &packets {
expect.extend_from_slice(&bs[o..o + s]);
}
assert_eq!(walk(&bytes, chunk), expect);
}
/// The step always rounds DOWN to whole windows and never to zero — a target below one window
/// degenerates to one window per chunk rather than an empty chunk (which would spin forever).
#[test]
fn chunk_step_rounds_down_to_whole_windows() {
// 262144 / 1408 = 186.2 → 186 whole windows (261 888 B), never the 262 144 asked for.
assert_eq!(chunk_step(1408, 256 * 1024), 186 * 1408);
assert_eq!(chunk_step(1408, 1408), 1408);
assert_eq!(chunk_step(1408, 1407), 1408); // below one window → one window
assert_eq!(chunk_step(1408, 0), 1408);
assert_eq!(chunk_step(0, 4096), 4096); // defensive: never divides by zero
}
/// An AU that fits one chunk is a single `first && last` piece — the shape the host's
/// `handle_chunk` turns into begin+finish on one message, and byte-identical on the wire to
/// what the whole-AU path would have sealed.
#[test]
fn single_chunk_au_opens_and_closes_itself() {
let au = vec![7u8; 512];
let (bytes, lens, firsts, lasts) = drain(AuChunker::new(frame(au.clone()), 4096));
assert_eq!(bytes, au);
assert_eq!(lens, vec![512]);
assert_eq!(firsts, vec![true]);
assert_eq!(lasts, vec![true]);
}
/// The degenerate empty AU still owes exactly ONE self-closing chunk: a chunked poll that
/// returned nothing would leave the host's `StreamedAu` open forever (its `begin` fires on
/// `first`, its `finish` on `last`).
#[test]
fn empty_au_still_emits_one_self_closing_chunk() {
let mut c = AuChunker::new(frame(Vec::new()), 4096);
let ch = c.next().expect("one chunk");
assert!(ch.first && ch.last && ch.data.is_empty());
assert!(c.next().is_none(), "and never a second one");
}
/// Dense (non-windowed) AUs never stream: there is no window framing to cut on, so a chunk
/// boundary would be neither shard-aligned nor a parse boundary.
#[test]
fn dense_mode_never_streams() {
assert!(stream_chunk_step(None).is_none());
assert!(stream_chunk_step(Some(0)).is_none());
}
}
@@ -128,6 +128,11 @@ pub struct PyroWaveEncoder {
wire_budget: pyrowave_wire::WireBudget,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
/// The AU currently being handed out in streamed chunks (PW6 — `Some` strictly between a
/// `first` chunk and its `last`). See [`pyrowave_wire::AuChunker`]: this backend's encode is
/// synchronous, so the AU is COMPLETE before the first chunk leaves — the split is for the
/// send side, never an encode/send overlap.
chunker: Option<pyrowave_wire::AuChunker>,
}
// SAFETY: used only from the single encode thread; the pyrowave handles are owned and only touched
@@ -255,6 +260,7 @@ impl PyroWaveEncoder {
wire_budget: pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(),
pending: VecDeque::new(),
chunker: None,
})
}
}
@@ -676,10 +682,55 @@ impl Encoder for PyroWaveEncoder {
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// Trait contract: each AU is drained through ONE method. Erroring beats double-emitting
// the bytes the chunk cursor already handed out (which would reach the wire twice, under
// the same frame index, and fail the receiver's retro-validation).
if self.chunker.is_some() {
bail!("pyrowave: poll() on an AU already being drained through poll_chunk");
}
Ok(self.pending.pop_front())
}
// --- streamed AU (PW6) — see `pyrowave_wire::AuChunker` for what this does and does NOT buy.
// Byte-identical to the Linux twin BY CONSTRUCTION: all of the cutting lives in the shared
// helper, which compiles and unit-tests on every platform. This file cannot be compiled from
// a Linux/macOS dev box, so anything written here directly would ship unverified.
fn supports_chunked_poll(&self) -> bool {
pyrowave_wire::stream_chunk_step(self.wire_chunk).is_some()
}
fn poll_chunk(&mut self) -> Result<Option<crate::AuChunk>> {
// Finish the AU already in flight before opening the next one — the host's `handle_chunk`
// keys begin/finish off `first`/`last` and cannot interleave two AUs.
if let Some(c) = self.chunker.as_mut() {
if let Some(chunk) = c.next() {
return Ok(Some(chunk));
}
self.chunker = None;
}
let Some(f) = self.pending.pop_front() else {
return Ok(None);
};
// No blocking wait here (the trait allows one): `submit` already ran the whole encode
// synchronously, so an AU in `pending` is complete by construction.
match pyrowave_wire::stream_chunk_step(self.wire_chunk) {
Some(step) => Ok(self
.chunker
.insert(pyrowave_wire::AuChunker::new(f, step))
.next()),
// Unarmed / dense: the trait's own default shape, so a host that polls chunks anyway
// still gets whole AUs.
None => Ok(Some(crate::AuChunk::whole(f))),
}
}
fn reset(&mut self) -> bool {
// A rebuild forfeits every in-flight frame — including an AU only half-handed-out through
// `poll_chunk`. Dropping the cursor here (ahead of every `pending.clear()` arm below) is
// what keeps the next `poll_chunk` from splicing the tail of a dead AU onto a fresh one;
// the host sees a `first` without the previous `last`, logs "streamed AU abandoned
// mid-flight" and lets the client age that frame out.
self.chunker = None;
// Cheap in-place rebuild: recreate only the pyrowave encoder object (no rate-control /
// reference state to preserve). The device, imported textures and fence survive.
// SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder.
+33
View File
@@ -646,6 +646,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// translation automatically — the GTK launcher never turned it off either).
gamepad.set_menu_mode(true);
}
// Gaming Mode's Steam menu / QAM drive the SAME physical pad we forward, and gamescope
// never takes our X focus away (it resolves focus per Xwayland ctx, and we are alone in
// ours), so SDL's own background-input gate cannot fire there. `None` everywhere else,
// where window focus IS the signal — see the FocusLost/FocusGained arms below.
#[cfg(target_os = "linux")]
let overlay_focus = pf_client_core::overlay_focus::OverlayFocus::start();
// Two independent reasons the pad is not ours — window focus and the gamescope overlay —
// OR'd into ONE value that is pushed to the service on an edge. Kept as separate inputs
// rather than one flag each source writes: either would otherwise clear the other's mask
// (a focus-loss mask undone by the next overlay poll saying "no overlay", and vice versa).
let mut focus_lost = false;
let mut mask_applied = false;
// The native display mode — the `0 = native` fallback for the requested stream mode
// (the GTK client reads the monitor under its window; same idea).
@@ -758,8 +770,17 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
tracing::info!("focus lost — input released");
}
}
// Controllers go with the keyboard and mouse. SDL already stops
// delivering their PRESSES here, but nothing zeroed what the host
// still believes is held — so a stick deflected at the moment focus
// went away kept steering. Masking flushes it neutral.
focus_lost = true;
}
WindowEvent::FocusGained => {
// Unlike capture, the controller mask has no "the user meant it"
// variant to respect — it exists only to mirror who owns the pad —
// so regaining focus always lifts its half.
focus_lost = false;
// An auto-release (Alt-Tab) undoes itself; a chord release
// stays released until the user opts back in.
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
@@ -1070,6 +1091,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
other => pump.handle_event(other),
}
}
// Who owns the pad right now: window focus, plus Gaming Mode's overlay signal where it
// exists (one relaxed atomic load; `None` off gamescope). Edge-triggered — the service
// hears only about CHANGES, so an open QAM doesn't re-flush the pads every iteration.
#[cfg(target_os = "linux")]
let overlay_now = overlay_focus.as_ref().is_some_and(|of| of.is_open());
#[cfg(not(target_os = "linux"))]
let overlay_now = false;
let want_mask = focus_lost || overlay_now;
if want_mask != mask_applied {
mask_applied = want_mask;
gamepad.set_masked(want_mask);
}
pump.tick();
// One coalesced MouseMove per iteration — pure motion must reach the host
// without waiting for a click/key to flush it.
@@ -328,7 +328,7 @@ impl VirtualDisplay for GamescopeDisplay {
// client's resolution (the box is headless, so its game-mode mode is ours to set).
// Reuse if it already matches (fast, no restart); otherwise relaunch the box's own
// session at the client mode. Without this the client gets the box's default mode.
ensure_box_gamescope_mode(mode)?
ensure_box_gamescope_mode(mode, self.hdr)?
} else {
id.parse()
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
@@ -494,7 +494,7 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
"gamescope: managed takeover unavailable — degrading to ATTACH (mirroring the box's \
own game-mode session)"
);
let node_id = ensure_box_gamescope_mode(mode)?;
let node_id = ensure_box_gamescope_mode(mode, hdr)?;
point_injector_at_eis();
return Ok(VirtualOutput {
node_id,
@@ -923,6 +923,68 @@ fn remove_steamos_dropin() {
let _ = std::fs::remove_file(steamos_dropin_path());
}
/// Drop-in for the box's OWN autologin `gamescope-session-plus@*.service`.
///
/// The transient-unit path ([`launch_session`]) can pass `BindReadOnlyPaths` straight to
/// `systemd-run`, but a box that owns an autologin session is RESTARTED in place instead — no
/// `systemd-run`, so the bind has to arrive as a drop-in or Nobara's hardcoded
/// `/usr/bin/gamescope` wins there too (see [`DISTRO_GAMESCOPE_PATH`]).
///
/// `zz-` so it sorts last, matching the SteamOS drop-in convention above.
fn session_plus_dropin_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/deck".to_string());
std::path::Path::new(&home)
.join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf")
}
/// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and
/// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS`
/// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60).
///
/// A no-op returning `Ok(false)` when there is nothing to redirect, so a box already running our
/// binary keeps a clean unit.
fn write_session_plus_dropin(
wrapper: &std::path::Path,
mode: Mode,
hdr: bool,
wsi_ok: bool,
) -> Result<bool> {
if gamescope_bin() == DISTRO_GAMESCOPE_PATH {
return Ok(false);
}
let path = session_plus_dropin_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
}
let body = format!(
"[Service]\n\
BindReadOnlyPaths={wrapper}:{DISTRO_GAMESCOPE_PATH}\n\
Environment=PF_HZ={hz}\n\
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
{wsi}",
wrapper = wrapper.display(),
hz = game_hz(mode.refresh_hz),
hdr_args = hdr_args(hdr)
.into_iter()
.chain(cursor_args())
.collect::<Vec<_>>()
.join(" "),
wsi = if wsi_ok {
String::new()
} else {
"Environment=ENABLE_GAMESCOPE_WSI=0\n".to_string()
},
);
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
Ok(true)
}
/// Remove the box-session drop-in (restore-on-disconnect). Best-effort, mirroring
/// [`remove_steamos_dropin`].
fn remove_session_plus_dropin() {
let _ = std::fs::remove_file(session_plus_dropin_path());
}
/// Take over SteamOS's `gamescope-session.target` headless at the CLIENT's mode: write the shim + a
/// drop-in carrying the mode, `daemon-reload`, then RESTART the target so `steam-launcher.service`
/// brings Steam up in the fresh headless gamescope — and attach to its node. A same-mode reconnect
@@ -1012,7 +1074,7 @@ fn create_managed_session_steamos(mode: Mode, hdr: bool) -> Result<VirtualOutput
/// box's own unit (rather than spawning a competing one) avoids the autologin-respawn fight the old
/// MANAGED path hit. A headless box has no physical panel, so its game-mode resolution is ours to set;
/// Steam restarts only on an actual resolution CHANGE.
fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
fn ensure_box_gamescope_mode(mode: Mode, hdr: bool) -> Result<u32> {
let target = (mode.width, mode.height);
// Fast path: already at the client's resolution — just attach to the live node.
if current_gamescope_output_size() == Some(target) {
@@ -1084,6 +1146,26 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
&format!("SCREEN_HEIGHT={}", mode.height),
&format!("CUSTOM_REFRESH_RATES={}", mode.refresh_hz.max(1)),
]);
// Same two fixes the transient path gets, but this unit is the BOX's own — they have to arrive
// as a drop-in, and `daemon-reload` before the restart or systemd runs the old unit.
match write_gamescope_bin_wrapper()
.and_then(|w| write_session_plus_dropin(&w, mode, hdr, wsi_layer_matches_our_gamescope()))
{
Ok(true) => {
tracing::info!(
bin = %gamescope_bin(),
%unit,
"gamescope: dropped in a bind over {DISTRO_GAMESCOPE_PATH} for the box's own \
session unit a session script that hardcodes that path (Nobara) gets the \
patched build on this restart too"
);
systemctl_user(&["daemon-reload"]);
}
Ok(false) => {}
// Best-effort: a box whose session already runs our binary loses nothing, and a failure
// here must not block a restart that would otherwise work.
Err(e) => tracing::warn!(error = %e, "gamescope: could not write the box-session drop-in"),
}
systemctl_user(&["restart", &unit]);
// Wait for the relaunched session to come up at the new size and publish its capture node. The
// node appears when gamescope is up (well before Steam finishes booting); the caller's
@@ -2257,6 +2339,12 @@ fn do_restore_tv_session() {
}
return;
}
// Hand the box back its OWN gamescope before restarting its session: our bind drop-in exists
// to serve a punktfunk stream, and leaving it would silently put the patched build (plus our
// HDR/cursor flags) under the user's ordinary game mode — exactly the "sits beside the distro
// package" rule this whole design rests on.
remove_session_plus_dropin();
systemctl_user(&["daemon-reload"]);
for unit in units {
let _ = Command::new("systemctl")
.args(["--user", "start", &unit])
@@ -2413,6 +2501,60 @@ fn write_gamescope_bin_wrapper() -> Result<std::path::PathBuf> {
Ok(path)
}
/// The absolute path a session script may hardcode instead of honouring `GAMESCOPE_BIN`.
///
/// Nobara's `gamescope-session-plus` builds its command as `GAMESCOPECMD="/usr/bin/gamescope …"`
/// and reads `GAMESCOPE_BIN` NOWHERE, so all three of our spawn levers miss at once: the env var
/// is ignored, and an absolute path cannot be redirected by a PATH shim. The session then runs a
/// stock gamescope, the capability probe rejects it, and every session dies with
/// "pipeline build failed (out of retries)".
const DISTRO_GAMESCOPE_PATH: &str = "/usr/bin/gamescope";
/// Bind our wrapper over [`DISTRO_GAMESCOPE_PATH`] **inside the session unit's mount namespace**,
/// so a script that hardcodes that path still gets the patched build.
///
/// Deliberately a bind rather than replacing the distro's binary: `punktfunk-gamescope` ships under
/// its own name precisely so it sits BESIDE the distro package (a Steam gaming session keeps using
/// its own gamescope — see packaging/gamescope/README.md). The bind is scoped to this transient
/// unit, so nothing outside the session sees it and nothing is written to `/usr`.
///
/// Skipped when the resolved binary IS the distro path (nothing to redirect) — binding a file over
/// itself is pointless, and on a box with no `punktfunk-gamescope` we must not pretend otherwise.
fn session_gamescope_bind(wrapper: &std::path::Path) -> Option<String> {
if gamescope_bin() == DISTRO_GAMESCOPE_PATH {
return None;
}
Some(format!(
"--property=BindReadOnlyPaths={}:{DISTRO_GAMESCOPE_PATH}",
wrapper.display()
))
}
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
///
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
/// run our own build. When the two disagree the compositor rejects the client's
/// `swapchain_feedback` ("message too short") and **kills every Vulkan client** — Steam never
/// paints and the stream is a black screen with no error anywhere else.
///
/// Measured on Nobara 44 (`vkcube` under each build, layer on):
/// distro 3.16.23.2 → 0 errors; our 3.16.25 → 1 rejected client. The upstream protocol XML is
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
/// bump — which is why the check is "do the version triples differ", not a floor.
///
/// `ENABLE_GAMESCOPE_WSI=0` is gamescope's own opt-out and costs only the layer's extras
/// (present-mode control, client HDR metadata) — far cheaper than a client that cannot start.
fn wsi_layer_matches_our_gamescope() -> bool {
let ours = discovery::gamescope_version_of(std::path::Path::new(gamescope_bin()));
let distro = discovery::gamescope_version_of(std::path::Path::new(DISTRO_GAMESCOPE_PATH));
match (ours, distro) {
// Same upstream triple ⇒ the layer was built from the same protocol. Keep it.
(Some(a), Some(b)) => a == b,
// Either side unreadable: leave the layer alone rather than degrade a box that works.
_ => true,
}
}
/// Launch `gamescope-session-plus <client>` headless at `mode` as a transient `systemd --user`
/// unit (clean cgroup teardown of the whole Steam tree on stop). Injects `--nested-refresh` (via
/// the wrapper) + `--generate-drm-mode cvt` so games see exactly `mode` (resolution + refresh) and
@@ -2451,9 +2593,38 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
r.dedup();
r.iter().map(u32::to_string).collect::<Vec<_>>().join(",")
};
// Redirect a hardcoded `/usr/bin/gamescope` at our wrapper, for session scripts that never
// read `GAMESCOPE_BIN` (Nobara). Computed once so the log line below reflects what we did.
let bind = session_gamescope_bind(&wrapper);
if bind.is_some() {
tracing::info!(
bin = %gamescope_bin(),
"gamescope: binding the patched build over {DISTRO_GAMESCOPE_PATH} inside the session \
unit a session script that hardcodes that path (Nobara) gets the patched build \
instead of the distro's stock one. Nothing outside this unit is affected."
);
}
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
// mismatch kills every Vulkan client (Steam included) with no error but a black screen.
let wsi_ok = wsi_layer_matches_our_gamescope();
if !wsi_ok {
tracing::warn!(
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
than the one we run disabling it for this session (ENABLE_GAMESCOPE_WSI=0). Left \
enabled it rejects the client's swapchain_feedback and every Vulkan client dies, \
which shows up as a black screen with no other symptom."
);
}
let start_unit = || -> Result<()> {
let status = Command::new("systemd-run")
.args(["--user", "--collect", &format!("--unit={unit_name}")])
let mut cmd = Command::new("systemd-run");
cmd.args(["--user", "--collect", &format!("--unit={unit_name}")]);
if let Some(b) = bind.as_deref() {
cmd.arg(b);
}
if !wsi_ok {
cmd.arg("--setenv=ENABLE_GAMESCOPE_WSI=0");
}
let status = cmd
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
// that would abort gamescope at startup.
@@ -524,6 +524,22 @@ fn parse_patch_level(banner: &str) -> u32 {
.unwrap_or(0)
}
/// The upstream `X.Y.Z` a specific gamescope binary reports, or `None` if it cannot be run/parsed.
///
/// Split from [`check_gamescope_version`] (which only ever probes the RESOLVED binary) because the
/// WSI-layer check has to compare TWO binaries — ours and the distro's — and a `None` there means
/// "leave the layer alone", not "assume old".
pub(super) fn gamescope_version_of(bin: &std::path::Path) -> Option<(u32, u32, u32)> {
let out = Command::new(bin).arg("--version").output().ok()?;
// Same stdout/stderr split as the version gate: builds disagree on where the banner goes.
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
parse_version(&text)
}
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
+114 -5
View File
@@ -13,8 +13,11 @@
//! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship
//! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`,
//! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first
//! connects. The headless test path instead exposes it to bare clients via
//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
//! connects. That identification is also why **the host binary must carry no file capability**: a
//! process holding capabilities KWin lacks is one the kernel will not let KWin resolve
//! `/proc/<pid>/exe` for, so it can never be matched to a `.desktop` no matter how correctly the
//! file is installed (see [`capability_denial_hint`]). The headless test path instead exposes it to
//! bare clients via `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
//! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin
//! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with
//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside
@@ -1071,6 +1074,107 @@ impl Drop for StopOnDrop {
}
}
/// Extra sentence appended to every "KWin never advertised the screencast global" error when this
/// process carries capabilities — the one cause that is completely invisible from the Wayland side.
///
/// KWin authorizes a restricted interface by resolving the *client's* `/proc/<pid>/exe` and
/// matching it against an installed `.desktop`. The kernel refuses that readlink to any reader
/// whose effective set is not a superset of the target's **permitted** set
/// (`cap_ptrace_access_check`), and KWin has no capabilities at all. So a host binary carrying any
/// file capability is simply unidentifiable: `executablePath()` comes back empty, no `.desktop` can
/// match, and the global is never advertised — indistinguishable, from here, from a missing
/// `.desktop`. Neither half of the obvious workaround helps: `prctl(PR_SET_DUMPABLE, 1)` leaves the
/// permitted-set check failing, and moving the grant to systemd `AmbientCapabilities=` lands the
/// capability in the same permitted set. Only an uncapped binary is identifiable.
///
/// This is not hypothetical: 0.26.0-1 setcap'd `cap_sys_nice` on the host for the GPU-priority
/// lever and took out desktop streaming on every KDE box until the capability was removed again.
fn capability_denial_hint() -> String {
let permitted = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|status| permitted_caps_from_status(&status));
capability_denial_hint_for(permitted)
}
/// The message half of [`capability_denial_hint`], split from the `/proc/self/status` read so it is
/// testable against a *given* mask instead of whatever the test process happens to hold.
///
/// That distinction is not academic: the first version of this asserted the empty case by calling
/// the real thing and trusting the test process to be uncapped. That holds on a dev box and is
/// false in CI, where the runner container is root with a full permitted set
/// (`CapPrm=0x000001ffffffffff`) — so the hint fired, correctly, and the test failed on a machine
/// where nothing was wrong. A check whose answer depends on the ambient environment tests the
/// environment, not the code.
fn capability_denial_hint_for(permitted: Option<u64>) -> String {
match permitted {
Some(caps) if caps != 0 => format!(
" — NOTE: this process carries capabilities (CapPrm={caps:#018x}), which is enough on \
its own to cause this: the kernel then refuses KWin the /proc/<pid>/exe read it \
identifies clients by, so no .desktop can match however correctly it is installed. \
Clear them with `sudo setcap -r /usr/bin/punktfunk-host` and restart the host"
),
_ => String::new(),
}
}
/// The permitted-capability mask out of a `/proc/<pid>/status` body, or `None` if the field is
/// absent/unparseable. The kernel prints it as a tab-separated 16-digit hex word with no `0x`
/// (`CapPrm:\t0000000000800000` = CAP_SYS_NICE), which is what the split-and-radix-16 parse below
/// expects — split out from [`capability_denial_hint`] purely so that shape is testable without a
/// capability-carrying process to point at.
fn permitted_caps_from_status(status: &str) -> Option<u64> {
let field = status.lines().find(|l| l.starts_with("CapPrm:"))?;
u64::from_str_radix(field.split_whitespace().nth(1)?, 16).ok()
}
#[cfg(test)]
mod capability_hint_tests {
use super::*;
/// Verbatim from a `cap_sys_nice=ep` process on CachyOS — the case that broke 0.26.0-1.
const CAPPED: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000800000\nCapEff:\t0000000000800000\n";
/// ...and from the same binary with no capability, where the hint must stay silent.
const CLEAN: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\n";
#[test]
fn parses_the_kernels_permitted_mask() {
assert_eq!(permitted_caps_from_status(CAPPED), Some(0x0080_0000));
assert_eq!(permitted_caps_from_status(CLEAN), Some(0));
// CapPrm is not guaranteed present (older/again-different kernels): stay quiet, never panic.
assert_eq!(permitted_caps_from_status("Name:\tx\n"), None);
assert_eq!(permitted_caps_from_status("CapPrm:\tzzzz\n"), None);
assert_eq!(permitted_caps_from_status("CapPrm:\n"), None);
}
/// A capability-free host must not append the hint — the message it decorates is also printed
/// on genuinely missing `.desktop` files, and a spurious "you have capabilities" line would
/// send the reader chasing a setcap that was never there.
///
/// Driven off an explicit mask rather than the test process's own: see
/// [`capability_denial_hint_for`] for why calling the real reader here fails in CI.
#[test]
fn silent_without_capabilities() {
assert_eq!(
capability_denial_hint_for(permitted_caps_from_status(CLEAN)),
""
);
// Absent or unparseable field: also silent, never a panic and never a spurious hint.
assert_eq!(capability_denial_hint_for(None), "");
}
/// ...and the case that matters actually speaks, naming the mask and the repair. Without this
/// the test above passes just as well against a function that returns `""` unconditionally.
#[test]
fn names_the_mask_and_the_repair_when_capped() {
let hint = capability_denial_hint_for(permitted_caps_from_status(CAPPED));
assert!(
hint.contains("0x0000000000800000"),
"names the mask: {hint}"
);
assert!(hint.contains("setcap -r"), "names the repair: {hint}");
}
}
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
@@ -1090,7 +1194,8 @@ pub fn probe() -> Result<()> {
it on the host's .desktop X-KDE-Wayland-Interfaces (install \
io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \
re-reads it the grant is cached per-exe on first connect), or set \
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin 6.5.6"
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin 6.5.6{}",
capability_denial_hint()
);
}
Ok(())
@@ -1134,7 +1239,9 @@ fn run_existing(
anyhow!(
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \
test){}",
capability_denial_hint()
)
})?;
@@ -1223,7 +1330,9 @@ fn run(
anyhow!(
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \
test){}",
capability_denial_hint()
)
})?;
+322 -28
View File
@@ -19,7 +19,7 @@ pub mod vkslot;
pub mod vulkan;
pub mod worker;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
pub use cuda::DeviceBuffer;
pub use egl::{DmabufPlane, EglImporter};
@@ -261,56 +261,223 @@ pub fn gpu_import_disabled() -> bool {
/// operator found `PUNKTFUNK_ZEROCOPY=0` by hand. The host already knows how to encode that
/// machine — capture just has to stop handing it dmabufs. Latching here is what makes the next
/// session negotiate CPU frames on its own.
static RAW_DMABUF_FAILURE_STREAK: AtomicU32 = AtomicU32::new(0);
static RAW_DMABUF_DISABLED: AtomicBool = AtomicBool::new(false);
/// Below the encoder's own rebuild budget, so the latch is set before the session it doomed ends.
const RAW_DMABUF_FAILURE_LATCH: u32 = 3;
/// Record an encoder-side raw-dmabuf import failure. Latches the process-wide disable after
/// `RAW_DMABUF_FAILURE_LATCH` consecutive failures.
/// Consecutive capture rebuilds whose dmabuf-only offer never negotiated before the passthrough is
/// latched off. **2 = one retry**, deliberately: each failed negotiation costs a ~10 s stall, so a
/// larger budget is paid by the user in dead air. One retry is enough to survive a compositor
/// caught mid-restart, which is the transient this exists for; a compositor that genuinely never
/// accepts keeps the same capture identity, so its streak accumulates and it latches on the second
/// try — one extra stall versus the old behaviour, once per host lifetime.
const RAW_DMABUF_NEGOTIATION_LATCH: u32 = 2;
/// The raw-dmabuf passthrough's off-switch — **two causes with two different lifetimes**, which is
/// the whole point of this type.
///
/// They used to share one `AtomicBool`, so the cheap recoverable cause (a negotiation that timed
/// out, possibly because the compositor was mid-restart) was as permanent as the expensive
/// unrecoverable one (an encoder that cannot import what this compositor allocates). Once either
/// fired, EVERY later session on the host captured CPU frames until the process was restarted —
/// including sessions against a completely different compositor and node, which had never failed
/// at anything.
///
/// * **Import failures stay sticky.** A driver that will not take what the compositor allocates
/// refuses identically on every retry, and the encode-stall recovery above cannot tell that from
/// a transient — it rebuilt the same failing encoder five times and then ended the session, on
/// every connection, forever. That is what this latch was born to stop, and it must keep
/// stopping it.
/// * **Negotiation timeouts get a retry budget** ([`RAW_DMABUF_NEGOTIATION_LATCH`]).
/// * **Both are keyed to a capture identity.** A new node id — a fresh virtual output, the
/// Bazzite Gaming↔Desktop switch, a compositor restart — is a genuinely different question, so
/// it earns a fresh dmabuf attempt instead of inheriting a verdict about something else.
///
/// Atomics rather than a lock because [`note_import_ok`](Self::note_import_ok) is on the per-frame
/// import path; everything else here runs at pipeline build or on failure.
#[derive(Debug)]
pub struct RawDmabufLatch {
import_streak: AtomicU32,
import_latched: AtomicBool,
negotiation_streak: AtomicU32,
negotiation_latched: AtomicBool,
/// The capture identity the counters above describe. `u64::MAX` = nothing observed yet (a real
/// identity is a node id, so it can never collide with the sentinel).
identity: AtomicU64,
}
/// Nothing observed yet — distinct from any real capture identity.
const NO_IDENTITY: u64 = u64::MAX;
impl RawDmabufLatch {
pub const fn new() -> Self {
RawDmabufLatch {
import_streak: AtomicU32::new(0),
import_latched: AtomicBool::new(false),
negotiation_streak: AtomicU32::new(0),
negotiation_latched: AtomicBool::new(false),
identity: AtomicU64::new(NO_IDENTITY),
}
}
/// Whether the raw-dmabuf passthrough is currently off, for either cause.
pub fn disabled(&self) -> bool {
self.import_latched.load(Ordering::Relaxed)
|| self.negotiation_latched.load(Ordering::Relaxed)
}
/// Tell the latch which capture is about to be built. A DIFFERENT capture from the one the
/// current verdict was formed against clears every counter and both latches, so the new
/// pipeline earns a fresh dmabuf attempt.
///
/// Returns `true` only when that clear actually **re-armed something** — i.e. the identity
/// changed *and* a latch was set. Deliberately not "the identity changed": every session on a
/// fresh virtual output changes it, and a caller that logged on that would print a re-arm line
/// on every healthy session open, which is noise. `true` means "this capture would have been
/// forced to CPU by an earlier capture's verdict, and no longer is".
///
/// Call this BEFORE reading [`disabled`](Self::disabled) for a negotiation decision, or the
/// decision is made against the previous capture's verdict.
pub fn observe_capture(&self, identity: u64) -> bool {
if self.identity.swap(identity, Ordering::Relaxed) == identity {
return false;
}
let was_latched = self.disabled();
self.import_streak.store(0, Ordering::Relaxed);
self.import_latched.store(false, Ordering::Relaxed);
self.negotiation_streak.store(0, Ordering::Relaxed);
self.negotiation_latched.store(false, Ordering::Relaxed);
was_latched
}
/// Record an encoder-side raw-dmabuf import failure. Returns `true` if this failure is the one
/// that latched the passthrough off.
pub fn note_import_failure(&self) -> Option<u32> {
let streak = self.import_streak.fetch_add(1, Ordering::Relaxed) + 1;
(streak >= RAW_DMABUF_FAILURE_LATCH && !self.import_latched.swap(true, Ordering::Relaxed))
.then_some(streak)
}
/// Record a raw dmabuf that imported and encoded — resets the failure streak. The per-frame
/// hot path, hence a single relaxed store.
///
/// Deliberately does NOT clear `import_latched`: once the latch fires, capture has already
/// moved to CPU frames, so there are no more dmabuf imports to succeed. Only a new capture
/// identity clears it.
pub fn note_import_ok(&self) {
self.import_streak.store(0, Ordering::Relaxed);
}
/// Record a capture rebuild whose dmabuf-only offer never negotiated. Returns `Some(streak)`
/// if this is the failure that latched the passthrough off, `None` while retries remain.
pub fn note_negotiation_timeout(&self) -> Option<u32> {
let streak = self.negotiation_streak.fetch_add(1, Ordering::Relaxed) + 1;
(streak >= RAW_DMABUF_NEGOTIATION_LATCH
&& !self.negotiation_latched.swap(true, Ordering::Relaxed))
.then_some(streak)
}
/// Record a capture whose dmabuf offer DID negotiate — the retry budget is per consecutive
/// run of failures, so a success spends none of it.
pub fn note_negotiation_ok(&self) {
self.negotiation_streak.store(0, Ordering::Relaxed);
}
/// Diagnostic for the session-open line: which cause (if any) currently holds it off.
pub fn state(&self) -> &'static str {
match (
self.import_latched.load(Ordering::Relaxed),
self.negotiation_latched.load(Ordering::Relaxed),
) {
(true, true) => "latched: encoder-import + negotiation",
(true, false) => "latched: encoder-import failures",
(false, true) => "latched: negotiation timeouts",
(false, false) => "live",
}
}
}
impl Default for RawDmabufLatch {
fn default() -> Self {
Self::new()
}
}
static RAW_DMABUF: RawDmabufLatch = RawDmabufLatch::new();
/// Record an encoder-side raw-dmabuf import failure. Latches the passthrough off after
/// `RAW_DMABUF_FAILURE_LATCH` consecutive failures, until the capture identity changes.
pub fn note_raw_dmabuf_import_failure(reason: &str) {
let streak = RAW_DMABUF_FAILURE_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
if streak >= RAW_DMABUF_FAILURE_LATCH && !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
if let Some(streak) = RAW_DMABUF.note_import_failure() {
tracing::error!(
streak,
reason,
"zero-copy raw-dmabuf passthrough disabled for this host process: the encoder failed \
to import the compositor's dmabuf {streak} times in a row captures fall back to the \
CPU path (slower, but this host could not stream at all otherwise)"
"zero-copy raw-dmabuf passthrough disabled: the encoder failed to import the \
compositor's dmabuf {streak} times in a row captures fall back to the CPU path \
(slower, but this host could not stream at all otherwise). A new capture (different \
node / compositor) clears this."
);
}
}
/// Record a raw dmabuf that imported and encoded — resets the failure streak.
pub fn note_raw_dmabuf_import_ok() {
RAW_DMABUF_FAILURE_STREAK.store(0, Ordering::Relaxed);
RAW_DMABUF.note_import_ok();
}
/// Latch the raw-dmabuf passthrough off because its dmabuf-only *offer never negotiated* — the
/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak. One
/// timeout is conclusive for this offer (a compositor that cannot allocate the requested
/// LINEAR/modifier BGRx dmabuf refuses it identically on every retry), so there is no streak to
/// count: the next capture skips the passthrough and negotiates SHM/CPU instead of re-running the
/// same 10 s timeout on every reconnect.
/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak.
///
/// Unlike the import streak this gets a retry budget: the offer can time out because the
/// compositor was mid-restart rather than because it will never accept, and the old behaviour
/// (one timeout = CPU capture for the rest of the host's life, for every compositor and every
/// node) turned a transient into a permanent downgrade nobody could see.
///
/// Scoped deliberately. This used to be `note_vaapi_dmabuf_failed`, which fed [`enabled`] and so
/// disabled ALL zero-copy host-wide — see [`enabled`]. `RAW_DMABUF_DISABLED` gates only the
/// raw-passthrough decision, so the EGL→CUDA importer that a later NVENC session builds is
/// untouched.
/// disabled ALL zero-copy host-wide — see [`enabled`]. It gates only the raw-passthrough decision,
/// so the EGL→CUDA importer that a later NVENC session builds is untouched.
pub fn note_raw_dmabuf_negotiation_failed() {
if !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"zero-copy raw-dmabuf passthrough disabled for this host process: the compositor never \
accepted the dmabuf-only capture offer, so later captures negotiate the CPU path \
instead of repeating that timeout (the EGLCUDA import path is NOT affected)"
);
match RAW_DMABUF.note_negotiation_timeout() {
Some(streak) => tracing::warn!(
streak,
"zero-copy raw-dmabuf passthrough disabled: the compositor did not accept the \
dmabuf-only capture offer {streak} builds in a row, so later captures negotiate the \
CPU path instead of repeating that timeout (the EGLCUDA import path is NOT \
affected). A new capture (different node / compositor) clears this."
),
None => tracing::warn!(
"the compositor did not accept the dmabuf-only capture offer — retrying dmabuf on the \
next capture build before giving up on it"
),
}
}
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
/// [`note_raw_dmabuf_import_failure`]).
/// Record a capture whose dmabuf offer negotiated — spends none of the retry budget.
pub fn note_raw_dmabuf_negotiation_ok() {
RAW_DMABUF.note_negotiation_ok();
}
/// Tell the latch which capture is about to be built, so a verdict formed against a DIFFERENT
/// compositor/node is not inherited. Returns `true` if a latch was cleared by the change.
pub fn note_raw_dmabuf_capture(identity: u64) -> bool {
let cleared = RAW_DMABUF.observe_capture(identity);
if cleared {
tracing::info!(
identity,
"zero-copy raw-dmabuf passthrough re-armed: this is a different capture from the one \
that failed, so it gets a fresh dmabuf attempt"
);
}
cleared
}
/// True while either cause holds the raw-dmabuf passthrough off (see [`RawDmabufLatch`]).
pub fn raw_dmabuf_import_disabled() -> bool {
RAW_DMABUF_DISABLED.load(Ordering::Relaxed)
RAW_DMABUF.disabled()
}
/// Which cause holds the passthrough off, for the session-open diagnostic line.
pub fn raw_dmabuf_latch_state() -> &'static str {
RAW_DMABUF.state()
}
/// The EGL→CUDA twin of the raw-passthrough negotiation latch: the capture advertised the GPU
@@ -564,4 +731,131 @@ mod tests {
note_gpu_import_death(); // third consecutive death
assert!(gpu_import_disabled());
}
// ---- PW3: the raw-dmabuf latch's two lifetimes ------------------------------------------
//
// Against a LOCAL `RawDmabufLatch`, never the process-wide static: these assertions are about
// the state machine, and sharing one global across a test binary's threads is how a latch test
// becomes order-dependent.
/// The expensive cause stays sticky. A driver that cannot import what this compositor
/// allocates refuses identically every time, and the encode-stall recovery cannot tell that
/// from a transient — this latch is what stops it rebuilding the same doomed encoder forever.
#[test]
fn import_failures_latch_and_stay_latched() {
let l = RawDmabufLatch::new();
assert!(!l.disabled());
assert_eq!(l.note_import_failure(), None); // 1
assert_eq!(l.note_import_failure(), None); // 2
assert!(!l.disabled(), "must not latch before the streak completes");
assert_eq!(l.note_import_failure(), Some(3));
assert!(l.disabled());
// Only the FIRST crossing reports, so the error line cannot repeat per frame.
assert_eq!(l.note_import_failure(), None);
// A success resets the streak but must NOT unlatch: once capture moved to CPU frames there
// are no more dmabuf imports, so an "ok" here would be about something else entirely.
l.note_import_ok();
assert!(l.disabled());
}
/// A run of failures broken by a success spends none of the budget — the streak is
/// consecutive-only, which is what makes an occasional failure survivable.
#[test]
fn a_success_breaks_the_import_streak() {
let l = RawDmabufLatch::new();
l.note_import_failure();
l.note_import_failure();
l.note_import_ok();
assert_eq!(l.note_import_failure(), None, "streak restarted at 1");
assert_eq!(l.note_import_failure(), None);
assert!(!l.disabled());
assert_eq!(l.note_import_failure(), Some(3));
}
/// The cheap cause gets a retry. This is the behaviour change PW3 exists for: one timeout used
/// to mean CPU capture for the rest of the host's life, on every compositor and every node.
#[test]
fn a_negotiation_timeout_is_retried_before_it_latches() {
let l = RawDmabufLatch::new();
assert_eq!(l.note_negotiation_timeout(), None, "first one retries");
assert!(
!l.disabled(),
"the next capture build must still be allowed to try dmabuf"
);
assert_eq!(l.note_negotiation_timeout(), Some(2));
assert!(l.disabled());
assert_eq!(l.note_negotiation_timeout(), None, "reports once");
}
/// A capture that negotiates credits the budget back, so a compositor that fails once and then
/// works never accumulates its way to a latch across an evening of reconnects.
#[test]
fn a_negotiated_capture_credits_the_retry_budget() {
let l = RawDmabufLatch::new();
for _ in 0..10 {
assert_eq!(l.note_negotiation_timeout(), None);
l.note_negotiation_ok();
}
assert!(!l.disabled());
}
/// A different capture is a different question. New node id (fresh virtual output, compositor
/// restart, the Bazzite Gaming↔Desktop switch) clears BOTH causes — the same capture does not.
#[test]
fn a_new_capture_identity_clears_the_latch_and_the_same_one_does_not() {
let l = RawDmabufLatch::new();
// Nothing is latched yet, so observing a new capture re-arms NOTHING — that is what the
// return value means, and it is why a healthy session open logs no re-arm line.
assert!(
!l.observe_capture(7),
"nothing was latched, nothing re-armed"
);
assert!(!l.observe_capture(7), "same capture, no clear");
for _ in 0..RAW_DMABUF_FAILURE_LATCH {
l.note_import_failure();
}
assert!(l.disabled());
assert!(
!l.observe_capture(7),
"the SAME capture must keep its verdict — this is the 10s-stall hazard the latch exists for"
);
assert!(l.disabled());
assert!(l.observe_capture(9), "a different node re-arms it");
assert!(!l.disabled());
// ...and the streaks reset with it, so the fresh attempt gets a full budget.
assert_eq!(l.note_import_failure(), None);
}
/// The negotiation latch is keyed the same way — a compositor restart must not inherit the
/// previous one's timeout verdict.
#[test]
fn a_new_capture_identity_clears_the_negotiation_latch_too() {
let l = RawDmabufLatch::new();
l.observe_capture(1);
l.note_negotiation_timeout();
l.note_negotiation_timeout();
assert!(l.disabled());
assert!(l.observe_capture(2));
assert!(!l.disabled());
}
/// The session-open line has to name WHICH cause holds it off — "cpu because nothing here
/// does dmabuf" and "cpu because something failed earlier" are different bugs.
#[test]
fn latch_state_names_the_cause() {
let l = RawDmabufLatch::new();
assert_eq!(l.state(), "live");
l.note_negotiation_timeout();
l.note_negotiation_timeout();
assert_eq!(l.state(), "latched: negotiation timeouts");
let l = RawDmabufLatch::new();
for _ in 0..RAW_DMABUF_FAILURE_LATCH {
l.note_import_failure();
}
assert_eq!(l.state(), "latched: encoder-import failures");
for _ in 0..RAW_DMABUF_NEGOTIATION_LATCH {
l.note_negotiation_timeout();
}
assert_eq!(l.state(), "latched: encoder-import + negotiation");
}
}
@@ -353,6 +353,18 @@ impl FrameChannel {
/// all-intra stream ([`Self::set_all_intra`]) a multi-deep queue drains to the NEWEST AU
/// instead — the skipped ones are already superseded and decode independently, so showing
/// them only adds latency.
///
/// ⚠ **The all-intra drain counts QUEUE ENTRIES and assumes one entry == one AU.** That holds
/// today only because slice-progressive delivery is refused on PyroWave
/// (`client/pump/handshake.rs`; see [`crate::session::Session::set_deliver_frame_parts`]).
/// Turn parts on for an all-intra stream and one AU pushes several entries, at which point
/// `len > 1` no longer means "the consumer is behind": this fires mid-AU, hands back a SUFFIX
/// and `clear()`s that AU's own prefixes — a headerless frame, every frame. Anyone making the
/// two composable must skip whole SUPERSEDED AUs (drop up to the newest entry whose
/// `part.first` is set, never split an AU), give `push`'s `FRAME_QUEUE_HARD_CAP` eviction the
/// same rule, and count `skipped_total` in AUs. Host-side streamed AUs
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) are NOT affected — they still arrive as one
/// completed `Frame` per AU.
pub(crate) fn pop(&self, timeout: Duration) -> FramePop {
let mut st = self.inner.lock().unwrap();
if st.q.is_empty() && !st.closed {
@@ -229,7 +229,10 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
}
// Slice-progressive delivery (the embedder's opt-in): AU prefixes hand up as
// `Frame::part` pieces while the tail is still on the wire. Never on PyroWave — its
// all-intra frame channel drains newest-wins, which assumes whole AUs.
// all-intra frame channel drains newest-wins per QUEUE ENTRY, so parts of one AU read as
// separate AUs and the drain shreds the AU it is mid-way through (`FrameChannel::pop`
// spells out the mechanism and what a fix would take). Unrelated to the host's streamed-AU
// wire (`VIDEO_CAP_STREAMED_AU`), which still completes one whole `Frame` per AU.
if args.frame_parts && welcome.codec != crate::quic::CODEC_PYROWAVE {
session.set_deliver_frame_parts(true);
}
+128 -1
View File
@@ -82,6 +82,49 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfi
Arc::new(t)
}
/// Endpoint config for the CLIENT endpoint — the half of the jumbo opt-in that lives on the
/// receiving side, and without which the whole jumbo leg is unreachable.
///
/// `EndpointConfig::max_udp_payload_size` is the QUIC transport parameter this endpoint
/// advertises: "the largest UDP payload I accept". quinn defaults it to **1472** (a 1500-byte
/// Ethernet MTU), and a peer's MTU-discovery search is upper-bounded by
/// `min(MtuDiscoveryConfig::upper_bound, the value the OTHER side advertised)`
/// (`quinn_proto::connection::mtud::SearchState::new`). So raising the host's probe ceiling
/// alone — which is all [`stream_transport_idle`] did — can never make a host's discovery
/// settle above 1472: the *client's* default advertisement caps it, and the host's
/// settled-at-jumbo proof (`native/wire_mtu.rs`, both the mid-session grow and the
/// session-start one) could never fire. This raises the advertisement to the sealed jumbo
/// datagram size so the proof is obtainable at all.
///
/// Gated on the SAME operator opt-in as the probe ceiling ([`crate::config::jumbo_wire_mtu`],
/// i.e. `PUNKTFUNK_JUMBO=1` / `PUNKTFUNK_WIRE_MTU` > 1500) because it is not free: quinn sizes
/// its endpoint receive buffer as `max_udp_payload_size × max_receive_segments × BATCH_SIZE`,
/// which on a GRO-capable Linux/Android client is 64 × 32 segments — ~2.9 MiB at the 1472
/// default, ~18 MiB at jumbo. A jumbo LAN is a deliberate deployment; every other client keeps
/// today's buffer to the byte. Without the opt-in this returns the stock config, so the
/// advertisement, the wire, and the memory are all unchanged.
fn endpoint_config() -> quinn::EndpointConfig {
let mut cfg = quinn::EndpointConfig::default();
if let Some(mtu) = crate::config::jumbo_wire_mtu() {
// Derived exactly like the probe ceiling above (IPv4 overhead — a v6 peer's sealed
// target is smaller, so this covers it), and clamped into quinn's accepted range.
let shard = crate::config::jumbo_shard_payload_for(
mtu,
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
);
let accept = crate::config::sealed_datagram_bytes(shard).clamp(1200, 65_527) as u16;
if cfg.max_udp_payload_size(accept).is_ok() {
tracing::info!(
max_udp_payload_size = accept,
wire_mtu = mtu,
"jumbo opt-in: this endpoint advertises a jumbo QUIC receive ceiling, so the \
peer's MTU discovery can prove a jumbo path (it is capped by this value)"
);
}
}
cfg
}
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
/// persist an identity and use [`server_with_identity`] so clients can pin it).
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
@@ -238,7 +281,15 @@ pub fn client_pinned_with_identity(
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
// `Endpoint::client` hardcodes `EndpointConfig::default()`, whose 1472-byte
// `max_udp_payload_size` caps the HOST's MTU discovery (see `endpoint_config`), so the
// endpoint is built by hand to carry the jumbo opt-in. Same bind as before
// (`0.0.0.0:0`, v4 — no dual-stack flag to reproduce) and the same default runtime.
let socket = std::net::UdpSocket::bind("0.0.0.0:0")?;
let runtime = quinn::default_runtime()
.ok_or_else(|| anyhow_result::Error::msg("no async runtime found".into()))?;
let mut ep = quinn::Endpoint::new(endpoint_config(), None, socket, runtime)?;
ep.set_default_client_config(client_cfg);
Ok(ep)
})();
@@ -348,4 +399,80 @@ mod tests {
let _ = super::stream_transport_idle(std::time::Duration::MAX);
let _ = super::stream_transport_idle(std::time::Duration::ZERO);
}
/// Where a connection's MTU discovery is allowed to climb to, measured rather than argued
/// (PW7a). Loopback's own MTU is 64 KiB, so the ONLY thing that can stop the search here is
/// configuration — which makes this a clean instrument for the two ceilings:
///
/// * **leg A** — server opted in, client NOT: the search stalls at the client's default
/// `max_udp_payload_size` advertisement (1472) no matter how high the server's probe
/// ceiling is. This is why the shipped jumbo grow could never fire: `wire_mtu.rs` waits
/// for a settle at the sealed jumbo size and the peer's transport parameter forbids it.
/// * **leg B** — both opted in: the search reaches the sealed jumbo datagram, and the
/// elapsed time is what the `Welcome`'s bounded proof-wait has to cover.
///
/// `#[ignore]`d: it sets process-wide env (each endpoint reads the opt-in at construction,
/// which is exactly how the two legs are built) and spends seconds of wall clock.
/// Run it alone: `cargo test -p punktfunk-core --features quic mtu_discovery -- --ignored
/// --nocapture --test-threads=1`.
#[tokio::test]
#[ignore = "measurement: sets process env and takes ~15 s of wall clock"]
async fn mtu_discovery_climbs_only_as_high_as_the_peer_advertises() {
async fn climb(server_jumbo: bool, client_jumbo: bool) -> (u16, u128) {
let set = |on: bool| {
if on {
std::env::set_var("PUNKTFUNK_JUMBO", "1");
} else {
std::env::remove_var("PUNKTFUNK_JUMBO");
}
};
set(server_jumbo);
let server = endpoint::server("127.0.0.1:0".parse().unwrap()).unwrap();
let addr = server.local_addr().unwrap();
set(client_jumbo);
let client = endpoint::client_insecure().unwrap();
set(false);
let accept = tokio::spawn(async move {
let incoming = server.accept().await.expect("incoming");
let conn = incoming.await.expect("host side connects");
(server, conn)
});
let client_conn = client.connect(addr, "punktfunk").unwrap().await.unwrap();
let (_server_ep, host_conn) = accept.await.unwrap();
// A stream write gives the driver something to transmit, which is what starts the
// search (probes ride `poll_transmit`); after that each probe's ack drives the next.
let mut s = host_conn.open_uni().await.unwrap();
s.write_all(b"go").await.unwrap();
let want = crate::config::sealed_datagram_bytes(crate::config::jumbo_shard_payload_for(
9000,
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
)) as u16;
let t0 = std::time::Instant::now();
let mut mtu = host_conn.stats().path.current_mtu;
while t0.elapsed() < std::time::Duration::from_secs(6) && mtu < want {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
mtu = host_conn.stats().path.current_mtu;
}
let elapsed = t0.elapsed().as_millis();
drop(client_conn);
drop(client);
(mtu, elapsed)
}
let (capped, _) = climb(true, false).await;
println!("leg A (server opted in, client not): settled at {capped} B UDP payload");
assert_eq!(
capped, 1472,
"a peer that advertises the stock max_udp_payload_size caps the search at 1472 — \
the whole point of raising it on the client endpoint"
);
let (grown, ms) = climb(true, true).await;
println!("leg B (both opted in): reached {grown} B UDP payload in {ms} ms");
assert!(
grown >= 8972,
"both sides opted in, loopback MTU is 64 KiB — discovery should reach the sealed \
jumbo datagram, got {grown}"
);
}
}
+15 -2
View File
@@ -677,8 +677,21 @@ impl Session {
/// [`Frame::part`]` = Some` while the rest is still on the wire, instead of one whole-AU
/// delivery (the slice-progressive decode path — [`crate::packet::USER_FLAG_SLICE_STREAM`]).
/// With it on, EVERY video frame delivery carries `part: Some` (a frame with no early
/// parts arrives as the degenerate `{offset: 0, first, last}` whole). Do not combine with
/// an all-intra (PyroWave) stream: its newest-wins draining assumes whole AUs.
/// parts arrives as the degenerate `{offset: 0, first, last}` whole).
///
/// **Do not combine with an all-intra (PyroWave) stream**, and the reason is sharper than
/// "newest-wins draining assumes whole AUs" (2026-08-08, PW6): the drain
/// (`client::frame_channel::FrameChannel::pop`) counts QUEUE ENTRIES and takes one entry to be
/// one AU. With parts on, a single AU pushes K entries, so `len > 1` stops meaning "the consumer
/// is behind" — the drain fires mid-AU, returns the newest entry (a SUFFIX) and clears that
/// same AU's prefixes. For PyroWave that is unrecoverable rather than lossy: the sequence
/// header lives in window 0 of every AU, so every frame would arrive headerless. Making the
/// two composable means teaching the drain to skip whole superseded AUs (never to split one)
/// — see the PW6 section of `design/linux-host-performance-wave2-pyrowave.md`.
///
/// Note this is a DIFFERENT axis from the host's streamed-AU wire
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]): a streamed AU still completes as ONE `Frame`
/// here, so it is unaffected by any of the above.
pub fn set_deliver_frame_parts(&mut self, on: bool) {
self.reassembler.set_deliver_parts(on);
}
+23 -2
View File
@@ -844,6 +844,7 @@ fn parse_spike(args: &[String]) -> Result<Options> {
let mut bitrate_mbps = 20u64;
let mut out: Option<PathBuf> = None;
let mut loopback = true;
let mut wire_chunk: Option<usize> = None;
let mut i = 0;
while i < args.len() {
@@ -890,7 +891,13 @@ fn parse_spike(args: &[String]) -> Result<Options> {
"h264" => Codec::H264,
"h265" | "hevc" => Codec::H265,
"av1" => Codec::Av1,
other => bail!("unknown --codec '{other}' (h264|h265|av1)"),
// The spike is the only way to drive a PyroWave capture→encode pass without
// a client, which is what the Linux-host PyroWave work measures against.
// Needs the `pyrowave` feature (default-on) and pairs with
// `PUNKTFUNK_ENCODER=pyrowave`, which is what puts the CAPTURE side on the
// raw-dmabuf passthrough.
"pyrowave" => Codec::PyroWave,
other => bail!("unknown --codec '{other}' (h264|h265|av1|pyrowave)"),
}
}
"--bitrate" => {
@@ -900,6 +907,12 @@ fn parse_spike(args: &[String]) -> Result<Options> {
}
"--out" => out = Some(PathBuf::from(next()?)),
"--no-loopback" => loopback = false,
"--wire-chunk" => {
let v: usize = next()?
.parse()
.map_err(|_| anyhow::anyhow!("bad --wire-chunk (bytes)"))?;
wire_chunk = (v > 0).then_some(v);
}
"-h" | "--help" => {
print_usage();
std::process::exit(0);
@@ -934,6 +947,7 @@ fn parse_spike(args: &[String]) -> Result<Options> {
bitrate_bps: bitrate_mbps.saturating_mul(1_000_000),
out,
loopback,
wire_chunk,
})
}
@@ -1007,11 +1021,18 @@ SPIKE OPTIONS:
KWin virtual output at --width x --height and captures it
--seconds <N> capture duration in seconds (default: 5)
--fps <N> target frame rate (default: 60)
--codec <h264|h265|av1> NVENC codec (default: h265)
--codec <h264|h265|av1|pyrowave>
encode codec (default: h265). 'pyrowave' also wants
PUNKTFUNK_ENCODER=pyrowave so capture takes the passthrough
--bitrate <MBPS> target bitrate in Mbps (default: 20)
--width <W> --height <H> synthetic source size (default: 1920x1080)
--out <PATH> raw Annex-B output (default: /tmp/punktfunk-spike.<ext>)
--no-loopback skip the punktfunk_core round-trip verification
--wire-chunk <BYTES> PyroWave datagram-aligned packetization at this shard payload
(a real session passes its negotiated shard_payload, e.g. 1408).
With PUNKTFUNK_PYROWAVE_STREAMED_AU=1 also armed, the AU is
drained through poll_chunk and sealed as a STREAMED wire frame
(VIDEO_CAP_STREAMED_AU), then byte-verified by the loopback
-h, --help this help
NOTES:
+6 -1
View File
@@ -1148,7 +1148,12 @@ async fn serve_session(
// path verdict (WARN + learned clamp for the next session on a constrained path; clears
// a stale clamp on a healthy one) — and, with the driver above, heal or grow THIS
// session mid-stream. Bounded ~10 s task unless a jumbo grow leaves it as revert guard.
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize, shard_reneg);
wire_mtu::spawn_watch(
conn.clone(),
welcome.shard_payload as usize,
hello.max_shard_payload,
shard_reneg,
);
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
// blend-capability gate — re-running it here could drift, and would re-probe).
+14 -9
View File
@@ -148,7 +148,6 @@ pub(super) async fn negotiate(
Option<crate::vdisplay::GamescopeRoute>,
Option<super::stream::PrepHandle>,
)> {
let peer = conn.remote_address();
let mut hello = Hello::decode(first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
if hello.abi_version != punktfunk_core::WIRE_VERSION {
close_rejected(
@@ -497,6 +496,11 @@ pub(super) async fn negotiate(
let (data_sock, direct) = bind_data_socket(data_port)?;
let udp_port = data_sock.local_addr()?.port();
// The session's video geometry (see the `shard_payload` field below). Resolved before the
// Welcome struct because a path a previous session proved jumbo is given a bounded moment
// to re-prove itself live on THIS connection — the awaited part of `negotiated_shard_payload`.
let shard_payload = wire_mtu::negotiated_shard_payload(conn, hello.max_shard_payload).await;
let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key);
// Fresh per-session salt alongside the fresh key. GCM nonce uniqueness only *requires* one
@@ -548,14 +552,15 @@ pub(super) async fn negotiate(
// hardcoded 1452 overshot the v4 ceiling (its math forgot the header/crypto ride
// inside the UDP payload) and silently IP-fragmented EVERY video datagram, doubling
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
// Resolution order (wire_mtu.rs): `PUNKTFUNK_WIRE_MTU` operator override, then a path
// budget learned from a prior session whose QUIC MTU discovery settled below the
// video-datagram ceiling (the "VPN on the host blackholes every video packet" field
// shape — small flows pass, the stream is an endless black screen), then this family
// default. Healthy paths take the default branch and are byte-identical to before.
shard_payload: wire_mtu::negotiated_shard_payload(peer.ip()) as u16,
// Negotiated, so the client follows.
// Resolution order (wire_mtu.rs): a JUMBO start (≈8900) on a path a previous session
// proved AND this connection has just re-proved live, then the `PUNKTFUNK_WIRE_MTU`
// operator override, then a path budget learned from a prior session whose QUIC MTU
// discovery settled below the video-datagram ceiling (the "VPN on the host blackholes
// every video packet" field shape — small flows pass, the stream is an endless black
// screen), then this family default. Healthy paths take the default branch and are
// byte-identical to before.
shard_payload: shard_payload as u16,
encrypt: true,
key,
salt,
+501 -23
View File
@@ -24,6 +24,14 @@
//! - **Heal** — the next handshake from that peer clamps `shard_payload` to the recorded
//! budget, so a reconnect fixes the stream. A later session that reaches the ceiling erases
//! the record (the learn/heal loop is self-correcting in both directions).
//! - **Grow** (PW7a) — the mirror image, for the jumbo half: a connection whose discovery
//! settles at the sealed JUMBO size has proven the path carries ~8.9 KB video datagrams, and
//! the next session on that same path *starts* there instead of at the 1500-byte default.
//! PyroWave sessions cannot be re-keyed mid-stream (the client's parse window is the
//! `Welcome` value, read once over the C ABI), so the session-start value is the ONLY way
//! they ever reach jumbo — and it is exactly where ~6× fewer datagrams per frame is worth
//! the most. See [`jumbo_session_start`] for why a remembered verdict alone is never
//! allowed to seal one byte above the default.
use std::collections::HashMap;
use std::net::IpAddr;
@@ -63,10 +71,155 @@ fn learned() -> &'static Mutex<HashMap<IpAddr, u16>> {
LEARNED.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The shard payload for a new session to `peer`: `PUNKTFUNK_WIRE_MTU` override, else the
/// peer's learned path budget, else the family default (today's exact behavior). Logs whenever
/// the result differs from the default.
pub(super) fn negotiated_shard_payload(peer: IpAddr) -> usize {
/// Identity of a PATH, not of a peer — the key the jumbo verdict is filed under.
///
/// The clamp above is keyed by peer IP alone, and that is safe *because being wrong is benign*:
/// a stale clamp only makes video datagrams smaller than they had to be. A stale GROW is the
/// opposite — one oversized datagram on a 1500-byte path is silently dropped, which is the
/// "connects fine, black screen forever" shape this whole module exists to kill. So the grow
/// keys strictly: a verdict earned over the host's 10 GbE NIC does not apply to the same peer
/// IP reached over the host's Wi-Fi or a VPN adapter, because those are different routes with
/// different MTUs.
///
/// `local` is `Connection::local_ip()` (the address the connection was actually received on);
/// `None` where the platform can't report it, which degrades this key to the clamp's — safely,
/// because the live re-proof in [`jumbo_session_start`] is what actually protects the grow.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
struct PathKey {
local: Option<IpAddr>,
peer: IpAddr,
}
/// A path that a completed MTU-discovery search proved carries jumbo video datagrams.
#[derive(Clone, Copy, Debug)]
struct JumboVerdict {
/// The settled UDP-payload budget the proof measured.
udp_budget: u16,
/// The operator's jumbo target when the proof was taken. A changed `PUNKTFUNK_JUMBO` /
/// `PUNKTFUNK_WIRE_MTU` invalidates it rather than being silently reinterpreted.
target_wire_mtu: usize,
/// When it was taken ([`JUMBO_VERDICT_TTL`]).
at: std::time::Instant,
}
/// How long a jumbo verdict may be redeemed for. Contrary evidence erases it long before this
/// (any settle below the sealed target, on any later session over the same path — the same
/// self-correction the clamp has), so the TTL is not the safety mechanism; it is a bound on how
/// stale an *unrefreshed* memory can get, for the case where the path changes while no session
/// is running.
const JUMBO_VERDICT_TTL: std::time::Duration = std::time::Duration::from_secs(6 * 3600);
/// How long the `Welcome` may wait for THIS connection's MTU discovery to re-prove a jumbo
/// path.
///
/// The wait is structural, not laziness: every connection restarts discovery from ~1200 bytes,
/// so the live proof the grow requires does not exist yet when the `Welcome` is built — and the
/// binary search up to sealed-jumbo needs an ACKED probe per step, each of which a peer may sit
/// on for its ack delay. Without a wait the gate would never pass and the feature would be dead.
///
/// It is honestly on the bring-up critical path (`handshake.rs` sends the `Welcome` and only
/// THEN kicks the display prep), so it is bounded, returns the instant the proof lands, and is
/// entered ONLY for a path a previous session already proved jumbo — i.e. an opted-in operator
/// on a jumbo LAN, never anyone else. The worst case (the full wait, no proof) is the moved
/// laptop, and it is self-limiting: that session's watcher erases the verdict, so the next
/// connect doesn't wait at all.
const JUMBO_PROOF_WAIT: std::time::Duration = std::time::Duration::from_millis(300);
const JUMBO_PROOF_POLL: std::time::Duration = std::time::Duration::from_millis(10);
/// Proven-jumbo paths. Same lifetime rules as [`learned`] — in-memory, re-earned in one session
/// after a host restart.
fn jumbo_verdicts() -> &'static Mutex<HashMap<PathKey, JumboVerdict>> {
static JUMBO: OnceLock<Mutex<HashMap<PathKey, JumboVerdict>>> = OnceLock::new();
JUMBO.get_or_init(|| Mutex::new(HashMap::new()))
}
fn path_key(conn: &quinn::Connection) -> PathKey {
PathKey {
local: conn.local_ip(),
peer: conn.remote_address().ip(),
}
}
/// Everything the session-start jumbo decision reads. Every field but `proven_udp_budget` is
/// observed on THIS connection during THIS handshake — which is the point (see
/// [`jumbo_session_start`]).
#[derive(Clone, Copy, Debug)]
struct JumboStart {
/// The host operator's opt-in ([`jumbo_wire_mtu`]) — `None` = no jumbo, ever.
target_wire_mtu: Option<usize>,
/// `Hello::max_shard_payload`: the client's own receive ceiling (0 = legacy client, which
/// never gets a geometry it didn't ask for).
client_ceiling: u16,
/// `conn.stats().path.current_mtu` right now: the largest UDP payload quinn has had ACKED
/// on this connection.
live_udp_mtu: u16,
/// What a previous session over this same [`PathKey`] settled at, if any.
proven_udp_budget: Option<u16>,
/// The constrained-path clamp [`learned`] for this peer, if any. Contradictory evidence
/// (this peer black-screened on a small MTU recently) vetoes the grow — the two memories
/// are keyed differently and the safe one wins.
clamped_udp_budget: Option<u16>,
}
/// The jumbo shard payload a session to `peer` could use, or `None` when there is nothing to
/// gain (no opt-in, a legacy/low client ceiling, or a target that isn't bigger than the family
/// default). Shared by the decision, the wait, and the watcher so all three agree on the number.
fn jumbo_target(
target_wire_mtu: Option<usize>,
client_ceiling: u16,
peer: IpAddr,
) -> Option<usize> {
let mtu = target_wire_mtu?;
let t = jumbo_shard_payload_for(mtu, peer).min(client_ceiling as usize);
let t = t - t % 2; // FEC requires even shards
(t > mtu1500_shard_payload_for(peer)).then_some(t)
}
/// The session-START jumbo decision: `Some(shard_payload)` only when every gate below holds.
///
/// **Why a remembered verdict is never enough.** A laptop that proved jumbo on the wired LAN
/// and comes back on Wi-Fi, a switch that lost its jumbo config, a client IP recycled by DHCP —
/// all of them present a path that cannot carry an 8.9 KB datagram, and a PyroWave session
/// sealed at that size cannot be re-keyed mid-stream, so it would black-screen for its whole
/// life. The memory therefore only decides whether it is worth WAITING for a proof; what
/// actually authorises the grow is `live_udp_mtu` — a datagram of exactly that size, acked by
/// this client, on this connection, seconds ago. That is why this is as safe as the clamp
/// despite the failure modes being opposite: a wrong memory cannot produce a jumbo `Welcome`,
/// only a live measurement can.
///
/// The gates, in order: the host operator opted in; the client advertised enough receive
/// headroom; the target beats the family default (nothing to gain otherwise); no constrained-path
/// clamp contradicts it; a prior session over this exact path settled at or above the sealed
/// target; and this connection has re-proven it live.
fn jumbo_session_start(i: JumboStart, peer: IpAddr) -> Option<usize> {
let target = jumbo_target(i.target_wire_mtu, i.client_ceiling, peer)?;
let sealed = sealed_datagram_bytes(target);
if let Some(clamp) = i.clamped_udp_budget {
if (clamp as usize) < sealed {
return None;
}
}
if (i.proven_udp_budget? as usize) < sealed {
return None;
}
if (i.live_udp_mtu as usize) < sealed {
return None;
}
Some(target)
}
/// The shard payload for a new session on `conn`: a proven-jumbo grow, else the
/// `PUNKTFUNK_WIRE_MTU` override, else the peer's learned path budget, else the family default
/// (today's exact behavior). Logs whenever the result differs from the default.
///
/// `client_ceiling` is the client's `Hello::max_shard_payload`. Async only for the bounded
/// [`JUMBO_PROOF_WAIT`], which is entered *only* on a path a previous session already proved
/// jumbo — every other session resolves without awaiting anything.
pub(super) async fn negotiated_shard_payload(
conn: &quinn::Connection,
client_ceiling: u16,
) -> usize {
let peer = conn.remote_address().ip();
let env = match std::env::var("PUNKTFUNK_WIRE_MTU") {
Ok(v) => match v.trim().parse::<usize>() {
Ok(mtu) => Some(mtu),
@@ -78,13 +231,80 @@ pub(super) fn negotiated_shard_payload(peer: IpAddr) -> usize {
Err(_) => None,
};
let learned_budget = learned().lock().unwrap().get(&peer).copied();
resolve(env, learned_budget, peer)
let target_wire_mtu = jumbo_wire_mtu();
let proven_udp_budget = fresh_verdict(path_key(conn), target_wire_mtu);
let mut jumbo = JumboStart {
target_wire_mtu,
client_ceiling,
live_udp_mtu: conn.stats().path.current_mtu,
proven_udp_budget,
clamped_udp_budget: learned_budget,
};
// A proven path is worth waiting a moment for: MTU discovery starts when the handshake
// completes and needs an acked probe per binary-search step, so at `Welcome` time it may
// simply not have got there yet. Bounded, and only on paths that already proved it once.
let awaited_proof = proven_udp_budget
.and_then(|_| jumbo_target(target_wire_mtu, client_ceiling, peer))
.map(|t| sealed_datagram_bytes(t) as u16);
if let Some(sealed) = awaited_proof {
if jumbo.live_udp_mtu < sealed {
let t0 = std::time::Instant::now();
while t0.elapsed() < JUMBO_PROOF_WAIT {
tokio::time::sleep(JUMBO_PROOF_POLL).await;
jumbo.live_udp_mtu = conn.stats().path.current_mtu;
if jumbo.live_udp_mtu >= sealed {
break;
}
}
tracing::debug!(
peer = %peer,
waited_ms = t0.elapsed().as_millis() as u64,
live_udp_mtu = jumbo.live_udp_mtu,
needed = sealed,
"wire MTU: waited for this connection to re-prove its jumbo path"
);
}
}
resolve(env, learned_budget, jumbo, peer)
}
/// Pure resolution (env override > learned budget > family default) — the tested core of
/// [`negotiated_shard_payload`].
fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: IpAddr) -> usize {
/// The peer's jumbo verdict if it is still redeemable: same operator target, inside the TTL.
/// A verdict that fails either test is dropped on the spot rather than left to rot.
fn fresh_verdict(key: PathKey, target_wire_mtu: Option<usize>) -> Option<u16> {
let target = target_wire_mtu?;
let mut map = jumbo_verdicts().lock().unwrap();
let v = *map.get(&key)?;
if v.target_wire_mtu != target || v.at.elapsed() > JUMBO_VERDICT_TTL {
map.remove(&key);
return None;
}
Some(v.udp_budget)
}
/// Pure resolution (proven jumbo > env override > learned budget > family default) — the tested
/// core of [`negotiated_shard_payload`].
fn resolve(
env_wire_mtu: Option<usize>,
learned_udp_budget: Option<u16>,
jumbo: JumboStart,
peer: IpAddr,
) -> usize {
let default = mtu1500_shard_payload_for(peer);
// First, because the two are mutually exclusive by construction: `jumbo_wire_mtu()` only
// fires above 1500, and the env branch below CLAMPS to the family default, so a
// `PUNKTFUNK_WIRE_MTU=9000` operator would otherwise get 1408 and never a jumbo start.
if let Some(p) = jumbo_session_start(jumbo, peer) {
tracing::info!(
peer = %peer,
shard_payload = p,
default,
live_udp_mtu = jumbo.live_udp_mtu,
proven_udp_budget = jumbo.proven_udp_budget,
"wire MTU: session starts at the JUMBO shard — this path proved it in a previous \
session AND re-proved it live on this connection (~6× fewer datagrams per frame)"
);
return p;
}
if let Some(mtu) = env_wire_mtu {
let p = shard_payload_for_wire_mtu(mtu, peer);
if p != default {
@@ -119,34 +339,73 @@ fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: I
/// into a verdict — and, with a [`ShardReneg`] driver, act on it MID-SESSION
/// (design/shard-payload-reneg.md Phase 2): a below-ceiling verdict shrinks the live wire at
/// the ~310 s mark (session 1 heals instead of staying black), and a settled-at-jumbo
/// verdict grows it, ack-gated, when the operator opted in. Spawned once per negotiated
/// session; without a grow the task ends after the final sample (bounded ~10 s lifetime,
/// holding only a cheap `Connection` handle) — after a grow it stays as the revert guard
/// until the connection closes.
/// verdict grows it, ack-gated, when the operator opted in. The same settled-at-jumbo reading
/// also writes this path's next-session verdict (PW7a) — `client_ceiling` is the client's
/// `Hello::max_shard_payload`, which decides what "jumbo" is worth proving for this peer.
/// Spawned once per negotiated session; without a grow the task ends after the final sample
/// (bounded ~10 s lifetime, holding only a cheap `Connection` handle) — after a grow, or on a
/// session that STARTED jumbo, it stays as the revert guard until the connection closes.
pub(super) fn spawn_watch(
conn: quinn::Connection,
session_shard_payload: usize,
client_ceiling: u16,
reneg: Option<ShardReneg>,
) {
tokio::spawn(async move {
let peer = conn.remote_address().ip();
let ceiling = video_datagram_udp_ceiling() as u16;
// The sealed size a JUMBO proof has to reach on this path (PW7a) — `None` unless the
// operator opted in AND this client advertised the headroom. Read once: the verdict
// records the target it was proven under, and the two must be the same number.
let target_wire_mtu = jumbo_wire_mtu();
let jumbo_proof =
jumbo_target(target_wire_mtu, client_ceiling, peer).map(sealed_datagram_bytes);
// Discovery finishes in a handful of RTTs on a LAN (well under the first sample) but
// needs a loss timeout per failed probe on a constrained path — the second sample
// covers that with margin. Max, because discovery only ever raises `current_mtu`
// (the post-grow revert guard below re-reads it live, where blackhole detection CAN
// lower it again).
// lower it again). Stop early only once nothing more is expected: with a jumbo opt-in
// the search keeps climbing past the 1500-byte ceiling, and stopping there would throw
// away the very measurement the proof needs.
let goal = jumbo_proof
.unwrap_or(ceiling as usize)
.max(ceiling as usize) as u16;
let mut settled = 0u16;
for wait_s in [3u64, 7] {
tokio::time::sleep(std::time::Duration::from_secs(wait_s)).await;
settled = settled.max(conn.stats().path.current_mtu);
if settled >= ceiling {
if settled >= goal {
break;
}
}
// The wire this session is CURRENTLY sealed at — moves on a mid-session shrink/grow.
let mut current = session_shard_payload;
let mut reneg = reneg;
// PW7a bookkeeping, before anything else can return: this is where a jumbo path earns
// its next-session verdict — and, far more importantly, where it LOSES it. Recording
// needs a live connection that reached the sealed target; anything else (a lower
// settle, a connection that died before the window closed, i.e. exactly what a client
// staring at a black screen does) erases, so the next session falls back to the
// 1500-byte default and has to prove itself again from scratch.
if let Some(need) = jumbo_proof {
let key = path_key(&conn);
if settled as usize >= need && conn.close_reason().is_none() {
jumbo_verdicts().lock().unwrap().insert(
key,
JumboVerdict {
udp_budget: settled,
target_wire_mtu: target_wire_mtu.unwrap_or_default(),
at: std::time::Instant::now(),
},
);
tracing::info!(peer = %peer, discovered_udp_mtu = settled, needed = need,
"wire MTU: this path carries JUMBO video datagrams — the next session over \
it starts at the big shard (it still has to re-prove the path live)");
} else if jumbo_verdicts().lock().unwrap().remove(&key).is_some() {
tracing::info!(peer = %peer, discovered_udp_mtu = settled, needed = need,
"wire MTU: jumbo verdict cleared — this path no longer proves it");
}
}
if settled >= ceiling {
// The path carries full-size video datagrams — erase any stale learned clamp so
// the next session returns to the default wire.
@@ -154,6 +413,34 @@ pub(super) fn spawn_watch(
tracing::info!(peer = %peer,
"wire MTU: path re-measured at full size — learned clamp cleared");
}
// …but "full size" is the 1500-byte ceiling, and this session may have STARTED
// above it (a PW7a jumbo start whose path changed since the proof, or a client
// that roamed onto a 1500-MTU link). Then every video datagram is dying right now.
// The verdict is already erased above; heal the live wire if this session can be
// re-keyed at all — a PyroWave client cannot (its parse window is the `Welcome`
// value), so for those the WARN plus a corrected next session is all there is.
if sealed_datagram_bytes(current) > settled as usize {
tracing::warn!(
peer = %peer,
discovered_udp_mtu = settled,
shard_payload = current,
"wire MTU: this session started at a JUMBO shard but the path does not \
carry it video datagrams are oversized for a hop, which streams as a \
black screen with zero reported loss. The jumbo verdict for this path is \
cleared: the next connect starts at the standard 1500-byte wire."
);
if let Some(r) = reneg.as_ref() {
let back = shard_payload_for_udp_budget(settled as usize, peer);
if back < current
&& r.change_tx.send(back as u16).is_ok()
&& r.apply_tx.send(back).is_ok()
{
tracing::info!(peer = %peer, shard_payload = back, was = current,
"wire MTU: video re-keyed mid-session back to the standard wire");
current = back;
}
}
}
} else {
// A closed connection stops discovering, so a session that ended before the final
// sample proves nothing (a healthy high-RTT path could still be mid-search): learn
@@ -203,12 +490,41 @@ pub(super) fn spawn_watch(
}
}
}
// PW7a revert guard for a session that STARTED jumbo and has no re-key channel (the
// PyroWave case, and the only reason the session-start grow exists). Nothing can save
// this session if the path stops fitting mid-stream — but the NEXT one must not repeat
// it, so keep sampling and drop the verdict the moment quinn's blackhole detection or
// a re-search says the path shrank. Cheap: one `Connection` handle, one sample per 5 s.
// Only for a session that is currently FITTING — one that already failed the check
// above has been warned about and had its verdict erased there.
if current > mtu1500_shard_payload_for(peer)
&& reneg.is_none()
&& sealed_datagram_bytes(current) <= settled as usize
{
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if conn.close_reason().is_some() {
return;
}
let mtu_now = conn.stats().path.current_mtu;
if (mtu_now as usize) < sealed_datagram_bytes(current) {
jumbo_verdicts().lock().unwrap().remove(&path_key(&conn));
tracing::warn!(peer = %peer, discovered_udp_mtu = mtu_now,
shard_payload = current,
"wire MTU: the jumbo path this session started on stopped fitting — this \
session cannot be re-keyed (chunk-aligned client parse window), so it \
will not recover, but the verdict is cleared and the next connect \
starts at the standard wire");
return;
}
}
}
// Phase 2 up-leg: jumbo grow — operator opt-in (PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU
// > 1500, which also raised the endpoint's probe ceiling so `settled` can even reach
// here), client-advertised headroom, and a settled-at-jumbo proof. The grow is
// ACK-GATED: not one sealed datagram above the old size leaves before the client's
// ack, even though its buffers are statically sized — the rule must not erode.
let (Some(mtu), Some(r)) = (jumbo_wire_mtu(), reneg.as_mut()) else {
let (Some(mtu), Some(r)) = (target_wire_mtu, reneg.as_mut()) else {
return;
};
let target = jumbo_shard_payload_for(mtu, peer).min(r.client_ceiling as usize);
@@ -275,34 +591,196 @@ mod tests {
const V4: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2));
const V6: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
/// No jumbo anywhere — what every session that isn't on an opted-in jumbo LAN passes.
const NO_JUMBO: JumboStart = JumboStart {
target_wire_mtu: None,
client_ceiling: 0,
live_udp_mtu: 0,
proven_udp_budget: None,
clamped_udp_budget: None,
};
/// A 9000-MTU LAN, a modern client, a path proven last session and re-proven live now.
fn proven_jumbo() -> JumboStart {
JumboStart {
target_wire_mtu: Some(9000),
client_ceiling: punktfunk_core::config::max_shard_payload() as u16,
live_udp_mtu: 8972,
proven_udp_budget: Some(8972),
clamped_udp_budget: None,
}
}
#[test]
fn default_when_nothing_known() {
assert_eq!(resolve(None, None, V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(None, None, V6), mtu1500_shard_payload_for(V6));
assert_eq!(
resolve(None, None, NO_JUMBO, V4),
mtu1500_shard_payload_for(V4)
);
assert_eq!(
resolve(None, None, NO_JUMBO, V6),
mtu1500_shard_payload_for(V6)
);
}
#[test]
fn env_override_beats_learned() {
// 1280 wire 28 IP/UDP 64 header/crypto = 1188.
assert_eq!(resolve(Some(1280), Some(1472), V4), 1188);
assert_eq!(resolve(Some(1280), Some(1472), NO_JUMBO, V4), 1188);
}
#[test]
fn learned_budget_clamps() {
// A WARP-shaped path: 1280-byte UDP budget → 1280 64 = 1216.
assert_eq!(resolve(None, Some(1280), V4), 1216);
assert_eq!(resolve(None, Some(1280), NO_JUMBO, V4), 1216);
}
#[test]
fn learned_at_or_above_ceiling_is_the_default_wire() {
assert_eq!(resolve(None, Some(1472), V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(None, Some(2000), V4), mtu1500_shard_payload_for(V4));
assert_eq!(
resolve(None, Some(1472), NO_JUMBO, V4),
mtu1500_shard_payload_for(V4)
);
assert_eq!(
resolve(None, Some(2000), NO_JUMBO, V4),
mtu1500_shard_payload_for(V4)
);
}
#[test]
fn env_full_mtu_is_the_default_wire_both_families() {
assert_eq!(resolve(Some(1500), None, V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(Some(1500), None, V6), mtu1500_shard_payload_for(V6));
assert_eq!(
resolve(Some(1500), None, NO_JUMBO, V4),
mtu1500_shard_payload_for(V4)
);
assert_eq!(
resolve(Some(1500), None, NO_JUMBO, V6),
mtu1500_shard_payload_for(V6)
);
}
/// The happy path, both families: 9000 28 (IPv4) 64 = 8908, and 9000 48 64 = 8888.
#[test]
fn proven_and_reproven_path_starts_jumbo() {
assert_eq!(jumbo_session_start(proven_jumbo(), V4), Some(8908));
let mut v6 = proven_jumbo();
v6.live_udp_mtu = 8952;
v6.proven_udp_budget = Some(8952);
assert_eq!(jumbo_session_start(v6, V6), Some(8888));
// …and it is what `resolve` returns, ahead of the env branch that would clamp a
// >1500 `PUNKTFUNK_WIRE_MTU` back down to the family default.
assert_eq!(resolve(Some(9000), None, proven_jumbo(), V4), 8908);
}
/// THE guard: the laptop that proved jumbo on the wired LAN and came back on a 1500-MTU
/// link. The memory still says jumbo; the live connection says otherwise; the live one
/// wins, every time. This is what makes the grow as safe as the clamp.
#[test]
fn a_remembered_verdict_never_grows_without_a_live_reproof() {
let mut moved = proven_jumbo();
moved.live_udp_mtu = 1472; // a clean 1500-MTU path, freshly measured
assert_eq!(jumbo_session_start(moved, V4), None);
assert_eq!(
resolve(None, None, moved, V4),
mtu1500_shard_payload_for(V4)
);
// Not even one byte of headroom short of the sealed target is enough.
let mut nearly = proven_jumbo();
nearly.live_udp_mtu = 8971;
assert_eq!(jumbo_session_start(nearly, V4), None);
}
/// …and the mirror: a live-proven path with no prior verdict still starts at the default.
/// Both halves are required, so a single fluke on either side cannot seal a jumbo wire.
#[test]
fn a_live_proof_alone_does_not_grow() {
let mut first_ever = proven_jumbo();
first_ever.proven_udp_budget = None;
assert_eq!(jumbo_session_start(first_ever, V4), None);
let mut weak_memory = proven_jumbo();
weak_memory.proven_udp_budget = Some(1472);
assert_eq!(jumbo_session_start(weak_memory, V4), None);
}
/// The two memories are keyed differently (clamp: peer; verdict: route), so they can
/// disagree. When they do, the one that keeps datagrams small wins.
#[test]
fn a_constrained_path_clamp_vetoes_the_grow() {
let mut contradicted = proven_jumbo();
contradicted.clamped_udp_budget = Some(1280);
assert_eq!(jumbo_session_start(contradicted, V4), None);
// A clamp that is itself at or above the sealed target isn't contrary evidence.
let mut roomy = proven_jumbo();
roomy.clamped_udp_budget = Some(8972);
assert_eq!(jumbo_session_start(roomy, V4), Some(8908));
}
#[test]
fn without_the_operator_opt_in_nothing_grows() {
let mut no_optin = proven_jumbo();
no_optin.target_wire_mtu = None;
assert_eq!(jumbo_session_start(no_optin, V4), None);
}
/// A legacy client (no `Hello::max_shard_payload`) is never handed a geometry it did not
/// advertise, and a client whose ceiling lands under the family default is left alone
/// rather than being "grown" to something smaller.
#[test]
fn the_client_ceiling_is_binding() {
let mut legacy = proven_jumbo();
legacy.client_ceiling = 0;
assert_eq!(jumbo_session_start(legacy, V4), None);
let mut small = proven_jumbo();
small.client_ceiling = 1408;
assert_eq!(jumbo_session_start(small, V4), None);
// A ceiling between the default and the path target caps the grow — and the proof
// then only has to cover the SMALLER sealed size.
let mut capped = proven_jumbo();
capped.client_ceiling = 4000;
assert_eq!(jumbo_session_start(capped, V4), Some(4000));
}
/// Every shard payload the grow can produce is even (Leopard FEC splits shards in halves)
/// and fits the receive ceiling every client sizes its buffers from.
#[test]
fn grown_shards_stay_even_and_inside_the_receive_ceiling() {
for mtu in [2000usize, 4000, 4001, 9000, 9216, 64000] {
for peer in [V4, V6] {
let Some(t) = jumbo_target(Some(mtu), u16::MAX, peer) else {
continue;
};
assert_eq!(t % 2, 0, "odd shard for mtu {mtu}");
assert!(t <= punktfunk_core::config::max_shard_payload());
assert!(t > mtu1500_shard_payload_for(peer));
assert!(
sealed_datagram_bytes(t) <= punktfunk_core::packet::MAX_DATAGRAM_BYTES,
"sealed datagram overflows the receive ceiling at mtu {mtu}"
);
}
}
// Below the family default there is nothing to grow to.
assert_eq!(jumbo_target(Some(1500), u16::MAX, V4), None);
assert_eq!(jumbo_target(None, u16::MAX, V4), None);
}
/// A path is a (local interface, peer) pair, not a peer: the same client reached over the
/// host's other NIC is a different route with a different MTU.
#[test]
fn the_verdict_key_separates_routes_to_the_same_peer() {
let over_10g = PathKey {
local: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
peer: V4,
};
let over_wifi = PathKey {
local: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))),
peer: V4,
};
assert_ne!(over_10g, over_wifi);
assert_ne!(
over_10g,
PathKey {
local: None,
peer: V4
}
);
}
}
+23 -6
View File
@@ -193,12 +193,29 @@ impl SessionPlan {
// Surface the trade loudly: this is the single biggest per-frame cost a 4:4:4
// session adds (full-res CPU readback + swscale RGB→YUV444P every frame), and
// it looks like an unexplained fps ceiling if you don't know it happened.
tracing::warn!(
"4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy GPU \
capture DISABLED every frame is CPU RGB + swscale RGBYUV444P; expect a \
lower fps ceiling than 4:2:0 at this mode (set PUNKTFUNK_ZEROCOPY=1 for the \
GPU 4:4:4 convert)"
);
//
// Name the SESSION's codec, not the backend the gate is named after. The gate
// keys on `linux_zero_copy_is_vaapi()`, which reads the host-global encoder pref
// — so a per-session PyroWave negotiation on an NVENC/auto host lands here and
// was told it was "on the NVENC path", which is false in every particular: the
// wavelet encoder never touches NVENC, never swscales to YUV444P, and what it
// actually loses is the raw-dmabuf passthrough its whole design assumes.
if self.codec == crate::encode::Codec::PyroWave {
tracing::warn!(
"4:4:4 PyroWave session with PUNKTFUNK_ZEROCOPY off: zero-copy GPU \
capture DISABLED the wavelet encoder loses its raw-dmabuf passthrough \
and every frame becomes a full-resolution CPU readback plus an upload \
into its own Vulkan device; expect a materially lower fps ceiling (set \
PUNKTFUNK_ZEROCOPY=1 to restore the passthrough)"
);
} else {
tracing::warn!(
"4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy \
GPU capture DISABLED every frame is CPU RGB + swscale RGBYUV444P; \
expect a lower fps ceiling than 4:2:0 at this mode (set \
PUNKTFUNK_ZEROCOPY=1 for the GPU 4:4:4 convert)"
);
}
}
gpu && !force_cpu_for_nvenc_444
};
+194 -1
View File
@@ -48,6 +48,17 @@ pub struct Options {
pub out: PathBuf,
/// Also round-trip every AU through a `punktfunk_core` host→client loopback and verify.
pub loopback: bool,
/// PyroWave datagram-aligned packetization at this shard payload
/// ([`Encoder::set_wire_chunking`], plan §4.4) — what a real session passes from its
/// negotiated `shard_payload`. `None` = the dense one-packet-per-AU shape.
///
/// This is also the switch that makes the STREAMED-AU wire reachable from the spike: with
/// it set and `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` armed, the encoder's `poll_chunk` hands the
/// AU out in window-aligned pieces and the loopback seals them through
/// `begin_streamed_frame_at`/`seal_streamed_chunk`/`seal_streamed_finish` — the same path a
/// `VIDEO_CAP_STREAMED_AU` client drives. Without it there is no way to exercise PW6 end to
/// end outside a real client session.
pub wire_chunk: Option<usize>,
}
pub fn run(opts: Options) -> Result<()> {
@@ -114,9 +125,21 @@ pub fn run(opts: Options) -> Result<()> {
refresh_hz: opts.fps,
})
.context("create virtual output")?;
// `resolve` is the shared GameStream/spike constructor and hard-codes `pyrowave: false`
// (GameStream never negotiates it). The spike DOES know its codec, and on Linux that
// flag is what puts the capture on the raw-dmabuf passthrough
// (`ZeroCopyPolicy::pyrowave_session`, set from the same comparison in
// `session_plan::output_format`). Left false, `--codec pyrowave` encoded PyroWave off a
// capture negotiated for somebody else, and the only way to exercise the real path was
// the host-global `PUNKTFUNK_ENCODER=pyrowave` lever — which ALSO flips
// `backend_is_vaapi`, so it cannot reproduce a per-session PyroWave negotiation on an
// auto/NVENC host at all. That is precisely the configuration PW2 exists for.
let mut want =
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu());
want.pyrowave = opts.codec == Codec::PyroWave;
capture::capture_virtual_output(
vout,
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()),
want,
crate::session_plan::CaptureBackend::resolve(),
compositor == crate::vdisplay::Compositor::Kwin,
)
@@ -155,6 +178,18 @@ pub fn run(opts: Options) -> Result<()> {
)
.context("open encoder")?;
// Datagram-aligned packetization (§4.4) — and, with the PW6 knob armed, the gate that makes
// `supports_chunked_poll()` true so the drain below takes the streamed-AU path.
if let Some(c) = opts.wire_chunk {
encoder.set_wire_chunking(c);
tracing::info!(
shard_payload = c,
chunked_poll = encoder.supports_chunked_poll(),
"spike: wire chunking on (chunked_poll=false means PUNKTFUNK_PYROWAVE_STREAMED_AU \
is not armed the AU still goes out whole)"
);
}
let mut sink = BufWriter::new(
File::create(&opts.out).with_context(|| format!("create {}", opts.out.display()))?,
);
@@ -194,6 +229,12 @@ pub fn run(opts: Options) -> Result<()> {
out = %opts.out.display(),
elapsed_s = format!("{elapsed:.2}"),
encode_fps = format!("{:.1}", stats.encoded as f64 / elapsed.max(1e-9)),
// 0 = the whole-AU drain; > encoded = the streamed drain actually cut AUs into pieces.
chunks = stats.chunks,
chunks_per_au = format!(
"{:.1}",
stats.chunks as f64 / (stats.encoded.max(1)) as f64
),
"spike capture→encode→file complete"
);
@@ -217,6 +258,9 @@ struct Stats {
encoded: u64,
keyframes: u64,
bytes_out: u64,
/// Streamed-AU drain only: total chunks polled across all AUs (1 per AU means the cut never
/// engaged — the knob is off or the AU fits one chunk).
chunks: u64,
}
fn drain_encoder(
@@ -225,6 +269,12 @@ fn drain_encoder(
mut lb: Option<&mut Loopback>,
stats: &mut Stats,
) -> Result<()> {
// Streamed-AU drain (PW6): the encoder hands the finished AU out in shard-aligned pieces and
// the loopback seals each piece as it arrives, exactly as the native host's send thread does.
// Re-queried per drain, never cached — the trait's contract.
if encoder.supports_chunked_poll() {
return drain_encoder_chunked(encoder, sink, lb, stats);
}
while let Some(au) = encoder.poll().context("encoder poll")? {
sink.write_all(&au.data).context("write AU to file")?;
stats.encoded += 1;
@@ -239,6 +289,49 @@ fn drain_encoder(
Ok(())
}
/// The streamed-AU drain. Each chunk is sealed into the open wire frame the moment it is polled;
/// the concatenation is kept only so the completed AU can still be written to the file sink and
/// byte-compared against what the client reassembled — which is the point of the leg: it proves
/// the chunks the encoder cut, sealed through the sentinel-block wire, reassemble to EXACTLY the
/// AU `poll()` would have produced.
fn drain_encoder_chunked(
encoder: &mut dyn Encoder,
sink: &mut impl Write,
mut lb: Option<&mut Loopback>,
stats: &mut Stats,
) -> Result<()> {
let mut whole: Vec<u8> = Vec::new();
let mut chunks = 0u32;
while let Some(c) = encoder.poll_chunk().context("encoder poll_chunk")? {
if c.first {
whole.clear();
chunks = 0;
if let Some(lb) = lb.as_deref_mut() {
lb.streamed_begin(c.pts_ns, c.keyframe)?;
}
}
whole.extend_from_slice(&c.data);
chunks += 1;
if let Some(lb) = lb.as_deref_mut() {
lb.streamed_chunk(&c.data)?;
}
if !c.last {
continue;
}
sink.write_all(&whole).context("write AU to file")?;
stats.encoded += 1;
stats.bytes_out += whole.len() as u64;
stats.chunks += chunks as u64;
if c.keyframe {
stats.keyframes += 1;
}
if let Some(lb) = lb.as_deref_mut() {
lb.streamed_finish(&whole)?;
}
}
Ok(())
}
/// A host↔client `punktfunk_core` pair over a lossless in-process loopback. Each encoded AU is
/// FEC-protected, packetized, sent, then reassembled on the client and byte-compared to the
/// original — exercising the core on real encoder output (the spike "feed into a Session" goal).
@@ -249,6 +342,14 @@ struct Loopback {
recovered: u64,
mismatches: u64,
bytes: u64,
/// The streamed AU currently open (PW6). `Some` strictly between `streamed_begin` and
/// `streamed_finish`, mirroring the native send thread's `StreamedOpen`.
open: Option<punktfunk_core::packet::StreamedAu>,
/// Wire frame index for the streamed path. `submit_frame` uses the packetizer's internal
/// counter and `begin_streamed_frame_at` takes an explicit one; a session must use ONE
/// numbering style, and the spike never mixes them (`supports_chunked_poll()` is constant
/// for a PyroWave session, so every AU takes the same route).
next_index: u32,
}
impl Loopback {
@@ -265,9 +366,101 @@ impl Loopback {
recovered: 0,
mismatches: 0,
bytes: 0,
open: None,
next_index: 0,
})
}
/// Open a streamed AU on the wire (PW6). The client side needs no opt-in: a streamed frame
/// completes exactly like a whole one and is handed up as a single `Frame` — which is the
/// finding this leg exists to demonstrate rather than assert.
fn streamed_begin(&mut self, pts_ns: u64, keyframe: bool) -> Result<()> {
if self.open.is_some() {
return Err(anyhow!(
"streamed AU still open at begin — a previous AU never sent its `last` chunk"
));
}
let mut flags = FLAG_PIC as u32;
if keyframe {
flags |= FLAG_SOF as u32;
}
let idx = self.next_index;
self.next_index = self.next_index.wrapping_add(1);
self.open = Some(
self.host
.begin_streamed_frame_at(pts_ns, flags, idx)
.map_err(|e| anyhow!("begin_streamed_frame_at: {e:?}"))?,
);
Ok(())
}
/// Seal + send one encoder chunk. The returned batch is often EMPTY (the sealer buffers
/// until a whole FEC block accumulates) — that is the normal case, not an error.
fn streamed_chunk(&mut self, data: &[u8]) -> Result<()> {
let au = self
.open
.as_mut()
.ok_or_else(|| anyhow!("streamed chunk with no open AU"))?;
let wires = self
.host
.seal_streamed_chunk(au, data, false)
.map_err(|e| anyhow!("seal_streamed_chunk: {e:?}"))?;
self.send(wires)
}
/// Close the AU (final block carries the real totals) and verify what the client got.
fn streamed_finish(&mut self, expect: &[u8]) -> Result<()> {
let au = self
.open
.take()
.ok_or_else(|| anyhow!("streamed finish with no open AU"))?;
let wires = self
.host
.seal_streamed_finish(au)
.map_err(|e| anyhow!("seal_streamed_finish: {e:?}"))?;
self.send(wires)?;
self.submitted += 1;
self.bytes += expect.len() as u64;
self.verify(expect)
}
fn send(&mut self, wires: Vec<Vec<u8>>) -> Result<()> {
if wires.is_empty() {
return Ok(());
}
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
self.host
.send_sealed(&refs)
.map_err(|e| anyhow!("send_sealed: {e:?}"))?;
drop(refs);
self.host.reclaim_wires(wires);
Ok(())
}
/// Drain whatever the client can now reassemble and byte-compare it to `expect`.
fn verify(&mut self, expect: &[u8]) -> Result<()> {
loop {
match self.client.poll_frame() {
Ok(frame) => {
self.recovered += 1;
if frame.data != expect {
self.mismatches += 1;
tracing::warn!(
recovered = self.recovered,
got = frame.data.len(),
expected = expect.len(),
complete = frame.complete,
"loopback AU mismatch"
);
}
}
Err(punktfunk_core::PunktfunkError::NoFrame) => break,
Err(e) => return Err(anyhow!("client poll_frame: {e:?}")),
}
}
Ok(())
}
fn submit(&mut self, au: &EncodedFrame) -> Result<()> {
let mut flags = FLAG_PIC as u32;
if au.keyframe {
@@ -17,7 +17,38 @@ VK_ERROR_NOT_PERMITTED_KHR so a refused class NEVER regresses the encoder. Gated
NOTE: on an RTX 4090 / Windows / WDDM this did not reduce the spikes (the graphics-vs-compute
preemption granularity is the wall) — kept because it is correct, harmless (graceful fallback), and
may help other GPUs/drivers. Reduce the encode's GPU cost (4:2:0/8-bit) or use H.265 for a
GPU-saturated game.
GPU-saturated game. **That measurement is Windows/WDDM and does NOT transfer to Linux** — a
different driver stack with a different preemption model.
MEASURED ON LINUX/NVIDIA 2026-08-08, and it comes out the OTHER WAY: the elevated queue DOES cut
the tail. RTX 5070 Ti (driver 610.57.04), GRID 2 benchmark loop saturating the GPU at 54-87 %,
PyroWave 1080p, same binary in both arms (the only difference is CAP_SYS_NICE, i.e. whether the
class is granted at all), steady-state windows of 30 frames:
arm p50 p99 worst frame
default priority (refused) ~2.6 ms ~6.4 ms 9.5 ms
REALTIME granted ~3.2 ms ~4.4 ms 5.4 ms (repeat: p50 ~3.35, p99 ~4.8)
So on this stack the priority class buys a materially tighter TAIL — p99 down ~30 %, worst frame
roughly halved — at the cost of ~0.6 ms on the median. For a streaming encoder that is the right
side of the trade: the tail is what shows up as a visible hitch. Do NOT delete this patch on the
strength of the RTX 4090/WDDM result above; the two stacks disagree.
Caveats, so the number is not over-read: the arms were not interleaved and the background game
load drifted between them, capture was frame-starved (~2.5 fps) so this measures encode latency
under contention rather than a full-rate stream, and it is two granted runs against one refused
run. The direction was consistent across all 25 measurement windows.
NOTE 2 — WHERE THIS PATCH IS ACTUALLY LIVE. It is gated `if (!inherit_info)`, and only the WINDOWS
path leaves `inherit_info` null: `crates/pf-encode/src/enc/windows/pyrowave.rs` calls
`pyrowave_create_device_by_compat`, so Granite builds the device itself and this block runs.
**On LINUX it has never done anything.** `crates/pf-encode/src/enc/linux/pyrowave.rs::open_inner`
passes its own instance/device create-infos into `pyrowave_device_create_info`, Granite's
`MyDeviceFactory::get_existing_create_info()` returns them, `create_device` takes the inherit
branch, and the whole block above is skipped. The Linux request is therefore wired natively in
**`crates/pf-encode/src/enc/linux/pyrowave.rs`** (search `queue_priority_candidates`), which
implements the SAME env grammar and the SAME downgrade ladder so one knob means one thing on both
platforms. If you change the grammar here, change it there in the same commit.
diff --git a/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/context.cpp b/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/context.cpp
index 5257fc33..479eeded 100644
@@ -0,0 +1,123 @@
Encoder wire-sequence override — PUNKTFUNK LOCAL PATCH.
Not upstream. Exposes `Encoder::set_next_sequence(uint32_t)` (and a
`pyrowave_encoder_set_next_sequence` C entry) so the caller can stamp the 3-bit wire sequence
counter itself instead of relying on the encoder object's private one.
WHY IT EXISTS. PyroWave's `Encoder` structurally cannot hold two frames in flight: `Encoder::Impl`
owns ONE each of `wavelet_img_high_res`, `bucket_buffer`, `meta_buffer`, `block_stat_buffer`,
`payload_data` and `quant_buffer`, and `Impl::encode` OPENS by discarding them — an image barrier
with `VK_IMAGE_LAYOUT_UNDEFINED` as the old layout (a written promise nothing else is reading it)
plus three `fill_buffer` clears. Two `encode()` calls recorded into two command buffers and
submitted to the same queue have no execution dependency in Vulkan, so encode N+1's DWT would
overwrite the bands and zero the RDO buckets while encode N's block packing still reads them.
So overlapping frames means TWO encoder handles on one device, alternated — which is fine for
every resource above, because each handle gets its own. It is NOT fine for `sequence_count`, which
also lives on `Impl` and is stamped into every block header (pyrowave_encoder.cpp `packing_push`).
Two alternating handles each count 1,2,3... independently, so the wire sees 1,1,2,2,3,3...
That is silently fatal on the decode side. `pyrowave_decoder.cpp` computes
`diff = (hdr.sequence - last_seq) & 0x7` and treats `restart = diff != 0`, so a REPEATED value
reads as "more blocks of the same frame": `clear()` never runs, `decoded_frame_for_current_sequence`
stays true, and every second frame is swallowed. The symptom is "it works, just at half rate, with
occasional mixed-frame blocks" — the kind of failure that passes a smoke test. It would hit every
client, since pf-client-core and the Apple Metal hand-port parse the same field.
WHAT IT DOES. `set_next_sequence(seq)` stores `(seq - 1) & SequenceCountMask`, because
`Impl::encode` pre-increments before stamping — the setter's contract is about the next ENCODE, not
the next store. The Rust side keeps one monotonic counter across both handles and calls this before
each encode, so the wire sequence increments by exactly 1 mod 8 regardless of which handle produced
the frame.
INERT WHEN UNUSED. Nothing calls it unless the caller does, so the single-handle paths — including
the whole Windows backend — behave exactly as before. No `.def` change is needed: the C API is
built as a static archive (crates/pyrowave-sys/CMakeLists.txt).
Upstream status: not reported. It is a hook for a use case upstream explicitly designed against
("For low-latency use cases, overlapping frames in encode is meaningless due to latency and the
encoder is so fast anyway" — pyrowave.h). That reasoning holds at 1080p60 and stops holding at 4K
or under a GPU-bound game, which is what PW5 measured.
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave.h b/crates/pyrowave-sys/vendor/pyrowave/pyrowave.h
index fc0d5834..aeb22ffc 100644
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave.h
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave.h
@@ -476,6 +476,19 @@ PYROWAVE_PUBLIC_API pyrowave_result
pyrowave_encoder_packetize(pyrowave_encoder encoder, pyrowave_packet *packets, size_t packet_boundary,
size_t *out_packets, void *bitstream, size_t size);
+// PUNKTFUNK LOCAL EXTENSION (patches/0007-encoder-sequence-override.patch), not upstream.
+// The wire sequence counter is 3 bits (PyroWave::SequenceCountMask, pyrowave_common.hpp);
+// exported here so callers mask with the codec's own value instead of a copied literal.
+#define PYROWAVE_SEQUENCE_MASK 0x7u
+
+// Overrides the 3-bit wire sequence counter the NEXT encode will stamp into every block header.
+// The counter lives on the encoder object, so a caller that alternates TWO encoders to overlap
+// frames emits 1,1,2,2,3,3... and the decoder — which restarts a frame only when the value
+// CHANGES — reads the repeat as more blocks of the same frame and silently swallows every second
+// frame. Stamp a single monotonic counter across the handles with this. Value is masked to 3 bits.
+PYROWAVE_PUBLIC_API pyrowave_result
+pyrowave_encoder_set_next_sequence(pyrowave_encoder encoder, uint32_t sequence);
+
// Implementation ensures GPU is idle before destroying objects.
PYROWAVE_PUBLIC_API void
pyrowave_encoder_destroy(pyrowave_encoder encoder);
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
index 985cd0a9..fcd7d6f8 100644
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
@@ -1196,6 +1196,17 @@ pyrowave_encoder_packetize(pyrowave_encoder encoder, pyrowave_packet *packets, s
return PYROWAVE_SUCCESS;
}
+// PUNKTFUNK LOCAL EXTENSION (patches/0007-encoder-sequence-override.patch), not upstream.
+pyrowave_result
+pyrowave_encoder_set_next_sequence(pyrowave_encoder encoder, uint32_t sequence)
+{
+ Util::set_thread_logging_interface(&null_logger);
+ if (!encoder)
+ return PYROWAVE_ERROR_GENERIC;
+ encoder->encoder.set_next_sequence(sequence);
+ return PYROWAVE_SUCCESS;
+}
+
void pyrowave_encoder_destroy(pyrowave_encoder encoder)
{
auto *device = encoder->device;
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
index ad4e9746..f23717f3 100644
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
@@ -1230,6 +1230,14 @@ bool Encoder::encode(CommandBuffer &cmd, const ViewBuffers &views, const Bitstre
return impl->encode(cmd, views, buffers);
}
+// PUNKTFUNK: see the declaration in pyrowave_encoder.hpp. Impl::encode PRE-increments
+// (sequence_count = (sequence_count + 1) & mask before stamping), so store one less than the value
+// the caller wants stamped — the setter's contract is about the next ENCODE, not the next store.
+void Encoder::set_next_sequence(uint32_t sequence)
+{
+ impl->sequence_count = (sequence - 1) & SequenceCountMask;
+}
+
const Vulkan::ImageView &Encoder::get_wavelet_band(int component, int level)
{
return *impl->component_layer_views[component][level];
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.hpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.hpp
index a65447d5..8c0ef0d0 100644
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.hpp
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.hpp
@@ -37,6 +37,12 @@ public:
bool init(Vulkan::Device *device, int width, int height, ChromaSubsampling chroma);
bool encode(Vulkan::CommandBuffer &cmd, const ViewBuffers &views, const BitstreamBuffers &buffers);
+ // PUNKTFUNK: override the 3-bit wire sequence counter the NEXT encode will stamp.
+ // The counter is per-Encoder, so alternating two encoder objects to overlap frames emits
+ // 1,1,2,2,3,3... and the decoder reads a repeated value as "more blocks of the same frame".
+ // See crates/pyrowave-sys/patches/0007-encoder-sequence-override.patch.
+ void set_next_sequence(uint32_t sequence);
+
// Debug hackery
const Vulkan::ImageView &get_wavelet_band(int component, int level);
bool encode_pre_transformed(Vulkan::CommandBuffer &cmd, const BitstreamBuffers &buffers, float quant_scale);
+6 -1
View File
@@ -46,4 +46,9 @@ upstream:
realtime) so the wavelet encode can preempt a GPU-bound game on the shared shader cores. A
create loop downgrades on NOT_PERMITTED so a refused class never regresses the encoder. Did
not overcome the graphics-vs-compute preemption wall on an RTX 4090 (kept: correct + harmless,
may help other HW/drivers).
may help other HW/drivers) — that measurement is Windows/WDDM and does not transfer to Linux.
GATED ON !inherit_info, so it is LIVE ONLY ON THE WINDOWS PATH (pyrowave_create_device_by_compat,
where Granite builds its own device). Linux passes its own create-infos and takes the inherit
branch, so this patch is inert there; the Linux request lives natively in
crates/pf-encode/src/enc/linux/pyrowave.rs (queue_priority_candidates), with the same grammar
and the same downgrade ladder. Change one, change both.
+13
View File
@@ -476,6 +476,19 @@ PYROWAVE_PUBLIC_API pyrowave_result
pyrowave_encoder_packetize(pyrowave_encoder encoder, pyrowave_packet *packets, size_t packet_boundary,
size_t *out_packets, void *bitstream, size_t size);
// PUNKTFUNK LOCAL EXTENSION (patches/0007-encoder-sequence-override.patch), not upstream.
// The wire sequence counter is 3 bits (PyroWave::SequenceCountMask, pyrowave_common.hpp);
// exported here so callers mask with the codec's own value instead of a copied literal.
#define PYROWAVE_SEQUENCE_MASK 0x7u
// Overrides the 3-bit wire sequence counter the NEXT encode will stamp into every block header.
// The counter lives on the encoder object, so a caller that alternates TWO encoders to overlap
// frames emits 1,1,2,2,3,3... and the decoder — which restarts a frame only when the value
// CHANGES — reads the repeat as more blocks of the same frame and silently swallows every second
// frame. Stamp a single monotonic counter across the handles with this. Value is masked to 3 bits.
PYROWAVE_PUBLIC_API pyrowave_result
pyrowave_encoder_set_next_sequence(pyrowave_encoder encoder, uint32_t sequence);
// Implementation ensures GPU is idle before destroying objects.
PYROWAVE_PUBLIC_API void
pyrowave_encoder_destroy(pyrowave_encoder encoder);
+11
View File
@@ -1196,6 +1196,17 @@ pyrowave_encoder_packetize(pyrowave_encoder encoder, pyrowave_packet *packets, s
return PYROWAVE_SUCCESS;
}
// PUNKTFUNK LOCAL EXTENSION (patches/0007-encoder-sequence-override.patch), not upstream.
pyrowave_result
pyrowave_encoder_set_next_sequence(pyrowave_encoder encoder, uint32_t sequence)
{
Util::set_thread_logging_interface(&null_logger);
if (!encoder)
return PYROWAVE_ERROR_GENERIC;
encoder->encoder.set_next_sequence(sequence);
return PYROWAVE_SUCCESS;
}
void pyrowave_encoder_destroy(pyrowave_encoder encoder)
{
auto *device = encoder->device;
@@ -1230,6 +1230,14 @@ bool Encoder::encode(CommandBuffer &cmd, const ViewBuffers &views, const Bitstre
return impl->encode(cmd, views, buffers);
}
// PUNKTFUNK: see the declaration in pyrowave_encoder.hpp. Impl::encode PRE-increments
// (sequence_count = (sequence_count + 1) & mask before stamping), so store one less than the value
// the caller wants stamped — the setter's contract is about the next ENCODE, not the next store.
void Encoder::set_next_sequence(uint32_t sequence)
{
impl->sequence_count = (sequence - 1) & SequenceCountMask;
}
const Vulkan::ImageView &Encoder::get_wavelet_band(int component, int level)
{
return *impl->component_layer_views[component][level];
@@ -37,6 +37,12 @@ public:
bool init(Vulkan::Device *device, int width, int height, ChromaSubsampling chroma);
bool encode(Vulkan::CommandBuffer &cmd, const ViewBuffers &views, const BitstreamBuffers &buffers);
// PUNKTFUNK: override the 3-bit wire sequence counter the NEXT encode will stamp.
// The counter is per-Encoder, so alternating two encoder objects to overlap frames emits
// 1,1,2,2,3,3... and the decoder reads a repeated value as "more blocks of the same frame".
// See crates/pyrowave-sys/patches/0007-encoder-sequence-override.patch.
void set_next_sequence(uint32_t sequence);
// Debug hackery
const Vulkan::ImageView &get_wavelet_band(int component, int level);
bool encode_pre_transformed(Vulkan::CommandBuffer &cmd, const BitstreamBuffers &buffers, float quant_scale);
+1
View File
@@ -241,6 +241,7 @@ notes for context.
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE` | `1` | Opt-in: let the host change its split-encode decision **live**, mid-session, as the pixel rate moves, instead of only choosing once at session start. Currently wired on the Linux direct-NVENC path. Only interesting alongside `PUNKTFUNK_SPLIT_ENCODE=auto` at very high pixel rates. |
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. This needs the `CAP_SYS_NICE` capability, which the packages deliberately do **not** grant: a host holding a capability cannot be identified by KWin and loses desktop streaming entirely (see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority)). The request is therefore refused on a stock install and the host says so once at session start. Set `off` if you see the desktop stutter while streaming. |
## Diagnostics
+21
View File
@@ -101,6 +101,27 @@ dropped packets.
The stats overlay shows `pyrowave` as the decode path when the mode is active.
## Checking the host is really zero-copy
On a Linux host the CPU fallback mentioned above is not an error — the session still streams, it
just pays a full-resolution copy of every frame, which shows up as a lower frame-rate ceiling and
higher CPU use rather than as anything obviously broken. The host log states which path a session
took, once, when the capture starts:
```
capture pipeline resolved: dmabuf-passthrough → pyrowave
```
`dmabuf-passthrough` is the good one: the compositor's buffer goes straight into the wavelet
encoder. `cpu` means the copy is happening, and a second line says why — a compositor that would
not allocate a dmabuf, `PUNKTFUNK_ZEROCOPY` set to `0`, or a per-frame fall-through such as the
compositor serving shared memory after agreeing to dmabufs. Each distinct reason is logged once per
session with a running count, so a persistent downgrade is easy to tell from a hiccup while the
display mode settles.
If you see `cpu` and did not ask for it, check that `PUNKTFUNK_ZEROCOPY` is unset (it defaults to
on) and read the accompanying line — it names the cause and the fix.
## Current limits
- Linux and Windows hosts; Linux clients (the GTK desktop app and the session client, including
@@ -205,6 +205,44 @@ the host.
If the host answers, it's up. If not, check `journalctl --user -u punktfunk-host` on the host — on
a Windows host, run `punktfunk-host service status` from an elevated prompt on the machine itself.
## GPU scheduling priority
The host binary carries **no Linux capability**, and on a KDE desktop it must not.
The [PyroWave](/docs/pyrowave) codec encodes on the same GPU shader cores your game is using, so a
demanding game can crowd it out and the stream's frame rate drops with it. The fix is to ask the
driver to schedule the encode ahead of the game, and every driver we tested gates that request on
`CAP_SYS_NICE`. Version 0.26.0-1 granted it for that reason — and it broke desktop streaming on
every KDE box, so 0.26.0-2 takes it away again. The other codecs use a separate video engine on the
GPU and were never affected.
The two cannot coexist. To hand the host its virtual display, KWin first has to work out *which*
program is asking, which it does by reading the connecting process's `/proc/<pid>/exe` and matching
it against the `.desktop` file the packages install. Linux refuses that read for any process holding
a capability the reader does not also hold — and KWin holds none. So a host with `CAP_SYS_NICE` is a
host KWin cannot identify, and every session fails with:
```
KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client
```
which looks exactly like a missing `.desktop` file and cannot be fixed by reinstalling. Moving the
grant into the systemd unit does not help either — same capability, same refused read.
If you are on 0.26.0-1, update. On the Bazzite image the `/usr` is read-only, so the only repair is
the next image (`sudo punktfunk-sysext update`). Elsewhere you can clear it by hand:
```sh
getcap /usr/bin/punktfunk-host # prints nothing when correct
sudo setcap -r /usr/bin/punktfunk-host # clear it, then restart the host
```
Losing the capability costs frame pacing under a GPU-bound game and nothing else — the host asks for
the elevated priority, is refused, and encodes at the normal one. `PYROWAVE_QUEUE_PRIORITY=off`
stops it asking at all. If you stream only with gamescope (Steam Gaming Mode) you can grant the
capability yourself and keep the pacing, at the cost of desktop streaming; gamescope has no such
identity check.
## Stopping and removing
After a Linux package update the user service keeps running the old binary until it's restarted, and
+67
View File
@@ -0,0 +1,67 @@
Wire-compatible with 0.25.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
Most of this release came from people reporting what 0.25.0 did on their own machines. Bluetooth headphones get their sound back on iPhone and iPad, a Steam Deck stops losing HEVC halfway through the week and stops moving the game behind the Steam menu, games running inside a gamescope session are finally told the refresh rate they are actually being given, and a Windows host recovers its display after the machine sleeps. Linux hosts also get a substantial round of streaming performance work.
## TL;DR
- **Bluetooth headphones had no game audio on iPhone and iPad.** On the default settings the phone played through its own speaker instead of your headset. Update the client.
- **On a Steam Deck, opening the Steam menu also moved the game.** The same controller drove both, so browsing the menu was steering whatever was running on the host behind it — an invisible second player. Update the client.
- **A Steam Deck could lose HEVC and drop to H.264.** The "Full chroma" switch was promising something no Deck can decode, and the cost was the entire codec rather than a little crispness. It looked random because it is a per-profile setting. Update the client.
- **Games in a gamescope session were told their display was 60 Hz.** A game capped itself to 60 while your stream ran at 120, and the in-session display settings offered nothing else. Update the host.
- **One capture hiccup could slow a Linux host down until you restarted it.** A single timeout switched that host to the slow capture path for every session afterwards — including sessions with an entirely different desktop, which had never failed at anything — and said nothing.
- **Windows hosts stopped recovering after sleep**, and **"Native resolution" streamed a smaller, blurry picture** on desktops that scale fractionally. Both fixed; update the host and the client respectively.
## Before you update
Most people need to do nothing. Check this list if any of it applies to you.
- **Linux, if the virtual Steam Deck controller never attached on 0.25.0:** this is why. 0.25.0 moved that permission onto a new `punktfunk` group, but four of the six ways of installing never created the group — so the permission step failed quietly, and `sudo usermod -aG punktfunk "$USER"` answered "group does not exist". Arch upgrades, Nix, the Bazzite system extension and the Steam Deck script are all fixed. After updating, run `sudo usermod -aG punktfunk "$USER"` and log back in. Ordinary virtual gamepads were never affected.
- **Steam Deck, if `update.sh --pull` was aborting:** it no longer does, and it clears the mess it made. The updater had been rewriting one of its own tracked files on every run, so the next update refused to start and deleting that file by hand was the only way through. Nothing needs deleting now.
- **If you turned "Full chroma" on and HEVC came and went:** leave the setting wherever you like. It no longer costs you the codec, and on hardware that cannot decode it the option simply stops being offered.
- **Steam Deck, if you have been told the client was up to date:** it may not have been. The plugin's client update check had never once succeeded since 0.24 and reported "up to date" whenever it failed. Update the plugin, then check again.
## New
- **The statistics overlay is reachable on an Apple TV.** There had been no way to it from inside a stream at all — every other client cycles it with a key combination or a three-finger tap, and a TV has neither. Press **Select + X** on a controller, or **hold Play/Pause** on the Siri Remote. A tap on Play/Pause still right-clicks as before.
- **An OLED palette.** A thirteenth colour scheme for the controller interface whose dark half is genuinely black — pixels switched off rather than very dark grey — with a faint indigo ember in one corner so the background still goes somewhere. On the Steam Deck, TV and handheld interfaces.
- **Choose when the controller interface appears.** The switch that turns it on had also been deciding that it shows up only while a controller is plugged in. Those are now separate: keep the old behaviour, or have it always on. On every settings surface.
- **Hide a single game.** Visibility used to be all-or-nothing per source — a Proton tool the filter missed, a demo, or something you would rather not have on the TV meant hiding its whole launcher. Individual titles can now be hidden, and the choice survives a re-scan.
- **A gamescope session can offer more refresh rates** in Steam's in-session display settings, and the Deck's own performance overlay can be composited into what you stream instead of staying on the local screen. Both are off unless you ask for them.
- **The patched gamescope is installable on Fedora and on Debian and Ubuntu**, not only Arch — so a virtual display in game mode no longer depends on which distribution you picked.
- **Add-ons can publish games the host has no way to name.** A library add-on can now list a title whose launch is entirely its own business — an emulator with the core and flags you configured, say — and the host asks the add-on what to run at the moment you press play. Nothing runnable is stored or sent to a client, and an emulator that has moved is picked up on the next launch instead of leaving a dead tile behind.
## Improved
- **Audio catches jitter before you can hear it.** The rule that decides how much sound to keep buffered only ever learned from failures you could already hear: it waited for three audible dropouts before deepening the buffer, and it re-tested a shallower one every few quiet seconds, paying for a wrong guess with a click, forever. It now reads the near-misses nobody hears, backs off after a probe that fails, and refills in one go rather than limping. Simulating ten minutes of a Wi-Fi power-saving pattern went from roughly two thousand audible events to nine. Update the client.
- **A gamescope session says so when its refresh rate has been lost.** If something in the session's own configuration drops the setting that carries it, the stream still runs and still looks right while the game underneath is capped to 60 — which is exactly the kind of fault that costs a week to find. It is now one line in the log.
- **The low-latency wavelet codec got a serious round of work on Linux hosts.** It encodes on the same graphics cores your game is using, so under heavy load it was being crowded out — the encode step measured around 2 ms idle and 1518 ms at 95% game load, with the stream's frame rate collapsing along with it. The encoder can now work on two frames at once, and the capture path asks your desktop for enough buffers to keep up instead of taking whatever it was handed without ever expressing a preference. The switch that asks your graphics card to put that work ahead of the game's had also never been applied on Linux, and now is — though it stays dormant on an ordinary install, because switching it on requires a system privilege that turns out to stop KDE recognising the host at all. There is more on that below.
- **Jumbo frames can now be proven rather than hoped for.** The whole path was dead code: the discovery that was supposed to find a larger packet size could never settle above the ordinary limit, so the setting that grows mid-stream was unreachable on every path that has ever existed. A network that genuinely carries big packets is now detected, and a wavelet session starts at the large size instead of never getting there — around six times fewer packets per frame. Still opt-in, on both ends.
- **The configuration documentation caught up with 0.25**, including the jumbo-frame option and several other settings that had shipped with nothing written about them.
## Fixed
- **Bluetooth headphones got no game audio on iPhone and iPad.** With the microphone on — which is the default — the app was forcing output to the phone's own speaker, and that override outranks a Bluetooth headset. Wired headphones beat it, which is why plugging in a cable made it look correct. Turning the microphone off was the accidental workaround people found. Audio now goes to whatever you have connected, and dropping a headset mid-stream no longer lands on the earpiece. Update the client.
- **On a Steam Deck, the Steam menu and the Quick Access Menu also moved the game.** Both are driven by the same physical controller the client is forwarding, so opening either one played the game behind it at the same time — a second, invisible player picking things up and walking into walls while you browsed. Steam masks a normal game here and cannot mask this one, because the client deliberately forwards your real controller rather than Steam's stand-in, which has no gyro, trackpads or paddles. The stream now stops forwarding while an overlay has the controller, and hands it back without the button that dismissed the menu firing in the game. Update the client.
- **Streaming a KDE desktop keeps working.** An interim build of this release gave the host an extra system privilege, so that the wavelet encoder could ask your graphics card for priority. On KDE the side effect was total: KDE decides whether it trusts a program by looking up which file it is running from, the system refuses that lookup for any program holding a privilege, and so KDE stopped recognising the host at all — desktop streaming failed outright, complaining about a missing screen-capture interface, and it survived a clean reinstall of both host and client. Reported from CachyOS on both NVIDIA and AMD. No Linux package grants that privilege any more, on any of the five ways we ship, and upgrading strips it from a machine that already has it. If a host somehow holds one anyway, the error now names it and gives you the command that undoes it, instead of blaming a missing desktop file.
- **One capture timeout could slow a Linux host down for good.** Two very different problems shared a single switch: a graphics driver that genuinely cannot handle what your desktop produces, and a desktop that simply happened to be restarting. The second was being treated as permanently as the first, so a single moment of bad timing put that host on the slow capture path for every session until the process was restarted — including sessions against a completely different desktop that had never failed at anything, and with nothing at all in the log. The two now have the lifetimes they should, and a capture that works credits the budget back.
- **A wavelet-codec session could quietly fall back to slow capture and log nothing whatsoever.** The warning was asking a host-wide question about a per-session decision, so a degraded host and a healthy one produced identical logs while one of them touched every pixel on the processor.
- **"Full chroma" could cost a Steam Deck its codec.** The client advertised the feature on the strength of the setting alone, with nothing checking whether the device could decode it — and no AMD hardware can. The host grants it on HEVC only, so a Deck with the switch on lost HEVC entirely and reconnected on H.264. It looked intermittent because it is a per-profile setting: a "Work" profile lost HEVC where "Game" kept it, on the same machine and the same host. The client now asks the graphics driver the same question the decoder will, so the advertisement and what actually works cannot disagree.
- **The decoder triage tool reported the opposite of the truth on a Steam Deck.** `--probe-decode` ran before the Deck's own video-decode option was applied, so it said no hardware decoding was available on a machine that had been decoding on it all along — and anyone triaging a Deck with it was led away from the answer.
- **Every gamescope session ended in a crash.** The graphics device was being torn down after the driver behind it had already been unloaded, so the process fell over on the way out — after the compositor had finished, which is why the stream looked perfectly fine and this surfaced only as crash reports piling up. Five in one ordinary ten-minute session.
- **A Windows host no longer refuses its own recovery after sleep.** When a machine woke with its virtual display detached, every repair attempt came back refused — because the host was still holding the device open, and that is precisely what blocks the reset the repair depends on. Stopping the whole service by hand was the only thing that worked. The host now lets go before it asks.
- **"Native resolution" streamed the wrong resolution on fractional scaling.** A 2560×1600 laptop panel at 150% negotiated 1706×1066 and streamed a visibly soft picture: the desktop reports its size in scaled units, and that was being read as pixels. The window itself was not high-density either, so even a correct stream was shrunk and then stretched back. Both are fixed, and "Match window" was quietly losing the same detail. Wayland desktops that scale fractionally — KDE among them.
- **Pinning a compositor silently cancelled dedicated game sessions.** A host set to launch games into their own session went on saying it did while every launch landed on the desktop instead, with nothing in the log either way — on the machine that surfaced this, for thirty days. The pin still wins, but it now says so and names itself. The same pin also made a configured session-recovery command unreachable, and a mid-startup mode change could take the whole GNOME desktop down with it, killing the game it had just launched.
- **The Steam Deck plugin's "update the client" had never once detected an update.** It asked about the app without saying which release channel, the answer was refused as ambiguous, and a failed check was displayed as "up to date" — so it went a week unnoticed while offering to update only itself. A check that cannot run now says so.
- **The Steam Deck updater kept sabotaging its own next update.** It rewrote a tracked generated file on every run, so the following update refused to start and hand-deleting that file was the only way past it. It no longer creates the mess, and it clears the one already there.
- **Add-on scanners appeared in the console sidebar they had opted out of**, their settings could not be reached from the Library screen, and syncing artwork from a local folder failed — one disagreement about how a folder path is written, and a change that had never been published. Library source settings also stopped opening at all after add-ons moved to their own address.
- **A fix to the add-on toolkit could never reach an add-on already installed**, because the copy each one runs was pinned at install time and nothing updated it.
- **A routine system update can no longer leave an Arch or CachyOS host unable to start**, and the rebuild that ships the corrected package no longer fails on a step that cannot exist during a rebuild. This is the same fault 0.25.0-2 was published for; it is now fixed in the pipeline rather than by hand.
- **A stale virtual monitor left behind by a crash is cleaned up properly**, and when the cleanup is refused it says why instead of failing silently or aiming at a device that is no longer there.
## For developers
Protocol, ABI, driver and add-on detail — the version table, the new environment variables and what did *not* move — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.26.0/CHANGELOG.md).
The short version: **nothing breaks.** The wire protocol stays at 2 and the C ABI stays at 17, so this release adds no new call, no new message and no new capability bit.
If you write a library add-on, there is one addition worth reading about: the new launch kind that lets you publish a title the host cannot name, and the add-on toolkit release that carries it.
+3
View File
@@ -0,0 +1,3 @@
• New: an OLED colour scheme whose dark half is truly black, not dark grey.
• The controller interface can now stay on screen when no controller is attached, instead of only appearing while one is plugged in.
• Sound catches jitter before you can hear it. The buffer learns from the near-misses nobody notices rather than waiting for three audible dropouts, so a busy Wi-Fi network clicks far less.
+4 -2
View File
@@ -173,8 +173,10 @@ systemctl --user enable --now punktfunk-host # the user unit is now under /u
```
The udev rule, sysctl, and systemd **user** unit all live under `/usr/lib`, so the merged sysext
exposes them. `systemd-sysext refresh` re-merges after a reboot. (One HDR nuance of the sysext
path: file capabilities don't survive it, so gamescope runs without `CAP_SYS_NICE` — everything
works, frame pacing is marginally worse than the pacman install, whose `.install` sets the cap.)
path: the image ships gamescope without `CAP_SYS_NICE`, so its frame pacing is marginally worse —
everything works. Note the host binary carries no capability on *either* path, deliberately: one
would make the host unidentifiable to KWin and break desktop streaming, see
[Running as a service](https://punktfunk.io/docs/running-as-a-service#gpu-scheduling-priority).)
## Steam Deck — the client (what the Decky plugin launches)
+11 -3
View File
@@ -14,9 +14,17 @@
# instead of 8-bit SDR (the host prefers that name on PATH and attempts HDR by default). Mirrors
# the Bazzite image's fold-in, including the honesty check: the binary is verified by executing
# its `+pfhdr` banner, never trusted by filename. Omit it and the image is exactly what it was —
# the host then stays SDR on that backend, by design. (No CAP_SYS_NICE inside the image: file
# capabilities don't survive this squashfs path — gamescope runs without it, pacing slightly
# worse, same as the Bazzite sysext.)
# the host then stays SDR on that backend, by design.
#
# No CAP_SYS_NICE inside the image, for either binary. ⚠ NOT because capabilities are lost on the
# way in — that was this comment's earlier claim and it is false: mksquashfs records
# security.capability, and the published Bazzite 0.26.0-1 image really did carry `cap_sys_nice=ep`
# on usr/bin/punktfunk-host. It is left out on purpose. A capability on the HOST binary makes it
# unidentifiable to KWin (which resolves a client's /proc/<pid>/exe to match it against a .desktop,
# and cannot read it for a capability-carrying process) and kills every Desktop-mode session — see
# packaging/bazzite/build-sysext.sh, which now hard-fails if one is staged. `punktfunk-gamescope`
# is a compositor, not a KWin client, so it is unaffected by that rule and simply runs without the
# capability here, pacing slightly worse.
set -euo pipefail
GAMESCOPE=""
+41
View File
@@ -12,9 +12,48 @@ _ensure_punktfunk_group() {
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true
}
# NO capability on the host binary — and an active removal of the one 0.26.0-1 granted.
#
# 0.26.0-1 ran `setcap cap_sys_nice=ep` here, to let the encoder open an elevated global-priority
# Vulkan queue (PyroWave shares the GPU's shader cores with the game; measured 2026-08-08 on an
# RTX 5070 Ti, the encode dispatch goes ~2 ms -> 15-18 ms at 95 % game load without it). That grant
# BROKE DESKTOP STREAMING ON EVERY KDE BOX, and it cannot be made to work — the two are mutually
# exclusive at the kernel level:
#
# KWin hands out its restricted Wayland protocols (zkde_screencast_unstable_v1, which mints our
# virtual output, and org_kde_kwin_fake_input, which injects input) only to a client it can
# IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
# .desktop's Exec= (ours is io.unom.Punktfunk.Host.desktop). The kernel refuses that readlink to
# any reader whose effective set is not a superset of the target's PERMITTED set
# (cap_ptrace_access_check), and KWin holds no capabilities. So the moment this binary carries a
# capability it becomes unidentifiable: KWin's executablePath() is empty, nothing matches, the
# globals are never advertised, and every session dies with
# "KWin does not expose zkde_screencast_unstable_v1 to this client" after 8 retries — while
# looking exactly like a missing or wrong .desktop file.
#
# Verified on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:
# no capability .............................. readlink /proc/<pid>/exe OK
# capability ................................. EPERM
# capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM <- dumpable is NOT the gate
# capability dropped + PR_SET_DUMPABLE(1) .... OK <- only a capability-free process works
#
# The third row also rules out the obvious "move it to the systemd unit": AmbientCapabilities= puts
# CAP_SYS_NICE in exactly the same permitted set and fails identically. Nothing short of not having
# the capability restores identification, so the host does not get one. The encoder already walks
# REALTIME -> HIGH -> default when the class is refused (pf-zerocopy vulkan.rs), so this costs
# pacing under a GPU-bound game and nothing else — 0.25.0's behaviour exactly.
#
# The removal below heals boxes that ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode
# and file capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade
# paths — cheap, and the failure it prevents is an 8-retry session death with a misleading message.
_revoke_sched_capability() {
setcap -r usr/bin/punktfunk-host 2>/dev/null || true
}
post_install() {
_ensure_update_group
_ensure_punktfunk_group
_revoke_sched_capability
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
@@ -73,6 +112,8 @@ post_upgrade() {
# root-only, and the virtual Steam Deck pad silently unable to attach. groupadd is idempotent, so
# this is a no-op on boxes that installed fresh.
_ensure_punktfunk_group
# Strip the cap_sys_nice 0.26.0-1 granted: it makes the host unidentifiable to KWin (see above).
_revoke_sched_capability
udevadm control --reload-rules 2>/dev/null || true
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
_warn_stale_firewall_ports
+17 -5
View File
@@ -421,11 +421,23 @@ bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
# then log out + back into the KDE Desktop session once (or reboot) so KWin restarts with the flag
```
That writes `~/.config/environment.d/10-punktfunk-kwin.conf`
(`KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`) and seeds the `kde-authorized` RemoteDesktop grant into
`~/.local/share/flatpak/db/`. Gaming Mode is unaffected. To connect from Desktop Mode, switch to it
(Steam → Power → Switch to Desktop), then connect the client; switching **mid-stream** requires a
reconnect (the host resolves the backend per connect).
That seeds the `kde-authorized` RemoteDesktop grant into `~/.local/share/flatpak/db/` — the input
half. The **video** half needs no session-wide override: the image ships
`io.unom.Punktfunk.Host.desktop`, whose `X-KDE-Wayland-Interfaces` grants the host KWin's
`zkde_screencast` protocol on a normal Plasma login (least-privilege — only this binary, only that
interface). Older versions of the script wrote a session-wide
`KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` into `~/.config/environment.d/10-punktfunk-kwin.conf`; it now
*removes* that file as an over-broad leftover. Gaming Mode is unaffected. To connect from Desktop
Mode, switch to it (Steam → Power → Switch to Desktop), then connect the client; switching
**mid-stream** requires a reconnect (the host resolves the backend per connect).
> **On 0.26.0-1 specifically, Desktop mode is broken and no amount of this setup fixes it.** That
> image shipped `cap_sys_nice=ep` on `/usr/bin/punktfunk-host`, and a capability-carrying process is
> one KWin cannot identify (it resolves `/proc/<pid>/exe` to match the `.desktop`, and the kernel
> refuses that read), so the session dies with `KWin does not expose zkde_screencast_unstable_v1 to
> this client`. A merged sysext's `/usr` is read-only, so it cannot be repaired in place — take the
> next image (`sudo punktfunk-sysext update`). `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` works around it
> meanwhile by disabling the check that needs the identification.
---
+32
View File
@@ -130,6 +130,38 @@ SYSEXT_VERSION_ID=$PF_VR
EXTENSION_RELOAD_MANAGER=1
EOF
# NO CAP_SYS_NICE in the image — and an assertion that none crept back in.
#
# 0.26.0-1 setcap'd the staged binary here for the GPU-priority lever. mksquashfs records
# security.capability, so the capability really did ship: verified by mounting the published
# punktfunk-0.26.0-1-x86-64.raw, where `getcap usr/bin/punktfunk-host` reports `cap_sys_nice=ep`.
# That broke desktop streaming on every Bazzite KDE box, field-reported as
# "KWin does not expose zkde_screencast_unstable_v1 to this client".
#
# KWin advertises its restricted protocols (zkde_screencast_unstable_v1 for the virtual output,
# org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by resolving that client's
# /proc/<pid>/exe and matching it against an installed .desktop's Exec= — the image ships
# usr/share/applications/io.unom.Punktfunk.Host.desktop for exactly that. The kernel refuses that
# readlink to any reader whose effective set is not a superset of the target's PERMITTED set
# (cap_ptrace_access_check), and KWin holds no capabilities. So a capability in this image makes the
# host unidentifiable and every Desktop-mode session dies. Full matrix, including why neither
# prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it, in
# packaging/arch/punktfunk-host.install.
#
# A merged sysext's /usr is a read-only squashfs, so this cannot be repaired on the box — the image
# is the only place it can be got right. Assert it rather than trust it: the RPM payload arrives via
# `rpm2cpio | cpio`, which carries no capabilities today, but the spec is one `%caps()` away from
# changing that and this build would silently bake it in.
if [ -f "$STAGE/usr/bin/punktfunk-host" ] && command -v getcap >/dev/null 2>&1; then
staged_caps="$(getcap "$STAGE/usr/bin/punktfunk-host" 2>/dev/null || true)"
if [ -n "$staged_caps" ]; then
echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2
echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2
echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2
exit 1
fi
fi
# SELinux labels as pseudo-xattrs (see header). matchpathcon resolves each target path against
# the targeted policy's file_contexts; <<none>> means "no specific entry" — skip those (the
# handful of matches all resolve to real contexts for our payload).
+31 -2
View File
@@ -55,7 +55,9 @@ sy5uhYGZD6lMJ4uZAQC7W81H2gHlTDTA2Nq35HKW9IOU+Ll2c9fqa7fAIKf9Bg==
usage() {
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]"
echo " punktfunk-sysext update [--from-file X.raw] | status | remove"
echo " punktfunk-sysext update [--from-file X.raw] | reapply | status | remove"
echo " reapply: re-run the host-state steps a sysext image cannot carry (groups, /etc"
echo " mirrors, udev, sysctl, modules) without reinstalling the image."
exit "${1:-0}"
}
need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; }
@@ -174,6 +176,17 @@ post_merge() {
# 'input': writing 'attach' materialises an arbitrary emulated USB device (review 2026-08-05 M-4),
# so it stays a group users join on purpose — see `ujust add-user-to-input-group` for the other one.
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
# Creating the group is necessary but NOT sufficient, and the difference is invisible until a
# stream fails: `pf-dm-helper` gates on MEMBERSHIP, so a host whose user never joined gets
# "stopping the display manager needs privilege" on every managed takeover — sddm's autologin
# Relogin loop then churns logind sessions for the whole stream. Joining stays opt-in (writing
# vhci `attach` materialises an arbitrary emulated USB device), so say so instead of doing it.
local _pf_user="${SUDO_USER:-}"
if [ -n "$_pf_user" ] && ! id -nG "$_pf_user" 2>/dev/null | tr ' ' '\n' | grep -qx punktfunk; then
echo "!! $_pf_user is not in the 'punktfunk' group — the managed gamescope takeover cannot stop"
echo "!! the display manager, and the virtual Steam Deck pad cannot attach. To opt in:"
echo "!! sudo usermod -aG punktfunk $_pf_user"
fi
modprobe vhci-hcd 2>/dev/null || :
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
# the input-group ownership even when the module's original add event predated the reloaded rule.
@@ -265,7 +278,22 @@ cmd_update() {
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
ver="${l%% *}"
if [ "$ver" = "$cur" ] && merged; then
echo "already on $cur (channel $(channel)) — nothing to do."
# NOT "nothing to do": re-run post_merge. Every step in it is idempotent, and skipping it here
# is how host state silently rots one release behind the image.
#
# The trap, field-proven on a Bazzite host that took 0.25.0 -> 0.26.0 (2026-08-09): an upgrade
# is driven by the script from the OLD image — this file is replaced by the very
# `systemd-sysext refresh` that runs mid-upgrade — so a post_merge step ADDED in the new
# release is executed by nobody. The old script doesn't have it, and the new script never gets
# a turn, because from then on `update` matches this branch and returns. The step is then
# permanently unreachable on exactly the installs that need it.
#
# That cost the `punktfunk` group (added to post_merge in 0.26.0): it was never created, so
# `pf-dm-helper` refused every caller — it gates on membership — and every managed gamescope
# takeover fell back to "stopping the display manager needs privilege", leaving sddm's autologin
# Relogin loop churning for the whole stream.
echo "already on $cur (channel $(channel)) — re-applying host state."
post_merge
return
fi
echo "updating: ${cur:-<none>} -> $ver"
@@ -311,6 +339,7 @@ cmd_remove() {
case "${1:-}" in
install) shift; cmd_install "$@" ;;
update) shift; cmd_update "$@" ;;
reapply) shift; need_root; post_merge ;;
status) shift; cmd_status ;;
remove) shift; cmd_remove ;;
*) usage ;;
+17
View File
@@ -294,6 +294,23 @@ if [ "$1" = "configure" ]; then
# primitive that must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true
# NO capability on the host binary — and an active removal of the one 0.26.0-1 granted here.
#
# 0.26.0-1 ran `setcap cap_sys_nice=ep` at this point for the GPU-priority lever, and that broke
# desktop streaming on every KDE box. KWin advertises its restricted protocols
# (zkde_screencast_unstable_v1 for the virtual output, org_kde_kwin_fake_input for input) only
# to a client it can IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it
# against an installed .desktop's Exec=. The kernel refuses that readlink to any reader whose
# effective set is not a superset of the target's PERMITTED set (cap_ptrace_access_check), and
# KWin holds no capabilities — so a capability here makes the host unidentifiable and the
# session dies with "KWin does not expose zkde_screencast_unstable_v1 to this client". Full
# matrix (and why PR_SET_DUMPABLE and AmbientCapabilities= both fail to rescue it) in
# packaging/arch/punktfunk-host.install.
#
# Costs pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a class is refused.
# postinst runs on upgrade too, so this heals boxes that installed 0.26.0-1. `setcap -r` exits
# non-zero on a file that has no capability, hence the redirect and `|| true`.
setcap -r /usr/bin/punktfunk-host 2>/dev/null || true
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
+9 -1
View File
@@ -65,6 +65,14 @@ finish-args:
- --socket=wayland # GTK4 native Wayland window (the client is Wayland-first)
- --socket=fallback-x11 # Xwayland fallback when no Wayland socket is exposed
- --share=ipc # required alongside X11 for shared-memory surfaces
# Gaming Mode's overlay signal lives on a DIFFERENT X server than ours. gamescope runs
# `--xwayland-count 2`: Steam and the GAMESCOPE_FOCUSED_APP/_GFX atoms are on the first,
# the app is handed the second, so `$DISPLAY` alone can never see them — and --socket=x11
# would not help, since flatpak binds only the ONE socket named by DISPLAY. Read-only
# access to the socket directory is what lets `overlay_focus` reach the root ctx and stop
# forwarding the pad while the Steam menu / QAM is up. gamescope's Xwayland takes
# unauthenticated local connections, so no cookie has to cross with it.
- --filesystem=/tmp/.X11-unix:ro
# --- GPU + all input devices ---
# --device=all (not just --device=dri): covers the GPU render node (VAAPI HEVC decode + GL),
# evdev joysticks, AND the hidraw CHAR devices SDL3's HIDAPI needs for DualSense touchpad/
@@ -264,7 +272,7 @@ modules:
- type: git
url: https://github.com/ValveSoftware/gamescope.git
# KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD.
commit: 8c676c399c761e4540587f61004c957993d12fea
commit: 5fb8dce4a09d0a68d097b9faf9513782106bc843
# Wrap pins as of that rev (`subprojects/*.wrap`). These are meson WRAPS, not gamescope
# submodules, so nothing else populates them and they need explicit sources.
#
+8 -2
View File
@@ -18,11 +18,17 @@ pkgname=punktfunk-gamescope
# The `.pfhdrN` suffix is the patch-set revision the binary stamps into its banner (see README.md);
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=8c676c399c761e4540587f61004c957993d12fea
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
pkgver="${_gsver}.pfhdr4"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
pkgrel=2
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
# `vulkan_get_rgb10_capture_format()` — the XBGR2101010 fallback for devices with no linear-tiled
# A2R10G10B10 storage (every NVIDIA). That fixes the NV12/P010 capture intermediate and AVIF
# screenshots, which are upstream's paths, not ours. Patch 0001 additionally now offers
# `xBGR_210LE` BEFORE `xRGB_210LE` so a third-party consumer cannot pick the one NVIDIA fills
# byte-reversed. Still no capability the host probes for, so `.pfhdrN` stays at 4.
pkgrel=3
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
url="https://git.unom.io/unom/punktfunk"
+11 -4
View File
@@ -97,14 +97,21 @@ distro's `gamescope`.
## Building
Pinned upstream: `8c676c39` (master, 2026-07-27 — tags through 3.16.25). The patches apply
cleanly to that commit; they touch `src/pipewire.cpp`, `src/steamcompmgr.cpp` and
`src/meson.build` only.
Pinned upstream: `5fb8dce4` (master, 2026-08-03 — `3.16.25-11-g5fb8dce`). The patches apply
cleanly to that commit; they touch `src/pipewire.cpp`, `src/steamcompmgr.cpp`,
`src/rendervulkan.cpp`, `src/rendervulkan.hpp` and `src/meson.build` only.
The bump from `8c676c39` is deliberate: it brings upstream's `vulkan_get_rgb10_capture_format()`
(`ff6b924`), which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to
`DRM_FORMAT_XBGR2101010` on devices that cannot do linear-tiled `A2R10G10B10` — i.e. every
NVIDIA. That covers the paths that are upstream's rather than ours: the RGB intermediate
`paint_pipewire()` acquires when the stream is YCbCr, and AVIF screenshots. Our own 10-bit RGB
node is covered by patch `0001`, which offers `xBGR_210LE` first for the same reason.
```sh
git clone https://github.com/ValveSoftware/gamescope.git
cd gamescope
git checkout 8c676c39
git checkout 5fb8dce4
git submodule update --init --recursive # or let meson fetch the subprojects
git am /path/to/punktfunk/packaging/gamescope/patches/*.patch
@@ -25,7 +25,7 @@ set -euo pipefail
# The pinned upstream. Bump together with the patches (they are `git am`-able and rebase cheaply —
# two files, mirroring code that already exists in-tree; see README.md).
GAMESCOPE_REV="8c676c399c761e4540587f61004c957993d12fea"
GAMESCOPE_REV="5fb8dce4a09d0a68d097b9faf9513782106bc843"
GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git"
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1
@@ -32,12 +32,12 @@ follow what the app happens to render.
Works on the headless backend as well as a real connector: no HDR display is
involved anywhere in the LUT set.
---
src/pipewire.cpp | 114 +++++++++++++++++++++++++++++++++----------
src/pipewire.cpp | 123 ++++++++++++++++++++++++++++++++++---------
src/steamcompmgr.cpp | 21 ++++++--
2 files changed, 106 insertions(+), 29 deletions(-)
2 files changed, 115 insertions(+), 29 deletions(-)
diff --git a/src/pipewire.cpp b/src/pipewire.cpp
index 76b3ea8..6b56b01 100644
index 76b3ea8..c84b19c 100644
--- a/src/pipewire.cpp
+++ b/src/pipewire.cpp
@@ -18,6 +18,40 @@
@@ -159,7 +159,7 @@ index 76b3ea8..6b56b01 100644
params.push_back((const struct spa_pod *) spa_pod_builder_pop(builder, &obj_frame));
// for (auto& param : params)
@@ -166,6 +209,14 @@ static std::vector<const struct spa_pod *> build_format_params(struct spa_pod_bu
@@ -166,6 +209,23 @@ static std::vector<const struct spa_pod *> build_format_params(struct spa_pod_bu
build_format_params(builder, SPA_VIDEO_FORMAT_BGRx, params);
build_format_params(builder, SPA_VIDEO_FORMAT_NV12, params);
@@ -168,13 +168,22 @@ index 76b3ea8..6b56b01 100644
+ // negotiates today's 8-bit stream keeps negotiating it bit-for-bit. Only a consumer that
+ // asks for a 10-bit format by name — and accepts the MANDATORY BT.2020 + PQ colorimetry
+ // above — ever reaches these.
+ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params);
+ //
+ // xBGR_210LE FIRST, and that order is correctness, not style. A consumer takes the first pod
+ // it can use, and xBGR is the only one every vendor fills correctly: capture textures are
+ // mappable, hence linear-tiled, and linear STORAGE for A2R10G10B10 is an optional Vulkan
+ // feature NVIDIA does not implement — there the composite's `imageStore` lands in XBGR order,
+ // so a consumer that took xRGB_210LE gets a buffer LABELLED XRGB2101010 and FILLED as XBGR,
+ // i.e. red and blue swapped, with every format mapping on both ends individually correct.
+ // A2B10G10R10 is the universally supported packed-10 format, so leading with it costs nothing
+ // on AMD or Intel. xRGB_210LE stays as the second pod for a consumer that only speaks it.
+ build_format_params(builder, SPA_VIDEO_FORMAT_xBGR_210LE, params);
+ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params);
+#endif
return params;
}
@@ -288,7 +339,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
@@ -288,7 +348,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
if (s_nCaptureWidth != state->video_info.size.width || s_nCaptureHeight != state->video_info.size.height) {
pwr_log.debugf("renegotiating stream params (size: %dx%d)", s_nCaptureWidth, s_nCaptureHeight);
@@ -183,7 +192,7 @@ index 76b3ea8..6b56b01 100644
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
int ret = pw_stream_update_params(state->stream, format_params.data(), format_params.size());
@@ -412,6 +463,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp
@@ -412,6 +472,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp
state->video_info.size.width, state->video_info.size.height,
s_nRequestedWidth, s_nRequestedHeight,
state->video_info.format, state->shm_stride, shm_size, state->dmabuf);
@@ -196,7 +205,7 @@ index 76b3ea8..6b56b01 100644
}
static void randname(char *buf)
@@ -450,6 +507,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format)
@@ -450,6 +516,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format)
switch (spa_format)
{
case SPA_VIDEO_FORMAT_NV12: return DRM_FORMAT_NV12;
@@ -208,7 +217,7 @@ index 76b3ea8..6b56b01 100644
default:
case SPA_VIDEO_FORMAT_BGR: return DRM_FORMAT_XRGB8888;
}
@@ -715,7 +777,7 @@ bool init_pipewire(void)
@@ -715,7 +786,7 @@ bool init_pipewire(void)
s_nOutputHeight = g_nOutputHeight;
calculate_capture_size();
@@ -218,7 +227,7 @@ index 76b3ea8..6b56b01 100644
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index ff9ae1f..01b2abf 100644
index ecb3808..83c751b 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2335,17 +2335,32 @@ static void paint_pipewire()
@@ -58,7 +58,7 @@ index 9fc54f0..1eb35b3 100644
" If this is not set, and there is a HDR client, it will be tonemapped SDR.\n"
" --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n"
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 01b2abf..5c65420 100644
index 83c751b..a8a816f 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2316,6 +2316,13 @@ static void update_touch_scaling( const struct FrameInfo_t *frameInfo )
@@ -146,9 +146,9 @@ index 01b2abf..5c65420 100644
+ }
+
gamescope::Rc<CVulkanTexture> pRGBTexture = s_pPipewireBuffer->texture->isYcbcr()
? vulkan_acquire_capture_texture( uWidth, uHeight, false, DRM_FORMAT_XRGB2101010 )
? vulkan_acquire_capture_texture( uWidth, uHeight, false, vulkan_get_rgb10_capture_format() )
: gamescope::Rc<CVulkanTexture>{ s_pPipewireBuffer->texture };
@@ -8397,6 +8452,12 @@ steamcompmgr_main(int argc, char **argv)
@@ -8404,6 +8459,12 @@ steamcompmgr_main(int argc, char **argv)
g_FadeOutDuration = atoi(optarg);
} else if (strcmp(opt_name, "force-windows-fullscreen") == 0) {
bForceWindowsFullscreen = true;
@@ -62,7 +62,7 @@ index 2c4fb50..b406caf 100644
" If this is not set, and there is a HDR client, it will be tonemapped SDR.\n"
" --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n"
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 5c65420..0d293c6 100644
index a8a816f..64e1a8c 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2323,6 +2323,12 @@ gamescope::ConVar<bool> cv_pipewire_composite_cursor{ "pipewire_composite_cursor
@@ -127,7 +127,7 @@ index 5c65420..0d293c6 100644
// The cursor, when this stream was asked for it. gamescope keeps the pointer OUT of the
// PipeWire node by default — it lives on a hardware plane for scanout, and a remote-play
// consumer that draws its own would end up with two — so a consumer that has no cursor of
@@ -8457,6 +8490,12 @@ steamcompmgr_main(int argc, char **argv)
@@ -8464,6 +8497,12 @@ steamcompmgr_main(int argc, char **argv)
cv_pipewire_composite_cursor = true;
#else
fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" );
@@ -1,4 +1,4 @@
From 509fb928c7dc3307372629ca692f4c895c4fe984 Mon Sep 17 00:00:00 2001
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
Date: Sat, 8 Aug 2026 19:17:25 +0200
Subject: [PATCH] punktfunk: never destroy the Vulkan device or output at exit
@@ -49,7 +49,7 @@ Both are needed: pinning only the device relocated the fault into
2 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp
index 5c2dd11..8cd5ca2 100644
index 3f7ea90..0382a4b 100644
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -118,7 +118,37 @@ static VkResult vulkan_load_module()
@@ -102,10 +102,10 @@ index 5c2dd11..8cd5ca2 100644
static bool allDMABUFsEqual( wlr_dmabuf_attributes *pDMA )
{
diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp
index b6749d4..a9335c4 100644
index c49b95f..ee98b38 100644
--- a/src/rendervulkan.hpp
+++ b/src/rendervulkan.hpp
@@ -564,7 +564,7 @@ enum ShaderType {
@@ -565,7 +565,7 @@ enum ShaderType {
SHADER_TYPE_COUNT
};
@@ -114,12 +114,9 @@ index b6749d4..a9335c4 100644
struct SamplerState
{
@@ -1007,4 +1007,4 @@ void vulkan_wait_idle();
// Whether the driver implements VK_EXT_physical_device_drm
bool vulkan_has_drm_props();
@@ -1010,4 +1010,4 @@ bool vulkan_has_drm_props();
bool vulkan_has_drm_modifiers_for_features(VkFormat format, VkFormatFeatureFlags features);
-extern CVulkanDevice g_device;
+extern CVulkanDevice &g_device;
--
2.55.0
+8
View File
@@ -20,6 +20,14 @@
# been stable across the 3.16 series (`src/pipewire.cpp`'s format builders, `paint_pipewire()` in
# `src/steamcompmgr.cpp`), so this normally just works — and when it does not, the build fails
# loudly at `patchPhase` rather than producing a gamescope that quietly cannot do HDR.
#
# ⚠️ Kept deliberately free of any dependency on the pinned rev. The pin moved past upstream's
# `vulkan_get_rgb10_capture_format()` (`ff6b924`, after 3.16.25) to fix red/blue on NVIDIA, and it
# would have been natural to have patch `0001` call it — that is what the host-side note in
# `crates/pf-capture/src/linux/pw_pods.rs` proposes. It does NOT, precisely so this derivation
# keeps building against a nixpkgs that still pins 3.16.25, where that symbol does not exist and
# the failure would be an opaque C++ error rather than a patch conflict. Patch `0001` gets the
# same outcome version-independently by offering `xBGR_210LE` ahead of `xRGB_210LE`.
{
lib,
gamescope,
+24
View File
@@ -356,6 +356,26 @@ in
allowedUDPPorts = nativeUDP ++ optionals cfg.host.gamestream gamestreamUDP;
};
# NO CAP_SYS_NICE wrapper here — deliberately. 0.26.0-1 gave the host a
# `security.wrappers.punktfunk-host` carrying `cap_sys_nice=ep` for the GPU-priority lever,
# and that broke desktop streaming on every KDE box.
#
# KWin advertises its restricted Wayland protocols (zkde_screencast_unstable_v1 for the
# virtual output, org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by
# resolving that client's /proc/<pid>/exe and matching it against an installed .desktop's
# Exec= (packages.nix substitutes ours to the store path). The kernel refuses that readlink to
# any reader whose effective set is not a superset of the target's PERMITTED set
# (cap_ptrace_access_check), and KWin holds no capabilities.
#
# A NixOS wrapper does not dodge this. It raises the capability into its AMBIENT set before
# exec'ing the store binary, precisely so the capability survives — which lands CAP_SYS_NICE
# in the exec'd process's permitted set and fails the readlink identically. Measured: an
# ambient-only grant (dumpable=1, CapPrm set) is refused exactly like a file capability. See
# packaging/arch/punktfunk-host.install for the full matrix.
#
# Costs pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a priority class is
# refused, and pf-frame's thread nice is a best-effort no-op — 0.25.0's behaviour exactly.
systemd.user.services.punktfunk-host = {
description = "punktfunk GameStream + punktfunk/1 streaming host";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
@@ -374,6 +394,10 @@ in
# PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins.
++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage;
serviceConfig = {
# The store path DIRECTLY — not a capability wrapper. /proc/<pid>/exe then resolves to the
# very path packages.nix substituted into io.unom.Punktfunk.Host.desktop's Exec=, which is
# what lets KWin identify the host and grant it the screencast/fake-input protocols (see
# the note above the firewall block).
ExecStart =
"${cfg.host.package}/bin/punktfunk-host serve" + optionalString cfg.host.gamestream " --gamestream";
Restart = "on-failure";
+21
View File
@@ -477,6 +477,27 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%files
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%doc README.md packaging/README.md
# NO %caps() on the host binary. 0.26.0-1 declared `%caps(cap_sys_nice=ep)` here for the
# GPU-priority lever and that BROKE DESKTOP STREAMING ON EVERY KDE BOX — on Fedora and, via
# rpm-ostree layering, on Bazzite, where it was field-reported as
# "KWin does not expose zkde_screencast_unstable_v1 to this client".
#
# KWin hands out its restricted Wayland protocols (zkde_screencast_unstable_v1 for the virtual
# output, org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by resolving that
# client's /proc/<pid>/exe and matching it against an installed .desktop's Exec= — ours is the
# io.unom.Punktfunk.Host.desktop installed below. The kernel refuses that readlink to any reader
# whose effective set is not a superset of the target's PERMITTED set (cap_ptrace_access_check),
# and KWin holds no capabilities. So a capability here makes the host unidentifiable: KWin's
# executablePath() is empty, no .desktop can match, and the globals are never advertised.
# Measured on kernel 7.1.6 — see packaging/arch/punktfunk-host.install for the full matrix, incl.
# why neither prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it.
#
# The cost of not having it is pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a
# priority class is refused, and pf-frame's thread nice is a best-effort no-op. That is exactly
# how 0.25.0 behaved, which is the behaviour that worked.
#
# rpm applies file capabilities from package metadata, so a package built WITHOUT %caps() installs
# the binary with none and an upgrade from 0.26.0-1 clears it — no scriptlet needed.
%{_bindir}/punktfunk-host
%{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules
+19 -3
View File
@@ -17,8 +17,24 @@
# can't supply a passphrase non-interactively here.
#
# Usage (in rpm.yml, after build-rpm.sh): RPM_GPG_PRIVATE_KEY=... bash packaging/rpm/sign-rpms.sh
#
# Takes the RPMs to sign as arguments, defaulting to `dist/*.rpm`. The argument form exists because
# punktfunk-gamescope is built LATER in the job than the host RPMs — it is a ~10-minute C++ build
# behind its own cache — so it misses the main signing pass entirely and needs a second one just
# for itself. It shipped unsigned into a `gpgcheck=1` repo that way, which made
# `dnf install punktfunk-gamescope` fail with "The package is not signed" for every Fedora/Nobara
# user: the package was in the channel and still uninstallable.
set -euo pipefail
# Default target, and a real glob rather than a literal when nothing matched.
if [ "$#" -gt 0 ]; then
RPMS=("$@")
else
shopt -s nullglob
RPMS=(dist/*.rpm)
fi
[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs to sign" >&2; exit 1; }
if [ -z "${RPM_GPG_PRIVATE_KEY:-}" ]; then
case "${GITHUB_REF:-}" in
refs/tags/v*)
@@ -47,11 +63,11 @@ KEYID="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/{print $5; exit}
# correctly. (A custom __gpg_sign_cmd passed via --define reached gpg with those filename macros
# UNEXPANDED -> "No such file or directory".) Just point rpm at our key; the GNUPGHOME above
# (passphrase-less key + loopback) lets gpg sign headless.
for rpm in dist/*.rpm; do
for rpm in "${RPMS[@]}"; do
rpmsign --define "_gpg_name $KEYID" --addsign "$rpm"
done
# Verify locally so a bad signature fails the build before publishing.
rpm --import <(gpg --export --armor "$KEYID")
rpmkeys --checksig dist/*.rpm
echo "signed + verified $(find dist -name '*.rpm' | wc -l) RPM(s) with key $KEYID"
rpmkeys --checksig "${RPMS[@]}"
echo "signed + verified ${#RPMS[@]} RPM(s) with key $KEYID"
+18
View File
@@ -344,6 +344,24 @@ if [ "$SUDO_OK" = 1 ]; then
warn "(everything else works; the pad arrives as a generic Xbox 360 controller). By hand:"
warn " sudo groupadd --system punktfunk; sudo usermod -aG punktfunk $USER"
fi
# NO CAP_SYS_NICE on the host binary — and a removal of the one 0.26.0-1 granted here.
#
# 0.26.0-1 setcap'd this binary for the GPU-priority lever, which on a Van Gogh APU is a real
# win. It also broke Desktop-mode streaming outright. Just above, this installer writes
# ~/.local/share/applications/io.unom.Punktfunk.Host.desktop with Exec=$BIN so KWin will grant
# the host its restricted protocols — and KWin makes that grant by resolving the client's
# /proc/<pid>/exe and matching it against that Exec=. The kernel refuses that readlink to any
# reader whose effective set is not a superset of the target's PERMITTED set
# (cap_ptrace_access_check), and KWin holds no capabilities. So the capability silently voided
# the .desktop written six lines earlier, and every Desktop-mode session died with
# "KWin does not expose zkde_screencast_unstable_v1 to this client". Gaming Mode (gamescope) is
# unaffected — it has no such gate. Full matrix in packaging/arch/punktfunk-host.install.
#
# Costs pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a class is refused.
# `setcap -r` exits non-zero on a file that has no capability, hence the redirect.
if [ -x "$BIN" ]; then
sudo setcap -r "$BIN" 2>/dev/null || true
fi
# SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified
# live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently
# degrade to Xbox 360, buffers back to 208 KB). The sanctioned fix is a preserve drop-in in
+278
View File
@@ -9,6 +9,7 @@
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use punktfunk_core::crypto::SessionKey;
use punktfunk_core::error::PunktfunkError;
use punktfunk_core::packet::{FLAG_PIC, FLAG_SOF, USER_FLAG_CHUNK_ALIGNED};
use punktfunk_core::session::Session;
use punktfunk_core::transport::loopback_pair;
@@ -83,6 +84,281 @@ fn run(
(completed, frames)
}
// ---------------------------------------------------------------------------
// PW6: partial delivery under loss — streamed AU vs whole AU
// ---------------------------------------------------------------------------
//
// The question this answers (wave-2 plan PW6, security-review finding 10): a PyroWave client
// enables `set_deliver_partial_frames` unconditionally, so a chunk-aligned AU that loses shards
// is still handed up as blocks-with-holes — one frame of localized blur instead of a freeze. But
// a STREAMED frame is excluded from that when it is UNPINNED: its size lives only on the FINAL
// block's headers (`frame_bytes` is the 0 sentinel until then), and `advance_window` refuses to
// deliver a partial it cannot truncate. So where the whole-AU path delivers blur, a streamed
// frame whose final block is entirely lost delivers NOTHING.
//
// Three legs, because a bare 2 % sweep cannot see the effect (see `partial_sweep`'s note):
// 1. `final_block_probe` — DETERMINISTIC: drop exactly the frame's last block in both shapes.
// Proves the mechanism exists (or does not) without any statistics.
// 2. `partial_sweep` — RANDOM Bernoulli loss, both shapes, same seed: the delivery rates.
// 3. the stress rows — the same sweep at higher loss, where the gap becomes measurable.
/// The realistic PyroWave wire geometry: 1500-MTU IPv4 shards, 200 data shards per FEC block,
/// and **FEC pinned OFF** (the Phase-4 recipe — parity would mask exactly the loss under study).
fn partial_config(role: Role) -> Config {
Config {
role,
phase: ProtocolPhase::P2Punktfunk,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 0,
max_data_per_block: 200,
},
shard_payload: 1408,
max_frame_bytes: 8 * 1024 * 1024,
encrypt: false,
key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4],
loopback_drop_period: 0, // loss is injected here, per packet, so it can be random
}
}
/// Reproducible xorshift64* — the harness must be re-runnable to the same numbers, and
/// `loopback_drop_period`'s deterministic 1-in-N cannot model independent per-packet loss
/// (it would systematically hit or miss the final block, which is the whole question).
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Rng {
Rng(seed | 1)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// True with probability `pct`/10000 (basis points, so 2 % = 200).
fn hits(&mut self, bp: u32) -> bool {
(self.next_u64() % 10_000) < bp as u64
}
fn range(&mut self, lo: usize, hi: usize) -> usize {
lo + (self.next_u64() as usize) % (hi - lo).max(1)
}
}
/// How each source frame ended up at the client.
#[derive(Default, Clone, Copy)]
struct Outcome {
complete: usize,
partial: usize,
nothing: usize,
}
impl Outcome {
fn total(&self) -> usize {
self.complete + self.partial + self.nothing
}
/// Partial deliveries as a percentage of frames that did NOT arrive complete — "when the
/// frame was damaged, how often did the user still get a picture?". That ratio, not the raw
/// count, is what the two wire shapes must be compared on: they damage different numbers of
/// frames at the same packet-loss rate (streamed adds no parity but does add a final block
/// whose loss is fatal, and the shapes' block splits differ slightly).
fn rescue_pct(&self) -> f64 {
let damaged = self.partial + self.nothing;
if damaged == 0 {
return 100.0;
}
100.0 * self.partial as f64 / damaged as f64
}
}
/// How many packets the frame's LAST FEC block occupies, and how many packets the whole AU
/// should seal into. With FEC pinned off there is no parity, so `packetize_each` emits exactly
/// one packet per data shard in block order — the final block is therefore the last `final_k`
/// packets of the batch. Returned together so the caller can ASSERT the packet count and fail
/// loudly if that emission shape ever changes, rather than silently probing the wrong packets.
fn final_block_span(len: usize, shard: usize, per_block: usize) -> (usize, usize) {
let shards = len.div_ceil(shard);
let blocks = shards.div_ceil(per_block);
let final_k = shards - (blocks - 1) * per_block;
(shards, final_k)
}
/// Drive `frames` AUs through a host→client pair and classify each one. `loss_bp` is the
/// per-packet loss probability in basis points; `final_only` instead forces the frame's LAST
/// block to be dropped wholesale, and nothing else (the deterministic mechanism probe).
///
/// `sizes` gives each frame's AU length. Real PyroWave AUs vary frame to frame under rate
/// control, and the FINAL block's size is what bounds this trap's exposure, so the sweep varies
/// the length across the whole 1..=200-shard range of final-block sizes rather than pinning one.
fn run_partial(
streamed: bool,
sizes: &[usize],
loss_bp: u32,
final_only: bool,
seed: u64,
) -> Outcome {
// Flush frames: `advance_window` only ages a frame out once something NEWER exists and the
// capture-time fuse has passed (PARTIAL_WINDOW_NS = 30 ms vs a 16.67 ms frame period), so
// the tail of the run needs successors before its verdicts land.
const FLUSH: usize = 8;
const FRAME_NS: u64 = 16_666_667;
let (h, c) = loopback_pair(0, 0);
let mut host = Session::new(partial_config(Role::Host), Box::new(h)).unwrap();
let mut client = Session::new(partial_config(Role::Client), Box::new(c)).unwrap();
// The PyroWave client's real setting (`client/pump/handshake.rs` turns this on for every
// CODEC_PYROWAVE session).
client.set_deliver_partial_frames(true);
let mut rng = Rng::new(seed);
// frame_index -> saw a complete delivery
let mut delivered: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
let flags = FLAG_PIC as u32 | FLAG_SOF as u32 | USER_FLAG_CHUNK_ALIGNED;
let n = sizes.len();
for f in 0..(n + FLUSH) {
let len = sizes[f.min(n - 1)];
// Busy, frame-varying content — never a flat fill (a constant buffer would still
// reassemble byte-identically, but it makes every debug dump look alike).
let data: Vec<u8> = (0..len).map(|b| (b.wrapping_mul(31) ^ f) as u8).collect();
let pts = f as u64 * FRAME_NS;
let fi = f as u32;
// Send one sealed batch. `kill_from` is the index at/after which every packet is dropped
// outright (the deterministic final-block probe); otherwise each packet is lost
// independently at `loss_bp`.
let mut send = |host: &mut Session, wires: Vec<Vec<u8>>, kill_from: usize| {
let refs: Vec<&[u8]> = wires
.iter()
.enumerate()
.filter(|(i, _)| *i < kill_from && !(loss_bp > 0 && rng.hits(loss_bp)))
.map(|(_, w)| w.as_slice())
.collect();
if !refs.is_empty() {
host.send_sealed(&refs).unwrap();
}
drop(refs);
host.reclaim_wires(wires);
};
if streamed {
let mut au = host.begin_streamed_frame_at(pts, flags, fi).unwrap();
// Cut at the encoder's chunk granularity (the PW6 `AuChunker` default: 256 KiB
// rounded down to whole 1408-byte windows = 186 windows).
for chunk in data.chunks(186 * 1408) {
let wires = host.seal_streamed_chunk(&mut au, chunk, false).unwrap();
send(&mut host, wires, usize::MAX);
}
let wires = host.seal_streamed_finish(au).unwrap();
// The finish batch IS the final block — the only one carrying the real totals.
send(&mut host, wires, if final_only { 0 } else { usize::MAX });
} else {
let wires = host.seal_frame_at(&data, pts, flags, fi).unwrap();
let (shards, final_k) = final_block_span(len, 1408, 200);
assert_eq!(
wires.len(),
shards,
"FEC is off, so the whole-AU batch must be exactly one packet per data shard — \
the final-block probe's index rule depends on it"
);
let kill_from = if final_only {
wires.len() - final_k
} else {
usize::MAX
};
send(&mut host, wires, kill_from);
}
loop {
match client.poll_frame() {
Ok(got) => {
let e = delivered.entry(got.frame_index).or_insert(false);
*e |= got.complete;
}
Err(PunktfunkError::NoFrame) => break,
Err(e) => panic!("unexpected error: {e}"),
}
}
}
let mut out = Outcome::default();
for f in 0..n {
match delivered.get(&(f as u32)) {
Some(true) => out.complete += 1,
Some(false) => out.partial += 1,
None => out.nothing += 1,
}
}
out
}
/// AU lengths spanning the full range of FINAL-block sizes (1..=200 shards on top of two full
/// 200-shard blocks) — 564 KB…845 KB, i.e. the 400 Mb/s-at-60fps operating point.
fn varied_sizes(count: usize, seed: u64) -> Vec<usize> {
let mut rng = Rng::new(seed);
(0..count)
.map(|_| rng.range(401 * 1408, 600 * 1408 + 1))
.collect()
}
fn partial_section() {
let frames: usize = std::env::var("PW6_FRAMES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2000);
println!("\n\npunktfunk PW6 — partial delivery under loss: STREAMED vs WHOLE AU");
println!("(chunk-aligned AUs, deliver_partial ON, FEC pinned OFF, shard 1408, 200/block)\n");
// ---- Leg 1: the mechanism, deterministically -------------------------------------------
println!("Leg 1 — DETERMINISTIC probe: the frame's LAST block is lost, nothing else.");
let sizes = varied_sizes(200, 0xC0FFEE);
for (label, streamed) in [("whole-AU", false), ("streamed", true)] {
let o = run_partial(streamed, &sizes, 0, true, 1);
println!(
" {label:>8}: complete {:>4} partial {:>4} NOTHING {:>4} (of {})",
o.complete,
o.partial,
o.nothing,
o.total()
);
}
println!(
" → if the streamed row shows NOTHING where whole-AU shows partial, the trap is real."
);
// ---- Leg 2 + 3: rates under random loss -------------------------------------------------
println!("\nLeg 2/3 — RANDOM per-packet loss, same seed and same AU sizes for both shapes.");
println!(" 'rescue' = partials / (partials + nothing): of the frames that arrived DAMAGED,");
println!(" how many still reached the decoder as blur instead of vanishing.\n");
println!(
"{:>7} {:>9} {:>26} {:>26}",
"loss", "shape", "complete / partial / none", "rescue of damaged"
);
println!("{}", "-".repeat(78));
let sizes = varied_sizes(frames, 0xBEEF);
for &bp in &[200u32, 1000, 3000, 5000] {
for (label, streamed) in [("whole-AU", false), ("streamed", true)] {
let o = run_partial(streamed, &sizes, bp, false, 0x5EED);
println!(
"{:>6.1}% {label:>9} {:>8} / {:>7} / {:>5} {:>24.2}%",
bp as f64 / 100.0,
o.complete,
o.partial,
o.nothing,
o.rescue_pct()
);
}
}
println!(
"\nNote: at 2 % the streamed penalty is bounded by P(final block fully lost) =\n\
E[0.02^k] over final-block sizes k ~1e-4 so the 2 % row is EXPECTED to tie.\n\
The higher-loss rows are what make the gap (if any) visible; Leg 1 proves the mechanism."
);
}
fn main() {
let frames = 50;
let frame_len = 100_000; // ~98 shards across 2 FEC blocks
@@ -111,4 +387,6 @@ fn main() {
);
}
println!("\nNote: recovery drops off once per-block loss exceeds the 25% recovery budget.");
partial_section();
}
+2 -2
View File
@@ -10,7 +10,7 @@
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16",
"@unom/ui": "^0.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
@@ -982,7 +982,7 @@
"@unom/style": ["@unom/style@0.4.4", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fstyle/-/0.4.4/style-0.4.4.tgz", { "peerDependencies": { "motion": "^12" } }, "sha512-M45nihK+LGyxwy2mmHYRKggaocTt+EKNVFNaMpTvTaIUpozi7bmKIkbM2/enMYS0/UYTaZrBSZs/a0nPXqkAKw=="],
"@unom/ui": ["@unom/ui@0.8.16", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.8.16/ui-0.8.16.tgz", { "dependencies": { "@tanstack/react-router": "^1.170.11", "@tsdown/css": "^0.22.1", "clsx": "^2.1.1", "howler": "^2.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@payloadcms/richtext-lexical": "^3.85.0", "@tanstack/react-virtual": "^3.14.2", "@unom/style": "^0.4.4", "class-variance-authority": "^0.7.1", "embla-carousel-react": "^8.6.0", "lucide-react": "^1.17.0", "motion": "^12.40.0", "radix-ui": "^1.4.3", "react": "^19.2.7", "react-dom": "^19.2.7", "typescript": "^6.0.3", "zod": "^4.4.3" } }, "sha512-ZH7VOyaRDT81VY8nm1hmx8a4CeObykP8egZbnV4Nju6kE8rQ28wdpBo0X+Zsdu8WvTEmHZGwPR53NHWJULyciw=="],
"@unom/ui": ["@unom/ui@0.9.2", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.9.2/ui-0.9.2.tgz", { "dependencies": { "@tanstack/react-router": "^1.170.11", "@tsdown/css": "^0.22.1", "clsx": "^2.1.1", "howler": "^2.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@payloadcms/richtext-lexical": "^3.85.0", "@tanstack/react-virtual": "^3.14.2", "@unom/style": "^0.4.4", "class-variance-authority": "^0.7.1", "embla-carousel-react": "^8.6.0", "lucide-react": "^1.17.0", "motion": "^12.40.0", "radix-ui": "^1.4.3", "react": "^19.2.7", "react-dom": "^19.2.7", "typescript": "^6.0.3", "zod": "^4.4.3" } }, "sha512-UbpNQEu6zRNMkAxsINRj6HvT53ty7+/QxN3TZv6WgQd/rLiLe767mjz4Zh765ASc3NY7EguHqVNuWX6L7V9TLA=="],
"@vercel/nft": ["@vercel/nft@1.10.2", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw=="],
+4 -4
View File
@@ -1902,10 +1902,10 @@
hash = "sha512-M45nihK+LGyxwy2mmHYRKggaocTt+EKNVFNaMpTvTaIUpozi7bmKIkbM2/enMYS0/UYTaZrBSZs/a0nPXqkAKw==";
name = "style-0.4.4.tgz";
};
"@unom/ui@0.8.16" = fetchurl {
url = "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.8.16/ui-0.8.16.tgz";
hash = "sha512-ZH7VOyaRDT81VY8nm1hmx8a4CeObykP8egZbnV4Nju6kE8rQ28wdpBo0X+Zsdu8WvTEmHZGwPR53NHWJULyciw==";
name = "ui-0.8.16.tgz";
"@unom/ui@0.9.2" = fetchurl {
url = "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.9.2/ui-0.9.2.tgz";
hash = "sha512-UbpNQEu6zRNMkAxsINRj6HvT53ty7+/QxN3TZv6WgQd/rLiLe767mjz4Zh765ASc3NY7EguHqVNuWX6L7V9TLA==";
name = "ui-0.9.2.tgz";
};
"@vercel/nft@1.10.2" = fetchurl {
url = "https://registry.npmjs.org/@vercel/nft/-/nft-1.10.2.tgz";
+73 -73
View File
@@ -1,75 +1,75 @@
{
"name": "punktfunk-web",
"private": true,
"type": "module",
"description": "punktfunk management console \u2014 TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
"scripts": {
"prepare": "bun run codegen",
"postinstall": "bun2nix -o bun.nix",
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
"predev": "orval --config orval.config.ts",
"dev": "vite dev --port 47992",
"prebuild": "orval --config orval.config.ts",
"build": "vite build",
"postbuild": "node tools/check-i18n.mjs",
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
"test": "bun test server/",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs",
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.0",
"tailwind-merge": "^2.6.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@inlang/paraglide-js": "^2.22.0",
"@inlang/plugin-message-format": "^4.4.0",
"@storybook/react-vite": "^10.5.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"bun2nix": "2.1.2",
"orval": "^8.22.0",
"playwright": "^1.61.1",
"storybook": "^10.5.3",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"tar": "^7.5.21",
"dompurify": "^3.4.12",
"linkify-it": "^5.0.2",
"sharp": "^0.35.3",
"fast-uri": "^3.1.5",
"immutable": "^4.3.9",
"undici": "^7.29.0",
"postcss": "^8.5.25",
"js-yaml": "^4.3.0",
"brace-expansion": "^5.0.9"
}
"name": "punktfunk-web",
"private": true,
"type": "module",
"description": "punktfunk management console TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
"scripts": {
"prepare": "bun run codegen",
"postinstall": "bun2nix -o bun.nix",
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
"predev": "orval --config orval.config.ts",
"dev": "vite dev --port 47992",
"prebuild": "orval --config orval.config.ts",
"build": "vite build",
"postbuild": "node tools/check-i18n.mjs",
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
"test": "bun test server/",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs",
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.0",
"tailwind-merge": "^2.6.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@inlang/paraglide-js": "^2.22.0",
"@inlang/plugin-message-format": "^4.4.0",
"@storybook/react-vite": "^10.5.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"bun2nix": "2.1.2",
"orval": "^8.22.0",
"playwright": "^1.61.1",
"storybook": "^10.5.3",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"tar": "^7.5.21",
"dompurify": "^3.4.12",
"linkify-it": "^5.0.2",
"sharp": "^0.35.3",
"fast-uri": "^3.1.5",
"immutable": "^4.3.9",
"undici": "^7.29.0",
"postcss": "^8.5.25",
"js-yaml": "^4.3.0",
"brace-expansion": "^5.0.9"
}
}
+33 -8
View File
@@ -27,13 +27,37 @@ const Card = ({
);
Card.displayName = "Card";
/**
* The card inset, as ONE utility.
*
* It used to be `p-4 sm:p-6`, and that responsive pair is what made every padding override in this
* codebase unreliable: tailwind-merge resolves conflicts only *within* a variant, so a call-site
* `pt-6` beat the base `pt-0` and lost to `sm:pt-0` correct on mobile, zero on desktop. Seven call
* sites had grown their own compensation for that in five different dialects.
*
* A single-variant token cannot half-lose. `--spacing-padding-card` is also what @unom/ui's own
* `Card` uses, so nested cards finally agree on their inset.
*/
const INSET = "p-padding-card";
/**
* Body/footer padding, minus the top when something already sits above.
*
* The old code hard-coded `pt-0` because "a CardHeader supplies the top inset" an assumption about
* a SIBLING that nothing enforced. Delete the header (exactly what tabbing a page does, since the
* tab label replaces the card title) and the top inset silently vanished at 640px. Asking the DOM
* instead of the author makes it self-correcting: first child keeps its inset, later children drop
* it.
*/
const INSET_AFTER_SIBLING = `${INSET} [&:not(:first-child)]:pt-0`;
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4 sm:p-6", className)}
className={cn("flex flex-col space-y-1.5", INSET, className)}
{...props}
/>
));
@@ -67,11 +91,12 @@ CardDescription.displayName = "CardDescription";
* Card body. Pass `flush` for content that should meet the card's edges a full-bleed table, most
* commonly instead of trying to cancel the padding from the outside.
*
* `className="p-0"` does NOT work for that: tailwind-merge only resolves conflicts *within the same
* variant*, so `p-0` cancels `p-4` but leaves `sm:p-6` standing, and the padding silently returns at
* 640px. Every call site that tried it ended up with a doubled inset once a `CardHeader` (which
* brings its own `sm:p-6`) was nested inside visible as one card whose title sits 24px further in
* than its neighbours'.
* Do NOT reach for `className="p-0"`: `flush` exists precisely so that intent is expressed as a prop
* the component honours, rather than as a utility that has to out-argue the one already there.
*
* Conversely, you no longer need to ADD top padding when there is no header that is automatic now.
* If you find yourself writing `pt-*` on a CardContent, the layout is telling you something else is
* wrong.
*/
const CardContent = React.forwardRef<
HTMLDivElement,
@@ -79,7 +104,7 @@ const CardContent = React.forwardRef<
>(({ className, flush = false, ...props }, ref) => (
<div
ref={ref}
className={cn(!flush && "p-4 pt-0 sm:p-6 sm:pt-0", className)}
className={cn(!flush && INSET_AFTER_SIBLING, className)}
{...props}
/>
));
@@ -91,7 +116,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-4 pt-0 sm:p-6 sm:pt-0", className)}
className={cn("flex items-center", INSET_AFTER_SIBLING, className)}
{...props}
/>
));
+7 -4
View File
@@ -62,7 +62,7 @@ export const DashboardView: FC<{
only the GameStream certs read as "0 paired" on a host every
one of whose clients was in fact paired. */}
<Card>
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<CardContent className="flex flex-1 items-center justify-between">
<span className="text-sm text-muted-foreground">
{m.status_paired_count()}
</span>
@@ -72,7 +72,7 @@ export const DashboardView: FC<{
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<CardContent className="flex flex-1 items-center justify-between">
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
@@ -206,7 +206,10 @@ export const DashboardView: FC<{
* else except the host log.
*/
const AudioWiringCard: FC<{ audio: AudioWiring }> = ({ audio }) => {
const badge: { variant: "success" | "secondary" | "destructive"; text: string } =
const badge: {
variant: "success" | "secondary" | "destructive";
text: string;
} =
audio.readiness === "full"
? { variant: "success", text: m.audio_ready() }
: audio.readiness === "audio_only"
@@ -257,7 +260,7 @@ const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
on,
}) => (
<Card>
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<CardContent className="flex flex-1 items-center justify-between">
<span className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{label}
+62 -23
View File
@@ -41,9 +41,10 @@ import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
@@ -177,17 +178,11 @@ export const DisplaySection: FC = () => {
});
return (
<div className="flex flex-col gap-card">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>{m.display_config_title()}</CardTitle>
{/* Visible without scrolling to the save button the card is taller than the
viewport, which is exactly how the pending edits went unnoticed. */}
{dirty && <Badge variant="warning">{m.display_unsaved()}</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
<DisplayTabs
dirty={dirty}
live={<LiveDisplays />}
configuration={
<>
<p className="max-w-prose text-sm text-muted-foreground">
{m.host_displays_help()}
</p>
@@ -226,20 +221,64 @@ export const DisplaySection: FC = () => {
/>
)}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.display_live()}</CardTitle>
</CardHeader>
<CardContent>
<LiveDisplays />
</CardContent>
</Card>
</div>
</>
}
/>
);
};
/**
* The page's tab shell: **Configuration** and **Live displays** as the same pill strip the plugin
* UIs use, over a card per tab.
*
* Tabs rather than two stacked cards because the configuration card alone is taller than the
* viewport which is how pending edits went unnoticed and the live list sat below it, effectively
* off screen.
*
* Presentational on purpose, taking both panes as nodes: `DisplaySection` cannot be rendered in
* Storybook (it calls `useBlocker`, which needs a router), so putting the strip here is what keeps
* it reachable from a story. That matters more than usual on this page `Displays.stories.tsx`
* exists to pin the MOTION NESTING of the preset grid, and inserting tabs changes that ancestor
* chain, so the story has to render the real one.
*/
export const DisplayTabs: FC<{
dirty: boolean;
configuration: ReactNode;
live: ReactNode;
}> = ({ dirty, configuration, live }) => (
<Tabs defaultValue="configuration" className="gap-card">
<TabsList>
<TabsTrigger value="configuration">
{m.display_config_title()}
{/* The dirty marker rides the TAB, not the card header. It used to sit inside a card
taller than the viewport; behind a tab it would vanish altogether while the Live
tab was open. On the trigger it survives both and the Custom block keeps its
own inline badge, so nothing is lost when this tab IS open. */}
{dirty && (
<span
role="status"
aria-label={m.display_unsaved()}
className="ml-1.5 size-2 shrink-0 rounded-full bg-[var(--warning)]"
/>
)}
</TabsTrigger>
<TabsTrigger value="live">{m.display_live()}</TabsTrigger>
</TabsList>
<TabsContent value="configuration">
<Card>
<CardContent className="space-y-4">{configuration}</CardContent>
</Card>
</TabsContent>
<TabsContent value="live">
<Card>
<CardContent>{live}</CardContent>
</Card>
</TabsContent>
</Tabs>
);
/**
* The gate on anything that would throw unsaved Custom fields away asked from three places (a
* preset click, applying a saved preset, and leaving the page), so it is written once. A function
+1 -1
View File
@@ -28,7 +28,7 @@ export const ConflictsCard: FC = () => {
if (conflicts.length === 0) return null;
return (
<Card className="border-amber-600/40 dark:border-amber-500/40">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<CardContent className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
+4 -7
View File
@@ -238,13 +238,10 @@ export const LogsCard: FC<{
return (
<Card>
{/* This card has no CardHeader, so it has to put the top padding back itself and it
must do so at BOTH breakpoints. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`, and
tailwind-merge only resolves conflicts within the same variant: a bare `pt-6` cancels
`pt-0` but leaves `sm:pt-0` standing, so the padding was 24px on a phone and 0 on a
desktop, with the filter row touching the card's edge. (Same trap the `p-0` note in
components/ui/card.tsx describes, in the other direction.) */}
<CardContent className="flex flex-col gap-3 pt-4 sm:pt-6">
{/* No CardHeader here, and that no longer needs saying: CardContent keeps its top inset
unless something precedes it. This card used to restore it by hand at both
breakpoints. */}
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
+1 -1
View File
@@ -135,7 +135,7 @@ export const PairedDevices: FC<{
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
</CardHeader>
<CardContent className="p-6">
<CardContent>
<QueryState isLoading={isLoading} error={error} refetch={refetch}>
{rows.length === 0 ? (
m.pairing_native_empty()
+2 -2
View File
@@ -55,7 +55,7 @@ export const JobProgressSection: FC<{
if (!job.isError) return null;
return (
<Card className="ring-2 ring-destructive/60">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<CardContent className="flex items-start gap-3">
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{m.store_job_lost()}</p>
@@ -92,7 +92,7 @@ export const JobProgressCard: FC<{
className={failed ? "ring-2 ring-destructive/60" : undefined}
aria-live="polite"
>
<CardContent className="space-y-3 p-card pt-card sm:pt-card">
<CardContent className="space-y-3">
<div className="flex items-start gap-3">
{running ? (
<Spinner className="mt-0.5 size-5 shrink-0" />
+36
View File
@@ -21,6 +21,42 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
/**
* The inset contract the thing this card got wrong most often.
*
* `CardContent` drops its top padding only when something already sits above it. The pair below is
* the regression guard: both cards must show the same inset on every side, and the headerless one
* must not have its first line touching the top edge.
*
* It used to be wrong invisibly, and only on desktop. The padding was `p-4 pt-0 sm:p-6 sm:pt-0`, so
* a headerless card had to restore the top inset itself and a call-site `pt-6` beat the base
* `pt-0` while losing to `sm:pt-0`, because tailwind-merge resolves conflicts only within a variant.
* Right on a phone, zero on a desktop. Seven call sites had grown their own workaround for it.
*
* Check this at BOTH viewport widths. A single width cannot show that class of bug.
*/
export const InsetWithAndWithoutHeader: Story = {
render: () => (
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>With a header</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
The body drops its top inset because the header above already supplied
one.
</CardContent>
</Card>
<Card>
<CardContent className="text-sm text-muted-foreground">
No header, so the body keeps its own top inset automatically, with
nothing for the call site to remember.
</CardContent>
</Card>
</div>
),
};
export const HostCard: Story = {
render: () => (
<Card className="max-w-sm">
+46 -21
View File
@@ -1,9 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { userEvent, within } from "storybook/test";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
import { DisplayForm } from "@/sections/Displays/DisplayCard";
import { DisplayForm, DisplayTabs } from "@/sections/Displays/DisplayCard";
import {
displayCustomPresets,
displayEffective,
@@ -19,17 +18,29 @@ import {
* frame while every other grid in the console staggered. It is invisible in a diff and invisible to
* `tsc`; only a rendered page shows it.
*
* So the `<Card>` wrapper below is NOT decoration. It reproduces the page's motion nesting, which is
* the thing under test dropping it would make the story pass for the wrong reason.
* So the wrapper below is NOT decoration. It reproduces the page's motion nesting, which is the
* thing under test dropping it would make the story pass for the wrong reason. It renders the
* page's real `DisplayTabs` shell for exactly that reason: the tabs sit between the page `<Section>`
* and the card, so they are part of the ancestor chain this story exists to pin.
*/
const Harness = ({ seed }: { seed: DisplayPolicy }) => {
const Harness = ({
seed,
dirty = false,
}: {
seed: DisplayPolicy;
dirty?: boolean;
}) => {
const [draft, setDraft] = useState<DisplayPolicy>(seed);
return (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayTabs
dirty={dirty}
live={
<p className="text-sm text-muted-foreground">
The live list reads `/display/state`, so it is not part of this story
see the tab strip and the Configuration pane.
</p>
}
configuration={
<DisplayForm
draft={draft}
setDraft={setDraft}
@@ -41,11 +52,11 @@ const Harness = ({ seed }: { seed: DisplayPolicy }) => {
applyAxis={(patch) => setDraft({ ...draft, ...patch })}
saveDraft={() => {}}
busy={false}
dirty={false}
dirty={dirty}
revert={() => {}}
/>
</CardContent>
</Card>
}
/>
);
};
@@ -70,11 +81,10 @@ export const CustomFields: Story = {
export const NoCustomPresets: Story = {
args: { seed: displayPolicy },
render: (args) => (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayTabs
dirty={false}
live={null}
configuration={
<DisplayForm
draft={args.seed}
setDraft={() => {}}
@@ -89,7 +99,22 @@ export const NoCustomPresets: Story = {
dirty={false}
revert={() => {}}
/>
</CardContent>
</Card>
}
/>
),
};
/**
* Unsaved Custom edits, with the Configuration tab NOT open.
*
* The dirty marker has to survive being on the other tab the whole reason it moved off the card
* header and onto the trigger. If this story ever shows a bare "Configuration" label, the warning
* has gone silent exactly when it matters most.
*/
export const UnsavedOnOtherTab: Story = {
args: { seed: { ...displayPolicy, preset: "custom" }, dirty: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("tab", { name: /Live/i }));
},
};