Compare commits

..
Author SHA1 Message Date
enricobuehler 85980b425e docs: state the real host floor — Ubuntu 26.04+ / Debian 13+ — and name Debian everywhere
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 1m33s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m56s
android / android (pull_request) Successful in 9m55s
ci / rust (pull_request) Successful in 15m59s
The floor that matters is the DESKTOP, not the package: 24.04 installs
punktfunk-host and then has no compositor over the version floors and no
gamescope, which reads as a bug rather than an unsupported base. The
requirements page now leads with a per-release table separating 'package
installs' from 'can actually host', and install.md carries the same
caveat next to the apt row.

Debian was already a supported target after the previous commits but was
still invisible at the entry points — README's install table, the docs
index cards, and the 'what you need' list all said Ubuntu only. All three
now name Debian and carry the version floor.
2026-08-13 12:20:33 +02:00
enricobuehler 107fa3472d docs: Linux Mint 22.x cannot host, and 24.04 gives the host no compositor
Measured on a real linuxmintd/mint22-amd64 image and on Ubuntu 24.04. The
package installs on both, which is exactly what makes this easy to miss —
nothing on the box can then produce a stream:

  * Cinnamon cannot host a virtual display (Muffin has no RecordVirtual).
  * gamescope is absent from 24.04 and cannot be built for it: the tree needs
    wayland >= 1.23.1 (has 1.22.0), libinput >= 1.26 (1.25), libavif >= 1.2.1
    (1.0.4), pixman >= 0.44 (0.42), plus libdisplay-info2 and libxcb-errors0,
    neither of which 24.04 packages at all.
  * Switching desktop does not rescue it — 24.04 has KWin 5.27 (floor 6.5.6)
    and GNOME Shell 46 (floor 48). Only sway 1.9 is even a candidate.

So the gamescope route documented for Cinnamon holds for LMDE 7 (Debian 13,
verified end to end) but NOT for Linux Mint 22.x — which is every mainstream
Mint until Mint 23 lands on a 26.04 base in December 2026. Both the Debian and
Ubuntu pages now say so, and the Debian page carries a per-edition table.

Also states what Debian 13 itself can drive: GNOME 48.7 and sway 1.10 are above
the floors; its KWin 6.3.6 is below.
2026-08-13 12:12:52 +02:00
enricobuehler 5d8682d7b7 feat(vdisplay): explain a cinnamon/muffin compositor pin instead of listing backends
PUNKTFUNK_COMPOSITOR=cinnamon is the first thing a Mint or LMDE user
reaches for, and the bare list of accepted values invites the
next-closest guess — 'mutter', since Muffin is a Mutter fork — which
starts a session that fails deep inside an org.gnome.Mutter.ScreenCast
call Muffin does not serve. No value of the variable can work, so say
that and name headless gamescope, which needs no desktop compositor.
2026-08-13 11:50:05 +02:00
enricobuehler 030bc8a1c2 docs(debian): use deb822 sources for non-free, not apt-add-repository
software-properties-common is not available in Debian 13, so the
apt-add-repository line could not have worked. Debian 13 keeps its
sources in deb822 format; edit Components there instead (verified in a
trixie container — the NVIDIA driver it then offers is 550, above our
535 floor).
2026-08-13 11:47:20 +02:00
enricobuehler 346385bad8 fix(deb): ship punktfunk-gamescope on apt at last, and support Debian 13
`punktfunk-gamescope` had never been published to the apt registry — not in any
release. It was built inside the host job's Ubuntu 24.04 image, where it cannot
build: our pin vendors wlroots 0.19.3, which floors `wayland-server` at 1.23.1,
and noble ships 1.22.0 (it also lacks libxcb-errors-dev and has only
libdisplay-info 0.1.1). Every rung of that path was a `::warning::` returning 0
and the one hard gate ran last by design, so v0.26.0 and v0.27.0 both released
with the package missing while docs-site told apt users to install it. The same
tags shipped it fine for Arch, Fedora 44 and Bazzite.

It now builds in its own job on Debian 13 (ci/gamescope-trixie.Dockerfile), the
oldest apt base the tree configures on. One package serves Debian 13 AND Ubuntu
26.04 — measured by installing and running it on both — because the build also
vendors libdisplay-info via the new `--extra-fallback` option: linked against
the distro copy it demands `libdisplay-info2` on trixie, which Ubuntu 26.04 does
not have (it carries libdisplay-info3). The option is opt-in, so the
Arch/Fedora/nix outputs are byte-for-byte unchanged. Ubuntu 24.04 gets no
gamescope package and cannot — its wayland is too old to run one however built.

Debian 13 is now a documented host target. That needed no packaging change at
all: the host .deb's glibc-2.39 floor and bundled FFmpeg already made it
installable, and it had been working for a long time while docs-site said Debian
was unsupported and unverified. Verified by installing: host, web console and
plugin runner install, resolve every soname and run. The desktop client stays
Ubuntu-26.04-only (built there, floors at `libc6 >= 2.43`; Debian 13 has 2.41).

Compositor detection now answers Cinnamon (Mint, LMDE) with the route that works
instead of advice that cannot help. Muffin forked from Mutter 3.36:
`org.cinnamon.Muffin.ScreenCast` has only RecordMonitor/RecordWindow, never
RecordVirtual, and xdg-desktop-portal-xapp implements no ScreenCast — so no
value of PUNKTFUNK_COMPOSITOR makes a Cinnamon desktop host a virtual display.
The error names headless gamescope, which needs no desktop compositor. The XDG
sniff moved into a pure function so those branches are testable; Cinnamon is
matched before GNOME, since it is a GNOME derivative and the generic arm would
otherwise hand it the Mutter backend (caught by the new test).

New `smoke-install` job installs every published package from the registry in
pristine ubuntu:24.04, ubuntu:26.04 and debian:trixie images, asserts each
binary resolves its libraries and runs, and insists the version served is the
one this run built. Nothing in deb.yml had ever installed a package it produced,
which is how both of the above survived unnoticed.

⚠ Bootstrap: seed `punktfunk-gamescope-trixie:latest` into the LAN registry once
(docker.yml builds it thereafter) or the new job cannot start.
2026-08-13 11:43:54 +02:00
enricobuehler 124cb66324 Merge pull request 'Wake-on-LAN: support WoWLAN so Wi-Fi hosts wake like wired ones' (#187) from worktree-wowlan-support into main
apple / swift (push) Successful in 1m40s
ci / rust-arm64 (push) Successful in 1m41s
ci / bun-nix (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m45s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 45s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
ci / rust (push) Successful in 5m55s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 22s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m59s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
apple / screenshots (push) Successful in 6m3s
arch / build-publish (push) Successful in 8m55s
ci / web (push) Successful in 1m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m42s
ci / docs-site (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 20s
docker / deploy-docs (push) Successful in 48s
deb / build-publish (push) Failing after 6m34s
deb / build-publish-host (push) Successful in 14m19s
android / android (push) Successful in 10m58s
release / apple (push) Successful in 10m0s
flatpak / build-publish (push) Successful in 12m38s
windows-host / package (push) Successful in 16m30s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m19s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m2s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m20s
2026-08-12 22:51:57 +00:00
enricobuehler 6774c4e7a2 feat(wol): support WoWLAN so Wi-Fi hosts wake like wired ones
ci / bun-nix (pull_request) Successful in 1m33s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m5s
ci / web (pull_request) Successful in 2m14s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m57s
ci / docs-site (pull_request) Successful in 3m4s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m41s
android / android (pull_request) Successful in 7m3s
ci / rust (pull_request) Successful in 10m23s
The host's arming check asked `ethtool` about every NIC, which is the wrong
question for Wi-Fi: the magic-packet trigger lives in nl80211's WoWLAN state,
and most wireless drivers print `Wake-on: d` whether or not it is armed. An
armed Wi-Fi host was therefore told it was NOT armed, and handed an
`ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy
(`/sys/class/net/<i>/phy80211`) is now asked `iw phy <phy> wowlan show`
instead, and the warning carries WoWLAN-correct guidance — `iw ... wowlan
enable magic-packet`, plus the NetworkManager
`802-11-wireless.wake-on-wlan magic` that survives a reconnect. Two fallbacks
for when `iw` can't answer (missing binary, driver without the command, or
privilege the user-level host service lacks): a POSITIVE ethtool reading
counts (brcmfmac & co do report there), a negative one never does, and sysfs
`device/power/wakeup` reading `disabled` is conclusive in the negative.

The client sender now emits from a socket bound to EACH non-loopback
interface's own address rather than leaving the path to the routing table. A
station in WoWLAN sleep stays associated and its AP buffers broadcast frames
for it until the next DTIM beacon — but only if the datagram reaches the
wireless segment at all, and with a VPN or mesh interface holding the default
route `255.255.255.255` never did. A failed bind falls back to the routed
socket, so no segment is lost.

Tests: `iw`/`ethtool` output parsing split from the commands so both are unit-
tested on any platform, and a new end-to-end test asserts a real listener
receives the 102 magic-packet bytes.

Verified on Linux (Ubuntu 26.04, 12 interfaces): `cargo fmt --all --check`,
`cargo clippy -p punktfunk-core -p punktfunk-host --all-targets --locked
-- -D warnings`, and both wol test sets green. NOT yet exercised against real
Wi-Fi hardware — no Wi-Fi Linux box was reachable.
2026-08-13 00:49:21 +02:00
enricobuehler f06b84be63 Merge pull request 'plugin-kit 0.4.1 — publish the icon field, without which no plugin can name its mark' (#186) from worktree-launcher-icon-kit-release into main
ci / web (push) Successful in 1m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
ci / bun-nix (push) Successful in 3m7s
ci / docs-site (push) Successful in 4m32s
ci / rust-arm64 (push) Successful in 5m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 33s
plugin-kit-publish / publish (push) Successful in 1m2s
ci / rust (push) Successful in 9m41s
nix / flake (push) Failing after 12m47s
2026-08-12 22:30:11 +00:00
enricobuehler d6dbb391d6 chore(plugin-kit): 0.4.1 — publish the icon field, without which no plugin can name its mark
ci / bun-nix (pull_request) Successful in 40s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 2m25s
ci / rust (pull_request) Successful in 11m40s
nix / flake (pull_request) Successful in 14m54s
`ProviderEntry.icon` landed in f62a48d4 along with the token's whole
supporting cast: the host-side shape guard, the seven masters, six client
renderers, the SDK and the OpenAPI. What it did not get was a version
bump, and the kit had cut 0.4.0 the day before.

So the registry's 0.4.0 is the tarball WITHOUT the field, and it is the
newest thing any plugin can resolve. A plugin that emits `icon` on a
launcher entry therefore fails `tsc --noEmit` — "Object literal may only
specify known properties, and 'icon' does not exist" — which is a CI gate
in every plugin repo. That is why the three plugins that were supposed to
carry the token never shipped it: the edits could not be committed
against a kit that had no field to fill.

Nothing but the version moves here. The only plugin-kit change since
0.4.0 was published is f62a48d4 itself, so 0.4.1 is exactly that commit's
kit surface — one optional string on an existing struct, additive, and
inert for a plugin that never sets it.
2026-08-13 00:29:26 +02:00
enricobuehler 907080f92b Merge pull request 'The Windows host could tank a local game's 1% lows — mint retries broadcast PnP device changes at the whole box, and session tuning never reverted' (#185) from worktree-audio-stutter-fixes into main
apple / swift (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 2m10s
ci / web (push) Successful in 2m48s
ci / docs-site (push) Successful in 1m18s
ci / bun-nix (push) Successful in 2m6s
ci / rust (push) Successful in 6m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
android / android (push) Successful in 6m42s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-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 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
apple / screenshots (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 6m34s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-client-arm64 (push) Failing after 5m41s
docker / builders-arm64cross (push) Successful in 1m54s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 22s
arch / build-publish (push) Successful in 11m8s
deb / build-publish (push) Successful in 12m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m15s
Reviewed-on: #185
2026-08-12 22:03:47 +00:00
enricobuehler 9425c6d40a docs(changelog): audio no longer costs local-game frame time on Windows hosts
ci / bun-nix (pull_request) Successful in 24s
ci / rust (pull_request) Failing after 57s
apple / swift (pull_request) Successful in 1m54s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 4m46s
android / android (pull_request) Successful in 11m12s
ci / web (pull_request) Failing after 13m50s
2026-08-12 22:38:35 +02:00
enricobuehler ab8c7ec37c fix(audio/windows): stop the mint retry path from broadcasting PnP device changes at the whole box, and revert session tuning when streaming ends
Field report (2026-08-12): Punktfunk's audio devices tank Helldivers 2 to
1% lows of 2-5 FPS; uninstalling restores performance. Two host-side
mechanisms can plausibly do that, both fixed here.

The mint retry storm: minted::ensure_blocking() ran a FULL provisioning
pass on every mic-pump open with no cooldown, no in-flight guard, and no
give-up - and ensure_role() reached UpdateDriverForPlugAndPlayDevicesW
even when the devnode already existed. On a box where minting never
latches, the pump's reopen backoff (capped 60 s) turned that into a PnP
driver rebind + system-wide device-change broadcast roughly once a
minute, forever - and games rebuild their audio graph on each broadcast.
Now:

* ensure_role() gets a steady-state fast path: a marker devnode whose
  endpoints are all live resolves without touching PnP or the
  default-device policy.
* ensure_blocking() waits on an in-flight pass instead of racing a
  second SetupAPI sweep against it (the dead-mic-air deploy race),
  honours RETRY_COOLDOWN after a failed pass (first-ever resolve still
  blocks, per the cold-boot mint contract), and
* five unlatched passes stop minting for the host lifetime (a service
  restart re-arms) - counted across the worker and the blocking path.

The never-reverted session tuning: pf-frame's tune_process_once() put
the whole host at HIGH_PRIORITY_CLASS with timeBeginPeriod(1) and DWM
MMCSS on the first hot stream thread and documented 'reverts at process
exit' - but the host is a 24/7 service, so after one stream it competed
at HIGH class with a 1 ms global timer against whatever the user played
locally, forever. The process-wide tuning is now refcounted across the
hot threads via a TLS guard: the first hot thread applies it, the last
one's exit reverts it (timeEndPeriod, DwmEnableMMCSS(0), NORMAL class) -
the same thread-exit lifetime the MMCSS and execution-state effects
already ride. Every on_hot_thread() call site is a session-scoped
thread (capture/encode, packetizer, send, NVENC retrieve), so the
revert lands at session teardown.
2026-08-12 22:29:54 +02:00
enricobuehler 64e2af17c5 Merge pull request 'fix(decky): duplicate shortcut minted every boot + toast noise cut' (#184) from worktree-decky-shortcut-dup into main
decky / build-publish (push) Successful in 1m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
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 1m34s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 20s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 1m32s
ci / web (push) Successful in 3m45s
ci / docs-site (push) Successful in 4m1s
ci / bun-nix (push) Successful in 29s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
docker / deploy-docs (push) Successful in 32s
ci / rust (push) Canceled after 4m8s
2026-08-12 20:25:57 +00:00
enricobuehler 72189b29ec Merge branch 'main' into worktree-decky-shortcut-dup
ci / rust (pull_request) Successful in 6m55s
ci / web (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 34s
ci / rust-arm64 (pull_request) Successful in 1m30s
2026-08-12 22:24:34 +02:00
enricobuehler 339a1d70f9 fix(decky): stop toasting on every launch and every failed panel refresh
Field complaint: the plugin toasts too much. Inventory of all 14 toast
sites says almost all are rare, explicit-tap feedback (pairing, update
buttons, recovery actions) — but two were routine-volume offenders:

  * startStream toasted "Starting stream — <host>" on EVERY successful
    launch, i.e. the overwhelming majority of all toasts the plugin ever
    shows. It repeats the button the user just pressed, and lands ON TOP
    of the starting stream after the QAM closes. Gone; launch FAILURES
    still toast (the QAM may already be closed, so inline state would go
    unseen).
  * useHosts.refresh() toasted "Couldn't list hosts" from its catch —
    and the panel remounts (and refreshes) on every QAM open, so a broken
    backend nagged on each open. It's now a third inline `problem` row
    ("Couldn't scan for hosts"), sitting next to the Refresh button that
    retries it, like the client-unavailable/client-outdated states
    already did.

The update-flow, pairing, trust and recovery toasts stay: each is a rare,
single, information-carrying response to an explicit tap (or, for the
request-access hint, the only warning that the connect is about to park).

Verified: tsc --noEmit and the rollup bundle pass.
2026-08-12 22:05:16 +02:00
enricobuehler 79dba7f95a fix(decky): a boot race minted a new library shortcut on every plugin load
Field report: each Steam start added another visible "Punktfunk" entry
(spotted in the desktop client, where the pile is plain to see).

Mechanism: db063792 made shortcutStillExists() actually answer for the
first time — and its callers treat a null overview as "the user deleted
the shortcut" and AddShortcut a replacement. But the plugin mounts while
Steam is still starting up, BEFORE appStore has registered its overviews,
so the remembered (perfectly live) appId looks up as null on every boot:
mint a duplicate, remember the new id, orphan yesterday's. One new entry
per load, forever.

The deleted verdict now has to be earned, and creation is a last resort:

  * shortcutStillExists() only believes "absent" once the store is
    demonstrably hydrated: wait out App.WaitForServicesInitialized (raced
    against the poll budget so a wedged signal can't hang the guard),
    poll until allApps is non-empty, then one grace recheck — overview
    registration can trail the bulk hydration. Unverifiable within budget
    answers true: a false "alive" merely no-ops until the next ask, a
    false "dead" duplicates forever.
  * On a genuinely lost id, both ensure paths first ADOPT an existing
    same-named shortcut (excluding the other role's) instead of minting
    an N+1th — which also heals installs the old builds already littered.
  * Both ensures are single-flight: mount's fire-and-forget can now be
    mid-wait when a QAM press arrives, and two ensures racing past the
    liveness check would each AddShortcut.
  * "Recreate library shortcut" additionally sweeps surplus "Punktfunk"
    shortcuts (RemoveShortcut) and toasts the count — cleanup for piles
    already minted. Deliberately button-only, never mount: automatic
    library deletion at boot is a bigger hazard than the mess.

Verified: tsc --noEmit and the rollup bundle both pass; the launch paths
(launchStream / launchGamepadUi) hit the fast path unchanged — a live
overview answers the first query and nothing waits.
2026-08-12 21:54:06 +02:00
enricobuehler d7430fe2bd Merge pull request 'fix(ci): gate C counts comments, and a comment named the env mutators verbatim' (#183) from fix/gate-c-comment-token into main
ci / rust-arm64 (push) Successful in 2m24s
ci / web (push) Successful in 1m46s
ci / bun-nix (push) Successful in 46s
ci / docs-site (push) Successful in 1m50s
deb / build-publish (push) Successful in 4m25s
deb / build-publish-client-arm64 (push) Successful in 6m32s
flatpak / build-publish (push) Successful in 6m59s
deb / build-publish-host (push) Successful in 12m2s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m42s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
arch / build-publish (push) Successful in 13m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m34s
ci / rust (push) Successful in 13m31s
2026-08-12 16:57:38 +00:00
enricobuehler 6f81ec24ba fix(ci): gate C counts comments, and a comment named the env mutators verbatim
ci / rust-arm64 (pull_request) Successful in 1m50s
ci / docs-site (pull_request) Successful in 1m33s
ci / web (pull_request) Successful in 2m30s
ci / rust (pull_request) Successful in 14m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / bun-nix (pull_request) Failing after 13m41s
55a3d8b9 (#181) added the edition-2024 lint-level rationale to the session
bin's header naming std::env::set_var/remove_var — gate C's grep counts
comments by contract, so main went red at 5 mentions against the 4-call-site
baseline. Reword the comment instead of raising the baseline: a baseline of 5
with one comment inside would hide the next real call site.

Verified: scripts/ci/check-unsafe-hygiene.sh clean, cargo fmt clean.
2026-08-12 18:57:07 +02:00
enricobuehler 539236de91 Merge pull request 'feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)' (#182) from worktree-linux-pad-audio into main
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 41s
ci / rust-arm64 (push) Canceled after 0s
ci / docs-site (push) Canceled after 14s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 17s
deb / build-publish-host (push) Canceled after 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
apple / swift (push) Successful in 1m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
android / android (push) Successful in 6m37s
apple / screenshots (push) Successful in 5m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m48s
docker / builders-arm64cross (push) Successful in 26s
windows-host / package (push) Successful in 13m15s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 46s
windows-host / canary-manifest (push) Successful in 32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 29m10s
2026-08-12 16:55:06 +00:00
enricobuehler 118758ff0b fix(pad-audio): the Linux pad sink speaks GE-Proton's AUX0-3 channel shape
ci / bun-nix (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 1m47s
ci / docs-site (pull_request) Successful in 1m54s
apple / swift (pull_request) Successful in 1m59s
ci / web (pull_request) Successful in 2m3s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m18s
android / android (pull_request) Successful in 6m59s
A field report (GE-Proton 11-5, real DualSense on-host) surfaced the missing
constraint: haptics only work when the pad's card runs the Pro Audio profile —
because GE's route opens the node through its bundled pipewire-alsa plugin
with aux_channels=1, and its pulse fallback forces a PA AUX0..3 map with
stream.dont-remix (proton-ds5-haptic patches 0013/0115/0116: "the hidden
PipeWire parent for a DualSense output exposes AUX0 through AUX3"). A
positioned FL FR RL RR sink puts those writers through position channelmix
instead of index passthrough.

The sink now advertises AUX0..AUX3. Proven on the box: an AUX-mapped
rear-pair-only tone captures index-exact (speaker pair 0.0000, coil pair
0.3662); a positioned stray stream folds into the speaker pair and never
excites the coils. The devtest reports per-pair peaks so exactly this class
of remix bug is visible.

Also confirmed from the GE patch set while here: device matching is
device.bus/vendor.id/product.id + the Sony/Wireless_Controller name
substrings (both of which the sink carries), and the MMDevice container is
now synthesized from the wine-side HID USB parent (patch 0112) — the old
pure-PW-node GUID_NULL concern no longer applies on GE >= 11-4.
2026-08-12 18:53:35 +02:00
enricobuehler dcde856178 feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)
The 0xD1 plane was Windows-host-only: host_cap() answered false and spawn()
was a stub everywhere else, so an Android tier-A client against a Linux host
negotiated the cap off and stayed on wire rumble. The whole downstream
machinery (framer, silence gate, lanes, 0xD1 send) was already capture-
agnostic — only the capturer was WASAPI.

- audio/linux/pad_sink.rs: one Audio/Sink stream node per DualSense-family
  pad, minted with the identity the matchers read (ALSA-style node.name with
  the pad's pairing MAC, description "Wireless Controller", bus/vendor/
  product/form-factor proplist, per-pad serial), 4-ch F32 48 kHz FL FR RL RR,
  no default-sink claim, priority.session 50. The process() callback IS the
  capture. PUNKTFUNK_PAD_SINK_NAME/_DESC override the strings for field
  debugging ({pad}/{mac} expand).
- native/pad_audio.rs: the shared logic and lanes compile on Linux;
  pad_audio_thread is generic over the capturer (open-with-backoff kept);
  host_cap() Linux arm = client asked + PUNKTFUNK_PAD_AUDIO + a reachable
  PipeWire socket; spawn() Linux arm mints the sink lazily in the streamer
  thread. spawn() gains an edge flag (Edge identity; ignored on Windows).
- devtest pad-sink-test: mint one sink and capture from it, no client — the
  WP3 on-glass gate. Verified on a Bazzite 44 host: identity served through
  pipewire-pulse, rear-pair (voice-coil) tone captured bit-exact over both
  the native and pulse legs.
- docs: PUNKTFUNK_PAD_AUDIO{,_SLOTS} are no longer (Windows); the roadmap
  non-goal narrows to Bluetooth client pads.

Gates (fedora:44 container, natively on the .41 box): cargo build --release
--locked (nvenc+vulkan-encode), clippy --all-targets -D warnings, cargo test
pad_audio+pad_sink 11/11, cargo fmt.
2026-08-12 18:53:35 +02:00
enricobuehler 77918674c3 Merge pull request 'An over-declared HEVC level no longer demotes native Vulkan decode, and the Windows client legs build again' (#181) from worktree-vk-level-gate-clamp into main
android / android (push) Failing after 1m20s
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m29s
ci / rust (push) Failing after 3m2s
ci / bun-nix (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m4s
ci / web (push) Successful in 3m46s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
apple / screenshots (push) Canceled after 3m29s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
arch / build-publish (push) Canceled after 5m25s
deb / build-publish (push) Canceled after 3m2s
deb / build-publish-host (push) Canceled after 2m45s
deb / build-publish-client-arm64 (push) Canceled after 2m39s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 30s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 28s
flatpak / build-publish (push) Canceled after 2m37s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 3m11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-12 16:48:56 +00:00
enricobuehler faefbae830 fix(pf-presenter): spell MAKEINTRESOURCE(1) as ptr::without_provenance — clippy 1.96's manual_dangling_ptr reads the integer-ordinal cast as a dangling pointer and fails the Windows -D warnings gate (masked on main by the client bins failing to build first)
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Failing after 1m35s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 2m21s
ci / rust-arm64 (pull_request) Successful in 3m45s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m42s
android / android (pull_request) Successful in 4m29s
2026-08-12 18:47:45 +02:00
enricobuehler 44fa12a298 test(pf-vkdecode): bind the PTL level reads so the SAFETY comments precede their unsafe blocks (clippy::undocumented_unsafe_blocks counts nothing inside macro arguments) 2026-08-12 18:47:45 +02:00
enricobuehler 55a3d8b919 fix(clients/session): the edition-2024 session bin cannot build on Windows
The half of the #177 fallout #180's follow-up could not reach: WP20 wrapped
the session bin's single-threaded-startup env writes in the `unsafe {}`
blocks edition 2024 requires — under `#![forbid(unsafe_code)]`, which no
inner attribute can override, so `punktfunk-client-session` fails with two
hard errors on every Windows leg (main push runs 17615/17616 red at Build;
verified on .173). Same resolution as #180 gave the GTK shell: `forbid`
becomes `deny`, and the three documented SAFETY sites carry the localized
`#[allow(unsafe_code)]` pf-update models.
2026-08-12 18:47:45 +02:00
enricobuehler a02014ec19 fix(pf-vkdecode): treat an over-declared stream level as a clamp, not a refusal
A 2026-08-12 field report (RTX 5060 client): every HEVC session demoted to
D3D11VA with 81 "outside device caps: stream level (Std code point 12) above
the device's maxLevelIdc (H.265 Std level 11)" refusals — the host's AMF
encoder stamps general_level_idc 6.2 (the codec maximum) on a 4K120 stream
that needs 5.2, and NVIDIA's driver caps H.265 decode at 6.1. The hardware
decodes the actual stream trivially; only the declaration was oversized.
AV1 passed the same gate, which is why "native-vulkan runs only with AV1".

The declared level is a claim, and the stream's real demands are enforced
where they are physical facts — coded extent and DPB depth, both checked at
session build. So the up-front level gate (H.264 + H.265) now warns once and
proceeds, and every SPS/VPS handed to the Vulkan parameters object has its
level clamped to the device ceiling (a set above maxLevelIdc is invalid
usage). AV1's gate is untouched: its code space is the bitstream's own and
no over-declaration has been seen in the field.

Verified on .173 (RTX 4090, driver 610.88): HEVC and AV1 both decode on the
native Vulkan rung at 60 fps against an NVENC host; unit tests pin the clamp
(lowers, only lowers, mutates the driver-visible block in place).
2026-08-12 18:46:57 +02:00
enricobuehler 5f55b820bc Merge pull request 'Edition-2024 follow-up: the three gates only the PR's own CI could reach' (#180) from worktree-edition-2024 into main
ci / rust (push) Failing after 3m38s
apple / swift (push) Successful in 1m49s
android / android (push) Failing after 5m32s
ci / docs-site (push) Successful in 1m12s
ci / rust-arm64 (push) Failing after 3m36s
ci / bun-nix (push) Successful in 1m24s
ci / web (push) Successful in 2m24s
arch / build-publish (push) Failing after 5m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish (push) Failing after 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m14s
docker / builders-arm64cross (push) Successful in 10s
deb / build-publish-client-arm64 (push) Failing after 3m43s
apple / screenshots (push) Successful in 5m38s
deb / build-publish-host (push) Successful in 6m17s
docker / deploy-docs (push) Failing after 4m38s
windows-host / package (push) Successful in 12m27s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
flatpak / build-publish (push) Canceled after 6m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m50s
2026-08-12 16:34:13 +00:00
44 changed files with 2635 additions and 355 deletions
+170 -99
View File
@@ -1,6 +1,6 @@
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
# apt distribution/component:
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Debian and
# Ubuntu boxes get new builds via `apt update && apt upgrade`. Five jobs; the four builders all
# publish to the same apt distribution/component, and the fifth checks the result:
#
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
@@ -11,8 +11,17 @@
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
# on Ubuntu 24.04 LTS through 26.04 — and, for free, on Debian 13.
# (A 26.04-built host .deb is uninstallable on 24.04 — the reason this job
# exists; see packaging/debian/README.md.)
# build-publish-gamescope
# — the patched `punktfunk-gamescope`, on DEBIAN 13. It lived in the host job
# until 2026-08 and never once succeeded there: noble's wayland is 1.22.0
# and the vendored wlroots floors it at 1.23.1, so v0.26.0 and v0.27.0 both
# shipped without the package while the docs told apt users to install it.
# smoke-install — installs what was just published, from the registry, in pristine
# ubuntu:24.04 / ubuntu:26.04 / debian:trixie images. Nothing here used to
# install a package it built, which is how both of the above survived.
#
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
# host and client packages always share a version line. The release-attach helpers are race-safe
@@ -337,82 +346,6 @@ jobs:
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
#
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the patched gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
# a half-built result impossible to ship.
run: |
set -x
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
- name: Build punktfunk-gamescope .deb
# Picked up by the publish loop below, which globs dist/*.deb.
run: |
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
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -440,25 +373,109 @@ 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')
# ---------------------------------------------------------------------------------------------
# `punktfunk-gamescope` for apt — the patched build that gives a gamescope session HDR, a cursor,
# and the client's real refresh rate instead of a hardcoded 60 Hz. Same reasoning as the RPM leg
# in rpm.yml: without a package, a Debian/Ubuntu box has no route to it except compiling by hand.
#
# ⚠ THIS IS ITS OWN JOB, ON DEBIAN 13, BECAUSE THE NOBLE BUILD COULD NEVER HAVE WORKED.
# It used to be a few best-effort steps inside build-publish-host (Ubuntu 24.04), where it failed
# every single time:
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
# Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 and
# always will. v0.26.0 AND v0.27.0 both shipped with no gamescope .deb — while the release notes
# and docs-site told apt users to install it — because every rung of that path was a `::warning::`
# that returned 0, and the one hard gate ran last by design (so good artifacts still publish) and
# was simply never acted on. Moving it to trixie makes the build possible; making it a job of its
# own makes its failure visible instead of a footnote in someone else's log.
#
# Debian 13 is the OLDEST apt distro the tree configures on (wayland 1.23.1, libxcb-errors,
# libdisplay-info 0.2). The binary it produces needs GLIBC_2.38 and links no libstdc++, so what
# actually bounds it is wayland: Debian 13 and Ubuntu 26.04 YES, Ubuntu 24.04 NO — and 24.04
# could not run this binary however it was built, so nothing was lost by leaving noble.
build-publish-gamescope:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
# version + our patch level, which build-gamescope-deb.sh derives itself; it deliberately
# does NOT follow the punktfunk version line, because this package moves on its own cadence.
- name: Channel
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[*]}"
git config --global --add safe.directory "$PWD"
case "$GITHUB_REF" in
refs/tags/v*) DIST=stable ;;
*) DIST=canary ;;
esac
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "gamescope -> apt distribution '$DIST'"
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-trixie-${{ hashFiles('packaging/gamescope/**') }}
# NOT best-effort, unlike the noble version of this step. Every dependency now comes from the
# image (which asserts the wayland floor at build time), so a failure here is a real
# regression in the tree or the pin — exactly the thing the previous arrangement hid.
- name: Build the patched gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# `--extra-fallback libdisplay-info` is what makes ONE .deb serve both Debian 13 and
# Ubuntu 26.04. Built against the distro's copy, the package picks up
# `Depends: libdisplay-info2 (>= 0.2.0)` on trixie — and Ubuntu 26.04 carries
# libdisplay-info **3** (0.3.0), so apt refuses it there ("Depends libdisplay-info2 …" —
# measured, not predicted). gamescope vendors the library as a submodule, so linking the
# vendored copy drops the dependency entirely. Same reasoning the build script already
# applies to wlroots: a binary we SHIP must not follow the build host's shared libraries.
run: |
bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
--extra-fallback libdisplay-info
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
# actually produced a working compositor.
- name: Build the .deb
run: |
gs-cache/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for DEB in dist/punktfunk-gamescope_*.deb; do
echo "uploading $DEB"
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done
echo "published gamescope to $OWNER/debian $DISTRIBUTION/$COMPONENT"
- name: Attach the gamescope .deb to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for DEB in dist/punktfunk-gamescope_*.deb; do
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
@@ -560,3 +577,57 @@ jobs:
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# Does the thing we just published actually INSTALL? Nothing in this repo asked that before, and
# the cost of not asking was two independent, long-lived facts nobody knew:
# * `punktfunk-host` has installed cleanly on Debian 13 for a long time — while docs-site said
# "Debian isn't a supported target … nobody has verified it".
# * `punktfunk-gamescope` was missing from apt entirely across two releases.
# Both are exactly what a five-minute install check catches, so it is now a job.
#
# It runs on the RUNNER (no `container:`) and drives docker directly — the same access
# docker.yml's image builds use — because the check must happen in a pristine distro image, not
# in a builder image with the deps already present.
#
# It installs FROM THE REGISTRY, after the publish jobs, rather than from a local .deb: that
# exercises the real path a user takes (repo key, apt distribution, dependency resolution against
# the distro's own package set), and it matches this workflow's established order — publish the
# good artifacts first, go red afterwards, never let a gate withhold a shipping fix.
smoke-install:
needs: [build-publish, build-publish-host, build-publish-gamescope]
# `needs` for ORDER only — this must still run when a builder went red, or the failure that
# matters most (a package that did not publish) is exactly the one that skips its own check and
# leaves the run looking merely "partly red" instead of saying what a user would hit.
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
# below can insist the registry is serving THIS run's build. Without that, a smoke job that
# beats the index regeneration installs the previous build, passes, and proves nothing.
- name: Channel + expected version
run: |
git config --global --add safe.directory "$PWD"
eval "$(bash scripts/ci/pf-version.sh)"
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "EXPECT_VERSION=$V" >> "$GITHUB_ENV"
echo "expecting $V in apt distribution '$DIST'"
# The support matrix, asserted rather than asserted-in-prose. Each row names the packages
# that MUST install on that distro; anything absent from the row is expected not to and is
# not checked here (the client's glibc 2.43 floor keeps it off 24.04 and Debian 13 —
# see docs-site/content/docs/debian.md).
- name: Install from the apt registry on every supported distro
run: bash scripts/ci/deb-install-smoke.sh
env:
PF_APT_DISTRIBUTION: ${{ env.DISTRIBUTION }}
PF_EXPECT_VERSION: ${{ env.EXPECT_VERSION }}
+6
View File
@@ -96,6 +96,12 @@ jobs:
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
- image: punktfunk-rust-ci-noble
dockerfile: ci/rust-ci-noble.Dockerfile
# Debian 13 gamescope builder. The gamescope .deb used to be built in the noble image
# and NEVER once succeeded there — noble's wayland is 1.22.0 and the vendored wlroots
# 0.19.3 floors it at 1.23.1, so two releases shipped without the package. trixie is the
# oldest apt distro the tree configures on. Consumed by deb.yml's build-publish-gamescope.
- image: punktfunk-gamescope-trixie
dockerfile: ci/gamescope-trixie.Dockerfile
- image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
+58
View File
@@ -198,6 +198,64 @@ Streaming sessions still hold the box awake through their own `PowerRequest` ass
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
third-party virtual audio driver misbehaves while its render side is paused.
### Windows host — audio no longer costs local-game frame time
🛑 **The host could tank a locally-played game's frame lows** (field-reported 2026-08-12:
Helldivers 2 at 1% lows of 25 FPS, cured by uninstalling). Two mechanisms, both fixed:
- **The minted-endpoint retry storm.** The virtual-mic resolve ran a FULL provisioning pass on
every reopen with no cooldown, no in-flight guard, and no give-up — and the pass reached
`UpdateDriverForPlugAndPlayDevicesW` even over an already-existing devnode. On a box where
minting cannot converge, the pump's reopen backoff (capped 60 s) turned that into a SetupAPI
sweep + PnP driver re-bind + default-device writes roughly once a minute, forever — each
raising the system-wide device-change broadcast games service by rebuilding their audio
graphs. Provisioning now short-circuits to a no-PnP fast path while the minted devices are
healthy, waits on an in-flight pass instead of racing a second one, honours the 60 s retry
cooldown from the blocking path too, and stops for the host lifetime after five unlatched
passes (a service restart re-arms minting).
- **Session tuning never reverted.** The first streaming session put the whole host process at
HIGH priority class with a 1 ms global timer (`timeBeginPeriod`) and DWM MMCSS, documented as
"reverts at process exit" — but the host is a 24/7 service, so after one stream it competed
at HIGH priority against whatever the user played locally, forever. The process-wide tuning
is now refcounted across the hot stream threads and reverts when the last one exits
(= session teardown), the same lifetime the per-thread MMCSS effects already ride.
### Debian 13 is a supported target, and `punktfunk-gamescope` reaches apt for the first time
🛑 **The `punktfunk-gamescope` .deb had never been published — not once, in any release.** It was
built inside the host job's Ubuntu 24.04 image, where it cannot build: our pin vendors wlroots
0.19.3, which floors `wayland-server` at 1.23.1, and noble ships 1.22.0 (it also has no
`libxcb-errors-dev` and only libdisplay-info 0.1.1). Every rung of that path was a `::warning::`
returning 0, and the one hard gate ran last by design so good artifacts still shipped — so
**v0.26.0 and v0.27.0 both released with the package missing** while the release notes and
docs-site told Debian/Ubuntu users to `apt install` it. The same tag shipped it fine for Arch,
Fedora 44 and Bazzite; apt was the only platform affected.
It now has its own job on **Debian 13** (`ci/gamescope-trixie.Dockerfile`), the oldest apt base the
tree configures on. One package serves Debian 13 **and** Ubuntu 26.04 — verified by installing and
running it on both — because the build additionally vendors libdisplay-info
(`build-punktfunk-gamescope.sh --extra-fallback libdisplay-info`, opt-in so the Arch/Fedora/nix
outputs are unchanged): linked against the distro copy it would demand `libdisplay-info2` on trixie,
which Ubuntu 26.04 does not have (it carries `libdisplay-info3`). **Ubuntu 24.04 gets no gamescope
package** — its wayland is too old to run one, however it is built.
**Debian 13 is now a documented, CI-tested host target** ([docs](https://docs.punktfunk.unom.io/docs/debian)).
It required no packaging change: the host .deb's glibc-2.39 floor and bundled FFmpeg already made
it installable, and it had been working for a long time while docs-site said Debian was unsupported
and unverified. The desktop **client** remains Ubuntu-26.04-only (built there, floors at
`libc6 >= 2.43`; Debian 13 has 2.41).
**Cinnamon (Linux Mint, LMDE) cannot host a virtual display**, and compositor detection now says
so instead of advising a `PUNKTFUNK_COMPOSITOR` value that cannot help. Muffin forked from Mutter
3.36: `org.cinnamon.Muffin.ScreenCast` has only `RecordMonitor`/`RecordWindow`, never
`RecordVirtual`, and `xdg-desktop-portal-xapp` implements no ScreenCast at all. The error names the
route that does work on those boxes — a headless gamescope, which needs no desktop compositor.
New CI job **`smoke-install`** installs every published package from the registry in pristine
`ubuntu:24.04`, `ubuntu:26.04` and `debian:trixie` images and asserts the version served is the one
the run just built. Nothing in `deb.yml` had ever installed a package it produced, which is how
both facts above survived for so long.
## v0.27.0
87 commits since v0.26.0.
+1 -1
View File
@@ -100,7 +100,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
| Platform | Install | Guide |
|--------|---------|-------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
+68
View File
@@ -0,0 +1,68 @@
# Builder for the `punktfunk-gamescope` .deb — Debian 13 (trixie).
#
# WHY THIS EXISTS, AND WHY IT IS NOT THE NOBLE IMAGE:
# The gamescope .deb was built in the host job's Ubuntu 24.04 (noble) image, and it has NEVER once
# succeeded there — v0.26.0 and v0.27.0 both shipped with no gamescope package while the release
# notes and docs-site said it was apt-installable. The failure is structural, not a flaky dep:
#
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
# subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
#
# Our gamescope pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1. Noble ships
# 1.22.0 and will never ship more — so no amount of `apt-get install` in that image can fix it.
# Noble also has no `libxcb-errors-dev` at all and only libdisplay-info 0.1.1 (the tree wants 0.2).
#
# Debian 13 ships wayland 1.23.1 exactly, libxcb-errors 1.0.1 and libdisplay-info 0.2.0 — the
# oldest apt distro the tree actually builds on. Building HERE rather than on Ubuntu 26.04
# (wayland 1.24, libdisplay-info 0.3) is deliberate twice over: it keeps the glibc floor low, and
# it stays on the libdisplay-info 0.2 line the pin was developed against.
#
# WHAT THE RESULTING BINARY RUNS ON — verified by building it and reading the ELF:
# * glibc floor GLIBC_2.38 (the C++ runtime is linked statically by
# build-punktfunk-gamescope.sh, so libstdc++ never enters the NEEDED list)
# * NEEDED libwayland-server.so.0 / libwayland-client.so.0 — wlroots 0.19 calls symbols
# added in 1.23.1, so THAT, not glibc, is the real floor.
# ⇒ Debian 13 (1.23.1) and Ubuntu 26.04 (1.24.0) YES; Ubuntu 24.04 (1.22.0) NO — and 24.04
# could not run this binary however it was built, so nothing is lost by moving off noble.
#
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-gamescope-trixie); consumed by
# the `build-publish-gamescope` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci-noble, the
# first deb.yml run after this image is added needs the image to already exist — seed it once by
# hand (docker build -f ci/gamescope-trixie.Dockerfile -t <registry>/punktfunk-gamescope-trixie:latest ci
# && docker push …) before that job can run.
FROM debian:trixie
ENV DEBIAN_FRONTEND=noninteractive
# nodejs is not optional: the Gitea runner executes the JS actions (checkout/cache) INSIDE this
# container, so an image without it fails before the first `run:` step ever starts.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config cmake meson ninja-build git curl ca-certificates nodejs \
# .deb assembly (dpkg-shlibdeps computes the runtime Depends from the built binary)
dpkg-dev \
# shader compilers gamescope's meson looks for
glslc glslang-tools \
# wayland + protocols. libwayland-dev 1.23.1 is the whole reason this image is Debian.
libwayland-dev wayland-protocols \
# gamescope's own dependency set. `apt-get build-dep gamescope` is useless here — Debian has
# no gamescope package to derive it from — so the tree's needs are named outright, exactly as
# the noble job had to. Kept as ONE transaction on purpose: in an image build a missing name
# SHOULD fail loudly at build time, unlike the workflow's per-package best-effort loop where a
# rename would have silently dropped a dep into a warning nobody reads.
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 \
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
libxcb-ewmh-dev libxcb-icccm4-dev libxcb-errors-dev libxcb-shape0-dev \
libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev xwayland \
&& rm -rf /var/lib/apt/lists/*
# Assert the ONE version that decides whether this image can do its job, so a future Debian base
# bump that regressed it fails HERE (loudly, at image build) instead of in a deb.yml run whose
# gamescope failure has historically been a `::warning::` nobody saw.
RUN set -eux; \
have="$(pkg-config --modversion wayland-server)"; \
pkg-config --atleast-version=1.23.1 wayland-server \
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
echo "wayland-server $have — OK"
+12 -4
View File
@@ -215,9 +215,10 @@ export function useHosts() {
const [views, setViews] = useState<HostView[]>([]);
const [scanning, setScanning] = useState(false);
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
// any of these as "No hosts yet" would blame the user's network for the plugin's problem:
// "client-outdated" — the installed client predates `punktfunk discover`
// "client-unavailable" — there is no client installed at all
// "list-failed" — the refresh itself blew up (backend down, call threw)
const [problem, setProblem] = useState<string | null>(null);
const refresh = useCallback(async () => {
@@ -236,7 +237,11 @@ export function useHosts() {
);
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
// Inline, not a toast: the panel remounts (and refreshes) on every QAM open, so while
// the backend is unhappy a toast here nagged on each open. The panel row also sits next
// to the Refresh button that retries it, which is where the eyes already are.
console.warn("punktfunk: host list refresh failed", e);
setProblem("list-failed");
} finally {
setScanning(false);
}
@@ -454,9 +459,12 @@ export async function startStream(
): Promise<void> {
try {
await launchStream(v.ref, opts);
// No success toast: the user just pressed the button that names this host/card, the QAM
// closes, and Steam's own launch UI takes over — a toast here fired on EVERY launch and
// then sat on top of the starting stream. Failure still toasts (the QAM may already be
// closed, so inline error state would go unseen).
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${v.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
toaster.toast({ title: "Punktfunk", body: `Launch failed${label ? ` (${label})` : ""}: ${e}` });
}
}
+18 -6
View File
@@ -46,15 +46,23 @@ import { OsMark } from "./os-icon";
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
import { TrustSheet } from "./trust";
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut
// and sweeps duplicate entries (the piles a boot race used to mint, one per Steam start).
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
// that works even without a reload. Always ends in a toast so the tap has feedback.
async function recreatePunktfunkShortcut(): Promise<void> {
const appId = await recreateShortcuts();
const { appId, removedDuplicates } = await recreateShortcuts();
toaster.toast({
title: "Punktfunk",
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
body:
appId == null
? "Couldn't create the shortcut"
: removedDuplicates > 0
? `Shortcut restored — removed ${removedDuplicates} duplicate ${
removedDuplicates === 1 ? "entry" : "entries"
}`
: "Shortcut restored to your library",
});
}
@@ -222,12 +230,16 @@ const QamPanel: FC = () => {
label={
problem === "client-unavailable"
? "Punktfunk isnt installed"
: "Update the Punktfunk client"
: problem === "list-failed"
? "Couldnt scan for hosts"
: "Update the Punktfunk client"
}
description={
problem === "client-unavailable"
? "This panel launches the Punktfunk app, which isnt on this Deck yet. Install it in Desktop Mode."
: "This client is too old to find hosts on your network. Saved hosts still work."
: problem === "list-failed"
? "Something went wrong while scanning — Refresh tries again."
: "This client is too old to find hosts on your network. Saved hosts still work."
}
/>
</PanelSectionRow>
@@ -313,7 +325,7 @@ const QamPanel: FC = () => {
<PanelSectionRow>
<ButtonItem
layout="below"
description="Missing the Punktfunk entry in your library? This puts it back."
description="Missing the Punktfunk entry in your library, or seeing several? This puts one back and removes the rest."
onClick={() => void recreatePunktfunkShortcut()}
>
<FaPlus style={{ marginRight: "0.5em" }} />
+220 -39
View File
@@ -44,6 +44,7 @@ declare const SteamClient: {
): Promise<unknown>;
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
TerminateApp(gameId: string, _b: boolean): void;
RemoveShortcut(appId: number): void;
};
};
@@ -62,29 +63,114 @@ declare const collectionStore:
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
// entry never comes back.
declare const appStore:
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
| {
GetAppOverviewByAppID?: (appId: number) => unknown | null;
allApps?: SteamAppOverviewLike[];
}
| undefined;
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
* we can't tell, so assume it exists better to keep reusing than risk a duplicate library
* entry from a false "missing". A confident null means the shortcut was deleted recreate. */
function shortcutStillExists(appId: number): boolean {
// The overview surface we read when scanning the library — Steam internals, so everything is
// optional and accessed defensively.
interface SteamAppOverviewLike {
appid?: number;
display_name?: string;
BIsShortcut?: () => boolean;
}
// Steam-injected global whose WaitForServicesInitialized resolves once the client's app
// services are up (the MoonDeck-verified readiness signal). Services-init alone doesn't
// guarantee the overview map is populated, so it's paired with the hydration witness below.
declare const App:
| { WaitForServicesInitialized?: () => Promise<boolean> }
| undefined;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
let servicesInitialized: Promise<void> | undefined;
function waitForServicesInitialized(): Promise<void> {
servicesInitialized ??= (async () => {
try {
if (typeof App !== "undefined" && App?.WaitForServicesInitialized) {
await App.WaitForServicesInitialized();
}
} catch {
/* no signal — the hydration witness still gates the verdict */
}
})();
return servicesInitialized;
}
/** Has appStore demonstrably finished its initial load? An empty `allApps` means "not yet":
* any account that ever had our shortcut has at least one app, so a populated map is the
* witness that a null overview lookup is an ANSWER rather than a not-loaded-yet. null =
* can't tell (missing global, API drift). */
function appStoreHydrated(): boolean | null {
try {
if (typeof appStore === "undefined" || !appStore) {
return null;
}
const apps = appStore.allApps;
return Array.isArray(apps) ? apps.length > 0 : null;
} catch {
return null;
}
}
/** One overview lookup: true = live, false = absent, null = can't tell. */
function queryShortcutAlive(appId: number): boolean | null {
try {
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
// "true". That is not a stale-data bug but a total one: the guard then answers "still
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
// one is a ReferenceError that optional chaining does NOT prevent.
// "can't tell". `typeof` first: `appStore` is a Steam-injected global, and a bare
// reference to a missing one is a ReferenceError that optional chaining does NOT prevent.
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
return true; // no way to verify — preserve the reuse path
return null;
}
return appStore.GetAppOverviewByAppID(appId) != null;
} catch {
return null;
}
}
// How long to wait for the app store before conceding liveness can't be verified. A Deck boot
// hydrates the store within a few seconds of plugin mount; 30 s is comfortably past any real
// boot, and the wait only burns on the absent/unverifiable paths — a live overview answers on
// the first query. Overview registration can trail the bulk hydration by a beat, so a
// "hydrated but absent" verdict gets one grace recheck before it counts as deleted.
const STORE_WAIT_MS = 30_000;
const STORE_POLL_MS = 1_000;
const STORE_GRACE_MS = 2_000;
/** True if a remembered appId still maps to a live Steam shortcut.
*
* The dangerous verdict is FALSE it sends the caller to AddShortcut, so a wrong "deleted"
* mints a duplicate library entry. And a bare null-overview check gets it wrong on EVERY
* boot: the plugin mounts while Steam is still starting up, before appStore has registered
* its overviews, so the remembered (perfectly live) appId looks up as null and each boot
* added another visible "Punktfunk" the field-reported duplicate pile. Absent is therefore
* only believed once the store is demonstrably hydrated; if that can't be established within
* budget the answer is true, because a false "alive" merely no-ops Set-calls until the next
* ask (and the recreate button re-asks when the store IS ready) while a false "dead"
* duplicates forever. */
async function shortcutStillExists(appId: number): Promise<boolean> {
if (queryShortcutAlive(appId) === true) {
return true;
}
// Race the init signal against the same budget the poll loop gets: a signal that never
// resolves must not wedge the guard (the single-flight ensure would stay occupied forever).
await Promise.race([waitForServicesInitialized(), sleep(STORE_WAIT_MS)]);
for (let waited = 0; waited < STORE_WAIT_MS; waited += STORE_POLL_MS) {
if (queryShortcutAlive(appId) === true) {
return true;
}
if (appStoreHydrated() === true) {
await sleep(STORE_GRACE_MS);
return queryShortcutAlive(appId) !== false; // null = unverifiable → reuse
}
await sleep(STORE_POLL_MS);
}
return true; // store never became inspectable — reusing beats duplicating
}
/** Set a shortcut's library visibility (best-effort, deferred the overview registers a moment
@@ -156,6 +242,67 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
const SHORTCUT_NAME = "Punktfunk";
/** Find an existing "Punktfunk" shortcut to ADOPT instead of minting a new library entry the
* healing path for a lost/wiped appId, and for the duplicate piles the boot race left behind
* in the field: rebind one of the existing entries to the role rather than adding an N+1th.
* (The caller rewrites exe/dir/opts/visibility anyway, so any of them serves.) Only overviews
* Steam itself says are shortcuts qualify, and the other role's remembered id is excluded so
* the two roles never collapse onto one shortcut. */
function findAdoptableShortcut(excludeAppId: number | null): number | null {
try {
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
return null;
}
for (const app of appStore.allApps) {
if (
app?.display_name === SHORTCUT_NAME &&
typeof app.appid === "number" &&
app.appid !== excludeAppId &&
app.BIsShortcut?.() === true
) {
return app.appid;
}
}
} catch {
/* Steam internals drifted — AddShortcut is the fallback */
}
return null;
}
/** Remove every "Punktfunk" shortcut beyond the two remembered role ids the cleanup for
* piles already minted by the boot race. Deliberately reachable ONLY from the user-pressed
* recreate button, never from mount: automatic library deletion at boot is a bigger hazard
* than the mess it would tidy. Returns how many entries were removed. */
function removeDuplicateShortcuts(): number {
let removed = 0;
try {
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
return 0;
}
const keep = [recall(STORAGE_KEY_STREAM), recall(STORAGE_KEY_UI)];
// Snapshot before removing — RemoveShortcut mutates the store's list under the iteration.
const surplus = appStore.allApps.filter(
(app) =>
app?.display_name === SHORTCUT_NAME &&
typeof app.appid === "number" &&
!keep.includes(app.appid) &&
app.BIsShortcut?.() === true,
);
for (const app of surplus) {
SteamClient.Apps.RemoveShortcut(app.appid as number);
try {
localStorage.removeItem(artKey(app.appid as number));
} catch {
/* ignore */
}
removed++;
}
} catch (e) {
console.warn("punktfunk: duplicate-shortcut sweep incomplete", e);
}
return removed;
}
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
// backend can't chmod it back on. Passing the script as an argument to the always-executable
@@ -223,7 +370,7 @@ async function ensureControllerConfig(): Promise<void> {
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
* across reinstalls, and pre-two-shortcut installs had this one visible).
*/
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
async function doEnsureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
const info = await runnerInfo();
if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`);
@@ -232,25 +379,38 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
// outlived it across a reinstall) must fall through, not be silently repointed. On a lost id,
// ADOPT an existing same-named shortcut before AddShortcut so a wiped key never duplicates.
const remembered = recall(STORAGE_KEY_STREAM);
if (remembered != null && shortcutStillExists(remembered)) {
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
void applyArtwork(remembered);
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
let appId =
remembered != null && (await shortcutStillExists(remembered)) ? remembered : null;
if (appId == null) {
appId =
findAdoptableShortcut(recall(STORAGE_KEY_UI)) ??
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
remember(STORAGE_KEY_STREAM, appId);
}
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SteamClient.Apps.SetShortcutExe(appId, SHELL);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
setShortcutHidden(appId, true);
setShortcutHidden(appId, true); // also migrates pre-two-shortcut installs (were visible)
void applyArtwork(appId);
remember(STORAGE_KEY_STREAM, appId);
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
}
// Concurrent ensure calls share one run per role — two ensures racing past the liveness check
// would each AddShortcut, which is exactly the duplicate class this file exists to prevent (and
// the store-readiness wait makes the window real: mount's fire-and-forget ensure can be mid-wait
// when a QAM press arrives). Sequential calls still re-run, so per-launch repointing is kept.
let streamEnsureInFlight: Promise<{ appId: number; runner: string; clientBin: string }> | null =
null;
function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
streamEnsureInFlight ??= doEnsureStreamShortcut().finally(() => {
streamEnsureInFlight = null;
});
return streamEnsureInFlight;
}
/**
* Ensure the GAMEPAD-UI shortcut (visible, stateless) the library-facing "Punktfunk" entry
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
@@ -258,7 +418,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
* kept VISIBLE. Idempotent call on plugin mount so the library entry always exists and stays
* repointed to the current plugin dir. Best-effort: returns null on any failure.
*/
export async function ensureGamepadUiShortcut(): Promise<number | null> {
async function doEnsureGamepadUiShortcut(): Promise<number | null> {
try {
const info = await runnerInfo();
if (!info.exists) {
@@ -275,18 +435,20 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
// library entry actually comes back instead of repointing a dead id.
// localStorage key survived a plugin reinstall) falls through so the visible library entry
// actually comes back instead of repointing a dead id. On a lost id, ADOPT an existing
// same-named shortcut (a boot-race duplicate, or the entry whose key was wiped) before
// AddShortcut — creation is the last resort, never the response to a mere lookup miss.
let appId = recall(STORAGE_KEY_UI);
if (appId != null && shortcutStillExists(appId)) {
SteamClient.Apps.SetShortcutExe(appId, SHELL);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
} else {
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
if (appId == null || !(await shortcutStillExists(appId))) {
appId =
findAdoptableShortcut(recall(STORAGE_KEY_STREAM)) ??
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
remember(STORAGE_KEY_UI, appId);
}
SteamClient.Apps.SetShortcutExe(appId, SHELL);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
setShortcutHidden(appId, false); // the visible library entry
void applyArtwork(appId);
@@ -297,18 +459,32 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
}
}
// Same single-flight rule as the stream role (see ensureStreamShortcut).
let uiEnsureInFlight: Promise<number | null> | null = null;
export function ensureGamepadUiShortcut(): Promise<number | null> {
uiEnsureInFlight ??= doEnsureGamepadUiShortcut().finally(() => {
uiEnsureInFlight = null;
});
return uiEnsureInFlight;
}
/**
* Force the visible "Punktfunk" library entry back into existence the recovery button for
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
* self-heal-on-mount can't deleting the shortcut WITHOUT reinstalling (no mount no ensure).
* Returns the (new or existing) visible appId, or null on failure.
* Also sweeps surplus "Punktfunk" entries (the piles the boot race minted before the store-
* readiness gate existed) the button is where that cleanup lives, never mount. Returns the
* (new or existing) visible appId (null on failure) plus how many duplicates were removed.
*/
export async function recreateShortcuts(): Promise<number | null> {
export async function recreateShortcuts(): Promise<{
appId: number | null;
removedDuplicates: number;
}> {
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
const id = recall(key);
if (id != null && !shortcutStillExists(id)) {
if (id != null && !(await shortcutStillExists(id))) {
try {
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
localStorage.removeItem(key);
@@ -317,8 +493,13 @@ export async function recreateShortcuts(): Promise<number | null> {
}
}
}
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
return ensureGamepadUiShortcut();
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next
// launch. Sweep AFTER the ensure so the remembered ids are fresh — and only when the ensure
// succeeded: on a failed ensure the "keep" list can't be trusted, and deleting candidates a
// later ensure would adopt could leave the library with no entry at all.
const appId = await ensureGamepadUiShortcut();
const removedDuplicates = appId != null ? removeDuplicateShortcuts() : 0;
return { appId, removedDuplicates };
}
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
+16 -3
View File
@@ -13,7 +13,13 @@
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
#![forbid(unsafe_code)]
// `deny`, not `forbid`: edition 2024 makes the std process-environment mutators unsafe
// (WP20 — the env-mutation class made visible; named-API mentions here would count against
// the unsafe-hygiene gate C baseline, which tracks this file's real call sites), and this
// bin's three single-threaded-startup env writes carry documented SAFETY comments under
// localized `#[allow(unsafe_code)]` (the pf-update idiom). A `forbid` cannot be overridden
// at those sites and refuses the file.
#![deny(unsafe_code)]
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
@@ -533,6 +539,7 @@ mod session_main {
/// initialises, so a call placed after them leaves the triage tool describing a device
/// that cannot decode while the streaming path decodes on it.
#[cfg(target_os = "linux")]
#[allow(unsafe_code)] // the two SAFETY-commented single-threaded-startup env writes below
fn enable_radv_video_decode() {
const TOKEN: &str = "video_decode";
match std::env::var("RADV_PERFTEST") {
@@ -840,7 +847,10 @@ mod session_main {
// SAFETY: still the single-threaded startup stretch of `run()` — the
// early-exit probes above return out of the process, and everything that
// spawns threads (the session, the console, SDL) only starts below.
unsafe { std::env::set_var(var, value) };
#[allow(unsafe_code)]
unsafe {
std::env::set_var(var, value)
};
}
}
}
@@ -856,7 +866,10 @@ mod session_main {
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
// SAFETY: as the settings block above — single-threaded startup, before SDL
// (the reader of these variables) or any other thread exists.
unsafe { std::env::remove_var(var) };
#[allow(unsafe_code)]
unsafe {
std::env::remove_var(var)
};
}
}
+72 -15
View File
@@ -8,14 +8,16 @@
//!
//! Raw C-ABI FFI (winmm/kernel32/dwmapi/avrt) rather than the `windows` crate so it builds without
//! pulling new windows-rs features. No-op on non-Windows. Per-thread effects (MMCSS, execution
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
//! state) auto-revert at thread exit (= session end); the process-wide bits are refcounted over
//! the hot threads and revert when the LAST one exits — the host must not keep HIGH priority and
//! a 1 ms global timer while a local game runs and nobody streams (2026-08-12 field report).
//! See `design/host-latency-plan.md` Tier 3A.
#[cfg(target_os = "windows")]
mod imp {
#![allow(non_snake_case)]
use std::ffi::c_void;
use std::sync::OnceLock;
use std::sync::Mutex;
type Handle = *mut c_void;
type Bool = i32;
@@ -23,6 +25,7 @@ mod imp {
#[link(name = "winmm")]
unsafe extern "system" {
fn timeBeginPeriod(uPeriod: u32) -> u32;
fn timeEndPeriod(uPeriod: u32) -> u32;
}
#[link(name = "kernel32")]
unsafe extern "system" {
@@ -55,6 +58,7 @@ mod imp {
}
const HIGH_PRIORITY_CLASS: u32 = 0x0000_0080;
const NORMAL_PRIORITY_CLASS: u32 = 0x0000_0020;
const ES_CONTINUOUS: u32 = 0x8000_0000;
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
const ES_DISPLAY_REQUIRED: u32 = 0x0000_0002;
@@ -114,16 +118,19 @@ mod imp {
}
}
static PROCESS_TUNED: OnceLock<()> = OnceLock::new();
/// Live hot (session) threads. A Mutex, not an atomic: the 0↔1 transitions carry the
/// apply/revert side effects, and an interleaved fetch_add/fetch_sub pair could otherwise
/// finish with a running session untuned (transitions are rare — thread start/exit only).
static HOT_THREADS: Mutex<usize> = Mutex::new(0);
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
fn tune_process_once() {
/// Process-wide tuning, applied when the FIRST hot thread registers. Best-effort: each call
/// is independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
fn tune_process() {
// SAFETY: each call is a C-ABI FFI into winmm/kernel32/dwmapi declared with a matching
// `extern "system"` signature; every argument is a plain integer (no pointers/buffers escape),
// and `GetCurrentProcess()` returns the current-process pseudo-handle (a constant, always valid,
// never closed). The body runs inside `get_or_init`, so it executes exactly once per process.
PROCESS_TUNED.get_or_init(|| unsafe {
// never closed).
unsafe {
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
// encode|send split's sub-ms sleeps.
timeBeginPeriod(1);
@@ -134,16 +141,66 @@ mod imp {
// control/capture/encode/send threads on the CPU (Apollo does the same).
SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);
tracing::info!("windows session tuning applied (timer 1ms, DWM MMCSS, HIGH priority)");
});
}
}
/// Call at the start of each capture/encode/send (hot stream) thread. Applies the process-wide
/// tuning once, registers the calling thread with MMCSS ("Games"), and asserts the display/system
/// must stay awake for as long as this thread lives. The MMCSS handle is intentionally leaked and
/// the execution-state assertion is bound to this thread — both are reverted by the OS when the
/// thread exits, so a session that ends tears them down without explicit bookkeeping.
/// The mirror of [`tune_process`], run when the LAST hot thread exits. Leaving the tuning in
/// place used to be the design ("reverts at process exit") — but the host is a 24/7 service,
/// so after one stream it competed at HIGH class with a 1 ms global timer against whatever
/// the user played locally, forever.
fn untune_process() {
// SAFETY: same FFI surface as `tune_process` — plain-integer arguments, constant
// pseudo-handle, no pointers or buffers.
unsafe {
timeEndPeriod(1); // pairs the timeBeginPeriod(1)
DwmEnableMMCSS(0);
SetPriorityClass(GetCurrentProcess(), NORMAL_PRIORITY_CLASS);
tracing::info!("windows session tuning reverted (timer, DWM MMCSS, NORMAL priority)");
}
}
/// One per hot thread, parked in TLS by [`on_hot_thread`]; its Drop runs at thread exit
/// (= session teardown), the same lifetime the MMCSS/execution-state effects already ride.
struct HotThreadGuard;
impl Drop for HotThreadGuard {
fn drop(&mut self) {
// A poisoned lock skips the revert (best-effort, like every call here) instead of
// panicking inside a TLS destructor.
if let Ok(mut n) = HOT_THREADS.lock() {
*n -= 1;
if *n == 0 {
untune_process();
}
}
}
}
thread_local! {
static HOT_THREAD: std::cell::OnceCell<HotThreadGuard> =
const { std::cell::OnceCell::new() };
}
/// Call at the start of each capture/encode/send (hot stream) thread. Registers the thread in
/// the process-tuning refcount (first in applies, last out reverts), registers it with MMCSS
/// ("Games"), and asserts the display/system must stay awake for as long as this thread lives.
/// The MMCSS handle is intentionally leaked and the execution-state assertion is bound to this
/// thread — both are reverted by the OS when the thread exits, and the refcount guard's TLS
/// Drop runs there too, so a session that ends tears everything down without explicit
/// bookkeeping.
pub fn on_hot_thread() {
tune_process_once();
HOT_THREAD.with(|slot| {
if slot.get().is_none() {
{
let mut n = HOT_THREADS.lock().unwrap();
*n += 1;
if *n == 1 {
tune_process();
}
}
let _ = slot.set(HotThreadGuard);
}
});
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. SetThreadExecutionState
// takes only flag bits. `task` is a local NUL-terminated UTF-16 buffer ("Games\0") alive for the
// whole block, so `task.as_ptr()` is a valid LPCWSTR for the call, and `&mut idx` is a live local
+11 -1
View File
@@ -63,7 +63,17 @@ pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
let module = GetModuleHandleW(std::ptr::null());
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
let px = GetSystemMetrics(metric);
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
// MAKEINTRESOURCE(1): an integer resource ordinal smuggled through the name
// pointer, never dereferenced — `without_provenance` says exactly that (and
// `1 as *const u16` reads as a dangling pointer to clippy 1.96).
let icon = LoadImageW(
module,
std::ptr::without_provenance(1),
IMAGE_ICON,
px,
px,
LR_DEFAULTCOLOR,
);
if !icon.is_null() {
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
}
+141 -19
View File
@@ -321,11 +321,7 @@ pub fn detect() -> Result<Compositor> {
#[cfg(target_os = "linux")]
{
if let Some(v) = pf_host_config::config().compositor.as_deref() {
return compositor_from_pin(v).ok_or_else(|| {
anyhow::anyhow!(
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
)
});
return compositor_from_pin(v).ok_or_else(|| unknown_pin_error(v));
}
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
return Ok(c);
@@ -338,20 +334,78 @@ pub fn detect() -> Result<Compositor> {
let desktop = with_env_lock(|| std::env::var("XDG_CURRENT_DESKTOP"))
.unwrap_or_default()
.to_ascii_uppercase();
if desktop.contains("KDE") {
Ok(Compositor::Kwin)
} else if desktop.contains("GNOME") {
Ok(Compositor::Mutter)
} else if desktop.contains("HYPRLAND") {
Ok(Compositor::Hyprland)
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
compositor_from_xdg(&desktop)
}
}
/// The error for a `PUNKTFUNK_COMPOSITOR` value that names no backend.
///
/// `cinnamon`/`muffin` get their own answer rather than the bare list: it is the value a Mint or
/// LMDE user reaches for first, and the plain list invites them to try the next-closest name
/// (`mutter` — Muffin *is* a Mutter fork), which starts a session that then fails deep inside a
/// `org.gnome.Mutter.ScreenCast` call Muffin does not serve. There is no working value; say so, and
/// name the route that does work.
#[cfg(target_os = "linux")]
fn unknown_pin_error(v: &str) -> anyhow::Error {
const ACCEPTED: &str = "kwin|wlroots|hyprland|mutter|gamescope";
if matches!(
v.trim().to_ascii_lowercase().as_str(),
"cinnamon" | "muffin"
) {
return anyhow::anyhow!(
"PUNKTFUNK_COMPOSITOR='{v}' is not a backend and cannot become one: Cinnamon's \
compositor Muffin has no virtual-output API (no `RecordVirtual`), so it cannot make a \
screen for a client. Do NOT substitute 'mutter' Muffin is a Mutter fork but serves \
none of that interface. Use PUNKTFUNK_COMPOSITOR=gamescope to stream games through a \
headless gamescope, which needs no desktop compositor. See \
https://docs.punktfunk.unom.io/docs/debian#cinnamon-linux-mint-and-lmde"
);
}
anyhow::anyhow!("unknown PUNKTFUNK_COMPOSITOR '{v}' ({ACCEPTED})")
}
/// The last-resort `XDG_CURRENT_DESKTOP` sniff, as a **pure function of the (uppercased) value** so
/// its branches — including the two that only ever produce an error — are testable without mutating
/// process-global env. Called only by [`detect`], after both the operator pin and live-session
/// detection have come up empty.
#[cfg(target_os = "linux")]
fn compositor_from_xdg(desktop: &str) -> Result<Compositor> {
// CINNAMON is tested FIRST, ahead of GNOME, and the order is load-bearing rather than
// stylistic: Cinnamon is a GNOME derivative, so a session that advertises both (`X-Cinnamon`
// alongside a GNOME-compatibility token) would otherwise match the GNOME arm and be handed the
// Mutter backend — which then fails deep in a `org.gnome.Mutter.ScreenCast` call that Muffin
// does not serve, i.e. an obscure D-Bus error instead of the explanation below. The more
// specific desktop wins.
if desktop.contains("CINNAMON") {
// Linux Mint / LMDE report `X-Cinnamon`. Cinnamon is NOT a missing backend we could add —
// its compositor (Muffin) exposes no virtual-output API at all: the fork base is Mutter
// 3.36, and `org.cinnamon.Muffin.ScreenCast` carries only `RecordMonitor` / `RecordWindow`,
// never Mutter 42+'s `RecordVirtual`. Its portal backend (xdg-desktop-portal-xapp)
// implements no ScreenCast either, so the sway/Hyprland portal route is closed too. The
// generic message below would send a Cinnamon user hunting for the setting that turns it
// on; there isn't one. Name the ONE route that does work on that box — a headless
// gamescope, which needs no desktop compositor at all — instead of a dead end.
anyhow::bail!(
"Cinnamon (XDG_CURRENT_DESKTOP='{desktop}') cannot host a virtual display: its \
compositor Muffin has no virtual-output API, so Punktfunk cannot create a screen \
for a client on it. Stream games instead by setting PUNKTFUNK_COMPOSITOR=gamescope \
in host.env the host then spawns its own headless gamescope per connect and needs \
no desktop session. See \
https://docs.punktfunk.unom.io/docs/debian#cinnamon-linux-mint-and-lmde"
)
} else if desktop.contains("KDE") {
Ok(Compositor::Kwin)
} else if desktop.contains("GNOME") {
Ok(Compositor::Mutter)
} else if desktop.contains("HYPRLAND") {
Ok(Compositor::Hyprland)
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
}
@@ -811,6 +865,74 @@ mod wlroots;
mod tests {
use super::*;
/// The XDG sniff is the last thing standing between an unrecognized desktop and a useless
/// error, and `mgmt/display.rs` puts that error VERBATIM in the console's `/display/monitors`
/// response — so its exact wording is a user-facing surface, tested as one.
#[cfg(target_os = "linux")]
#[test]
fn xdg_sniff_maps_known_desktops() {
// Real-world values, uppercased the way `detect` hands them over.
assert_eq!(compositor_from_xdg("KDE").unwrap(), Compositor::Kwin);
assert_eq!(compositor_from_xdg("GNOME").unwrap(), Compositor::Mutter);
assert_eq!(
compositor_from_xdg("UBUNTU:GNOME").unwrap(),
Compositor::Mutter
);
assert_eq!(
compositor_from_xdg("HYPRLAND").unwrap(),
Compositor::Hyprland
);
assert_eq!(compositor_from_xdg("SWAY").unwrap(), Compositor::Wlroots);
}
/// Cinnamon must NOT fall into the generic "set PUNKTFUNK_COMPOSITOR" arm: Muffin has no
/// virtual-output API, so there is no value of that variable which makes a Cinnamon desktop
/// host a virtual display. The error has to name gamescope — the one route that works on an
/// LMDE/Mint box — or the user is sent hunting for a setting that does not exist.
#[cfg(target_os = "linux")]
#[test]
fn cinnamon_is_told_to_use_gamescope_not_to_pick_a_backend() {
// `X-Cinnamon` is what Mint and LMDE actually set.
for v in ["X-CINNAMON", "CINNAMON", "X-CINNAMON:GNOME-FLASHBACK"] {
let err = compositor_from_xdg(v)
.expect_err("Cinnamon cannot host a virtual display")
.to_string();
assert!(err.contains("gamescope"), "no gamescope route named: {err}");
assert!(err.contains("Muffin"), "does not say why: {err}");
}
}
/// Pinning `cinnamon` explicitly must not answer with the plain list of accepted values: the
/// next thing a Mint user tries is `mutter` (Muffin is a Mutter fork), which fails much later
/// and much less clearly. A typo'd pin still gets the ordinary list.
#[cfg(target_os = "linux")]
#[test]
fn pinning_cinnamon_explains_instead_of_listing_backends() {
for v in ["cinnamon", "Cinnamon", "muffin", " MUFFIN "] {
let err = unknown_pin_error(v).to_string();
assert!(err.contains("gamescope"), "no working route named: {err}");
assert!(
err.contains("Muffin"),
"does not explain why it cannot work: {err}"
);
}
let typo = unknown_pin_error("kwim").to_string();
assert!(
typo.contains("kwin|wlroots|hyprland|mutter|gamescope"),
"{typo}"
);
assert!(!typo.contains("Muffin"), "{typo}");
}
/// An unknown desktop keeps the generic advice — the Cinnamon arm must not swallow it.
#[cfg(target_os = "linux")]
#[test]
fn unknown_desktop_keeps_the_generic_error() {
let err = compositor_from_xdg("XFCE").unwrap_err().to_string();
assert!(err.contains("PUNKTFUNK_COMPOSITOR"), "{err}");
assert!(!err.contains("Muffin"), "{err}");
}
#[test]
fn active_kind_maps_to_its_backend() {
assert_eq!(
+24 -10
View File
@@ -46,6 +46,7 @@ use pf_bitstream::h264::PlanError;
use pf_bitstream::h264::PlanWarning;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::derive_caps;
use crate::caps::query_h264_caps;
@@ -685,6 +686,9 @@ pub struct VkH264Decoder {
/// Session generation: bumped on every rebuild, stamped into frames.
generation: u64,
device_lost: bool,
/// The over-declared-level warning has fired (once per decoder — the condition
/// is a property of the stream's SPS, so repeating it per AU is noise).
level_clamp_warned: bool,
}
impl VkH264Decoder {
@@ -723,6 +727,7 @@ impl VkH264Decoder {
decoded: 0,
generation: 0,
device_lost: false,
level_clamp_warned: false,
})
}
@@ -1365,18 +1370,26 @@ impl VkH264Decoder {
unsafe { query_h264_caps(&self.dev, std_profile) }.map_err(VkDecodeError::from)?;
self.caps = Some((std_profile, derive_caps(&raw)?));
}
// The level gate: a stream above the device's maxLevelIdc is refused up
// front (within one codec the Std code points ascend with the level, so
// the comparison is numeric), never submitted on a hope. The ceiling came
// from an H.264 caps query, so it is compared against an H.264 code point
// — the pairing MaxLevelIdc's tag exists to keep honest.
// The declared level vs the device ceiling: a DECLARED level above
// `maxLevelIdc` is NOT a refusal — encoders over-claim levels in the wild
// (the H.265 twin carries the field evidence: AMF stamps the codec
// maximum). The stream's REAL demands are enforced where they are
// physical facts — coded extent and DPB depth, checked in
// `rebuild_state` — and the session's parameter sets are clamped to the
// ceiling (`SessionConfig::max_level_idc`) so the driver is never handed
// a level above its caps. The comparison stays within one codec's Std
// code space (`MaxLevelIdc`'s tag carries that argument).
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = level_to_std(plan.picture.level_idc);
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (Std code point {stream_level}) above the device's \
maxLevelIdc ({caps_max_level})"
)));
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
self.level_clamp_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an H.264 level above the device ceiling — the \
declared level is advisory (over-declared by some encoders); \
proceeding with the parameter sets clamped to the ceiling"
);
}
let coded = vk::Extent2D {
width: plan.picture.coded_width,
@@ -1488,6 +1501,7 @@ impl VkH264Decoder {
max_dpb_slots: required_slots,
max_active_references: (required_slots - 1).min(caps.max_active_references),
std_profile_idc: std_profile,
max_level_idc: caps.max_level_idc.code_point(),
};
let mut pool_plan = plan_pools(caps, required_slots);
// TEST-ONLY readback hook: the GPU parity test (tests/gpu_parity.rs)
+26 -10
View File
@@ -57,6 +57,7 @@ use pf_bitstream::h265::PlanError;
use pf_bitstream::h265::PlanWarning;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::DecodeCaps;
use crate::caps::DecodeProfile;
@@ -219,6 +220,9 @@ pub struct VkH265Decoder {
/// Recovery owed after a failed AU whose planning had already advanced
/// ([`RecoveryLatch`] docs for the whole argument).
recovery: RecoveryLatch,
/// The over-declared-level warning has fired (once per decoder — the condition
/// is a property of the stream's SPS, so repeating it per AU is noise).
level_clamp_warned: bool,
}
impl VkH265Decoder {
@@ -266,6 +270,7 @@ impl VkH265Decoder {
generation: 0,
device_lost: false,
recovery: RecoveryLatch::default(),
level_clamp_warned: false,
})
}
@@ -1004,18 +1009,28 @@ impl VkH265Decoder {
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
self.caps = Some((key, derive_caps_h265(&raw, wanted)?));
}
// The level gate: a stream above the device's maxLevelIdc is refused up
// front (within one codec the Std code points ascend with the level, so
// the comparison is numeric), never submitted on a hope. The ceiling came
// from an H.265 caps query, so it is compared against an H.265 code point
// — the pairing MaxLevelIdc's tag exists to keep honest.
// The declared level vs the device ceiling: a DECLARED level above
// `maxLevelIdc` is NOT a refusal. The level in an SPS is a claim, and
// encoders over-claim in the wild — AMF stamps 6.2 (the codec maximum)
// on 4K120 streams that need 5.2, which on an RTX 5060 (ceiling 6.1)
// demoted every HEVC session to D3D11VA (2026-08-12 field report). The
// stream's REAL demands are enforced where they are physical facts:
// coded extent and DPB depth, checked in `rebuild_state`. The session's
// parameter sets are clamped to the ceiling (`SessionConfigH265::
// max_level_idc`) so the driver is never handed a level above its caps,
// and the comparison stays within one codec's Std code space
// (`MaxLevelIdc`'s tag carries that argument).
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = level_to_std_h265(plan.picture.level_idc);
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (Std code point {stream_level}) above the device's \
maxLevelIdc ({caps_max_level})"
)));
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
self.level_clamp_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an H.265 level above the device ceiling — the \
declared level is advisory (over-declared by some encoders); \
proceeding with the parameter sets clamped to the ceiling"
);
}
let coded = vk::Extent2D {
width: plan.picture.coded_width,
@@ -1108,6 +1123,7 @@ impl VkH265Decoder {
max_dpb_slots: required_slots,
max_active_references: (required_slots - 1).min(caps.max_active_references),
profile: key,
max_level_idc: caps.max_level_idc.code_point(),
};
let mut pool_plan = plan_pools(caps, required_slots);
// TEST-ONLY readback hook, exactly as the H.264 decoder's: the parity
+31
View File
@@ -115,6 +115,17 @@ impl OwnedStdSps {
pub fn std(&self) -> &hh::StdVideoH264SequenceParameterSet {
&self.std
}
/// Lower `level_idc` to `max` when the stream declares a higher one. The
/// declared level is a claim encoders over-state in the wild, and a set above
/// the device's `maxLevelIdc` is invalid usage; the stream's real demands are
/// enforced by the session's coded extent and DPB depth. The "no mutation"
/// contract above is about a LIVE object's blocks — this runs before handover.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH264LevelIdc) {
if self.std.level_idc > max {
self.std.level_idc = max;
}
}
}
/// The converted PPS plus the scaling-list allocation its `pScalingLists` targets.
@@ -831,4 +842,24 @@ mod tests {
ParamsError::InvalidWeightedBipredIdc(3)
);
}
/// The over-declared-level clamp ([`OwnedStdSps::clamp_level`]): lowering
/// writes the ceiling into the Std SPS; a ceiling at or above the declared
/// level changes nothing.
#[test]
fn clamp_level_lowers_and_only_lowers() {
let sps = full_sps();
let declared = level_to_std(sps.level_idc);
let mut owned = sps_to_std(&sps).unwrap();
assert_eq!(owned.std().level_idc, declared);
// A ceiling above the declared level is a no-op.
owned.clamp_level(hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2);
assert_eq!(owned.std().level_idc, declared);
// A ceiling below it is written through.
let ceiling = hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1;
assert!(ceiling < declared, "fixture declares above 3.1");
owned.clamp_level(ceiling);
assert_eq!(owned.std().level_idc, ceiling);
}
}
+54
View File
@@ -202,6 +202,19 @@ impl OwnedStdH265Vps {
pub fn std(&self) -> &hh::StdVideoH265VideoParameterSet {
&self.std
}
/// Lower the profile/tier/level block's `general_level_idc` to `max` when the
/// stream declares a higher one. The declared level is a CLAIM, and encoders
/// over-claim in the wild (AMF stamps 6.2 — the codec maximum — on streams that
/// need 5.2); handing the driver a level above its `maxLevelIdc` is invalid
/// usage, while the stream's real demands are enforced by the session's coded
/// extent and DPB depth. The "no mutation" ownership contract is about blocks a
/// LIVE parameters object points at; this runs before the set is handed over.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
if self._ptl_backing.general_level_idc > max {
self._ptl_backing.general_level_idc = max;
}
}
}
/// The converted SPS plus the heap allocations its embedded pointers target.
@@ -229,6 +242,14 @@ impl OwnedStdH265Sps {
pub fn std(&self) -> &hh::StdVideoH265SequenceParameterSet {
&self.std
}
/// Lower `general_level_idc` to the device ceiling — [`OwnedStdH265Vps::clamp_level`]
/// carries the argument.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
if self._ptl_backing.general_level_idc > max {
self._ptl_backing.general_level_idc = max;
}
}
}
/// The converted PPS plus the scaling-list allocation its `pScalingLists`
@@ -2000,4 +2021,37 @@ mod tests {
"the vector opens with VPS + SPS + PPS"
);
}
/// The over-declared-level clamp (the AMF 6.2-on-everything field case):
/// lowering writes the ceiling into the PTL backing the driver will read;
/// a ceiling at or above the declared level changes nothing.
#[test]
fn clamp_level_lowers_the_ptl_and_only_lowers() {
let sps = full_sps();
let declared = level_to_std(sps.profile_tier_level.general_level_idc);
let mut owned = sps_to_std_h265(&sps).unwrap();
// SAFETY: pProfileTierLevel targets `owned`'s boxed backing.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, declared);
// A ceiling above the declared level is a no-op.
owned.clamp_level(hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2);
// SAFETY: as above.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, declared);
// A ceiling below it is written through — and the pointer still targets
// the wrapper's own backing (the clamp mutates in place, never re-points).
let ceiling = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1;
assert!(ceiling < declared, "fixture declares above 3.1");
owned.clamp_level(ceiling);
// SAFETY: as above.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, ceiling);
let mut owned_vps = fallback_vps_from_sps(&sps).unwrap();
owned_vps.clamp_level(ceiling);
// SAFETY: as above, the VPS wrapper's own backing.
let vps_level = unsafe { (*owned_vps.std().pProfileTierLevel).general_level_idc };
assert!(vps_level <= ceiling);
}
}
+10 -2
View File
@@ -168,6 +168,10 @@ pub struct SessionConfig {
/// The Std profile the session was created against (a profile change is a
/// renegotiation too).
pub std_profile_idc: hh::StdVideoH264ProfileIdc,
/// The device's `maxLevelIdc` for this profile (Std code point). Every SPS
/// handed to the parameters object has its declared level clamped to this —
/// see `SessionConfigH265::max_level_idc` for the whole argument.
pub max_level_idc: hh::StdVideoH264LevelIdc,
}
/// Session creation/parameter failures the decoder maps into its error type.
@@ -593,11 +597,14 @@ impl VideoSession {
match action {
ParamsAction::Current => Ok(()),
ParamsAction::Add { add_sps, add_pps } => {
let owned_sps = if add_sps {
let mut owned_sps = if add_sps {
Some(sps_to_std(sps)?)
} else {
None
};
if let Some(s) = owned_sps.as_mut() {
s.clamp_level(self.config.max_level_idc);
}
let owned_pps = if add_pps {
Some(pps_to_std(pps)?)
} else {
@@ -643,7 +650,8 @@ impl VideoSession {
pps_id = pps.pic_parameter_set_id,
"recreating session parameters (content change or capacity)"
);
let owned_sps = sps_to_std(sps)?;
let mut owned_sps = sps_to_std(sps)?;
owned_sps.clamp_level(self.config.max_level_idc);
let owned_pps = pps_to_std(pps)?;
// SAFETY: fn contract — live device + live session. The wrappers
// are MOVED IN and come back owned by the fresh object, so they
+18 -4
View File
@@ -260,6 +260,12 @@ pub struct SessionConfigH265 {
/// format / bit depths, all four of which a stream can renegotiate (an SPS
/// switching Main→Main 10 mid-stream is a session rebuild, not an update).
pub profile: H265ProfileKey,
/// The device's `maxLevelIdc` for this profile (Std code point). Every VPS/SPS
/// handed to the parameters object has its declared level clamped to this —
/// over-declared levels are common (AMF stamps 6.2 on 4K streams) and a set
/// above the ceiling is invalid usage, while the stream's real demands are
/// already enforced by `max_coded_extent` / `max_dpb_slots`.
pub max_level_idc: hh::StdVideoH265LevelIdc,
}
/// A live parameters object **and every Std parameter set it was given**, in one
@@ -525,12 +531,18 @@ impl VideoSessionH265 {
} => {
// Every owned wrapper below stays alive until after the update
// call: the Std structs embed pointers into their heap blocks.
let owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
let owned_sps = if add_sps {
let mut owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
let mut owned_sps = if add_sps {
Some(sps_to_std_h265(sps)?)
} else {
None
};
if let Some(v) = owned_vps.as_mut() {
v.clamp_level(self.config.max_level_idc);
}
if let Some(s) = owned_sps.as_mut() {
s.clamp_level(self.config.max_level_idc);
}
let owned_pps = if add_pps {
Some(pps_to_std_h265(pps)?)
} else {
@@ -582,8 +594,10 @@ impl VideoSessionH265 {
pps_id = pps.pic_parameter_set_id,
"recreating H.265 session parameters (content change or capacity)"
);
let owned_vps = vps.to_std()?;
let owned_sps = sps_to_std_h265(sps)?;
let mut owned_vps = vps.to_std()?;
let mut owned_sps = sps_to_std_h265(sps)?;
owned_vps.clamp_level(self.config.max_level_idc);
owned_sps.clamp_level(self.config.max_level_idc);
let owned_pps = pps_to_std_h265(pps)?;
// SAFETY: fn contract — live device + live session. The wrappers
// are MOVED IN and come back owned by the fresh object, so they
+131 -42
View File
@@ -7,14 +7,22 @@
//!
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
//! known host MAC we send the 102-byte packet to:
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
//! * the **limited broadcast** `255.255.255.255`, and
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
//! known host MAC we send the 102-byte packet:
//! * **out of every non-loopback IPv4 interface**, from a socket bound to that interface's own
//! address, to both that NIC's **subnet-directed broadcast** and the **limited broadcast**
//! `255.255.255.255` — binding the source is what forces the datagram onto that segment
//! instead of whatever the default route happens to be (a VPN/mesh interface, typically), and
//! * from an unbound socket to `255.255.255.255` and, when known, a **unicast** to the host's
//! last-known IP (covers the brief window where the host is reachable but hasn't
//! re-advertised, and NICs that wake on a directed unicast),
//!
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
//!
//! **Wi-Fi hosts (WoWLAN) ride the same path**, and the per-interface egress above is what makes
//! them work: a station in WoWLAN sleep stays associated, and the AP buffers broadcast frames for
//! its sleeping stations and flushes them on the next DTIM beacon — so the broadcast does reach
//! the sleeping NIC, but only if the datagram actually leaves via the wireless interface. The
//! host end of it (arming the NIC's magic-packet trigger) is `punktfunk-host`'s `wol` module.
use std::io;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
@@ -64,41 +72,63 @@ pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
/// could be opened or nothing could be sent at all.
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
send_magic_packet_on(macs, last_known_ip, &WOL_PORTS)
}
/// [`send_magic_packet`] with the destination ports spelled out. Private because the ports are
/// not a caller's business — it exists so the tests can aim a real send at a port they're allowed
/// to bind (9 and 7 are privileged) and assert the bytes that come off the wire.
fn send_magic_packet_on(
macs: &[Mac],
last_known_ip: Option<Ipv4Addr>,
ports: &[u16],
) -> io::Result<()> {
if macs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"no MAC addresses",
));
}
let packets: Vec<[u8; 102]> = macs.iter().map(|m| build_magic_packet(*m)).collect();
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
let mut targets = broadcast_addrs();
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
// Targets that go out the default route (or wherever the routing table sends them): the
// limited broadcast as a baseline, plus the optional unicast — destination routing picks the
// right NIC for a unicast, so it doesn't need per-interface treatment.
let mut routed: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
if let Some(ip) = last_known_ip {
targets.push(ip);
routed.push(ip);
}
targets.sort_unstable();
targets.dedup();
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
// matching NIC via the routing table; the limited broadcast leaves via the default route.
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
sock.set_broadcast(true)?;
let mut sent_any = false;
for _ in 0..BURST {
for mac in macs {
let pkt = build_magic_packet(*mac);
for ip in &targets {
for port in WOL_PORTS {
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
if sock.send_to(&pkt, dst).is_ok() {
sent_any = true;
}
}
}
// Per-interface pass. One socket per non-loopback IPv4 address, bound to that address so the
// datagram leaves on THAT segment: without this, `255.255.255.255` follows the default route
// only (a VPN/mesh NIC on most of these machines) and never touches the LAN — or the Wi-Fi
// segment the sleeping WoWLAN station is associated to.
for (local, bcast) in local_v4_segments() {
let Ok(sock) = UdpSocket::bind(SocketAddrV4::new(local, 0)) else {
// Bind failed (address just went away, or the OS refuses it) — fall back to the
// routed socket below, which still reaches this segment's directed broadcast.
routed.push(bcast);
continue;
};
if sock.set_broadcast(true).is_err() {
routed.push(bcast);
continue;
}
sent_any |= blast(&sock, &packets, &[bcast, Ipv4Addr::BROADCAST], ports);
}
// Routed pass, and the only pass on a machine whose interfaces can't be enumerated.
if let Ok(sock) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
// A refused SO_BROADCAST doesn't abort the pass: the unicast target still goes out, and
// the per-interface sockets above may already have carried the broadcast.
let _ = sock.set_broadcast(true);
routed.sort_unstable();
routed.dedup();
sent_any |= blast(&sock, &packets, &routed, ports);
} else if !sent_any {
return Err(io::Error::other("no socket could be opened for the wake"));
}
if sent_any {
@@ -108,10 +138,33 @@ pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::R
}
}
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
fn broadcast_addrs() -> Vec<Ipv4Addr> {
/// Send every packet to every target, on every port, [`BURST`] times. Returns whether any
/// single datagram made it out — an unroutable target is expected and never fails the wake.
fn blast(sock: &UdpSocket, packets: &[[u8; 102]], targets: &[Ipv4Addr], ports: &[u16]) -> bool {
let mut sent_any = false;
for _ in 0..BURST {
for pkt in packets {
for ip in targets {
// A degenerate 0.0.0.0 (unconfigured NIC) is not a destination.
if ip.is_unspecified() {
continue;
}
for port in ports {
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, *port));
if sock.send_to(pkt, dst).is_ok() {
sent_any = true;
}
}
}
}
}
sent_any
}
/// Every non-loopback IPv4 interface as `(its own address, its subnet-directed broadcast)`. The
/// broadcast is the OS-provided one where present, else `ip | !netmask`. Best-effort: enumeration
/// failing (permissions, exotic platform) yields an empty list and the routed pass still fires.
fn local_v4_segments() -> Vec<(Ipv4Addr, Ipv4Addr)> {
let mut out = Vec::new();
let ifaces = match if_addrs::get_if_addrs() {
Ok(i) => i,
@@ -122,14 +175,13 @@ fn broadcast_addrs() -> Vec<Ipv4Addr> {
continue;
}
if let if_addrs::IfAddr::V4(v4) = iface.addr {
if v4.ip.is_unspecified() {
continue; // nothing to bind to
}
let bcast = v4
.broadcast
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
// already add unconditionally.
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
out.push(bcast);
}
out.push((v4.ip, bcast));
}
}
out
@@ -183,10 +235,47 @@ mod tests {
}
#[test]
fn broadcast_addrs_never_contains_limited_or_unspecified() {
for b in broadcast_addrs() {
assert_ne!(b, Ipv4Addr::BROADCAST);
assert!(!b.is_unspecified());
fn local_segments_are_bindable_and_have_a_broadcast() {
for (local, bcast) in local_v4_segments() {
// The local address is what we bind the per-interface socket to, so it must be a
// real address — and it must never be the loopback (filtered) or unspecified.
assert!(!local.is_unspecified());
assert!(!local.is_loopback());
assert!(!bcast.is_unspecified());
// Binding to an address the OS just reported must work; a failure here would mean
// the per-interface pass silently degrades to the routed one.
assert!(UdpSocket::bind(SocketAddrV4::new(local, 0)).is_ok());
}
}
#[test]
fn blast_reports_nothing_sent_for_an_empty_target_list() {
let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback");
let pkt = [build_magic_packet([1, 2, 3, 4, 5, 6])];
assert!(!blast(&sock, &pkt, &[], &WOL_PORTS));
// An unconfigured 0.0.0.0 target is skipped rather than sent to.
assert!(!blast(&sock, &pkt, &[Ipv4Addr::UNSPECIFIED], &WOL_PORTS));
// Loopback is a real destination — this one must go out.
assert!(blast(&sock, &pkt, &[Ipv4Addr::LOCALHOST], &[9999]));
}
/// The whole send path, end to end: a real receiver gets a real magic packet with the right
/// bytes. Aimed at loopback on an unprivileged port (WoL's own 9 and 7 need root to bind),
/// which exercises the routed pass's unicast leg — the one a WoWLAN host is woken by when
/// the AP filters broadcast to sleeping stations.
#[test]
fn send_delivers_the_magic_packet_to_a_listener() {
let rx = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind receiver");
let port = rx.local_addr().expect("local addr").port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.expect("read timeout");
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
send_magic_packet_on(&[mac], Some(Ipv4Addr::LOCALHOST), &[port]).expect("send");
let mut buf = [0u8; 256];
let (n, _from) = rx.recv_from(&mut buf).expect("a magic packet must arrive");
assert_eq!(n, 102);
assert_eq!(buf[..102], build_magic_packet(mac));
}
}
+4
View File
@@ -183,6 +183,10 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
mod audio_control;
#[cfg(target_os = "linux")]
mod linux;
// DualSense pad-audio sink + capture, the Linux analogue of `pad_endpoint` below: the session
// layer mints per-pad sinks and the CLI exposes the `pad-sink-test` devtest.
#[cfg(target_os = "linux")]
pub(crate) use linux::pad_sink;
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
// `pad-endpoint` devtest.
@@ -27,6 +27,7 @@
//! surround session can replace a stereo capturer without leaking a PipeWire consumer (see
//! CLAUDE.md: a wedged link head-blocks the daemon).
pub(crate) mod pad_sink;
mod stream_sink;
use super::{AudioCapturer, MicBackendStats, VirtualMic, SAMPLE_RATE};
@@ -0,0 +1,452 @@
//! Per-pad DualSense audio sink (Linux): one PipeWire `Audio/Sink` stream node per
//! DualSense-family pad, wearing the identity DS5-native titles and GE-Proton's
//! controller-audio routing match on — so a game that renders voice-coil haptics or pad-speaker
//! audio finds "the controller's audio device" and plays into us. We own the sink, so the
//! `process()` callback IS the capture: 4-ch F32 48 kHz (FL FR RL RR — front pair = speaker,
//! back pair = voice coils, the same quad layout the Windows endpoint is stamped with) lands
//! directly in the chunk channel that feeds the 0xD1 lanes (`native/pad_audio.rs`).
//!
//! Modeled on the stream-sink mode of [`super::PwAudioCapturer`] (same MainLoop-on-a-thread,
//! Terminate channel, ready handshake, bounded lossy chunk hand-off) with two deliberate
//! differences: **no default-sink claim** (nothing may auto-route here — games target it BY
//! IDENTITY) and a low `priority.session` so WirePlumber never elects it against real hardware.
//!
//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton 11-2+
//! matches layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`,
//! `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings
//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`); the community
//! WirePlumber rule keys on the node-name substring and sets `node.description =
//! "Wireless Controller"` (we mint it that way from the start). A pure PipeWire node cannot
//! satisfy wine's ContainerId derivation (udev walk to a `usb_device` parent → `GUID_NULL`)
//! nor GE's raw-ALSA fast path — both fall back to the Pulse-routed leg, which winepulse
//! serves from exactly this node (it enumerates sinks). Every identity string has an env
//! override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC`, with
//! `{pad}` / `{mac}` placeholders).
use anyhow::{anyhow, Context, Result};
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
use std::thread;
use std::time::Duration;
/// Message asking the PipeWire loop thread to quit (sent from `Drop`).
struct Terminate;
/// The pad sink's fixed channel count — quad, mirroring the Windows endpoint stamp
/// (`native/pad_audio.rs::CAP_CHANNELS` splits on the same layout).
const PAD_CHANNELS: u32 = 4;
/// How many pad slots may carry a sink (`PUNKTFUNK_PAD_AUDIO_SLOTS`, default all 4 — a PipeWire
/// stream node is cheap, unlike the Windows devnode mint whose default is 1).
pub(crate) fn pad_audio_slots() -> u8 {
std::env::var("PUNKTFUNK_PAD_AUDIO_SLOTS")
.ok()
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(4)
.clamp(1, 4)
}
/// Whether a PipeWire daemon is plausibly reachable from this process — the Linux analogue of
/// "startup provisioning published at least one endpoint" for [`host_cap`]'s existence leg
/// (`native/pad_audio.rs`). A stat, not a connect: the handshake path runs per-Hello and must
/// not block. `PIPEWIRE_REMOTE` names a non-default socket — trust it (the session capturer
/// honors it via libpipewire, and a wrong value degrades to spawn-time failure, pad kept).
pub(crate) fn pipewire_reachable() -> bool {
if std::env::var_os("PIPEWIRE_REMOTE").is_some() {
return true;
}
std::env::var_os("XDG_RUNTIME_DIR")
.map(|dir| std::path::Path::new(&dir).join("pipewire-0").exists())
.unwrap_or(false)
}
/// The pad's virtual MAC as colon-separated display hex — [`ds_pairing_reply`]'s bytes 1..7
/// are LSB-first (the report layout `hid-playstation` adopts as the HID `uniq` via `%pMR`,
/// i.e. printed reversed), so the display form reverses them. Unique per pad (the low octet
/// carries the pad index), which keeps multi-pad sinks distinct for the same reason the MAC
/// itself must be: SDL/Steam and the matchers dedup by serial.
///
/// [`ds_pairing_reply`]: pf_inject::dualsense_proto::ds_pairing_reply
fn pad_mac(pad: u8) -> String {
let reply = crate::inject::dualsense_proto::ds_pairing_reply(pad);
let m = &reply[1..7];
format!(
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
m[5], m[4], m[3], m[2], m[1], m[0]
)
}
/// Expand the `{pad}` / `{mac}` placeholders of an identity template. Callers pass the MAC in
/// the form the surrounding string wants: colon display form for proplist values, bare hex for
/// the ALSA-style node name (udev serials carry no colons).
fn expand(template: &str, pad: u8, mac: &str) -> String {
template
.replace("{pad}", &pad.to_string())
.replace("{mac}", mac)
}
/// The full identity a pad sink wears, resolved once at open.
struct PadSinkIdentity {
node_name: String,
description: String,
serial: String,
product_id: &'static str,
product_name: &'static str,
}
impl PadSinkIdentity {
fn new(pad: u8, edge: bool) -> PadSinkIdentity {
let mac = pad_mac(pad);
let mac_bare: String = mac.chars().filter(|c| *c != ':').collect();
let (model, product_id, product_name) = if edge {
(
"DualSense_Edge",
"0df2",
"DualSense Edge Wireless Controller",
)
} else {
("DualSense", "0ce6", "DualSense Wireless Controller")
};
// The ALSA-style name a REAL pad's card gets from udev (vendor_product_serial), which
// is what every known name-substring matcher was written against. `-00.analog-surround-40`
// = card profile suffix for the quad layout.
let node_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") {
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac_bare),
_ => format!(
"alsa_output.usb-Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}-00.analog-surround-40"
),
};
// What the community WirePlumber rule renames real pads TO — minted that way directly.
let description = match std::env::var("PUNKTFUNK_PAD_SINK_DESC") {
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac),
_ => "Wireless Controller".to_string(),
};
PadSinkIdentity {
node_name,
description,
serial: format!(
"Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}"
),
product_id,
product_name,
}
}
}
/// A live per-pad sink + its capture. Same next-chunk contract as every
/// [`AudioCapturer`](crate::audio::AudioCapturer): empty chunk = quiet sink (keep me), `Err` =
/// dead loop thread (reopen me). Dropping tears the sink node down promptly via the Terminate
/// channel (a wedged PipeWire link head-blocks the daemon — see the session capturer's docs).
pub struct PadSinkCapturer {
chunks: Receiver<Vec<f32>>,
quit: pipewire::channel::Sender<Terminate>,
/// The minted node name, for logs and the devtest.
pub node_name: String,
}
impl PadSinkCapturer {
/// Mint the sink for wire pad `pad` (`edge` = DualSense Edge identity) and start capturing.
/// Fails if PipeWire is unreachable — the caller's reopen-with-backoff owns the retry.
pub fn open(pad: u8, edge: bool) -> Result<PadSinkCapturer> {
let identity = PadSinkIdentity::new(pad, edge);
let node_name = identity.node_name.clone();
let (tx, rx) = sync_channel::<Vec<f32>>(64);
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
// Bring-up handshake (the session capturer's discipline): a PipeWire that isn't running
// must surface as an open ERROR, engaging the caller's backoff — not a zombie thread.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
thread::Builder::new()
.name(format!("punktfunk-pw-pad{pad}"))
.spawn(move || {
if let Err(e) = pad_sink_thread(tx, quit_rx, identity, ready_tx) {
tracing::warn!(pad, error = %format!("{e:#}"), "pipewire pad-sink thread failed");
}
})
.context("spawn pipewire pad-sink thread")?;
match ready_rx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => return Err(anyhow!("pipewire pad-sink init timed out")),
}
Ok(PadSinkCapturer {
chunks: rx,
quit: quit_tx,
node_name,
})
}
}
impl Drop for PadSinkCapturer {
fn drop(&mut self) {
// A failed send means the loop thread already exited — nothing to tear down.
let _ = self.quit.send(Terminate);
}
}
impl crate::audio::AudioCapturer for PadSinkCapturer {
fn next_chunk(&mut self) -> Result<Vec<f32>> {
match self.chunks.recv_timeout(Duration::from_secs(5)) {
Ok(c) => Ok(c),
// A quiet pad sink (no game rendering pad audio — the common case) is NOT a
// failure; the per-pad streamer keeps us and its silence gate stays closed.
Err(RecvTimeoutError::Timeout) => Ok(Vec::new()),
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pipewire pad-sink thread ended")),
}
}
fn channels(&self) -> u32 {
PAD_CHANNELS
}
}
/// SPA channel positions for the pad quad: AUX0..AUX3 (`enum spa_audio_channel`:
/// `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a positioned FL FR RL RR layout. This is the
/// shape a REAL DualSense exposes on the PipeWire path GE-Proton's haptics were built and
/// field-validated against: its `open_dualsense_haptic_pcm` targets the node through the
/// bundled pipewire-alsa plugin with `aux_channels=1` — "the hidden PipeWire parent for a
/// DualSense output exposes AUX0 through AUX3" (proton-ds5-haptic patch 0115) — and its pulse
/// fallback forces a `PA_CHANNEL_POSITION_AUX0..3` map. On a real pad that shape is the card's
/// Pro Audio profile (the community-reported requirement for GE ≥11-4). Aux positions carry no
/// spatial meaning, so nothing in the graph position-remixes into (or out of) the sink —
/// writers land by INDEX, exactly the raw quad the pad speaks: ch0/1 = speaker, ch2/3 = voice
/// coils (the same order the Windows endpoint is stamped with and `split_quad` assumes).
fn pad_positions() -> [u32; 64] {
const AUX0: u32 = 0x1000;
let mut pos = [0u32; 64];
pos[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]);
pos
}
/// The `!Send` MainLoop/Stream thread: mint the sink, hand capture chunks over, run until
/// Terminate / daemon death. Mirrors the session capturer's `pw_thread` stream-sink arm minus
/// the default-sink claim and the desktop-plane stats (the pad plane's observability lives in
/// the streamer's gate/encode logs).
fn pad_sink_thread(
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
quit_rx: pipewire::channel::Receiver<Terminate>,
identity: PadSinkIdentity,
ready: std::sync::mpsc::SyncSender<Result<()>>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
let result = (|| -> Result<()> {
pf_capture::pwinit::ensure_init();
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw pad-sink MainLoop")?;
let context =
pw::context::ContextRc::new(&mainloop, None).context("pw pad-sink Context")?;
let core = context
.connect_rc(None)
.context("pw pad-sink connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
// Daemon death ends this thread → the chunk channel disconnects → `next_chunk` errors →
// the per-pad streamer reopens with backoff (the session capturer's zombie-thread fix).
let _core_listener = core
.add_listener_local()
.error({
let mainloop = mainloop.clone();
move |id, _seq, res, message| {
tracing::warn!(id, res, message, "pipewire core error — pad sink ends");
mainloop.quit();
}
})
.register();
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CLASS => "Audio/Sink",
// One Opus-haptics frame (~5 ms) per quantum, like the session sink — haptics are
// felt latency; bursty delivery would ride through to the client's jitter buffer.
*pw::keys::NODE_LATENCY => "240/48000",
// Must NEVER win WirePlumber's default election against real hardware — games reach
// this sink BY IDENTITY, nothing auto-routes here (no stream_sink claim either).
"priority.session" => "50",
// The pulse-proplist leg of GE-Proton's match (§3): bus + vendor/product ids, plus
// the human-readable pair pavucontrol and the game view show.
"device.bus" => "usb",
"device.vendor.id" => "054c",
"device.vendor.name" => "Sony Interactive Entertainment",
"device.form_factor" => "gamepad",
};
props.insert(*pw::keys::NODE_NAME, identity.node_name.as_str());
props.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str());
props.insert(*pw::keys::NODE_NICK, identity.description.as_str());
props.insert("device.serial", identity.serial.as_str());
props.insert("device.product.id", identity.product_id);
props.insert("device.product.name", identity.product_name);
let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props)
.context("pw pad-sink Stream")?;
// Lossy-drop counter: a full channel means the 0xD1 encode thread stalled. Invisible
// drops cost a field investigation on the desktop plane once — count and warn here too,
// power-of-two throttled (this callback runs at the graph quantum).
struct PadUd {
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
dropped: u64,
}
let ud = PadUd { tx, dropped: 0 };
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire pad-sink stream state");
if matches!(new, pw::stream::StreamState::Error(_)) {
mainloop.quit();
}
}
})
.param_changed(move |_stream, _ud, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = AudioInfoRaw::default();
if info.parse(param).is_ok() {
// We own the sink, so this IS the format games render into (nothing can
// have narrowed it upstream — the same guarantee as stream-sink mode).
tracing::info!(
format = ?info.format(),
rate = info.rate(),
channels = info.channels(),
"pad-sink format negotiated"
);
}
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let d = &mut datas[0];
let (offset, size) = {
let c = d.chunk();
(c.offset() as usize, c.size() as usize)
};
let Some(buf) = d.data() else { return };
if offset > buf.len() {
return;
}
let region = &buf[offset..(offset + size).min(buf.len())];
// Negotiated as F32LE; reinterpret the byte region as interleaved f32.
let n = region.len() / 4;
let mut samples = Vec::with_capacity(n);
for i in 0..n {
let b = [
region[i * 4],
region[i * 4 + 1],
region[i * 4 + 2],
region[i * 4 + 3],
];
samples.push(f32::from_le_bytes(b));
}
if ud.tx.try_send(samples).is_err() {
ud.dropped += 1;
if ud.dropped.is_power_of_two() {
tracing::warn!(
dropped = ud.dropped,
"pad-audio encode thread not keeping up — captured pad audio \
dropped (haptics will click)"
);
}
}
}));
if outcome.is_err() {
tracing::error!("panic in pipewire pad-sink callback — chunk dropped");
}
})
.register()
.context("register pad-sink stream listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(crate::audio::SAMPLE_RATE);
info.set_channels(PAD_CHANNELS);
info.set_position(pad_positions());
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize pad-sink format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("pad-sink pod from bytes")?];
// RT_PROCESS for the same reason as every host-owned stream node here: the sink must be
// a synchronous graph member that joins its producers' driver group, or `process()`
// never fires on a busy graph (see the mic's connect comment in mod.rs).
stream
.connect(
spa::utils::Direction::Input, // we CONSUME what games render into the sink
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.context("pw pad-sink stream connect")?;
let _ = ready.send(Ok(()));
mainloop.run();
tracing::debug!("pipewire pad-sink loop exited (capturer dropped)");
Ok(())
})();
if let Err(e) = &result {
let _ = ready.send(Err(anyhow!("{e:#}")));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pad_mac_is_reversed_display_form_and_per_pad_unique() {
// DS_FEATURE_PAIRING bytes 1..7 are 74 E7 D6 3A 53 35 LSB-first → display reverses.
assert_eq!(pad_mac(0), "35:53:3A:D6:E7:74");
// The pad index offsets the LOW octet — the LAST display octet.
assert_eq!(pad_mac(1), "35:53:3A:D6:E7:75");
assert_ne!(pad_mac(2), pad_mac(3));
}
#[test]
fn identity_carries_every_match_surface() {
let id = PadSinkIdentity::new(0, false);
// The name-substring matchers (GE-Proton + the community WirePlumber rule).
assert!(id.node_name.contains("Sony_Interactive_Entertainment"));
assert!(id.node_name.contains("Wireless_Controller"));
assert!(id.node_name.contains("DualSense"));
assert!(id.node_name.ends_with("-00.analog-surround-40"));
// No colons in a udev-style serial/name.
assert!(!id.node_name.contains(':'));
assert_eq!(id.description, "Wireless Controller");
assert_eq!(id.product_id, "0ce6");
let edge = PadSinkIdentity::new(1, true);
assert!(edge.node_name.contains("DualSense_Edge"));
assert_eq!(edge.product_id, "0df2");
// Distinct pads mint distinct names (the serial octet).
assert_ne!(id.node_name, PadSinkIdentity::new(1, false).node_name);
}
#[test]
fn template_expansion() {
assert_eq!(expand("pad{pad}-{mac}", 2, "AABB"), "pad2-AABB");
assert_eq!(expand("static", 0, "x"), "static");
}
}
+120 -15
View File
@@ -27,7 +27,7 @@
use super::pad_endpoint as pe;
use super::{audio_control, wiring_plan};
use anyhow::{bail, Context, Result};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
@@ -40,6 +40,17 @@ const ENDPOINT_WAIT: Duration = Duration::from_secs(15);
/// Minimum spacing between provisioning retries once the startup attempt failed
/// ([`ensure_provisioned`] is called from wiring passes, which recur freely).
const RETRY_COOLDOWN: Duration = Duration::from_secs(60);
/// Full passes that ended unlatched before minting gives up for this host lifetime (a service
/// restart re-arms). An unlatched pass that reaches the PnP surface costs the whole BOX, not
/// just us: the driver (re)bind raises a device-change broadcast every running app services,
/// and games rebuild their audio graph on it — a box that cannot mint must not pay that on
/// every retry forever (field-measured 2026-08-12 as Helldivers 2 hitching to 25 FPS 1% lows,
/// one hitch per mic-pump reopen).
const MAX_UNLATCHED_ATTEMPTS: u32 = 5;
/// How long [`ensure_blocking`] waits on a pass another thread already runs before giving the
/// wiring plan the unlatched answer (a full cold-boot pass worst-cases around two
/// [`ENDPOINT_WAIT`]s plus the stamp settles).
const BLOCKING_WAIT: Duration = Duration::from_secs(90);
/// The two minted roles. `value` is the persisted marker; the needles drive
/// [`discover_driver`].
@@ -107,6 +118,26 @@ static PROVISIONED: OnceLock<Arc<MintedAudio>> = OnceLock::new();
static PROVISIONING: AtomicBool = AtomicBool::new(false);
/// When the last attempt STARTED — the [`RETRY_COOLDOWN`] anchor.
static LAST_ATTEMPT: Mutex<Option<Instant>> = Mutex::new(None);
/// Completed passes that did not latch, across the worker and the blocking path — the
/// [`MAX_UNLATCHED_ATTEMPTS`] give-up counter.
static UNLATCHED_ATTEMPTS: AtomicU32 = AtomicU32::new(0);
/// Count one finished-but-unlatched pass; the crossing attempt logs the give-up exactly once.
fn record_unlatched_attempt() {
let n = UNLATCHED_ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
if n == MAX_UNLATCHED_ATTEMPTS {
tracing::warn!(
attempts = n,
"minted-audio provisioning keeps failing — giving up for this host lifetime so \
retries stop broadcasting device changes at the whole box; the wiring plan keeps \
the name-based ladder, a service restart re-arms minting"
);
}
}
fn gave_up() -> bool {
UNLATCHED_ATTEMPTS.load(Ordering::SeqCst) >= MAX_UNLATCHED_ATTEMPTS
}
/// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned.
///
@@ -135,7 +166,7 @@ pub(crate) fn provisioned() -> Option<Arc<MintedAudio>> {
/// Spawn the provisioning worker (idempotent; returns immediately). Called at host start next
/// to the pad provider, and again from [`ensure_provisioned`] on the retry path.
pub(crate) fn provision_at_startup() {
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() {
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || gave_up() {
return;
}
if PROVISIONED.get().is_some() || PROVISIONING.swap(true, Ordering::SeqCst) {
@@ -155,13 +186,19 @@ pub(crate) fn provision_at_startup() {
);
let _ = PROVISIONED.set(Arc::new(m));
}
Ok(_) => tracing::info!(
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
wiring plan keeps the name-based ladder"
),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"minted-audio provisioning failed — the wiring plan keeps the name-based \
ladder and a later wiring pass retries"),
Ok(_) => {
tracing::info!(
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
wiring plan keeps the name-based ladder"
);
record_unlatched_attempt();
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"),
"minted-audio provisioning failed — the wiring plan keeps the name-based \
ladder and a later wiring pass retries");
record_unlatched_attempt();
}
}
PROVISIONING.store(false, Ordering::SeqCst);
});
@@ -175,7 +212,7 @@ pub(crate) fn provision_at_startup() {
/// [`RETRY_COOLDOWN`] — a box where Steam arrives later mints on a later pass instead of at
/// the next reboot.
pub(crate) fn ensure_provisioned() {
if PROVISIONED.get().is_some() {
if PROVISIONED.get().is_some() || gave_up() {
return;
}
{
@@ -219,6 +256,19 @@ fn ensure_all() -> Result<MintedAudio> {
/// back any default device the fresh endpoint grabbed (measured on the pad program: a newly
/// registered endpoint can take either default).
fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
// Steady state: a previous run's devnode with all endpoints live — resolve by marker and
// return without touching PnP or the default-device policy. The full pass below (re)binds
// the driver even over an existing devnode, and that bind raises a device-change broadcast
// every running app services — right at first mint, ruinous from a retry path (each
// broadcast makes games rebuild their audio graph; see [`MAX_UNLATCHED_ATTEMPTS`]).
if let Some((devnode, render, capture)) = find_healthy_role(role)? {
stamp_identity(&render, role, false);
if let Some(cap) = capture.as_ref() {
stamp_identity(cap, role, true);
}
return Ok((devnode, render, capture));
}
let prev_render = audio_control::default_render_id();
let prev_capture = audio_control::default_capture_id();
@@ -284,6 +334,27 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
Ok((devnode, render, capture))
}
/// The role's marker devnode with EVERY endpoint the role owes already registered, or `None`
/// (missing devnode, missing endpoint, or an enumeration error → the caller runs the full
/// pass). Same endpoint resolvers [`wait_for`] polls, so "healthy" here is exactly the state
/// the full pass would declare ready.
fn find_healthy_role(role: Role) -> Result<Option<(String, String, Option<String>)>> {
let Some(devnode) = find_role_devnode(role)? else {
return Ok(None);
};
let Some(render) = pe::find_endpoint_for_devnode(&devnode)? else {
return Ok(None);
};
let capture = match role {
Role::Mic => match pe::find_capture_endpoint_for_devnode(&devnode)? {
Some(cap) => Some(cap),
None => return Ok(None),
},
Role::Speakers => None,
};
Ok(Some((devnode, render, capture)))
}
/// How many stamp/settle passes a name gets before we accept "stored but not yet served"
/// (a settled endpoint takes the stamp on the first pass; a freshly minted one may need the
/// audio stack to notice — it serves after the next Audiosrv restart/reboot at the latest).
@@ -510,7 +581,6 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
)
}
/// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed.
/// Synchronous provisioning — for the mic pump's resolve and the devtests.
///
/// The pump's FIRST open must not race the startup worker: measured on the target box, the
@@ -520,15 +590,50 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
/// (existing marker devnodes re-resolve in milliseconds; a cold boot pays the one-time mint)
/// keeps the pump's target and the plan's verdict the same thing. Latched calls return
/// immediately; the opt-out env is honoured like everywhere else.
///
/// While UNLATCHED this is where the pump's reopen backoff (capped at 60 s) used to meet an
/// unguarded full pass: one PnP rebind + device-change broadcast roughly every minute, forever,
/// on any box where minting cannot converge (the 2026-08-12 Helldivers 2 field report). Now a
/// pass someone else already runs is WAITED for instead of raced, a failed pass repeats at most
/// every [`RETRY_COOLDOWN`], and [`MAX_UNLATCHED_ATTEMPTS`] failures stop retrying for the
/// host lifetime.
pub(crate) fn ensure_blocking() {
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || PROVISIONED.get().is_some() {
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some()
|| PROVISIONED.get().is_some()
|| gave_up()
{
return;
}
if let Ok(m) = ensure_all() {
if m.any() {
let _ = PROVISIONED.set(Arc::new(m));
// A pass is in flight (the startup worker, or a concurrent resolve): wait for its verdict
// rather than racing a second SetupAPI/PnP sweep against it — that race is how the pump
// once ended up wired to the cable while the worker minted (the dead-mic-air deploy race).
if PROVISIONING.swap(true, Ordering::SeqCst) {
let deadline = Instant::now() + BLOCKING_WAIT;
while PROVISIONING.load(Ordering::SeqCst) && Instant::now() < deadline {
thread::sleep(Duration::from_millis(100));
}
return;
}
// We own the slot. First-ever resolve runs unconditionally (the cold-boot mint the doc
// above insists on); after a failed pass the cooldown answers instead of a re-run.
let run = {
let mut last = LAST_ATTEMPT.lock().unwrap();
if last.is_some_and(|t| t.elapsed() < RETRY_COOLDOWN) {
false
} else {
*last = Some(Instant::now());
true
}
};
if run {
match ensure_all() {
Ok(m) if m.any() => {
let _ = PROVISIONED.set(Arc::new(m));
}
_ => record_unlatched_attempt(),
}
}
PROVISIONING.store(false, Ordering::SeqCst);
}
pub(crate) fn devtest_mint() -> Result<()> {
+60
View File
@@ -231,6 +231,66 @@ pub fn dualsense_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Mint one pad-audio PipeWire sink (the Linux 0xD1 source, `audio::pad_sink`) and capture
/// from it — the WP3 on-glass gate with no client involved. Verify the identity with
/// `pactl list sinks` (name/description/proplist) and drive it with
/// `pw-play --target <node.name> <file>` (or `paplay -d <node.name>`); captured chunks print
/// a per-second summary here. `--pad N` (default 0), `--edge`, `--seconds N` (default 30).
#[cfg(target_os = "linux")]
pub fn pad_sink_test(args: &[String]) -> Result<()> {
use crate::audio::AudioCapturer as _;
use std::time::{Duration, Instant};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(30);
let pad: u8 = args
.iter()
.skip_while(|a| *a != "--pad")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let edge = args.iter().any(|a| a == "--edge");
let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge)
.context("mint pad-audio sink (is PipeWire running in this session?)")?;
println!(
"pad sink minted: node.name = {}\n inspect: pactl list sinks | grep -A20 punktfunk-pad\n \
drive it: pw-play --target '{}' <48k-file>\nCapturing for {secs}s",
cap.node_name, cap.node_name
);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut chunks, mut samples) = (0u64, 0u64);
// Per-pair peaks: ch0/1 = speaker, ch2/3 = voice coils — the split_quad contract. Proving
// the pairs separately is the point of this devtest: a positional remix upstream would
// smear or zero one pair while a global peak still looks healthy.
let (mut peak_spk, mut peak_coil) = (0f32, 0f32);
let mut last_report = Instant::now();
while Instant::now() < deadline {
let c = cap.next_chunk().context("pad sink capture")?;
if !c.is_empty() {
chunks += 1;
samples += c.len() as u64;
for f in c.chunks_exact(4) {
peak_spk = peak_spk.max(f[0].abs()).max(f[1].abs());
peak_coil = peak_coil.max(f[2].abs()).max(f[3].abs());
}
}
if last_report.elapsed() >= Duration::from_secs(1) {
last_report = Instant::now();
println!(
" chunks={chunks} samples={samples} (~{:.1}ms of 4ch audio) \
peak_speaker={peak_spk:.4} peak_coils={peak_coil:.4}",
samples as f64 / (4.0 * 48.0)
);
(chunks, samples, peak_spk, peak_coil) = (0, 0, 0.0, 0.0);
}
}
println!("pad-sink-test: done");
Ok(())
}
/// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
/// streaming session): answers the full hid-nintendo probe conversation, then cycles the
/// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
+3
View File
@@ -623,6 +623,9 @@ fn real_main() -> Result<()> {
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session).
#[cfg(target_os = "linux")]
Some("dualsense-test") => devtest::dualsense_test(&args),
// Mint one pad-audio PipeWire sink and capture from it — the Linux 0xD1 source gate.
#[cfg(target_os = "linux")]
Some("pad-sink-test") => devtest::pad_sink_test(&args),
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no session).
#[cfg(target_os = "linux")]
Some("switchpro-test") => devtest::switchpro_test(&args),
+11 -4
View File
@@ -616,8 +616,10 @@ impl PadAudioSlots {
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
/// retries, since arrivals are only re-sent a few times per slot open).
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
/// retries, since arrivals are only re-sent a few times per slot open). `edge` picks the
/// DualSense Edge identity for the Linux sink (ignored on Windows — endpoints are
/// pre-stamped).
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8, edge: bool) {
let idx = pad as usize;
if idx >= MAX_WIRE_PADS {
return;
@@ -648,7 +650,7 @@ impl PadAudioSlots {
self.stop(idx);
}
let stop = Arc::new(AtomicBool::new(false));
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, edge, stop) {
self.slots[idx] = Some((kinds, h));
}
}
@@ -1087,7 +1089,12 @@ pub(super) fn input_thread(
0
};
if want != 0 {
pad_streams.ensure(&conn, pad, want);
pad_streams.ensure(
&conn,
pad,
want,
matches!(kind, GamepadPref::DualSenseEdge),
);
} else {
pad_streams.stop(idx);
}
+109 -43
View File
@@ -1,6 +1,8 @@
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): capture of the pad's own audio device —
//! Windows: WASAPI loopback of a pre-provisioned endpoint ([`crate::audio::pad_endpoint`]);
//! Linux: the per-pad PipeWire sink we mint (`crate::audio::pad_sink`) — → 4-ch de-interleave
//! into the speaker (front) and voice-coil haptics (back) pairs → per-kind silence gate →
//! stereo Opus (48 kHz, CBR, LowDelay)
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
@@ -11,45 +13,45 @@ use super::*;
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
/// `kinds` bit for the speaker stream.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const HAPTICS_FRAME_MS: u32 = 5;
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const SPEAKER_FRAME_MS: u32 = 10;
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const HAPTICS_FRAME_SAMPLES: usize =
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const SPEAKER_FRAME_SAMPLES: usize =
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
/// the pure splitter logic keeps its own copy).
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const CAP_CHANNELS: usize = 4;
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
/// very frame (haptics are felt latency; the first active frame must ship). ≈ 60 dBFS.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const GATE_OPEN_PEAK: f32 = 1e-3;
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
/// clipped, short enough that an idle pad costs nothing in steady state.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const GATE_HANGOVER_MS: u32 = 250;
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
const PAD_AUDIO_BITRATE: i32 = 64_000;
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
@@ -57,7 +59,7 @@ const PAD_AUDIO_BITRATE: i32 = 64_000;
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
/// unit-tested below.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
struct SilenceGate {
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
hangover_frames: u32,
@@ -67,7 +69,7 @@ struct SilenceGate {
open: bool,
}
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
impl SilenceGate {
fn new(frame_ms: u32) -> SilenceGate {
SilenceGate {
@@ -101,13 +103,13 @@ impl SilenceGate {
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
/// sees a gap, not a restart.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
struct LaneCtl {
gate: SilenceGate,
seq: u32,
}
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
impl LaneCtl {
fn new(frame_ms: u32) -> LaneCtl {
LaneCtl {
@@ -133,7 +135,7 @@ impl LaneCtl {
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
/// across channels.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mut front = Vec::with_capacity(block.len() / 2);
let mut back = Vec::with_capacity(block.len() / 2);
@@ -148,7 +150,7 @@ fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
struct PadFramer {
kinds: u8,
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
@@ -157,7 +159,7 @@ struct PadFramer {
front: Vec<f32>,
}
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
impl PadFramer {
fn new(kinds: u8) -> PadFramer {
PadFramer {
@@ -238,11 +240,12 @@ impl Drop for PadAudioHandle {
/// Whether this session's Welcome should advertise
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
/// picks it up on its next connect.
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), the feature is on
/// (`PUNKTFUNK_PAD_AUDIO` != "0"), and the pad audio source exists — Windows: startup
/// provisioning published at least one endpoint (`pad_endpoint::provision_at_startup`; a
/// still-running provisioning reads as "none yet" and the next connect picks it up); Linux: a
/// PipeWire daemon is reachable (the per-pad sinks are minted lazily at spawn, so reachability
/// IS the existence question).
pub(super) fn host_cap(client_caps: u8) -> bool {
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
#[cfg(target_os = "windows")]
@@ -257,9 +260,15 @@ pub(super) fn host_cap(client_caps: u8) -> bool {
&& crate::audio::pad_endpoint::provisioned_endpoints()
.is_some_and(|eps| !eps.is_empty())
}
#[cfg(not(target_os = "windows"))]
#[cfg(target_os = "linux")]
{
// Only the Windows virtual DualSense exposes pad audio endpoints today.
asked
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
&& crate::audio::pad_sink::pipewire_reachable()
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
// No pad audio source on this host OS.
let _ = asked;
false
}
@@ -276,6 +285,7 @@ pub(super) fn spawn(
conn: quinn::Connection,
pad: u8,
kinds: u8,
_edge: bool,
stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
@@ -310,10 +320,18 @@ pub(super) fn spawn(
return None;
}
let stop_t = stop.clone();
let endpoint_id = ep.endpoint_id;
match std::thread::Builder::new()
.name(format!("punktfunk1-pad{pad}"))
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
{
.spawn(move || {
pad_audio_thread(
conn,
pad,
kinds,
move || crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id),
stop_t,
)
}) {
Ok(join) => Some(PadAudioHandle {
stop,
join: Some(join),
@@ -325,13 +343,60 @@ pub(super) fn spawn(
}
}
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
/// without the audio side (and never advertise the cap, see [`host_cap`]).
#[cfg(not(target_os = "windows"))]
/// Linux: mint the pad's PipeWire sink lazily inside the streamer thread (the same
/// open-with-backoff loop the Windows capture rides — a PipeWire hiccup at arrival time starts
/// pad audio late, not never). `edge` picks the DualSense Edge identity for the sink. `None`
/// only for empty kinds, a slot past `PUNKTFUNK_PAD_AUDIO_SLOTS`, or a failed thread spawn;
/// the pad itself keeps working either way, just without audio.
#[cfg(target_os = "linux")]
pub(super) fn spawn(
conn: quinn::Connection,
pad: u8,
kinds: u8,
edge: bool,
stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
return None;
}
if pad >= crate::audio::pad_sink::pad_audio_slots() {
tracing::debug!(
pad,
"pad-audio arrival past PUNKTFUNK_PAD_AUDIO_SLOTS — not streaming"
);
return None;
}
let stop_t = stop.clone();
match std::thread::Builder::new()
.name(format!("punktfunk1-pad{pad}"))
.spawn(move || {
pad_audio_thread(
conn,
pad,
kinds,
move || crate::audio::pad_sink::PadSinkCapturer::open(pad, edge),
stop_t,
)
}) {
Ok(join) => Some(PadAudioHandle {
stop,
join: Some(join),
}),
Err(e) => {
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
None
}
}
}
/// Stub — pad audio sources exist only behind the Windows and Linux virtual DualSense; other
/// hosts run pads without the audio side (and never advertise the cap, see [`host_cap`]).
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
pub(super) fn spawn(
_conn: quinn::Connection,
_pad: u8,
_kinds: u8,
_edge: bool,
_stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
None
@@ -339,7 +404,7 @@ pub(super) fn spawn(
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
struct Lane {
kind: u8,
ctl: LaneCtl,
@@ -349,7 +414,7 @@ struct Lane {
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
let mut lanes = Vec::new();
for (bit, kind, frame_ms) in [
@@ -384,18 +449,19 @@ fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
Ok(lanes)
}
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
#[cfg(target_os = "windows")]
fn pad_audio_thread(
/// The per-pad streaming thread: capture of the pad's audio device (`open` builds the
/// platform's capturer — Windows loopback / Linux minted sink) → framer → per-kind gate/encode
/// → 0xD1 datagrams. Capture death reopens with the session-audio backoff
/// ([`INJECTOR_REOPEN_BACKOFF`], encoders + seq kept); a send error ends the thread (the
/// connection — the session — is gone).
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn pad_audio_thread<C: crate::audio::AudioCapturer>(
conn: quinn::Connection,
pad: u8,
kinds: u8,
endpoint_id: String,
open: impl Fn() -> anyhow::Result<C>,
stop: Arc<AtomicBool>,
) {
use crate::audio::AudioCapturer as _;
let mut lanes = match build_lanes(kinds) {
Ok(l) => l,
Err(e) => {
@@ -413,7 +479,7 @@ fn pad_audio_thread(
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
let mut capturer: Option<C> = None;
let mut last_failed: Option<std::time::Instant> = None;
tracing::info!(
pad,
@@ -427,7 +493,7 @@ fn pad_audio_thread(
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
match open() {
Ok(c) => {
if last_failed.take().is_some() {
tracing::info!(pad, "pad-audio capture reopened");
+215 -6
View File
@@ -1,12 +1,21 @@
//! Host-side Wake-on-LAN support.
//! Host-side Wake-on-LAN / Wake-on-Wireless-LAN support.
//!
//! Two jobs, both best-effort (a failure here never affects streaming):
//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them
//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's
//! asleep and no longer advertising.
//! asleep and no longer advertising. Wired and Wi-Fi NICs alike: a magic packet is the same
//! packet either way, and an associated station in WoWLAN sleep receives the broadcast the
//! AP buffers for it.
//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a
//! magic packet. We never change NIC settings (that's the user's call); we just surface the
//! single most common reason WoL silently fails.
//!
//! Wired and wireless are armed through completely different interfaces, so the check follows the
//! NIC: `ethtool <iface>` reports the wired `Wake-on: g` bit, while a Wi-Fi NIC's magic-packet
//! trigger lives in nl80211's WoWLAN state and is read with `iw phy <phy> wowlan show`. Asking
//! ethtool about a Wi-Fi NIC is what the previous version did, and it is actively misleading:
//! most wireless drivers print `Wake-on: d` whether or not WoWLAN is armed, so an armed host got
//! warned that it wasn't — with a fix command (`ethtool -s wlan0 wol g`) that its driver rejects.
use std::net::IpAddr;
@@ -61,8 +70,8 @@ pub fn wake_macs(primary_ip: IpAddr) -> Vec<String> {
}
/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect &
/// warn only — never modifies settings. Linux-only (reads `ethtool <iface>`); a no-op elsewhere
/// and silent when it can't tell (no `ethtool`, insufficient privilege).
/// warn only — never modifies settings. Linux-only (shells out to `iw`/`ethtool`); a no-op
/// elsewhere and silent when it can't tell (tool missing, insufficient privilege).
#[cfg(target_os = "linux")]
pub fn warn_if_not_armed(primary_ip: IpAddr) {
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
@@ -73,6 +82,41 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
else {
return;
};
// A NIC with an nl80211 phy is wireless: ask nl80211 about WoWLAN, not ethtool about WoL.
if let Some(phy) = wireless_phy(&iface) {
match wowlan_has_magic(phy.as_deref(), &iface) {
Some(true) => tracing::info!(
iface = %iface,
phy = phy.as_deref().unwrap_or("?"),
"Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC"
),
Some(false) => {
let phy = phy.as_deref().unwrap_or("phy0");
// A device the kernel won't arm can't wake on anything, so name that separately
// — enabling a WoWLAN trigger alone would not fix it.
let extra = if device_wakeup_enabled(&iface) == Some(false) {
" The kernel also has wake-up switched off for this device \
(/sys/class/net/<iface>/device/power/wakeup reads `disabled`), which blocks \
a network wake by itself."
} else {
""
};
tracing::warn!(
iface = %iface,
"Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it \
from sleep. Enable it with: sudo iw phy {phy} wowlan enable magic-packet \
(NetworkManager resets that on every re-connect; make it stick with: sudo \
nmcli connection modify <connection> 802-11-wireless.wake-on-wlan magic). \
The adapter must also stay powered and associated while the host sleeps, and \
be allowed to wake the machine in BIOS/UEFI.{extra}",
)
}
None => {} // couldn't determine — stay quiet rather than cry wolf
}
return;
}
match ethtool_wol_has_magic(&iface) {
Some(true) => {
tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC")
@@ -81,7 +125,7 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
iface = %iface,
"Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \
Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \
PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.",
PCIe' in BIOS).",
),
None => {} // couldn't determine — stay quiet rather than cry wolf
}
@@ -90,6 +134,80 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
#[cfg(not(target_os = "linux"))]
pub fn warn_if_not_armed(_primary_ip: IpAddr) {}
/// Is `iface` a Wi-Fi NIC, and if so which nl80211 phy backs it? `Some(Some("phy0"))` = wireless
/// and we know the phy (so we can query and name it); `Some(None)` = wireless but the phy name
/// couldn't be read; `None` = wired (or sysfs is unavailable, which reads the same way — the
/// ethtool path then applies, exactly as before).
#[cfg(target_os = "linux")]
fn wireless_phy(iface: &str) -> Option<Option<String>> {
let dir = format!("/sys/class/net/{iface}/phy80211");
if !std::path::Path::new(&dir).exists() {
return None;
}
let name = std::fs::read_to_string(format!("{dir}/name"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Some(name)
}
/// Whether a Wi-Fi NIC is armed for a magic-packet wake. `iw` is authoritative — it reads the
/// live nl80211 WoWLAN state, which is where the trigger actually lives.
///
/// Two fallbacks for when `iw` can't answer (binary missing, driver without the WoWLAN command,
/// no phy name, or a kernel that wants privilege we don't have — the host runs as a plain user
/// service, so that last one is not hypothetical):
/// * a *positive* ethtool reading counts, a negative one never does — a handful of drivers
/// (brcmfmac and friends, i.e. most Raspberry Pi / SoC Wi-Fi) really do expose the
/// magic-packet bit through ethtool, while the far more common `Wake-on: d` from a wireless
/// driver means nothing at all;
/// * failing that, sysfs `device/power/wakeup` — world-readable, and a `disabled` there is
/// conclusive in the negative direction: the kernel will not arm this device to wake the
/// machine, so whatever WoWLAN triggers the firmware holds can never fire.
#[cfg(target_os = "linux")]
fn wowlan_has_magic(phy: Option<&str>, iface: &str) -> Option<bool> {
if let Some(v) = phy.and_then(iw_wowlan_has_magic) {
return Some(v);
}
if let Some(true) = ethtool_wol_has_magic(iface) {
return Some(true);
}
// Only the negative is meaningful: `enabled` says the device may wake the machine, not that a
// magic packet is one of the things that will do it.
match device_wakeup_enabled(iface) {
Some(false) => Some(false),
_ => None,
}
}
/// sysfs `/sys/class/net/<iface>/device/power/wakeup` — `enabled`/`disabled`, i.e. whether the
/// kernel will arm this device to wake the system at all. `None` when the attribute isn't there
/// (platform/SDIO devices often have none) or can't be read.
#[cfg(target_os = "linux")]
fn device_wakeup_enabled(iface: &str) -> Option<bool> {
let text =
std::fs::read_to_string(format!("/sys/class/net/{iface}/device/power/wakeup")).ok()?;
match text.trim() {
"enabled" => Some(true),
"disabled" => Some(false),
_ => None,
}
}
/// Ask nl80211 (via `iw phy <phy> wowlan show`) whether the magic-packet trigger is enabled.
/// `None` if `iw` is missing or the driver doesn't implement WoWLAN.
#[cfg(target_os = "linux")]
fn iw_wowlan_has_magic(phy: &str) -> Option<bool> {
let out = std::process::Command::new("iw")
.args(["phy", phy, "wowlan", "show"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse_iw_wowlan(&String::from_utf8_lossy(&out.stdout))
}
/// Parse `ethtool <iface>` for the *current* Wake-on setting and report whether it includes `g`
/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent.
#[cfg(target_os = "linux")]
@@ -101,7 +219,13 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
parse_ethtool_wol(&String::from_utf8_lossy(&out.stdout))
}
/// `ethtool <iface>` output → does the *current* Wake-on setting include `g` (MagicPacket)?
/// `None` when the field is absent. Split out from the command so it can be unit-tested on any
/// platform.
fn parse_ethtool_wol(text: &str) -> Option<bool> {
for line in text.lines() {
let t = line.trim();
// The current setting is "Wake-on: <flags>"; skip the "Supports Wake-on: ..." capability
@@ -112,3 +236,88 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
}
None
}
/// `iw phy <phy> wowlan show` output → is the magic-packet trigger enabled? The two shapes are
///
/// ```text
/// WoWLAN is disabled
/// ```
/// ```text
/// WoWLAN is enabled:
/// * wake up on magic packet
/// * wake up on pattern match, up to 20 patterns of 16 - 128 bytes
/// ```
///
/// `* wake up on anything` (the nl80211 `any` trigger) counts too — that NIC wakes on every frame
/// it receives, magic packets included. Enabled with only other triggers reads as NOT armed,
/// which is the honest answer: a magic packet won't wake it. `None` when the output says nothing
/// about WoWLAN at all. Split out from the command so it can be unit-tested on any platform.
fn parse_iw_wowlan(text: &str) -> Option<bool> {
let mut seen = false;
let mut magic = false;
for line in text.lines() {
let t = line.trim();
if let Some(state) = t.strip_prefix("WoWLAN is ") {
seen = true;
if state
.trim()
.trim_end_matches(':')
.eq_ignore_ascii_case("disabled")
{
return Some(false);
}
} else if seen && t.starts_with('*') {
let l = t.to_ascii_lowercase();
if l.contains("magic packet") || l.contains("anything") {
magic = true;
}
}
}
seen.then_some(magic)
}
#[cfg(test)]
mod tests {
use super::{parse_ethtool_wol, parse_iw_wowlan};
#[test]
fn ethtool_current_setting_not_capability_line() {
let armed =
"Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: g\n\tLink detected: yes\n";
assert_eq!(parse_ethtool_wol(armed), Some(true));
// "Supports Wake-on: ...g..." must NOT be read as the current setting.
let off = "Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: d\n";
assert_eq!(parse_ethtool_wol(off), Some(false));
assert_eq!(
parse_ethtool_wol("Settings for lo:\n\tLink detected: yes\n"),
None
);
}
#[test]
fn iw_wowlan_states() {
assert_eq!(parse_iw_wowlan("WoWLAN is disabled\n"), Some(false));
assert_eq!(
parse_iw_wowlan("WoWLAN is enabled:\n * wake up on magic packet\n"),
Some(true)
);
// Enabled, but not for magic packets — a magic packet will not wake this NIC.
assert_eq!(
parse_iw_wowlan(
"WoWLAN is enabled:\n * wake up on pattern match, up to 20 patterns of 16 - 128 bytes\n"
),
Some(false)
);
// The `any` trigger wakes on every received frame, magic packets included.
assert_eq!(
parse_iw_wowlan("WoWLAN is enabled:\n * wake up on anything (device continues operating normally)\n"),
Some(true)
);
// Nothing to go on — the driver has no WoWLAN command.
assert_eq!(parse_iw_wowlan(""), None);
assert_eq!(
parse_iw_wowlan("Wiphy phy0\n\tmax # scan SSIDs: 20\n"),
None
);
}
}
+3 -2
View File
@@ -144,8 +144,9 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|---|---|---|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | **(Windows)** Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. |
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1``4` *(default `1`)* | **(Windows)** How many controllers can have their own audio at once. Each slot is a pre-provisioned virtual endpoint, so the default stays at one; raise it for multi-pad sessions. |
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. On Windows the pad's audio device is a pre-provisioned virtual endpoint; on Linux it is a per-pad PipeWire sink minted with the DualSense identity games match on. |
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1``4` *(default: Windows `1`, Linux `4`)* | How many controllers can have their own audio at once. On Windows each slot is a pre-provisioned virtual endpoint, so the default stays at one; a Linux sink is minted lazily and costs nothing idle, so every slot is on. |
| `PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC` | templates | **(Linux, field debugging)** Override the minted pad sink's `node.name` / `node.description`. `{pad}` and `{mac}` expand per pad. Only for chasing a title whose device matcher wants different strings — the defaults carry every known match surface. |
## Audio / microphone
+227
View File
@@ -0,0 +1,227 @@
---
title: Debian
description: Install the Punktfunk host on Debian 13 with apt — including LMDE and Linux Mint.
---
Install a Punktfunk host on **Debian 13 ("trixie") or newer** from the apt registry. This page
covers the distro-level setup — GPU driver, package, gamepad access. How the host creates its
virtual display and injects input is desktop-specific, so pick your desktop on the
[configure pages](#configure-your-desktop) afterward rather than here.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
> **Which releases.** The host package needs **glibc 2.39 or newer**; Debian 13 has 2.41, so it
> installs and runs there. **Debian 12 (bookworm) has glibc 2.36 and cannot install it** — build
> from source ([Ubuntu appendix](/docs/ubuntu#appendix--build-from-source), which applies here too)
> or upgrade. Check yours with `ldd --version`.
> **The desktop client is not packaged for Debian yet.** `punktfunk-client` is built on Ubuntu 26.04
> and floors at `libc6 >= 2.43` (Debian 13 has 2.41), on top of needing GTK4 ≥ 4.20. On a Debian
> box, stream *to* it with a [different client](/docs/install-client) — the Flatpak, or a build from
> source. The **host**, the **web console** and the **plugin runner** all install normally.
## What works on Debian 13
| Package | Debian 13 | What it is |
|---|---|---|
| `punktfunk-host` | ✅ | The streaming host |
| `punktfunk-web` | ✅ | The browser management console |
| `punktfunk-scripting` | ✅ | The plugin/script runner |
| `punktfunk-gamescope` | ✅ | The patched gamescope (HDR + cursor + real refresh) |
| `punktfunk-client` | ❌ | Desktop client — `libc6 >= 2.43`, see above |
## 1. GPU driver
On **NVIDIA**, the driver lives in Debian's `contrib` / `non-free` / `non-free-firmware`
components, which a default install does not enable. Debian 13 keeps its sources in the deb822
format, so add them there and refresh:
```sh
sudo sed -i 's/^Components: .*/Components: main contrib non-free non-free-firmware/' \
/etc/apt/sources.list.d/debian.sources
sudo apt update
sudo apt install nvidia-driver firmware-misc-nonfree
```
Debian 13 ships driver 550, comfortably above the [535 floor](/docs/requirements).
Reboot, then confirm the driver and KMS modeset — Wayland on NVIDIA needs `modeset=1`:
```sh
nvidia-smi
cat /sys/module/nvidia_drm/parameters/modeset # should print Y
```
If modeset is not `Y`:
```sh
echo 'options nvidia-drm modeset=1' | sudo tee /etc/modprobe.d/nvidia-drm.conf
sudo update-initramfs -u && sudo reboot
```
> **Secure Boot:** with Secure Boot enabled, Debian's DKMS-built NVIDIA module must be signed and
> its key enrolled before it will load. If `nvidia-smi` can't talk to the driver, enrol the MOK
> (`sudo mokutil --import /var/lib/dkms/mok.pub`, reboot, choose **Enrol MOK**) or disable Secure
> Boot in firmware.
On **AMD/Intel** none of the NVIDIA steps apply. Encode runs on the Mesa stack: **Vulkan Video** for
HEVC and AV1 (`mesa-vulkan-drivers`), with **VAAPI** for H.264 and as the fallback —
`mesa-va-drivers` on AMD, `intel-media-va-driver` on Intel (the latter is in `non-free`).
## 2. Install the host (apt)
The registry is public — no auth needed, just trust its signing key:
```sh
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
| sudo tee /etc/apt/sources.list.d/punktfunk.list
sudo apt update
sudo apt install punktfunk-host
```
`punktfunk-host` `Recommends` the browser console (`punktfunk-web`), so apt pulls it in by default.
The NVIDIA driver is **not** a dependency — you installed it out of band in step 1. Later updates
are `sudo apt update && sudo apt upgrade`; restart the running host afterwards so it picks up the
new binary:
```sh
systemctl --user restart punktfunk-host
```
The `stable` component above is the stable channel. To track pre-release builds instead, see
[Release Channels](/docs/channels).
## 3. Grant gamepad access
Virtual gamepads inject through `/dev/uinput`, gated by the `input` group. Add yourself and re-login:
```sh
sudo usermod -aG input "$USER" # re-login to apply
```
Also join `punktfunk` if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro) —
it reaches games as a real USB device over usbip, which is what makes Steam Input adopt it. Join it
only on a machine you trust: writing the usbip `attach` file can materialise arbitrary emulated USB
hardware.
```sh
sudo usermod -aG punktfunk "$USER" # re-login to apply
```
## 4. Check it installed
```sh
punktfunk-host --version # the binary is on PATH
punktfunk-host detect-conflicts # exits 1 if Sunshine/Apollo is also installed
```
Two hosts on one machine is the most common reason a clean install never streams — see
[Troubleshooting](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
## 5. Open the firewall (if you have one)
**Debian ships no firewall enabled by default**, so out of the box there is nothing to open. If you
run one, the package installs the openers:
```sh
# ufw:
sudo ufw allow punktfunk-native
# firewalld:
sudo firewall-cmd --reload # load the installed definitions
sudo firewall-cmd --permanent --add-service=punktfunk-native
sudo firewall-cmd --reload
```
Add `punktfunk-gamestream` for Moonlight compat and `punktfunk-web` (TCP 47992) to reach the console
from another device. Full port lists are in
[`packaging/debian/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md#firewall).
## Cinnamon, Linux Mint and LMDE
**A Cinnamon desktop cannot host a virtual display, and no setting changes that.** Punktfunk gives
each client its own screen at that device's exact resolution by asking the compositor to create a
virtual output. Cinnamon's compositor, **Muffin**, has no such API: it forked from Mutter 3.36, and
its `org.cinnamon.Muffin.ScreenCast` interface offers only `RecordMonitor` and `RecordWindow`
never the `RecordVirtual` that Mutter gained in 42. Its portal backend
(`xdg-desktop-portal-xapp`) implements no ScreenCast either, so the route that serves Sway and
Hyprland is closed too. This is upstream's to fix, not a Punktfunk setting.
**Which Mint you run decides whether there is any route at all:**
| Edition | Base | Can it host? |
|---|---|---|
| **LMDE 7 "Gigi"** | Debian 13 | ✅ Yes — via gamescope (below) |
| **Linux Mint 22.x** ("Wilma"…"Zena") | Ubuntu 24.04 | ❌ No — see [below](#linux-mint-22x-cannot-host-yet) |
| **Linux Mint 23** | Ubuntu 26.04 | ✅ Expected — due December 2026 |
On **LMDE 7**, what works is **gamescope**: the host starts its own headless gamescope for each
connecting client and runs the game inside it, so it needs no desktop compositor at all. Your
Cinnamon session keeps running untouched; the stream is the game, not the desktop.
```sh
sudo apt install punktfunk-gamescope # LMDE 7 / Debian 13 — not available on Mint 22.x
echo 'PUNKTFUNK_COMPOSITOR=gamescope' >> ~/.config/punktfunk/host.env
systemctl --user restart punktfunk-host
```
The pin is required: auto-detection reads the live session, finds Cinnamon, and stops with an error
rather than guessing. Set a game to launch with
[`PUNKTFUNK_GAMESCOPE_APP`](/docs/gamescope) or per-session launch commands, then see
[Steam / gamescope](/docs/gamescope) for the rest.
> **Install `punktfunk-gamescope`, not Debian's.** Debian ships **no** `gamescope` package at all,
> and the patched build is what gives the stream HDR, a visible cursor, and the client's real
> refresh rate instead of a hardcoded 60 Hz.
If you want to stream the **desktop** from an LMDE box, the answer today is to log into a GNOME or
Sway session instead — Debian 13 ships GNOME 48.7 and sway 1.10, both above the
[floors](/docs/requirements). (Debian 13's KDE is KWin **6.3.6**, below the 6.5.6 floor, so Plasma
is not an option there yet.)
### Linux Mint 22.x cannot host yet
**On Linux Mint 22.x — the current mainstream release, and every version until Mint 23 in December
2026 — there is no working configuration.** `punktfunk-host` will install, which makes this easy to
miss, but nothing on the box can produce a stream:
- **Cinnamon** cannot host a virtual display (above).
- **gamescope is not available and cannot be made available.** Ubuntu 24.04 packages no gamescope,
and the patched `punktfunk-gamescope` cannot run there either: 24.04 is short of what the build
needs on *five* libraries — wayland 1.22.0 (needs ≥ 1.23.1), libinput 1.25 (≥ 1.26), libavif
1.0.4 (≥ 1.2.1), pixman 0.42 (≥ 0.44), and no `libdisplay-info2` or `libxcb-errors0` at all.
- **Switching desktop does not rescue it.** Ubuntu 24.04 ships KWin **5.27** (floor 6.5.6) and GNOME
Shell **46** (floor 48). Only `sway` 1.9 is even a candidate, and that means giving up Cinnamon.
If you want to run a host on Mint hardware today, use **LMDE 7** — it is the same desktop on a
Debian 13 base, where gamescope works. Otherwise wait for **Mint 23** (Ubuntu 26.04 base), where
both the patched gamescope and the newer compositors are available.
## Configure your desktop
How the host creates its virtual display and injects input depends on your desktop, not your distro:
- [KDE Plasma (KWin)](/docs/kde)
- [GNOME (Mutter)](/docs/gnome)
- [Steam / gamescope](/docs/gamescope)
- [Hyprland](/docs/hyprland)
- [Sway / wlroots](/docs/sway)
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect your first
[client](/docs/clients). To run the host at boot — including fully **headless** — see
[Running as a Service](/docs/running-as-a-service).
## Next steps
- **Keep it current** — [Updating the Host](/docs/updating).
- **Remove it again** — [Uninstalling](/docs/uninstall).
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
- **Build from source** (Debian 12, or tracking `main`) — the
[Ubuntu appendix](/docs/ubuntu#appendix--build-from-source) applies unchanged; Debian 13's
`libavcodec-dev` is new enough to build against.
+5 -1
View File
@@ -189,7 +189,11 @@ own name and does **not** replace your system gamescope — your Gaming Mode kee
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
- **Fedora, Nobara and other RPM boxes**`sudo dnf install punktfunk-gamescope` from the same
Punktfunk repo the host comes from.
- **Debian / Ubuntu**`sudo apt install punktfunk-gamescope` from the Punktfunk apt repo.
- **Debian 13 and Ubuntu 26.04**`sudo apt install punktfunk-gamescope` from the Punktfunk apt
repo. It is the only gamescope those two have: Debian packages none at all, and Ubuntu 26.04's
is 3.16.20 — below the [3.16.22 floor](/docs/requirements), so headless capture deadlocks
against the PipeWire 1.6 that same release ships. **Ubuntu 24.04 cannot run it**: the build needs
wayland ≥ 1.23.1 and noble has 1.22.0, so build from source or move to a newer release.
- **Arch** — the `punktfunk-gamescope` package.
- **SteamOS (Steam Deck installer)** — built and wired automatically by
`scripts/steamdeck/install.sh` / `update.sh`.
+2 -2
View File
@@ -36,7 +36,7 @@ stream and links out to the detail as you need it. The rest of these are for whe
<Card title="Quick Start" href="/docs/quickstart" description="From nothing to streaming: set up a host and connect your first client." />
<Card title="How It Works" href="/docs/how-it-works" description="The ideas behind Punktfunk in a few minutes — virtual displays, the two protocols, pairing." />
<Card title="Support Matrix" href="/docs/support-matrix" description="What works where — every host desktop, GPU and client app, each cell read out of the code that decides it." />
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Debian, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
<Card title="Connect a Client" href="/docs/clients" description="Stream with the native app for your device — macOS, Linux, Windows, Android — or any Moonlight client." />
<Card title="Your Game Library" href="/docs/game-library" description="The host finds your installed games by itself — browse a paired host and launch a title straight into the stream." />
<Card title="API Reference" href="/api" description="Interactive OpenAPI reference for the host's management REST API — status, devices, pairing, library." />
@@ -46,7 +46,7 @@ stream and links out to the detail as you need it. The rest of these are for whe
- A **host** with an NVIDIA, AMD, or Intel GPU (there's a software encoder to fall back on without
one) — either a **Linux** machine running one of the [supported setups](/docs/requirements)
(**Ubuntu**, **Fedora**, **Arch**, **Bazzite**, or **SteamOS**), or a
(**Ubuntu**, **Debian**, **Fedora**, **Arch**, **Bazzite**, or **SteamOS**), or a
**[Windows](/docs/windows-host) PC**. What each combination can actually do — codecs, HDR, 4:4:4,
cursor, input — is in the [Support matrix](/docs/support-matrix).
- A **client device** to stream to — there are native apps for **macOS, iOS/iPadOS, tvOS, Linux,
+8 -1
View File
@@ -16,13 +16,20 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
| Distro | Package manager | One-command happy path | Guide |
|--------|-----------------|------------------------|-------|
| **Ubuntu** | apt | `sudo apt install punktfunk-host` | [Ubuntu](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Ubuntu 26.04+** ¹ | apt | `sudo apt install punktfunk-host` | [Ubuntu](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Debian 13+** (incl. LMDE) | apt | `sudo apt install punktfunk-host` | [Debian](/docs/debian) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** | systemd-sysext | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
| **Fedora (dnf)** | dnf / rpm-ostree | `sudo dnf install punktfunk` | [Fedora](/docs/fedora) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch** | pacman | `sudo pacman -Syu punktfunk-host` (binary repo — always a full `-Syu`, never `-Sy`) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
| **SteamOS (host)** | on-device script | clone the repo, then `bash ~/punktfunk/scripts/steamdeck/install.sh` (builds on-device) | [SteamOS (Host)](/docs/steamos-host) |
| **NixOS / Nix** | nix flake | `nix run git+https://git.unom.io/unom/punktfunk#punktfunk-host -- serve --gamestream` | [NixOS](#nixos) · [packaging/nix](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/nix/README.md) |
> ¹ **Ubuntu 24.04 LTS installs the package but cannot host.** It ships no compositor that meets
> the [version floors](/docs/requirements) — KWin 5.27 against 6.5.6, GNOME Shell 46 against 48 —
> and no `gamescope`. Use 26.04 or newer. This is also why
> [Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet); LMDE 7 (Debian 13)
> can.
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds. On **NixOS** there
+1
View File
@@ -10,6 +10,7 @@
"requirements",
"install",
"ubuntu",
"debian",
"fedora",
"arch",
"bazzite",
+26 -1
View File
@@ -13,9 +13,34 @@ is also available. Setup splits along two axes: you **install** the package per
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## The floor for a working host
**On apt distros that means Ubuntu 26.04 or newer, or Debian 13 or newer.** Both are supported
targets and both install from the same repository.
This floor is about the **desktop**, not the package. A host needs a compositor that can create a
virtual display, and those have version floors of their own ([below](#desktop-session)). Older
releases will happily install `punktfunk-host` and then have nothing that can produce a stream —
so read this as the real requirement, not the package's:
| Release | Package installs | Can actually host |
|---|---|---|
| **Ubuntu 26.04+** | ✅ | ✅ KWin 6.5+, GNOME 48+, gamescope |
| **Debian 13+** | ✅ | ✅ GNOME 48.7, sway 1.10, gamescope (its KWin 6.3.6 is below the floor) |
| Ubuntu 24.04 LTS | ✅ | ❌ KWin 5.27 (floor 6.5.6), GNOME 46 (floor 48), no gamescope available |
| Debian 12 | ❌ glibc 2.36 | ❌ |
Ubuntu 24.04 is called out because the package *does* install there — it is built on 24.04 precisely
so one package spans the range — which makes the gap easy to mistake for a bug. It is not: 24.04
ships no compositor new enough, and no `gamescope` (the patched
[`punktfunk-gamescope`](/docs/gamescope) cannot run there either — 24.04 is too old on wayland,
libinput, libavif and pixman). The same gap is why
[Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet).
**Distros — install the package:**
- [Ubuntu](/docs/ubuntu)
- [Ubuntu](/docs/ubuntu) — 26.04 or newer for a working host
- [Debian](/docs/debian) — 13 or newer, including LMDE
- [Fedora](/docs/fedora)
- [Arch](/docs/arch)
- [Bazzite](/docs/bazzite)
+4 -3
View File
@@ -97,6 +97,7 @@ head-tracked remote spatial audio that no streaming stack does today.
simply has no 4:4:4 path yet, and it waits on hardware that advertises a HEVC 4:4:4 encode
entrypoint to build and validate against. On either vendor, [PyroWave](/docs/pyrowave) already
carries full chroma today.
- **DualSense voice-coil haptics.** Scoped and shelved — it rides the controller's USB audio
interface and has near-zero game support on Linux. Rumble, adaptive triggers and the lightbar
already work.
- **DualSense voice-coil haptics over Bluetooth client pads.** The controller exposes no audio
interface over Bluetooth, so the audio-haptics plane is USB-only on the client side — a BT
DualSense keeps classic rumble. (Hosts stream pad audio on both Windows and Linux; rumble,
adaptive triggers and the lightbar work everywhere regardless.)
+7
View File
@@ -71,6 +71,7 @@ mechanics. The one exception is a gamescope the host only *attaches* to, which k
| gamescope (SteamOS · Bazzite) | ✅ ⁴ | ⚠️ ⁵ | ✅ ⁶ |
| sway ⁹ | ✅ | ✅ | ⚠️ ⁷ |
| Hyprland | ⚠️ ⁸ | ✅ | ⚠️ ⁷ |
| Cinnamon (Mint · LMDE) | ❌ ¹⁰ | ❌ ¹⁰ | ❌ ¹⁰ |
| macOS / anything else | ❌ | ❌ | ❌ |
1. Punktfunk's own IddCx display driver. It requires **Windows 11 22H2 (build 22621) or newer**
@@ -108,6 +109,12 @@ mechanics. The one exception is a gamescope the host only *attaches* to, which k
(River, dwl, …) cannot host — the session fails at `swaymsg get_outputs`. Their input would work
(they do have the wlroots virtual pointer and keyboard protocols), but with no video there is no
stream. See [Sway / wlroots](/docs/sway).
10. Cinnamon's compositor **Muffin** exposes no virtual-output API and no monitor-capture route we
can reach: it forked from Mutter 3.36, so `org.cinnamon.Muffin.ScreenCast` has only
`RecordMonitor` / `RecordWindow` and never Mutter 42's `RecordVirtual`, and its portal backend
(`xdg-desktop-portal-xapp`) implements no ScreenCast at all. Nothing in Punktfunk can change
this. A Mint or LMDE box can still stream **games** through a headless gamescope, which needs no
desktop compositor — see [Debian → Cinnamon](/docs/debian#cinnamon-linux-mint-and-lmde).
### Input, cursor and HDR
+5 -4
View File
@@ -104,10 +104,11 @@ and capture/display glitches.
Clients wake a saved host by themselves — auto-wake is on by default — but only once they have seen
it awake, which is how they learn its MAC address, and only if the machine is armed to answer a magic
packet. The arming is what's usually missing, and a **Linux** host tells you outright: search the web
console's **Logs** page for `Wake-on-LAN`, and the line either confirms the card is armed or names
the interface and the exact command to arm it. Windows and macOS hosts don't run that check, so go
straight to the BIOS/UEFI and network-card steps in
[Arming the machine](/docs/wake-on-lan#arming-the-machine).
console's **Logs** page for `Wake-on-``Wake-on-LAN` for a wired card, `Wake-on-WLAN` for a Wi-Fi
one — and the line either confirms the card is armed or names the interface and the exact command to
arm it. A Wi-Fi card is armed by a different command than a wired one, and the log line gives the
right one. Windows and macOS hosts don't run that check, so go straight to the BIOS/UEFI and
network-card steps in [Arming the machine](/docs/wake-on-lan#arming-the-machine).
## Video is slow to start, or fails across subnets
+12 -5
View File
@@ -18,11 +18,18 @@ desktop on the [configure pages](#configure-your-desktop) afterward rather than
> and needs GTK4 ≥ 4.20 and SDL3, so it installs on **26.04 or newer** only — the host has no such
> limit.
> **Debian isn't a supported target.** The packages are built on Ubuntu images and their dependencies
> are resolved against Ubuntu's package names, and nothing in CI builds or tests on Debian. Debian 12
> (bookworm) is below the glibc floor and cannot install them at all. A newer Debian may work, but
> it's untested — build from source ([appendix](#appendix--build-from-source)) if you want to try.
> The `debian` in the repository URL below is the *package format*, not a supported distro.
> **On 24.04 LTS, the package installs but the distro gives it no compositor to drive.** The
> host `.deb` is built for 24.04 and installs cleanly — but streaming needs a compositor that meets
> the [version floors](/docs/requirements), and stock 24.04 meets none of them: KWin **5.27** (floor
> 6.5.6), GNOME Shell **46** (floor 48), and no `gamescope` package at all (nor can the patched one
> run there — 24.04 is too old on wayland, libinput, libavif and pixman). `sway` 1.9 is the only
> candidate. **For a working host, use 26.04**, where the patched gamescope and current KDE/GNOME
> are all available. This is why [Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet).
> **On Debian**, see [Debian](/docs/debian) — the host, console and plugin runner are supported and
> CI-tested on **Debian 13**; the desktop client is not packaged for it yet. Debian 12 (bookworm) is
> below the glibc floor and cannot install anything here. Note that the `debian` in the repository
> URL below is the *package format*, and is the same URL for both distros.
## 1. GPU driver
+85 -10
View File
@@ -30,14 +30,35 @@ That ordering is the whole prerequisite:
> says so rather than pretending. On every client but the Linux one you can also type the MAC in by
> hand; see the table below.
The packet goes to every local interface's subnet broadcast address *and* to `255.255.255.255`, on
The packet goes **out of every one of the client's network interfaces** — from a socket bound to
that interface's own address, aimed at both its subnet broadcast address and `255.255.255.255` — on
UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. That
spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it.
spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it, and a
broadcast sent without binding an interface leaves by the default route only, which on a machine
running a VPN or a mesh network is not the LAN the host sleeps on.
Neither the advert nor a magic packet is authenticated. That is fine here — a wrong address only
makes the wake fail, and the host's certificate fingerprint still gates the actual connection. See
[Security](/docs/security).
### Over Wi-Fi
A host on Wi-Fi wakes from the same packet. The mechanism is **WoWLAN** (Wake on Wireless LAN):
the adapter stays associated to your access point while the machine sleeps, the access point holds
broadcast frames for its sleeping stations and releases them on the next beacon, and the adapter
wakes the machine when one of them is a magic packet. Punktfunk publishes a Wi-Fi card's address
exactly like a wired one, so there is nothing different to do on the client — but the card has to be
armed for it, which is a different switch from the wired one. See
[Linux (Wi-Fi)](#linux-wi-fi) and [Windows](#windows) below.
Two things can still stop it, and neither is visible from Punktfunk:
- Some access points and mesh systems drop or rate-limit broadcast traffic to sleeping stations
(often as "multicast enhancement", "broadcast filtering" or IGMP snooping). If wired hosts wake
and a Wi-Fi one never does, that is the first thing to turn off.
- Some laptops and adapters cut power to the Wi-Fi card in deeper sleep states, which drops the
association and with it any chance of a wake.
## Waking from a client
**Auto-wake on connect** is a client setting, and it is **on by default**. You find it in Settings,
@@ -135,7 +156,7 @@ whether a machine may be woken off the network is yours to make.
### Check the host log first
This is the fastest diagnosis. On **Linux**, the host inspects the card carrying the address it
advertises, each time it starts advertising, and writes one of two lines:
advertises, each time it starts advertising, and writes one line about it. A wired card:
```text
Wake-on-LAN armed (magic packet) on host NIC
@@ -145,18 +166,29 @@ Wake-on-LAN armed (magic packet) on host NIC
Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep.
```
A Wi-Fi card, which is armed through an entirely different mechanism and is asked about separately
(`iw phy … wowlan show`, not `ethtool`):
```text
Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC
```
```text
Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it from sleep.
```
The warning line goes on to name the interface and the exact command to fix it. The host only
reports; it never changes the card's settings. It stays silent when it cannot tell — `ethtool`
missing, or not enough privilege — rather than guessing, and it says nothing at all when mDNS
adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`), because then no address is published
either.
reports; it never changes the card's settings. It stays silent when it cannot tell — `iw` or
`ethtool` missing, a driver that doesn't answer, or not enough privilege — rather than guessing, and
it says nothing at all when mDNS adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`),
because then no address is published either.
Read the line on the web console's **Logs** page, or in the journal with
`journalctl --user -u punktfunk-host`. See [Troubleshooting](/docs/troubleshooting#still-stuck).
**Windows and macOS hosts do not run this check**, so there is no log line to look for there.
### Linux
### Linux (wired)
Ask the card what it is doing. `Supports Wake-on:` is the capability; `Wake-on:` is the current
setting. `g` means magic packet, `d` means disabled.
@@ -174,6 +206,42 @@ sudo ethtool -s enp5s0 wol g
On many systems that does not survive a reboot. Re-run `ethtool enp5s0` after the next boot to check,
and make it permanent through your distribution's network configuration if it reset.
### Linux (Wi-Fi)
`ethtool` is the wrong tool here — most wireless drivers report `Wake-on: d` whether or not they are
armed, because the trigger lives in the wireless stack instead. Ask `iw`, using the *phy* behind the
interface (`/sys/class/net/wlan0/phy80211/name`, usually `phy0`):
```bash
iw phy phy0 wowlan show
```
`WoWLAN is disabled` means no wake. Armed looks like this, and the `* wake up on magic packet` line
is the one that matters:
```text
WoWLAN is enabled:
* wake up on magic packet
```
Arm it:
```bash
sudo iw phy phy0 wowlan enable magic-packet
```
That setting is per-phy and NetworkManager re-applies its own on every connection, so on a
NetworkManager system make it stick on the connection instead — this survives reboots and
reconnects:
```bash
sudo nmcli connection modify <connection> 802-11-wireless.wake-on-wlan magic
```
`iw phy phy0 wowlan show` reporting `command failed: Operation not supported` means the driver has no
WoWLAN support at all; that adapter cannot be woken over Wi-Fi. Check `iw list | grep -A5 "WoWLAN"`
for what the hardware claims to support.
### Windows
Open **Device Manager**, find the network adapter under **Network adapters**, and open its
@@ -181,10 +249,17 @@ properties. On the **Power Management** tab, allow the device to wake the comput
**Advanced** tab, enable the adapter's magic-packet wake property if it has one. Exact wording
depends on the driver.
Wi-Fi adapters use the same two tabs. The **Advanced** property is often called **Wake on Magic
Packet** there too, sometimes **Wake on Wireless LAN**; many Wi-Fi drivers expose neither, and those
cannot be woken over Wi-Fi. `powercfg /devicequery wake_armed` lists every device currently allowed
to wake the machine — if the adapter is not in it, nothing on the network can wake this host.
## Limits
- **Wired Ethernet is what works.** Waking over Wi-Fi is unreliable and depends entirely on the
adapter and the platform.
- **Wired Ethernet is the sure thing; Wi-Fi works when the adapter supports WoWLAN.** Punktfunk
sends the same packet either way and publishes a Wi-Fi card's address like any other, but whether
a sleeping adapter is still listening is the adapter's and the access point's decision —
see [Over Wi-Fi](#over-wi-fi).
- **Connect once while the host is awake**, on the same local network, before you rely on waking it.
A host you only ever added by address, on a network where mDNS never reached it, has no learned
address — the CLI will tell you so, and the apps will not offer the wake action. Typing the MAC in
+44
View File
@@ -1,5 +1,18 @@
# punktfunk-host — Debian/Ubuntu package (apt)
> **Which distros the published packages install on** — measured by installing them, not inferred
> from the build image (`scripts/ci/deb-install-smoke.sh` asserts this on every run):
>
> | | Ubuntu 24.04 | Ubuntu 26.04 | Debian 13 | Debian 12 |
> |---|---|---|---|---|
> | `punktfunk-host` | ✅ | ✅ | ✅ | ❌ glibc 2.36 < 2.39 |
> | `punktfunk-web` / `punktfunk-scripting` | ✅ | ✅ | ✅ | ✅ |
> | `punktfunk-gamescope` | ❌ wayland 1.22 | ✅ | ✅ | ❌ |
> | `punktfunk-client` | ❌ `libc6 >= 2.43` | ✅ | ❌ `libc6 >= 2.43` | ❌ |
>
> Debian 13 is a supported host target ([docs](https://docs.punktfunk.unom.io/docs/debian)); the
> client is the one gap, since it is built on 26.04 and floors at that release's glibc.
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
and publishes on every push to `main` (a rolling `<next-minor>~ciN.g<sha>` build — the base is
@@ -36,6 +49,37 @@ dropped from `Depends`). The result is **one** host `.deb` that installs on **Ub
on 26.04 (the native client needs SDL3 / GTK4 ≥ 4.20, absent on 24.04) — install the client on the box
you stream *to*, which is independent of the host's distro.
## `punktfunk-gamescope` is built on Debian 13, not Ubuntu
The patched gamescope has its own job (`build-publish-gamescope`) in a **Debian 13** image
(`ci/gamescope-trixie.Dockerfile`), and that is not a preference — it is the only apt distro the
tree configures on. Built in the noble host image, as it was until 2026-08, it failed every single
run:
```
wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
```
Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 (and has
no `libxcb-errors-dev`, and only libdisplay-info 0.1.1). Because every rung of that path was a
`::warning::` returning 0, **v0.26.0 and v0.27.0 both shipped with no gamescope .deb** while the
release notes and docs-site said it was apt-installable. Debian 13 has wayland 1.23.1 exactly —
the oldest apt base that works.
Two things make the one package serve both Debian 13 and Ubuntu 26.04:
- **`--extra-fallback libdisplay-info`** (see `packaging/gamescope/build-punktfunk-gamescope.sh`).
Linked against the distro copy, the package picks up `Depends: libdisplay-info2 (>= 0.2.0)` on
trixie — and Ubuntu 26.04 carries libdisplay-info **3** (0.3.0), so apt refuses it there.
gamescope vendors the library as a submodule, so the vendored build drops the dependency. Same
reasoning the script already applies to wlroots: a binary we ship must not follow the build
host's shared libraries.
- The **static C++ runtime** the build script already forces, so `libstdc++` never appears in
`NEEDED`. The binary asks only for `GLIBC_2.38`.
**Ubuntu 24.04 gets no gamescope package** and cannot: the wayland floor is a runtime one too.
## Install on a host (one-time)
The registry is public, so no apt auth is needed — just trust the repo's signing key:
@@ -28,7 +28,7 @@ set -euo pipefail
GAMESCOPE_REV="5fb8dce4a09d0a68d097b9faf9513782106bc843"
GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git"
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1 EXTRA_FALLBACK=""
while [ $# -gt 0 ]; do
case "$1" in
--rev) REV="${2:?}"; shift 2 ;;
@@ -37,6 +37,15 @@ while [ $# -gt 0 ]; do
--srcdir) SRCDIR="${2:?}"; shift 2 ;;
--jobs) JOBS="${2:?}"; shift 2 ;;
--no-setcap) SETCAP=0; shift ;;
# Extra `force_fallback_for` entries, comma-separated, appended to the mandatory three below.
# Exists for ONE package family: the .deb has to install on both Debian 13 and Ubuntu 26.04,
# and those two disagree on the libdisplay-info SONAME (0.2.0 -> libdisplay-info2 vs 0.3.0 ->
# libdisplay-info3), so a package built against either one is uninstallable on the other.
# Vendoring it makes ONE .deb serve both. Opt-in rather than baked in, so the Arch/Fedora/nix
# packages — which have no such split and are shipping fine — keep producing exactly the binary
# they produce today. Its only caller is the `build-publish-gamescope` job in
# .gitea/workflows/deb.yml, which passes `--extra-fallback libdisplay-info`.
--extra-fallback) EXTRA_FALLBACK="${2:?}"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
@@ -111,7 +120,7 @@ export LDFLAGS="${LDFLAGS:-} -static-libstdc++ -static-libgcc"
meson setup "$BUILD" "$SRCDIR" \
--prefix="$PREFIX" \
--buildtype=release \
-Dforce_fallback_for=libliftoff,vkroots,wlroots \
-Dforce_fallback_for="libliftoff,vkroots,wlroots${EXTRA_FALLBACK:+,$EXTRA_FALLBACK}" \
-Dpipewire=enabled \
-Denable_tests=false \
-Denable_openvr_support=false \
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.4.0",
"version": "0.4.1",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Install the published .debs on every supported apt distro, in a pristine image, the way a user
# would — repo key, sources.list line, `apt-get install`.
#
# WHY THIS EXISTS: nothing in deb.yml ever installed a package it built. Two facts survived a long
# time in that blind spot, in opposite directions:
# * `punktfunk-host` installed cleanly on Debian 13 for months while docs-site said Debian was
# unsupported and unverified.
# * `punktfunk-gamescope` was missing from the apt registry across two releases, while the docs
# told Debian/Ubuntu users to `apt install` it.
# Both are what a five-minute install check catches.
#
# Usage: bash scripts/ci/deb-install-smoke.sh
# PF_APT_DISTRIBUTION stable | canary (default: stable)
# PF_EXPECT_VERSION if set, `punktfunk-host --version` must contain it — proves the run is
# testing the artifact THIS run published and not a leftover from an
# earlier one (the failure mode that makes a green check meaningless).
# PF_SMOKE_IMAGES override the image list, space-separated (local runs / bisecting).
set -euo pipefail
DIST="${PF_APT_DISTRIBUTION:-stable}"
EXPECT="${PF_EXPECT_VERSION:-}"
REPO_URL="https://git.unom.io/api/packages/unom/debian"
# THE SUPPORT MATRIX, as an assertion instead of a paragraph. Each row is
# "<image>|<packages that must install>". A package absent from a row is one we do NOT claim works
# there, and it is deliberately NOT asserted absent — that would turn every future improvement into
# a red build. The omissions and their reasons:
# ubuntu:24.04 no client — built on 26.04, floors at libc6 >= 2.43 (noble has 2.39)
# no gamescope — noble's wayland is 1.22.0; the vendored wlroots needs >= 1.23.1,
# so the binary cannot even load there
# debian:trixie no client — same libc6 >= 2.43 floor (trixie has 2.41), plus GTK4 >= 4.20
# Debian 12 (bookworm) is absent entirely: glibc 2.36 is below the host's 2.39 floor, so nothing
# we ship installs there and there is nothing to smoke-test.
MATRIX=(
"ubuntu:24.04|punktfunk-host punktfunk-web punktfunk-scripting"
"ubuntu:26.04|punktfunk-host punktfunk-web punktfunk-scripting punktfunk-client punktfunk-gamescope"
"debian:trixie|punktfunk-host punktfunk-web punktfunk-scripting punktfunk-gamescope"
)
if [ -n "${PF_SMOKE_IMAGES:-}" ]; then
FILTERED=()
for row in "${MATRIX[@]}"; do
for want in $PF_SMOKE_IMAGES; do
[ "${row%%|*}" = "$want" ] && FILTERED+=("$row")
done
done
MATRIX=("${FILTERED[@]}")
fi
echo "smoke-installing from '$DIST'${EXPECT:+ (expecting host version $EXPECT)}"
FAILED=()
for row in "${MATRIX[@]}"; do
IMAGE="${row%%|*}"
PACKAGES="${row#*|}"
echo
echo "==================== $IMAGE ===================="
echo "packages: $PACKAGES"
# `docker run` inherits nothing from this shell — every value the container needs is passed
# explicitly, so a typo here is a hard failure rather than a silently empty variable.
if docker run --rm --platform linux/amd64 \
-e DEBIAN_FRONTEND=noninteractive \
-e DIST="$DIST" -e PACKAGES="$PACKAGES" -e EXPECT="$EXPECT" -e REPO_URL="$REPO_URL" \
"$IMAGE" bash -euxc '
apt-get update -qq
apt-get install -y -qq --no-install-recommends curl ca-certificates
install -d -m 0755 /etc/apt/keyrings
curl -fsSL --max-time 60 "$REPO_URL/repository.key" -o /etc/apt/keyrings/punktfunk.asc
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] $REPO_URL $DIST main" \
> /etc/apt/sources.list.d/punktfunk.list
apt-get update -qq
# Wait for the index to actually carry the version this run published. Gitea regenerates
# the apt Packages file after an upload, so a smoke job that starts immediately can see
# the PREVIOUS build — install it, pass, and prove nothing about the new one. Bounded:
# if it never appears, that is a real publishing failure and the job should say so.
if [ -n "$EXPECT" ]; then
for i in $(seq 1 10); do
apt-cache policy punktfunk-host | grep -qF "$EXPECT" && break
echo "index does not carry $EXPECT yet (attempt $i) — waiting"
sleep 15
apt-get update -qq
done
apt-cache policy punktfunk-host | grep -qF "$EXPECT" || {
echo "the apt index never served $EXPECT — publish did not land"
apt-cache policy punktfunk-host
exit 1
}
fi
# The real thing: unpack + run every maintainer script, exactly as a user would.
apt-get install -y $PACKAGES
# Installed is not the same as working. Assert every shipped binary RESOLVED its shared
# libraries and, where it is safe to run headless, that it executes — an unsatisfied
# soname is invisible to dpkg but fatal to the user, and it is exactly what a
# distro-mismatched build produces.
# (`if ldd | grep; then fail` rather than `grep && exit 1`: the latter leaves the block
# returning grep NOT-found = 1, which under `set -e` fails the container on success.)
for pkg in $PACKAGES; do
for bin in $(dpkg -L "$pkg" | grep "^/usr/bin/" || true); do
if ldd "$bin" 2>/dev/null | grep -F "not found"; then
echo "UNRESOLVED SONAME in $bin (from $pkg)"
exit 1
fi
done
done
# --version is the cheapest proof of "actually runs". Only for binaries that answer it
# without a session/GPU: the client opens GTK, the console is a bun bundle.
if echo "$PACKAGES" | grep -q punktfunk-host; then
punktfunk-host --version
[ -z "$EXPECT" ] || punktfunk-host --version | grep -F "$EXPECT"
fi
if echo "$PACKAGES" | grep -q punktfunk-gamescope; then
punktfunk-gamescope --version
fi
'; then
echo "PASS: $IMAGE"
else
echo "::error::$IMAGE — the published packages do not install ($PACKAGES)"
FAILED+=("$IMAGE")
fi
done
echo
if [ ${#FAILED[@]} -gt 0 ]; then
echo "install smoke FAILED on: ${FAILED[*]}"
exit 1
fi
echo "install smoke passed on every supported distro"